mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 08:15:45 +00:00
feat: add an Organisation area to the admin panel with domain and group scopes
This commit is contained in:
@@ -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 <BrandingPage />;
|
||||
case 'auth': return <AuthPage />;
|
||||
case 'policy': return <PolicyPage />;
|
||||
case 'organisation': return <OrganisationPage />;
|
||||
case 'org-signatures': return <OrgSignaturesPage />;
|
||||
case 'org-templates': return <OrgTemplatesPage />;
|
||||
case 'org-security': return <OrgSecurityPage />;
|
||||
case 'org-sharing': return <OrgSharingPage />;
|
||||
case 'plugins': return detail ? <ExtensionDetailPage key={detail} kind="plugin" id={detail} /> : <PluginsPage />;
|
||||
case 'themes': return detail ? <ExtensionDetailPage key={detail} kind="theme" id={detail} /> : <ThemesPage />;
|
||||
case 'marketplace': return detail ? <MarketplaceDetailPage key={detail} slug={detail} /> : <MarketplacePage />;
|
||||
|
||||
@@ -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<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
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(<OrganisationPage />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<OrgPage pageId="org-security" draft={draft}>
|
||||
<ExternalMailSection draft={draft} />
|
||||
<SessionsSection draft={draft} />
|
||||
<ThirdPartySection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<OrgPage pageId="org-sharing" draft={draft}>
|
||||
<SharedTagsSection draft={draft} />
|
||||
<DelegatesSection draft={draft} />
|
||||
<RecoverySection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<OrgPage pageId="org-signatures" draft={draft}>
|
||||
<SignatureSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<OrgPage pageId="org-templates" draft={draft}>
|
||||
<TemplatesSection draft={draft} />
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const shown = text ?? value.join(', ');
|
||||
const invalid = splitList(shown).filter((item) => !valid(item));
|
||||
return (
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={cn(inputClass, 'w-full', invalid.length > 0 && 'border-destructive')}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (text === null) return;
|
||||
onCommit(splitList(text));
|
||||
setText(null);
|
||||
}}
|
||||
/>
|
||||
{invalid.length > 0 && (
|
||||
<span className="mt-1 block text-xs text-destructive">Not valid, ignored when saved: {invalid.join(', ')}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-3" data-testid={`org-scope-${scope.id}`}>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block min-w-[10rem] flex-1">
|
||||
<span className={fieldLabelClass}>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={scope.label}
|
||||
className={cn(inputClass, 'w-full')}
|
||||
onChange={(e) => draft.updateScope(scope.id, (s) => { s.label = e.target.value; })}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" onClick={() => move(-1)} disabled={index === 0} aria-label="Move up" title="Move up"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={() => move(1)} disabled={index === count - 1} aria-label="Move down" title="Move down"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button type="button" aria-label="Remove" title="Remove"
|
||||
onClick={() => draft.update((p) => { p.scopes = (p.scopes ?? []).filter((s) => s.id !== scope.id); })}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<ListField
|
||||
label="Domains"
|
||||
value={scope.match.domains ?? []}
|
||||
placeholder="example.com, *.example.com"
|
||||
valid={(d) => DOMAIN_RE.test(d)}
|
||||
onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })}
|
||||
/>
|
||||
<ListField
|
||||
label="Groups"
|
||||
value={scope.match.groups ?? []}
|
||||
placeholder="sales@example.com"
|
||||
valid={(g) => GROUP_RE.test(g)}
|
||||
onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge tone="muted">{overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`}</Badge>
|
||||
<button type="button" className="underline-offset-2 hover:underline" onClick={() => draft.setScopeId(scope.id)}>
|
||||
{draft.scopeId === scope.id ? 'Selected on the Organisation pages' : 'Edit its settings on the Organisation pages'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>, 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<string, unknown> | 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>User address</span>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="anna@example.com"
|
||||
className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
|
||||
</label>
|
||||
<label className="block min-w-0 flex-1">
|
||||
<span className={fieldLabelClass}>Groups the user is a member of</span>
|
||||
<input type="text" value={groupsText} onChange={(e) => setGroupsText(e.target.value)} placeholder="sales@example.com"
|
||||
className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
{resolved && (
|
||||
<div className="space-y-3" data-testid="org-effective-policy">
|
||||
<p className="text-sm text-foreground">
|
||||
{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(', ')}.`}
|
||||
</p>
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everything is at Bulwark's defaults.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-start font-medium">Setting</th>
|
||||
<th className="px-3 py-2 text-start font-medium">Value</th>
|
||||
<th className="px-3 py-2 text-start font-medium">Set by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(([path, source]) => (
|
||||
<tr key={path} className="border-t border-border">
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{path}</td>
|
||||
<td className="px-3 py-1.5 font-mono text-xs break-all">{formatValue(valueAt(resolved.policy as unknown as Record<string, unknown>, path))}</td>
|
||||
<td className="px-3 py-1.5 text-xs">{sourceLabel(source, draft.scopes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bulwark can only see groups that have a mailbox: a user counts as a member when the group's account is in their session.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<OrgPage pageId="organisation" draft={draft} scoped={false}>
|
||||
<SettingsSection
|
||||
id="scopes"
|
||||
title="Domains and groups"
|
||||
description="Each entry gives the users of some domains or the members of some groups their own settings. Pick one at the top of the other Organisation pages to change its settings; everything it doesn't change comes from Everyone. When several apply, domains come first, then groups, each in the order of this list, and the later one wins."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => addScope('domain')}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add domains
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => addScope('group')}>
|
||||
<Plus className="h-3.5 w-3.5" /> Add groups
|
||||
</Button>
|
||||
</div>
|
||||
{draft.scopes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None yet: every user gets Everyone's settings.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{draft.scopes.map((scope, index) => (
|
||||
<ScopeCard key={scope.id} draft={draft} scope={scope} index={index} count={draft.scopes.length} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{draft.scopeId !== EVERYONE && draft.scope && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected for editing: {draft.scope.label} ({scopeMatchSummary(draft.scope)}).
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="effective"
|
||||
title="Effective policy"
|
||||
description="What a given user gets, and which entry set each value. Uses the settings on this page, saved or not."
|
||||
>
|
||||
<EffectivePolicy draft={draft} />
|
||||
</SettingsSection>
|
||||
</OrgPage>
|
||||
);
|
||||
}
|
||||
@@ -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<AdminPageId, AppIcon> = {
|
||||
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<AdminPageId, AppIcon> = {
|
||||
|
||||
const GROUP_LABELS: Record<AdminPageDef['group'], string | null> = {
|
||||
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]);
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3" data-testid="org-scope-bar">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground me-1">Applies to</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={draft.scopeId === EVERYONE}
|
||||
className={chipClass(draft.scopeId === EVERYONE)}
|
||||
onClick={() => draft.setScopeId(EVERYONE)}
|
||||
>
|
||||
Everyone
|
||||
</button>
|
||||
{draft.scopes.map((scope) => (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
aria-pressed={draft.scopeId === scope.id}
|
||||
className={chipClass(draft.scopeId === scope.id)}
|
||||
title={scopeMatchSummary(scope)}
|
||||
onClick={() => draft.setScopeId(scope.id)}
|
||||
>
|
||||
{scope.label}
|
||||
</button>
|
||||
))}
|
||||
<AdminLink href="/admin/organisation" className="ms-auto text-xs text-muted-foreground underline-offset-2 hover:underline">
|
||||
Manage domains and groups
|
||||
</AdminLink>
|
||||
</div>
|
||||
{draft.scope && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Changes here apply to {scopeMatchSummary(draft.scope)} only. Settings this scope doesn't change come from Everyone.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "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 (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{own ? <span>Everyone: {everyone}</span> : <span>From Everyone: {everyone}</span>}
|
||||
{own && (
|
||||
<button type="button" onClick={onReset} className="inline-flex items-center gap-1 underline-offset-2 hover:underline">
|
||||
<RotateCcw className="h-3 w-3" /> Use Everyone's value
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof current>) => 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 (
|
||||
<SettingItem label={def.label} description={def.description}>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{modes.includes('locked') && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!current.locked} onChange={(e) => set({ locked: e.target.checked })} />
|
||||
<Lock className="h-3 w-3" /> Lock
|
||||
</label>
|
||||
)}
|
||||
{modes.includes('hidden') && (
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!current.hidden} onChange={(e) => set({ hidden: e.target.checked })} />
|
||||
Hide
|
||||
</label>
|
||||
)}
|
||||
{modes.includes('allowedValues') && def.allowedValues?.map((option) => {
|
||||
const allowed = current.allowedValues as string[] | undefined;
|
||||
const checked = !allowed || allowed.length === 0 || allowed.includes(option);
|
||||
return (
|
||||
<label key={option} className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const base = allowed && allowed.length > 0 ? allowed : def.allowedValues!;
|
||||
const next = e.target.checked ? [...new Set([...base, option])] : base.filter((v) => v !== option);
|
||||
set({ allowedValues: next.length === def.allowedValues!.length ? undefined : next });
|
||||
}}
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InheritNote draft={draft} own={own} everyone={describe(everyone)} onReset={() => draft.setRestriction(def.key, undefined)} />
|
||||
</div>
|
||||
</SettingItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div>
|
||||
<AdminPageHeader title={page.label} description={page.description} />
|
||||
<Flash message={draft.message} onDismiss={() => draft.setMessage(null)} />
|
||||
{draft.loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{scoped && <ScopeBar draft={draft} />}
|
||||
<SectionNav />
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<AdminSaveBar
|
||||
dirty={draft.dirty}
|
||||
saving={draft.saving}
|
||||
onSave={() => void draft.save()}
|
||||
onDiscard={draft.discard}
|
||||
saveLabel="Save"
|
||||
note="Users get the change the next time Bulwark loads"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="delegates" title="Delegates" description="Whether users may give a colleague access to their mail, calendars and contacts.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="external" title="External mail" description="Label mail from senders outside the organisation and confirm before sending outside it.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="recovery" title="Deleted items" description="Let users recover deleted mail from the server's archive.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="sessions" title="Sessions" description="Sign users out after a time without activity, or a fixed time after they signed in.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="shared-tags" title="Shared tags" description="Tag names and colours that every member of a shared mailbox sees.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="signature" title="Signature" description="The signature every message starts with, filled in with each user's details.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="library" title="Organisation library" description="Templates every user can pick in the composer.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SettingsSection id="third-party" title="Third-party traffic" description="Whether Bulwark connects to anything outside your own servers.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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<S extends OrganisationSection> = OrganisationPolicy[S];
|
||||
|
||||
export interface OrgValue<T> {
|
||||
/** 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<T>(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<SettingsPolicy | null>(null);
|
||||
const [saved, setSaved] = useState<SettingsPolicy | null>(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<S extends OrganisationSection, K extends keyof SectionValues<S>>(section: S, key: K): OrgValue<SectionValues<S>[K]> {
|
||||
const everyone = org[section][key];
|
||||
if (!scope) return { value: everyone, own: true, everyone };
|
||||
const override = (scope.policy.organisation?.[section] as Partial<SectionValues<S>> | undefined)?.[key];
|
||||
return override === undefined
|
||||
? { value: everyone, own: false, everyone }
|
||||
: { value: override as SectionValues<S>[K], own: true, everyone };
|
||||
}
|
||||
|
||||
function setOrgValue<S extends OrganisationSection, K extends keyof SectionValues<S>>(section: S, key: K, value: SectionValues<S>[K]) {
|
||||
if (!scope) {
|
||||
update((draft) => {
|
||||
const target = (draft.organisation ?? clone(DEFAULT_ORGANISATION_POLICY)) as OrganisationPolicy;
|
||||
(target[section] as SectionValues<S>)[key] = value;
|
||||
draft.organisation = target;
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateScope(scope.id, (s) => {
|
||||
const organisation = (s.policy.organisation ??= {}) as Record<string, Record<string, unknown>>;
|
||||
(organisation[section] ??= {})[key as string] = value;
|
||||
});
|
||||
}
|
||||
|
||||
/** In a scope: use Everyone's value again. */
|
||||
function clearOrgValue<S extends OrganisationSection>(section: S, key: keyof SectionValues<S>) {
|
||||
if (!scope) return;
|
||||
updateScope(scope.id, (s) => {
|
||||
const values = s.policy.organisation?.[section] as Record<string, unknown> | undefined;
|
||||
if (!values) return;
|
||||
delete values[key as string];
|
||||
if (Object.keys(values).length === 0) delete (s.policy.organisation as Record<string, unknown>)[section];
|
||||
});
|
||||
}
|
||||
|
||||
function restriction(key: string): OrgValue<SettingRestriction | undefined> {
|
||||
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<boolean> {
|
||||
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<typeof useOrgPolicyDraft>;
|
||||
+66
-2
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user