feat: add the policy sections and admin pages for the enterprise features

This commit is contained in:
Linus Rath
2026-11-01 04:20:02 +01:00
parent 84de2395f0
commit 123018f322
22 changed files with 755 additions and 10 deletions
@@ -13,6 +13,10 @@ 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 { OrgLabelsPage } from '../_pages/org-labels';
import { OrgCalendarsPage } from '../_pages/org-calendars';
import { OrgNoticesPage } from '../_pages/org-notices';
import { OrgDevicesPage } from '../_pages/org-devices';
import { PluginsPage } from '../_pages/plugins';
import { ThemesPage } from '../_pages/themes';
import { MarketplacePage } from '../_pages/marketplace';
@@ -51,6 +55,10 @@ export default function AdminPage() {
case 'org-templates': return <OrgTemplatesPage />;
case 'org-security': return <OrgSecurityPage />;
case 'org-sharing': return <OrgSharingPage />;
case 'org-labels': return <OrgLabelsPage />;
case 'org-calendars': return <OrgCalendarsPage />;
case 'org-notices': return <OrgNoticesPage />;
case 'org-devices': return <OrgDevicesPage />;
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 />;
+18
View File
@@ -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 { CompanyCalendarsSection } from '@/components/admin/org/sections/company-calendars-section';
import { FreeBusySection } from '@/components/admin/org/sections/free-busy-section';
import { OutOfOfficeCalendarSection } from '@/components/admin/org/sections/out-of-office-calendar-section';
export function OrgCalendarsPage() {
const draft = useOrgPolicyDraft('org-calendars');
return (
<OrgPage pageId="org-calendars" draft={draft}>
<CompanyCalendarsSection draft={draft} />
<FreeBusySection draft={draft} />
<OutOfOfficeCalendarSection draft={draft} />
</OrgPage>
);
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { DevicesSection } from '@/components/admin/org/sections/devices-section';
import { OfflineSection } from '@/components/admin/org/sections/offline-section';
export function OrgDevicesPage() {
const draft = useOrgPolicyDraft('org-devices');
return (
<OrgPage pageId="org-devices" draft={draft}>
<DevicesSection draft={draft} />
<OfflineSection draft={draft} />
</OrgPage>
);
}
+14
View File
@@ -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 { LabelsSection } from '@/components/admin/org/sections/labels-section';
export function OrgLabelsPage() {
const draft = useOrgPolicyDraft('org-labels');
return (
<OrgPage pageId="org-labels" draft={draft}>
<LabelsSection draft={draft} />
</OrgPage>
);
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { AnnouncementSection } from '@/components/admin/org/sections/announcement-section';
import { RetentionSection } from '@/components/admin/org/sections/retention-section';
export function OrgNoticesPage() {
const draft = useOrgPolicyDraft('org-notices');
return (
<OrgPage pageId="org-notices" draft={draft}>
<AnnouncementSection draft={draft} />
<RetentionSection draft={draft} />
</OrgPage>
);
}
+2
View File
@@ -3,6 +3,7 @@
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 { PhishingReportSection } from '@/components/admin/org/sections/phishing-report-section';
import { SessionsSection } from '@/components/admin/org/sections/sessions-section';
import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section';
@@ -11,6 +12,7 @@ export function OrgSecurityPage() {
return (
<OrgPage pageId="org-security" draft={draft}>
<ExternalMailSection draft={draft} />
<PhishingReportSection draft={draft} />
<SessionsSection draft={draft} />
<ThirdPartySection draft={draft} />
</OrgPage>
@@ -3,12 +3,14 @@
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';
import { VacationTemplatesSection } from '@/components/admin/org/sections/vacation-templates-section';
export function OrgTemplatesPage() {
const draft = useOrgPolicyDraft('org-templates');
return (
<OrgPage pageId="org-templates" draft={draft}>
<TemplatesSection draft={draft} />
<VacationTemplatesSection draft={draft} />
</OrgPage>
);
}
+8
View File
@@ -7,7 +7,9 @@ import {
ArrowLeft,
ArrowUpCircle,
Activity,
Bell,
Building2,
CalendarDays,
ChevronRight,
KeyRound,
LayoutDashboard,
@@ -23,8 +25,10 @@ import {
Share2,
Shield,
ShieldCheck,
Smartphone,
Store,
SwatchBook,
Tag,
X,
type AppIcon,
} from '@/components/icons';
@@ -54,6 +58,10 @@ const PAGE_ICONS: Record<AdminPageId, AppIcon> = {
'org-templates': LayoutTemplate,
'org-security': ShieldCheck,
'org-sharing': Share2,
'org-labels': Tag,
'org-calendars': CalendarDays,
'org-notices': Bell,
'org-devices': Smartphone,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package D:
* An announcement across the apps.
*/
export function AnnouncementSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="announcement" title="Announcement" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package B:
* ICS feeds every user in scope sees read-only.
*/
export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="company-calendars" title="Company calendars" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package D:
* Device gates: downloads, export, printing, install prompt, push text, watermark.
*/
export function DevicesSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="devices" title="Downloads, printing and notifications" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package B:
* A group that gets free/busy access to every user's default calendar.
*/
export function FreeBusySection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="free-busy" title="Free/busy sharing" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package A:
* Classification labels, their default, and whether a label is required.
*/
export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="labels" title="Classification labels" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package F:
* Whether users may keep mail on their device (devices.noOffline).
*/
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="offline" title="Offline mode" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package B:
* Whether an out-of-office reply blocks the calendar (restriction vacationBlockCalendar).
*/
export function OutOfOfficeCalendarSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="out-of-office" title="Out-of-office" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package A:
* Report phishing: where reports go and in which format.
*/
export function PhishingReportSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="phishing" title="Phishing reports" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package D:
* How long Trash and Junk keep mail, as set in the mail server.
*/
export function RetentionSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="retention" title="Trash and Junk" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,13 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/**
* Placeholder from the enterprise-pack foundation, replaced by package D:
* Out-of-office reply texts users can pick.
*/
export function VacationTemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return <SettingsSection id="vacation-templates" title="Out-of-office replies" description="Not set up yet.">{null}</SettingsSection>;
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_ORGANISATION_POLICY,
MIN_FEED_REFRESH_MINUTES,
mergeOrganisation,
sanitizeOrganisationPartial,
sanitizeOrganisationPolicy,
} from '../organisation-policy';
import { resolvePolicy } from '../policy-scopes';
import { DEFAULT_POLICY } from '../types';
// The enterprise-pack sections of policy.json: everything off by default,
// hand-written input sanitised, and the merge rules across scopes.
const label = (id: string, extra: Record<string, unknown> = {}) => ({ id, name: id.toUpperCase(), ...extra });
describe('enterprise sections: defaults and sanitising', () => {
it('keeps everything off when nothing is set', () => {
const org = sanitizeOrganisationPolicy({});
expect(org.phishingReport).toEqual({ address: '', format: 'forward' });
expect(org.labels).toEqual({ list: [], defaultId: '', required: false, extraHeader: '' });
expect(org.announcement.text).toEqual({});
expect(Object.values(org.devices).every((v) => v === false)).toBe(true);
expect(org.calendars).toEqual({ feeds: [], freeBusyGroup: '' });
expect(org.retention).toEqual({ trashDays: 0, junkDays: 0 });
});
it('takes a valid phishing address and format, and refuses anything else', () => {
expect(sanitizeOrganisationPolicy({ phishingReport: { address: ' SOC@Example.org ', format: 'arf' } }).phishingReport)
.toEqual({ address: 'soc@example.org', format: 'arf' });
expect(sanitizeOrganisationPolicy({ phishingReport: { address: 'not an address', format: 'pdf' } }).phishingReport)
.toEqual(DEFAULT_ORGANISATION_POLICY.phishingReport);
});
it('cleans labels: no line breaks or quotes in header values, a fallback colour, unique ids', () => {
const { labels } = sanitizeOrganisationPolicy({ labels: {
list: [
label('conf', { marking: 'CONFIDENTIAL"\r\nBcc: x@evil.test', color: 'red', sensitivity: 'Company-Confidential', subjectPrefix: '[Conf]\n' }),
label('conf'),
{ id: 'bad id!', name: 'x' },
{ id: 'noname' },
],
defaultId: 'conf',
required: true,
extraHeader: 'X-Classification',
} });
expect(labels.list).toHaveLength(1);
expect(labels.list[0].marking).toBe('CONFIDENTIAL Bcc: x@evil.test');
expect(labels.list[0].marking).not.toMatch(/[\r\n"]/);
expect(labels.list[0].subjectPrefix).toBe('[Conf]');
expect(labels.list[0].color).toBe('#6b7280');
expect(labels).toMatchObject({ defaultId: 'conf', required: true, extraHeader: 'X-Classification' });
});
it('refuses an extra header that would replace a real one', () => {
for (const name of ['Subject', 'bcc', 'SIO-Label', 'Content-Type', 'X Bad']) {
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: name } }).labels.extraHeader).toBe('');
}
});
it('keeps announcement text per locale and normalises times', () => {
const { announcement } = sanitizeOrganisationPolicy({ announcement: {
id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' },
fallbackLocale: 'en', startsAt: '2026-11-01T20:00:00+01:00', endsAt: 'tomorrow', dismissible: false,
} });
expect(announcement.text).toEqual({ en: 'Maintenance tonight', 'pt-BR': 'Manutenção' });
expect(announcement.startsAt).toBe('2026-11-01T19:00:00.000Z');
expect(announcement.endsAt).toBe('');
expect(announcement).toMatchObject({ id: 'maint-1', severity: 'warning', dismissible: false });
});
it('takes http and https feeds (webcal as https), and a refresh of at least an hour', () => {
const { calendars } = sanitizeOrganisationPolicy({ calendars: {
feeds: [
{ id: 'hol', name: 'Holidays', url: 'webcal://cal.example.org/holidays.ics', refreshMinutes: 5 },
{ id: 'intra', name: 'Intranet', url: 'http://intranet.local/team.ics', color: '#ABCDEF', refreshMinutes: 240 },
{ id: 'js', name: 'Bad', url: 'javascript:alert(1)' },
],
freeBusyGroup: 'All-Staff@example.org',
} });
expect(calendars.feeds.map((f) => f.id)).toEqual(['hol', 'intra']);
expect(calendars.feeds[0].url).toBe('https://cal.example.org/holidays.ics');
expect(calendars.feeds[0].refreshMinutes).toBe(MIN_FEED_REFRESH_MINUTES);
expect(calendars.feeds[1]).toMatchObject({ color: '#abcdef', refreshMinutes: 240 });
expect(calendars.freeBusyGroup).toBe('all-staff@example.org');
});
it('keeps device switches only as booleans', () => {
const { devices } = sanitizeOrganisationPolicy({ devices: { noDownloads: true, noPrint: 'yes', watermark: 1 } });
expect(devices.noDownloads).toBe(true);
expect(devices.noPrint).toBe(false);
expect(devices.watermark).toBe(false);
});
});
describe('enterprise sections: merging scopes', () => {
const base = sanitizeOrganisationPolicy({
labels: { list: [label('pub'), label('int')], defaultId: 'int', required: true, extraHeader: 'X-Class' },
announcement: { id: 'all', severity: 'info', text: { en: 'For everyone' }, endsAt: '2026-12-01T00:00:00Z' },
vacationTemplates: { library: [{ id: 'a', name: 'Company' }] },
calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics' }] },
});
it('replaces the label scheme as a whole and clears a default the new list lacks', () => {
const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ labels: { list: [label('vsnfd')] } }));
expect(merged.labels.list.map((l) => l.id)).toEqual(['vsnfd']);
expect(merged.labels.defaultId).toBe('');
expect(merged.labels.extraHeader).toBe('');
expect(merged.labels.required).toBe(false);
});
it('drops "required" when a scope leaves no label to pick', () => {
const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ labels: { required: true } }));
expect(merged.labels.list).toEqual([]);
expect(merged.labels.required).toBe(false);
});
it("doesn't mix a scope's announcement with Everyone's", () => {
const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ announcement: { id: 'dept', text: { en: 'Only us' } } }));
expect(merged.announcement).toMatchObject({ id: 'dept', text: { en: 'Only us' }, endsAt: '' });
});
it('adds a scope\'s out-of-office templates and company calendars to Everyone\'s', () => {
const merged = mergeOrganisation(base, sanitizeOrganisationPartial({
vacationTemplates: { library: [{ id: 'b', name: 'Sales' }] },
calendars: { feeds: [{ id: 'sales', name: 'Sales events', url: 'https://cal.example.org/s.ics' }] },
}));
expect(merged.vacationTemplates.library.map((t) => t.id)).toEqual(['a', 'b']);
expect(merged.calendars.feeds.map((f) => f.id)).toEqual(['hol', 'sales']);
});
it('applies through resolvePolicy for a group scope', () => {
const policy = {
...DEFAULT_POLICY,
organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics' }] } },
scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {
organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'https://cal.example.org/t.ics' }] }, devices: { noDownloads: true } },
} }],
};
const member = resolvePolicy(policy as never, { email: 'a@example.org', groups: ['team@example.org'] }).policy.organisation!;
const other = resolvePolicy(policy as never, { email: 'c@example.org', groups: [] }).policy.organisation!;
expect(member.calendars.feeds.map((f) => f.id)).toEqual(['hol', 'team']);
expect(member.devices.noDownloads).toBe(true);
expect(other.calendars.feeds.map((f) => f.id)).toEqual(['hol']);
expect(other.devices.noDownloads).toBe(false);
});
});
+51 -1
View File
@@ -22,6 +22,10 @@ export const ADMIN_PAGE_IDS = [
'org-templates',
'org-security',
'org-sharing',
'org-labels',
'org-calendars',
'org-notices',
'org-devices',
'plugins',
'themes',
'marketplace',
@@ -157,16 +161,18 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
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: 'vacation-templates', title: 'Out-of-office replies', keywords: 'vacation away auto reply out of office absence deputy placeholder' },
],
},
{
id: 'org-security',
label: 'Security',
description: 'Warnings about mail from and to outside the organisation, sign-out times and outside connections',
description: 'Warnings about mail from and to outside the organisation, phishing reports, 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: 'phishing', title: 'Phishing reports', keywords: 'phishing report spam abuse security desk arf forward' },
{ 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' },
],
@@ -183,6 +189,50 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
{ id: 'recovery', title: 'Recover deleted items', keywords: 'recover restore deleted archive enterprise' },
],
},
{
id: 'org-labels',
label: 'Classification',
description: 'Labels such as Internal or Confidential that users pick when they write, and that arrive with the message',
group: 'organisation',
keywords: 'classification label sensitivity confidential internal public vs-nfd sio-label header subject prefix required',
sections: [
{ id: 'labels', title: 'Classification labels', keywords: 'labels presets default required header prefix colour' },
],
},
{
id: 'org-calendars',
label: 'Calendars',
description: 'Company calendars every user sees, free/busy sharing and out-of-office in the calendar',
group: 'organisation',
keywords: 'calendar ics feed subscription holidays company free busy availability out of office vacation',
sections: [
{ id: 'company-calendars', title: 'Company calendars', keywords: 'ics feed url subscription holidays read-only colour' },
{ id: 'free-busy', title: 'Free/busy sharing', keywords: 'free busy availability group share find a time' },
{ id: 'out-of-office', title: 'Out-of-office', keywords: 'vacation away busy block calendar lock' },
],
},
{
id: 'org-notices',
label: 'Notices',
description: 'An announcement across the apps, and how long Trash and Junk keep mail',
group: 'organisation',
keywords: 'announcement banner notice maintenance message retention trash junk days',
sections: [
{ id: 'announcement', title: 'Announcement', keywords: 'banner message severity start end dismiss languages' },
{ id: 'retention', title: 'Trash and Junk', keywords: 'retention days deleted expunge trash junk' },
],
},
{
id: 'org-devices',
label: 'Devices',
description: 'What users may save, print or keep on their devices',
group: 'organisation',
keywords: 'device download export print watermark install pwa push notification offline data loss',
sections: [
{ id: 'devices', title: 'Downloads, printing and notifications', keywords: 'download attachment export print watermark install prompt push sender subject' },
{ id: 'offline', title: 'Offline mode', keywords: 'offline cache device keep mail outbox' },
],
},
{
id: 'plugins',
label: 'Plugins',
+333 -9
View File
@@ -20,6 +20,57 @@ export type SignatureField = (typeof SIGNATURE_FIELDS)[number];
/** A template of the admin library: read-only for users. */
export type OrgTemplate = Pick<EmailTemplate, 'id' | 'name' | 'subject' | 'body' | 'isHTML' | 'category'>;
/** RFC 2156 Sensitivity values (what Outlook shows); '' writes none. */
export const LABEL_SENSITIVITIES = ['', 'Personal', 'Private', 'Company-Confidential'] as const;
export type LabelSensitivity = (typeof LABEL_SENSITIVITIES)[number];
/** A classification label the admin defines (item 3 of the enterprise pack). */
export interface ClassificationLabel {
id: string;
name: string;
/** #rrggbb */
color: string;
description: string;
/** The SIO-Label `marking` (RFC 7444); the name when empty. */
marking: string;
sensitivity: LabelSensitivity;
/** Added in front of the subject, e.g. "[Confidential]"; '' for none. */
subjectPrefix: string;
/** Value of the admin's extra header for this label; '' writes none. */
extraHeaderValue: string;
}
/** An out-of-office reply text of the admin library. */
export interface VacationTemplate {
id: string;
name: string;
category: string;
subject: string;
/** Plain text with placeholders like {name}, {end}, {deputy}. */
text: string;
/** Optional HTML version; '' for none. */
html: string;
}
/** An ICS feed every user in scope sees read-only. */
export interface CompanyFeed {
id: string;
name: string;
url: string;
/** #rrggbb */
color: string;
refreshMinutes: number;
}
export const ANNOUNCEMENT_SEVERITIES = ['info', 'warning', 'critical'] as const;
export type AnnouncementSeverity = (typeof ANNOUNCEMENT_SEVERITIES)[number];
export const PHISHING_REPORT_FORMATS = ['forward', 'arf'] as const;
export type PhishingReportFormat = (typeof PHISHING_REPORT_FORMATS)[number];
/** The shortest refresh interval for company calendars (each refresh may cost an upload). */
export const MIN_FEED_REFRESH_MINUTES = 60;
export interface OrganisationPolicy {
externalMail: {
/** "External" chip on mail from senders outside the organisation domains. */
@@ -62,6 +113,61 @@ export interface OrganisationPolicy {
/** How far back "Recover deleted items" lists; 0 is everything the server keeps. */
days: number;
};
phishingReport: {
/** Where "Report phishing" sends; '' turns the action off. */
address: string;
/** Forward the message as an attachment, or an ARF report (RFC 5965). */
format: PhishingReportFormat;
};
/** A scope that sets this section replaces it as a whole. */
labels: {
/** [] turns classification labels off. */
list: ClassificationLabel[];
/** The label new messages start with; '' for none. */
defaultId: string;
/** Sending needs a label. Dropped when the list is empty. */
required: boolean;
/** Name of an extra header each label may set (e.g. X-Classification); '' for none. */
extraHeader: string;
};
/** A scope that sets this section replaces it as a whole. */
announcement: {
/** Changes when the admin saves a new announcement, so dismissals reset. */
id: string;
severity: AnnouncementSeverity;
/** Locale (en, de, pt-BR) to plain text with links; {} shows nothing. */
text: Record<string, string>;
/** The locale shown when the user's isn't in `text`. */
fallbackLocale: string;
/** ISO times; '' for no limit. */
startsAt: string;
endsAt: string;
dismissible: boolean;
};
vacationTemplates: {
/** Adds up across scopes by id, like templates.library. */
library: VacationTemplate[];
};
retention: {
/** Days the server keeps mail in Trash and Junk, as the admin set it in the mail server; 0 shows no line. */
trashDays: number;
junkDays: number;
};
devices: {
noDownloads: boolean;
noExport: boolean;
noPrint: boolean;
noInstallPrompt: boolean;
pushWithoutContent: boolean;
watermark: boolean;
noOffline: boolean;
};
calendars: {
/** Adds up across scopes by id. */
feeds: CompanyFeed[];
/** A group address that gets free/busy access to every user's default calendar; '' is off. */
freeBusyGroup: string;
};
}
export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
@@ -71,8 +177,21 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
templates: { library: [] },
session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 },
recovery: { days: 0 },
phishingReport: { address: '', format: 'forward' },
labels: { list: [], defaultId: '', required: false, extraHeader: '' },
announcement: { id: '', severity: 'info', text: {}, fallbackLocale: 'en', startsAt: '', endsAt: '', dismissible: true },
vacationTemplates: { library: [] },
retention: { trashDays: 0, junkDays: 0 },
devices: {
noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
pushWithoutContent: false, watermark: false, noOffline: false,
},
calendars: { feeds: [], freeBusyGroup: '' },
};
/** Sections a scope replaces as a whole instead of per leaf. */
const WHOLE_SECTIONS = new Set<OrganisationSection>(['labels', 'announcement']);
export type OrganisationSection = keyof OrganisationPolicy;
export type DeepPartial<T> = {
@@ -157,6 +276,144 @@ function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined {
return out;
}
const EMAIL_RE = /^[^\s@<>"(),;:]+@[^\s@<>"(),;:]+\.[^\s@<>"(),;:]+$/;
const COLOR_RE = /^#[0-9a-f]{6}$/i;
const ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
const LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,75}$/;
// Headers the extra classification header may not be: they carry the
// message itself, or the labels Bulwark writes anyway.
const RESERVED_HEADERS = new Set([
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to',
'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition',
'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results',
'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to',
]);
const MAX_LABELS = 50;
const MAX_FEEDS = 50;
/** Text for a header value or a one-line field: no line breaks or control characters. */
function line(value: unknown, max: number): string | undefined {
const t = text(value);
// eslint-disable-next-line no-control-regex
return t === undefined ? undefined : t.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
}
function email(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim().toLowerCase();
return v === '' || EMAIL_RE.test(v) ? v.slice(0, 320) : undefined;
}
function color(value: unknown, fallback: string): string {
return typeof value === 'string' && COLOR_RE.test(value.trim()) ? value.trim().toLowerCase() : fallback;
}
function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
}
function isoTime(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim();
if (v === '') return '';
const t = Date.parse(v);
return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
}
function itemId(value: unknown): string | null {
return typeof value === 'string' && ID_RE.test(value.trim()) ? value.trim() : null;
}
/** Keeps the items with a valid, unique id, capped at `max`. */
function byIdList<T>(value: unknown, max: number, build: (item: Record<string, unknown>, id: string) => T | null): T[] | undefined {
if (!Array.isArray(value)) return undefined;
const seen = new Set<string>();
const out: T[] = [];
for (const item of value) {
if (!isRecord(item)) continue;
const id = itemId(item.id);
if (!id || seen.has(id)) continue;
const built = build(item, id);
if (!built) continue;
seen.add(id);
out.push(built);
if (out.length >= max) break;
}
return out;
}
function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
return byIdList(value, MAX_LABELS, (item, id) => {
const name = line(item.name, 100);
if (!name) return null;
return {
id,
name,
color: color(item.color, '#6b7280'),
description: (text(item.description) ?? '').slice(0, 1000),
// A quoted header parameter: no double quotes or backslashes.
marking: (line(item.marking, 200) ?? '').replace(/["\\]/g, ''),
sensitivity: oneOf(item.sensitivity, LABEL_SENSITIVITIES) ?? '',
subjectPrefix: line(item.subjectPrefix, 60) ?? '',
extraHeaderValue: line(item.extraHeaderValue, 500) ?? '',
};
});
}
function headerName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim();
if (v === '') return '';
return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase()) ? v : undefined;
}
function sanitizeLocaleText(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) return undefined;
const out: Record<string, string> = {};
for (const [locale, body] of Object.entries(value)) {
const t = text(body)?.trim();
if (LOCALE_RE.test(locale) && t) out[locale] = t.slice(0, 4000);
}
return out;
}
function sanitizeVacationLibrary(value: unknown): VacationTemplate[] | undefined {
return byIdList(value, MAX_LIBRARY, (item, id) => {
const name = line(item.name, 200);
if (!name) return null;
return {
id,
name,
category: line(item.category, 100) ?? '',
subject: line(item.subject, 511) ?? '',
text: text(item.text) ?? '',
html: text(item.html) ?? '',
};
});
}
function feedUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const v = value.trim().replace(/^webcals?:\/\//i, 'https://');
try {
const u = new URL(v);
return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname ? u.toString() : null;
} catch {
return null;
}
}
function sanitizeFeeds(value: unknown): CompanyFeed[] | undefined {
return byIdList(value, MAX_FEEDS, (item, id) => {
const name = line(item.name, 100);
const url = feedUrl(item.url);
if (!name || !url) return null;
const minutes = count(item.refreshMinutes, 7 * 24 * 60) ?? MIN_FEED_REFRESH_MINUTES;
return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes) };
});
}
/**
* The parts of `raw` that are valid organisation settings, without
* defaults filled in. Used for scope overrides, where a missing key means
@@ -204,33 +461,100 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
const rec = section('recovery');
if (rec) assign('recovery', { days: count(rec.days, 3650) });
const phish = section('phishingReport');
if (phish) assign('phishingReport', {
address: email(phish.address),
format: oneOf(phish.format, PHISHING_REPORT_FORMATS),
});
const lab = section('labels');
if (lab) assign('labels', {
list: sanitizeLabels(lab.list),
defaultId: typeof lab.defaultId === 'string' ? (itemId(lab.defaultId) ?? '') : undefined,
required: bool(lab.required),
extraHeader: headerName(lab.extraHeader),
});
const ann = section('announcement');
if (ann) assign('announcement', {
id: typeof ann.id === 'string' ? (itemId(ann.id) ?? '') : undefined,
severity: oneOf(ann.severity, ANNOUNCEMENT_SEVERITIES),
text: sanitizeLocaleText(ann.text),
fallbackLocale: typeof ann.fallbackLocale === 'string' && LOCALE_RE.test(ann.fallbackLocale) ? ann.fallbackLocale : undefined,
startsAt: isoTime(ann.startsAt),
endsAt: isoTime(ann.endsAt),
dismissible: bool(ann.dismissible),
});
const vac = section('vacationTemplates');
if (vac) assign('vacationTemplates', { library: sanitizeVacationLibrary(vac.library) });
const ret = section('retention');
if (ret) assign('retention', { trashDays: count(ret.trashDays, 3650), junkDays: count(ret.junkDays, 3650) });
const dev = section('devices');
if (dev) assign('devices', {
noDownloads: bool(dev.noDownloads),
noExport: bool(dev.noExport),
noPrint: bool(dev.noPrint),
noInstallPrompt: bool(dev.noInstallPrompt),
pushWithoutContent: bool(dev.pushWithoutContent),
watermark: bool(dev.watermark),
noOffline: bool(dev.noOffline),
});
const cal = section('calendars');
if (cal) assign('calendars', { feeds: sanitizeFeeds(cal.feeds), freeBusyGroup: email(cal.freeBusyGroup) });
return out;
}
/** Merges `override` into `base` per leaf of plain objects; arrays and values replace. */
/** Arrays that add up across scopes by id instead of replacing. */
const ADDITIVE_LISTS: Record<string, string> = { templates: 'library', vacationTemplates: 'library', calendars: 'feeds' };
/** Settings that can't hold together after a merge: a default label that isn't in the list, a required label with none to pick. */
function normaliseMerged(policy: OrganisationPolicy): OrganisationPolicy {
const labels = policy.labels;
if (!labels) return policy;
const hasDefault = labels.list.some((label) => label.id === labels.defaultId);
const required = labels.required && labels.list.length > 0;
if (hasDefault === (labels.defaultId !== '') && required === labels.required) return policy;
return { ...policy, labels: { ...labels, defaultId: hasDefault ? labels.defaultId : '', required } };
}
/**
* Merges `override` into `base` per leaf of plain objects; arrays and values
* replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, and the
* sections in WHOLE_SECTIONS are replaced as a whole (a scope's own label
* scheme or announcement never mixes with the inherited one), with the
* built-in defaults for keys the override leaves out.
*/
export function mergeOrganisation(
base: OrganisationPolicy,
override: DeepPartial<OrganisationPolicy>,
): OrganisationPolicy {
const out = { ...base } as Record<string, Record<string, unknown>>;
const defaults = DEFAULT_ORGANISATION_POLICY as unknown as Record<string, Record<string, unknown>>;
for (const [sectionKey, values] of Object.entries(override)) {
if (!isRecord(values) || !isRecord(out[sectionKey])) continue;
const merged = { ...out[sectionKey] };
const merged = WHOLE_SECTIONS.has(sectionKey as OrganisationSection)
? { ...defaults[sectionKey] }
: { ...out[sectionKey] };
for (const [key, value] of Object.entries(values)) {
if (value === undefined) continue;
if (sectionKey === 'templates' && key === 'library' && Array.isArray(value)) {
// The one array that adds up: a scope's templates join the ones it
// inherits; the same id replaces the inherited template.
const byId = new Map((merged.library as OrgTemplate[]).map((t) => [t.id, t]));
for (const template of value as OrgTemplate[]) byId.set(template.id, template);
merged.library = [...byId.values()];
if (ADDITIVE_LISTS[sectionKey] === key && Array.isArray(value)) {
// A scope's items join the ones it inherits; the same id replaces
// the inherited item.
const byId = new Map((merged[key] as Array<{ id: string }>).map((t) => [t.id, t]));
for (const item of value as Array<{ id: string }>) byId.set(item.id, item);
merged[key] = [...byId.values()];
} else {
merged[key] = value;
}
}
out[sectionKey] = merged;
}
return out as unknown as OrganisationPolicy;
return normaliseMerged(out as unknown as OrganisationPolicy);
}
/** A complete organisation policy from anything: defaults for whatever is missing or invalid. */
+10
View File
@@ -126,6 +126,16 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
{ key: 'deletedItemsRecovery', label: 'Recover deleted items', description: 'Hide removes Settings > Recover deleted items and its link in Trash, so users can no longer restore mail they deleted for good.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{
key: 'vacationBlockCalendar',
label: 'Out-of-office blocks the calendar',
description: 'Lock: an out-of-office reply with dates puts a busy event in the user\'s calendar, or never does, as set here. Users see the choice as set by the organisation.',
category: 'Calendar',
type: 'boolean',
page: 'org-calendars',
modes: ['locked'],
defaultValue: true,
},
];
export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {