From e76490580d08be47258545872676fe329df96d94 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:40:56 +0100 Subject: [PATCH] feat: add an Organisation area to the admin panel with domain and group scopes --- app/(main)/admin/[[...segments]]/page.tsx | 10 + .../__tests__/organisation-page.test.tsx | 97 +++++++ app/(main)/admin/_pages/org-security.tsx | 18 ++ app/(main)/admin/_pages/org-sharing.tsx | 18 ++ app/(main)/admin/_pages/org-signatures.tsx | 14 + app/(main)/admin/_pages/org-templates.tsx | 14 + app/(main)/admin/_pages/organisation.tsx | 265 ++++++++++++++++++ components/admin/admin-shell.tsx | 13 +- components/admin/org/org-page.tsx | 180 ++++++++++++ .../admin/org/sections/delegates-section.tsx | 14 + .../org/sections/external-mail-section.tsx | 14 + .../admin/org/sections/recovery-section.tsx | 14 + .../admin/org/sections/sessions-section.tsx | 14 + .../org/sections/shared-tags-section.tsx | 14 + .../admin/org/sections/signature-section.tsx | 14 + .../admin/org/sections/templates-section.tsx | 14 + .../org/sections/third-party-section.tsx | 14 + components/admin/org/use-org-policy-draft.ts | 237 ++++++++++++++++ lib/admin/admin-nav.ts | 68 ++++- 19 files changed, 1043 insertions(+), 3 deletions(-) create mode 100644 app/(main)/admin/__tests__/organisation-page.test.tsx create mode 100644 app/(main)/admin/_pages/org-security.tsx create mode 100644 app/(main)/admin/_pages/org-sharing.tsx create mode 100644 app/(main)/admin/_pages/org-signatures.tsx create mode 100644 app/(main)/admin/_pages/org-templates.tsx create mode 100644 app/(main)/admin/_pages/organisation.tsx create mode 100644 components/admin/org/org-page.tsx create mode 100644 components/admin/org/sections/delegates-section.tsx create mode 100644 components/admin/org/sections/external-mail-section.tsx create mode 100644 components/admin/org/sections/recovery-section.tsx create mode 100644 components/admin/org/sections/sessions-section.tsx create mode 100644 components/admin/org/sections/shared-tags-section.tsx create mode 100644 components/admin/org/sections/signature-section.tsx create mode 100644 components/admin/org/sections/templates-section.tsx create mode 100644 components/admin/org/sections/third-party-section.tsx create mode 100644 components/admin/org/use-org-policy-draft.ts diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx index 120e5b6a6..4d71528bc 100644 --- a/app/(main)/admin/[[...segments]]/page.tsx +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -8,6 +8,11 @@ import { SettingsPage } from '../_pages/settings'; import { BrandingPage } from '../_pages/branding'; import { AuthPage } from '../_pages/auth'; import { PolicyPage } from '../_pages/policy'; +import { OrganisationPage } from '../_pages/organisation'; +import { OrgSignaturesPage } from '../_pages/org-signatures'; +import { OrgTemplatesPage } from '../_pages/org-templates'; +import { OrgSecurityPage } from '../_pages/org-security'; +import { OrgSharingPage } from '../_pages/org-sharing'; import { PluginsPage } from '../_pages/plugins'; import { ThemesPage } from '../_pages/themes'; import { MarketplacePage } from '../_pages/marketplace'; @@ -41,6 +46,11 @@ export default function AdminPage() { case 'branding': return ; case 'auth': return ; case 'policy': return ; + case 'organisation': return ; + case 'org-signatures': return ; + case 'org-templates': return ; + case 'org-security': return ; + case 'org-sharing': return ; case 'plugins': return detail ? : ; case 'themes': return detail ? : ; case 'marketplace': return detail ? : ; diff --git a/app/(main)/admin/__tests__/organisation-page.test.tsx b/app/(main)/admin/__tests__/organisation-page.test.tsx new file mode 100644 index 000000000..8ff9c8868 --- /dev/null +++ b/app/(main)/admin/__tests__/organisation-page.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; + +// The Organisation pages edit one scope at a time: Everyone (the global +// policy) or a domain or group scope. A value a scope doesn't set comes from +// Everyone and can be reset to it; saving sends the whole policy. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { OrganisationPage } from '../_pages/organisation'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '@/components/admin/org/use-org-policy-draft'; + +const POLICY = { + restrictions: { density: { locked: true, value: 'compact' } }, + features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { session: { idleMinutes: 30 } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }], +}; + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('useOrgPolicyDraft', () => { + it('writes into the selected scope and shows Everyone\'s value until then', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setScopeId('team')); + expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 30, own: false, everyone: 30 }); + + act(() => result.current.setOrgValue('session', 'idleMinutes', 5)); + expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 5, own: true, everyone: 30 }); + expect(result.current.policy?.organisation?.session.idleMinutes).toBe(30); + expect(result.current.dirty).toBe(true); + + act(() => result.current.clearOrgValue('session', 'idleMinutes')); + expect(result.current.orgValue('session', 'idleMinutes').own).toBe(false); + expect(result.current.policy?.scopes?.[0].policy.organisation).toEqual({}); + }); + + it('lets a scope unlock what Everyone locks, and saves the whole policy', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setScopeId('team')); + act(() => result.current.setRestriction('density', { locked: false })); + expect(result.current.policy?.scopes?.[0].policy.restrictions).toEqual({ density: {} }); + expect(result.current.policy?.restrictions.density).toEqual({ locked: true, value: 'compact' }); + + await act(async () => { await result.current.save(); }); + expect(puts).toHaveLength(1); + expect((puts[0].scopes as Array<{ policy: unknown }>)[0].policy).toEqual({ restrictions: { density: {} } }); + expect(puts[0].organisation).toMatchObject({ session: { idleMinutes: 30 } }); + }); + + it('writes to the global policy for Everyone', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setOrgValue('externalMail', 'senderLabel', true)); + expect(result.current.policy?.organisation?.externalMail.senderLabel).toBe(true); + expect(result.current.policy?.scopes?.[0].policy).toEqual({}); + }); +}); + +describe('OrganisationPage', () => { + it('adds a domain scope and shows which entry sets what for a user', async () => { + render(); + await screen.findByTestId('org-scope-team'); + + fireEvent.click(screen.getByRole('button', { name: /Add domains/ })); + const names = screen.getAllByLabelText('Name') as HTMLInputElement[]; + expect(names.map((n) => n.value)).toEqual(['Team', 'New domain']); + + fireEvent.change(screen.getByLabelText('User address'), { target: { value: 'org-alice@example.org' } }); + fireEvent.change(screen.getByLabelText('Groups the user is a member of'), { target: { value: 'org-team@example.org' } }); + const table = await screen.findByTestId('org-effective-policy'); + expect(table.textContent).toContain('Applies, in this order: Everyone, Team.'); + expect(table.textContent).toContain('restrictions.density'); + expect(table.textContent).toContain('organisation.session.idleMinutes'); + }); +}); diff --git a/app/(main)/admin/_pages/org-security.tsx b/app/(main)/admin/_pages/org-security.tsx new file mode 100644 index 000000000..af778d1ee --- /dev/null +++ b/app/(main)/admin/_pages/org-security.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { ExternalMailSection } from '@/components/admin/org/sections/external-mail-section'; +import { SessionsSection } from '@/components/admin/org/sections/sessions-section'; +import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section'; + +export function OrgSecurityPage() { + const draft = useOrgPolicyDraft('org-security'); + return ( + + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-sharing.tsx b/app/(main)/admin/_pages/org-sharing.tsx new file mode 100644 index 000000000..2617ccc2e --- /dev/null +++ b/app/(main)/admin/_pages/org-sharing.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { SharedTagsSection } from '@/components/admin/org/sections/shared-tags-section'; +import { DelegatesSection } from '@/components/admin/org/sections/delegates-section'; +import { RecoverySection } from '@/components/admin/org/sections/recovery-section'; + +export function OrgSharingPage() { + const draft = useOrgPolicyDraft('org-sharing'); + return ( + + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-signatures.tsx b/app/(main)/admin/_pages/org-signatures.tsx new file mode 100644 index 000000000..7aa0b27c5 --- /dev/null +++ b/app/(main)/admin/_pages/org-signatures.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { SignatureSection } from '@/components/admin/org/sections/signature-section'; + +export function OrgSignaturesPage() { + const draft = useOrgPolicyDraft('org-signatures'); + return ( + + + + ); +} diff --git a/app/(main)/admin/_pages/org-templates.tsx b/app/(main)/admin/_pages/org-templates.tsx new file mode 100644 index 000000000..bc63b0578 --- /dev/null +++ b/app/(main)/admin/_pages/org-templates.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { TemplatesSection } from '@/components/admin/org/sections/templates-section'; + +export function OrgTemplatesPage() { + const draft = useOrgPolicyDraft('org-templates'); + return ( + + + + ); +} diff --git a/app/(main)/admin/_pages/organisation.tsx b/app/(main)/admin/_pages/organisation.tsx new file mode 100644 index 000000000..0276ef616 --- /dev/null +++ b/app/(main)/admin/_pages/organisation.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { ArrowDown, ArrowUp, Plus, Trash2 } from '@/components/icons'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/admin/badge'; +import { inputClass } from '@/components/admin/config-items'; +import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page'; +import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes'; +import { cn } from '@/lib/utils'; + +const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground'; +const DOMAIN_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const GROUP_RE = /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +function splitList(text: string): string[] { + return [...new Set(text.split(/[\s,;]+/).map((v) => v.trim().toLowerCase()).filter(Boolean))]; +} + +function newScopeId(existing: PolicyScope[]): string { + const taken = new Set(existing.map((s) => s.id)); + for (;;) { + const id = `scope-${Math.random().toString(36).slice(2, 8)}`; + if (!taken.has(id)) return id; + } +} + +/** A comma-separated list edited as text and stored as an array when the field loses focus. */ +function ListField({ label, value, placeholder, valid, onCommit }: { + label: string; + value: string[]; + placeholder: string; + valid: (item: string) => boolean; + onCommit: (items: string[]) => void; +}) { + const [text, setText] = useState(null); + const shown = text ?? value.join(', '); + const invalid = splitList(shown).filter((item) => !valid(item)); + return ( + + ); +} + +function ScopeCard({ draft, scope, index, count }: { draft: OrgPolicyDraft; scope: PolicyScope; index: number; count: number }) { + const move = (delta: number) => draft.update((p) => { + const list = p.scopes ?? []; + const [item] = list.splice(index, 1); + list.splice(index + delta, 0, item); + }); + const overrides = Object.keys(scope.policy.features ?? {}).length + + Object.keys(scope.policy.restrictions ?? {}).length + + Object.keys(scope.policy.defaults ?? {}).length + + Object.values(scope.policy.organisation ?? {}).reduce((n, section) => n + Object.keys(section ?? {}).length, 0); + return ( +
+
+ +
+ + + +
+
+
+ DOMAIN_RE.test(d)} + onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })} + /> + GROUP_RE.test(g)} + onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })} + /> +
+
+ {overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`} + +
+
+ ); +} + +function sourceLabel(source: string, scopes: PolicyScope[]): string { + if (source === 'global') return 'Everyone'; + return scopes.find((s) => s.id === source)?.label ?? source; +} + +function valueAt(policy: Record, path: string): unknown { + const [root, ...rest] = path.split('.'); + // Keys of features, restrictions and defaults never contain dots; organisation paths are section.key. + let value: unknown = policy[root]; + for (const part of rest) value = (value as Record | undefined)?.[part]; + return value; +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') return value.length > 60 ? `${value.slice(0, 60)}…` : value || '(empty)'; + const json = JSON.stringify(value); + return json && json.length > 80 ? `${json.slice(0, 80)}…` : String(json); +} + +function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) { + const [email, setEmail] = useState(''); + const [groupsText, setGroupsText] = useState(''); + const subject = email.includes('@') ? { email: email.trim().toLowerCase(), groups: splitList(groupsText) } : null; + const resolved = useMemo( + () => (draft.policy && subject ? resolvePolicy(draft.policy, subject) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft.policy, subject?.email, subject?.groups.join(',')], + ); + const rows = resolved + ? Object.entries(resolved.sources).sort(([a], [b]) => a.localeCompare(b)) + : []; + return ( +
+
+ + +
+ {resolved && ( +
+

+ {resolved.matched.length === 0 + ? 'No domain or group applies; this user gets Everyone\'s settings.' + : `Applies, in this order: Everyone, ${resolved.matched.map((id) => sourceLabel(id, draft.scopes)).join(', ')}.`} +

+ {rows.length === 0 ? ( +

Everything is at Bulwark's defaults.

+ ) : ( +
+ + + + + + + + + + {rows.map(([path, source]) => ( + + + + + + ))} + +
SettingValueSet by
{path}{formatValue(valueAt(resolved.policy as unknown as Record, path))}{sourceLabel(source, draft.scopes)}
+
+ )} +

+ Bulwark can only see groups that have a mailbox: a user counts as a member when the group's account is in their session. +

+
+ )} +
+ ); +} + +export function OrganisationPage() { + const draft = useOrgPolicyDraft('organisation'); + const addScope = (kind: 'domain' | 'group') => draft.update((p) => { + const id = newScopeId(p.scopes ?? []); + p.scopes = [...(p.scopes ?? []), { + id, + label: kind === 'domain' ? 'New domain' : 'New group', + match: {}, + policy: {}, + }]; + }); + return ( + + +
+ + +
+ {draft.scopes.length === 0 ? ( +

None yet: every user gets Everyone's settings.

+ ) : ( +
+ {draft.scopes.map((scope, index) => ( + + ))} +
+ )} + {draft.scopeId !== EVERYONE && draft.scope && ( +

+ Selected for editing: {draft.scope.label} ({scopeMatchSummary(draft.scope)}). +

+ )} +
+ + + + +
+ ); +} diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index e78a3ec63..208c9576a 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -6,17 +6,22 @@ import { ArrowLeft, ArrowUpCircle, Activity, + Building2, ChevronRight, KeyRound, LayoutDashboard, + LayoutTemplate, LogOut, Palette, + PenLine, Puzzle, Scale, ScrollText, Search, Server, + Share2, Shield, + ShieldCheck, Store, SwatchBook, X, @@ -42,6 +47,11 @@ const PAGE_ICONS: Record = { auth: Shield, branding: Palette, policy: Scale, + organisation: Building2, + 'org-signatures': PenLine, + 'org-templates': LayoutTemplate, + 'org-security': ShieldCheck, + 'org-sharing': Share2, plugins: Puzzle, themes: SwatchBook, marketplace: Store, @@ -52,6 +62,7 @@ const PAGE_ICONS: Record = { const GROUP_LABELS: Record = { top: null, + organisation: 'Organisation', extensions: 'Extensions', system: 'System', }; @@ -156,7 +167,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, }, [trimmed]); const groups = useMemo(() => ( - (['top', 'extensions', 'system'] as const) + (['top', 'organisation', 'extensions', 'system'] as const) .map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) })) .filter((g) => g.items.length > 0) ), [matches]); diff --git a/components/admin/org/org-page.tsx b/components/admin/org/org-page.tsx new file mode 100644 index 000000000..3cdad7641 --- /dev/null +++ b/components/admin/org/org-page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Loader2, Lock, RotateCcw } from '@/components/icons'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { AdminLink } from '@/components/admin/admin-link'; +import { Flash } from '@/components/admin/flash-message'; +import { SectionNav, SettingItem } from '@/components/settings/settings-section'; +import { adminPageDef, type AdminPageId } from '@/lib/admin/admin-nav'; +import type { RestrictionKeyDef } from '@/lib/admin/restriction-keys'; +import type { PolicyScope } from '@/lib/admin/policy-scopes'; +import { cn } from '@/lib/utils'; +import { EVERYONE, type OrgPolicyDraft } from './use-org-policy-draft'; + +export const chipClass = (selected: boolean) => cn( + 'h-8 px-3 rounded-md text-sm font-medium transition-colors max-w-full truncate', + selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground', +); + +/** "example.com, sales@example.com": what a scope matches, for labels. */ +export function scopeMatchSummary(scope: PolicyScope): string { + const parts = [...(scope.match.domains ?? []), ...(scope.match.groups ?? [])]; + return parts.length > 0 ? parts.join(', ') : 'matches nobody yet'; +} + +/** + * Which part of the organisation an Organisation page edits: Everyone, or + * one domain or group scope. Chips on wide screens; they wrap at phone width. + */ +export function ScopeBar({ draft }: { draft: OrgPolicyDraft }) { + return ( +
+
+ Applies to + + {draft.scopes.map((scope) => ( + + ))} + + Manage domains and groups + +
+ {draft.scope && ( +

+ Changes here apply to {scopeMatchSummary(draft.scope)} only. Settings this scope doesn't change come from Everyone. +

+ )} +
+ ); +} + +/** "From Everyone: 15 minutes" with a reset, under a field a scope inherits or overrides. */ +export function InheritNote({ draft, own, everyone, onReset }: { + draft: OrgPolicyDraft; + own: boolean; + everyone: ReactNode; + onReset: () => void; +}) { + if (!draft.scope) return null; + return ( +

+ {own ? Everyone: {everyone} : From Everyone: {everyone}} + {own && ( + + )} +

+ ); +} + +/** + * Lock, hide and allowed values of one restriction key, at the selected + * scope. Every user-facing option of the Organisation features is + * restricted through this, never through a flag of its own. + */ +export function RestrictionRow({ draft, def }: { draft: OrgPolicyDraft; def: RestrictionKeyDef }) { + const { value, own, everyone } = draft.restriction(def.key); + const current = value ?? {}; + const modes = def.modes ?? ['locked', 'hidden']; + const set = (patch: Partial) => draft.setRestriction(def.key, { ...current, ...patch }); + const describe = (r: typeof current | undefined) => + r?.hidden ? 'hidden' : r?.locked ? 'locked' : r?.allowedValues?.length ? `limited to ${r.allowedValues.join(', ')}` : 'users choose'; + return ( + +
+
+ {modes.includes('locked') && ( + + )} + {modes.includes('hidden') && ( + + )} + {modes.includes('allowedValues') && def.allowedValues?.map((option) => { + const allowed = current.allowedValues as string[] | undefined; + const checked = !allowed || allowed.length === 0 || allowed.includes(option); + return ( + + ); + })} +
+ draft.setRestriction(def.key, undefined)} /> +
+
+ ); +} + +/** + * The frame of an Organisation page: title and the one sentence on what it + * does for users, the scope bar, messages, and the save bar. + */ +export function OrgPage({ pageId, draft, children, scoped = true }: { + pageId: AdminPageId; + draft: OrgPolicyDraft; + children: ReactNode; + /** Pages whose settings can be set per scope show the scope bar. */ + scoped?: boolean; +}) { + const page = adminPageDef(pageId); + return ( +
+ + draft.setMessage(null)} /> + {draft.loading ? ( +
+ Loading +
+ ) : ( +
+ {scoped && } + + {children} +
+ )} + void draft.save()} + onDiscard={draft.discard} + saveLabel="Save" + note="Users get the change the next time Bulwark loads" + /> +
+ ); +} diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx new file mode 100644 index 000000000..d6af70217 --- /dev/null +++ b/components/admin/org/sections/delegates-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 7; filled in by its own step (local-data/org-features-plan.md). */ +export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx new file mode 100644 index 000000000..44c7a8a5a --- /dev/null +++ b/components/admin/org/sections/external-mail-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */ +export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/recovery-section.tsx b/components/admin/org/sections/recovery-section.tsx new file mode 100644 index 000000000..92ed6445b --- /dev/null +++ b/components/admin/org/sections/recovery-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 9; filled in by its own step (local-data/org-features-plan.md). */ +export function RecoverySection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/sessions-section.tsx b/components/admin/org/sections/sessions-section.tsx new file mode 100644 index 000000000..b964d20d4 --- /dev/null +++ b/components/admin/org/sections/sessions-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 4; filled in by its own step (local-data/org-features-plan.md). */ +export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx new file mode 100644 index 000000000..978c1a56d --- /dev/null +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 6; filled in by its own step (local-data/org-features-plan.md). */ +export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/signature-section.tsx b/components/admin/org/sections/signature-section.tsx new file mode 100644 index 000000000..b5bc1a84b --- /dev/null +++ b/components/admin/org/sections/signature-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 1; filled in by its own step (local-data/org-features-plan.md). */ +export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/templates-section.tsx b/components/admin/org/sections/templates-section.tsx new file mode 100644 index 000000000..8cf157268 --- /dev/null +++ b/components/admin/org/sections/templates-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */ +export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx new file mode 100644 index 000000000..2736dc26b --- /dev/null +++ b/components/admin/org/sections/third-party-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 5; filled in by its own step (local-data/org-features-plan.md). */ +export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/use-org-policy-draft.ts b/components/admin/org/use-org-policy-draft.ts new file mode 100644 index 000000000..e8e751731 --- /dev/null +++ b/components/admin/org/use-org-policy-draft.ts @@ -0,0 +1,237 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { create } from 'zustand'; +import type { FeatureGates, SettingRestriction, SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { + DEFAULT_ORGANISATION_POLICY, + sanitizeOrganisationPolicy, + type OrganisationPolicy, + type OrganisationSection, +} from '@/lib/admin/organisation-policy'; +import type { PolicyScope } from '@/lib/admin/policy-scopes'; +import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { useFlash } from '@/components/admin/flash-message'; + +/** The scope id the Organisation pages edit; '' is Everyone (the global policy). */ +export const EVERYONE = ''; + +/** The scope picked on one Organisation page stays picked on the others. */ +export const useOrgScopeStore = create<{ scopeId: string; setScopeId: (id: string) => void }>()((set) => ({ + scopeId: EVERYONE, + setScopeId: (scopeId) => set({ scopeId }), +})); + +type SectionValues = OrganisationPolicy[S]; + +export interface OrgValue { + /** What applies at the selected scope: its own value, or the inherited one. */ + value: T; + /** The selected scope sets it itself (always true for Everyone). */ + own: boolean; + /** Everyone's value, shown as "From Everyone" in a scope. */ + everyone: T; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Loading, editing and saving the user policy for one Organisation page. + * Edits go to the scope chosen in the scope bar (Everyone or one domain or + * group scope); in a scope, a value the scope doesn't set shows Everyone's + * and can be reset to it. Saving writes the whole policy, so the User + * policy page and every Organisation page work on the same policy.json. + */ +export function useOrgPolicyDraft(dirtyId: string) { + const [policy, setPolicy] = useState(null); + const [saved, setSaved] = useState(null); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useFlash(); + const scopeId = useOrgScopeStore((s) => s.scopeId); + const setScopeId = useOrgScopeStore((s) => s.setScopeId); + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/policy'); + if (!res.ok) throw new Error(); + const data = (await res.json()) as SettingsPolicy; + const normalised = { ...DEFAULT_POLICY, ...data, organisation: sanitizeOrganisationPolicy(data.organisation), scopes: data.scopes ?? [] }; + setPolicy(normalised); + setSaved(clone(normalised)); + } catch { + setMessage({ type: 'error', text: 'Could not load the policy' }); + setPolicy((p) => p ?? { ...DEFAULT_POLICY, organisation: clone(DEFAULT_ORGANISATION_POLICY), scopes: [] }); + } + }, [setMessage]); + + useEffect(() => { void load(); }, [load]); + + const scopes = useMemo(() => policy?.scopes ?? [], [policy]); + const scope = scopes.find((s) => s.id === scopeId) ?? null; + // A scope deleted elsewhere (or on another page) falls back to Everyone. + useEffect(() => { + if (policy && scopeId !== EVERYONE && !scope) setScopeId(EVERYONE); + }, [policy, scopeId, scope, setScopeId]); + + const dirty = !!policy && !!saved && JSON.stringify(policy) !== JSON.stringify(saved); + useAdminUnsavedChanges(dirtyId, dirty); + + const update = useCallback((change: (draft: SettingsPolicy) => void) => { + setPolicy((current) => { + if (!current) return current; + const draft = clone(current); + change(draft); + return draft; + }); + setMessage(null); + }, [setMessage]); + + const updateScope = useCallback((id: string, change: (scope: PolicyScope) => void) => { + update((draft) => { + const target = draft.scopes?.find((s) => s.id === id); + if (target) change(target); + }); + }, [update]); + + const org = policy?.organisation ?? DEFAULT_ORGANISATION_POLICY; + + function orgValue>(section: S, key: K): OrgValue[K]> { + const everyone = org[section][key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = (scope.policy.organisation?.[section] as Partial> | undefined)?.[key]; + return override === undefined + ? { value: everyone, own: false, everyone } + : { value: override as SectionValues[K], own: true, everyone }; + } + + function setOrgValue>(section: S, key: K, value: SectionValues[K]) { + if (!scope) { + update((draft) => { + const target = (draft.organisation ?? clone(DEFAULT_ORGANISATION_POLICY)) as OrganisationPolicy; + (target[section] as SectionValues)[key] = value; + draft.organisation = target; + }); + return; + } + updateScope(scope.id, (s) => { + const organisation = (s.policy.organisation ??= {}) as Record>; + (organisation[section] ??= {})[key as string] = value; + }); + } + + /** In a scope: use Everyone's value again. */ + function clearOrgValue(section: S, key: keyof SectionValues) { + if (!scope) return; + updateScope(scope.id, (s) => { + const values = s.policy.organisation?.[section] as Record | undefined; + if (!values) return; + delete values[key as string]; + if (Object.keys(values).length === 0) delete (s.policy.organisation as Record)[section]; + }); + } + + function restriction(key: string): OrgValue { + const everyone = policy?.restrictions[key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = scope.policy.restrictions?.[key]; + return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone }; + } + + /** Sets the restriction at the selected scope; undefined removes it (in a scope: inherit). */ + function setRestriction(key: string, value: SettingRestriction | undefined) { + const empty = !value || (!value.locked && !value.hidden && !value.allowedValues?.length && value.min === undefined && value.max === undefined); + if (!scope) { + update((draft) => { + if (empty) delete draft.restrictions[key]; + else draft.restrictions[key] = value!; + }); + return; + } + updateScope(scope.id, (s) => { + const restrictions = (s.policy.restrictions ??= {}); + // In a scope, "nothing" is a real override: it unlocks what Everyone locks. + if (value === undefined) delete restrictions[key]; + else restrictions[key] = empty ? {} : value; + }); + } + + function feature(key: keyof FeatureGates): OrgValue { + const everyone = policy?.features[key] ?? DEFAULT_POLICY.features[key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = scope.policy.features?.[key]; + return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone }; + } + + function setFeature(key: keyof FeatureGates, value: boolean | undefined) { + if (!scope) { + update((draft) => { draft.features[key] = value ?? DEFAULT_POLICY.features[key]; }); + return; + } + updateScope(scope.id, (s) => { + const features = (s.policy.features ??= {}); + if (value === undefined) delete features[key]; + else features[key] = value; + }); + } + + async function save() { + if (!policy) return; + setSaving(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + if (res.ok) { + // The server sanitises; show what it kept. + await load(); + setMessage({ type: 'success', text: 'Saved. Users get the change the next time Bulwark loads.' }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the policy' }); + } finally { + setSaving(false); + } + } + + function discard() { + if (saved) setPolicy(clone(saved)); + setMessage(null); + } + + return { + policy, + loading: policy === null, + scopes, + scope, + scopeId: scope ? scope.id : EVERYONE, + setScopeId, + update, + updateScope, + orgValue, + setOrgValue, + clearOrgValue, + restriction, + setRestriction, + feature, + setFeature, + dirty, + saving, + save, + discard, + message, + setMessage, + reload: load, + }; +} + +export type OrgPolicyDraft = ReturnType; diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index ccb8e6fb1..f10b80ea7 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -17,6 +17,11 @@ export const ADMIN_PAGE_IDS = [ 'auth', 'branding', 'policy', + 'organisation', + 'org-signatures', + 'org-templates', + 'org-security', + 'org-sharing', 'plugins', 'themes', 'marketplace', @@ -31,8 +36,8 @@ export function isAdminPageId(value: string | null | undefined): value is AdminP return typeof value === 'string' && (ADMIN_PAGE_IDS as readonly string[]).includes(value); } -/** `top` has no heading in the sidebar; the other two do. */ -export type AdminPageGroup = 'top' | 'extensions' | 'system'; +/** `top` has no heading in the sidebar; the others do. */ +export type AdminPageGroup = 'top' | 'organisation' | 'extensions' | 'system'; export interface AdminSectionDef { /** The `id` the page gives its SettingsSection, for jump links and search. */ @@ -118,6 +123,65 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ { id: 'restrictions', title: 'Locked settings', keywords: 'restrict hide lock font size density layout delete action reply mode session timeout notifications debug' }, ], }, + { + id: 'organisation', + label: 'Domains and groups', + description: 'Give some domains or groups their own settings, see what applies to a user, and keep the policy in one file', + group: 'organisation', + keywords: 'organisation organization scope domain group department customer tenant effective policy export import file json', + sections: [ + { id: 'scopes', title: 'Domains and groups', keywords: 'scope domain group add remove order' }, + { id: 'effective', title: 'Effective policy', keywords: 'user address lookup applies source' }, + { id: 'policy-file', title: 'Policy file', keywords: 'export import download upload json backup git' }, + ], + }, + { + id: 'org-signatures', + label: 'Signatures', + description: 'The signature and legal disclaimer every message carries', + group: 'organisation', + keywords: 'signature disclaimer footer legal imprint placeholder phone title department lock', + sections: [ + { id: 'signature', title: 'Signature', keywords: 'template placeholder name email lock' }, + { id: 'details', title: 'User details', keywords: 'phone mobile title department fields' }, + { id: 'disclaimer', title: 'Disclaimer', keywords: 'footer legal notice confidentiality' }, + ], + }, + { + id: 'org-templates', + label: 'Templates', + description: 'Templates every user can pick, and what users may keep of their own', + group: 'organisation', + keywords: 'templates library snippets canned responses team personal', + sections: [ + { id: 'library', title: 'Organisation library', keywords: 'templates read-only shared add edit' }, + { id: 'user-templates', title: 'Personal and team templates', keywords: 'personal team group lock hide' }, + ], + }, + { + id: 'org-security', + label: 'Security', + description: 'Warnings about mail from and to outside the organisation, sign-out times and outside connections', + group: 'organisation', + keywords: 'security external warning label recipients domains session timeout idle sign out third party traffic privacy', + sections: [ + { id: 'external', title: 'External mail', keywords: 'external sender label recipient warning organisation domains contacts' }, + { id: 'sessions', title: 'Sessions', keywords: 'idle timeout maximum length sign out warning' }, + { id: 'third-party', title: 'Third-party traffic', keywords: 'telemetry favicon relay directory outgoing hosts privacy gdpr' }, + ], + }, + { + id: 'org-sharing', + label: 'Sharing', + description: 'Tags in shared mailboxes, delegates and recovering deleted items', + group: 'organisation', + keywords: 'sharing shared mailbox tags delegate access recover deleted items archive', + sections: [ + { id: 'shared-tags', title: 'Shared tags', keywords: 'tags labels colours group mailbox edit lock' }, + { id: 'delegates', title: 'Delegates', keywords: 'delegate access mail calendar contacts rights read write' }, + { id: 'recovery', title: 'Deleted items', keywords: 'recover restore deleted archive enterprise' }, + ], + }, { id: 'plugins', label: 'Plugins',