diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx index 4d71528bc..9af174495 100644 --- a/app/(main)/admin/[[...segments]]/page.tsx +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -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 ; case 'org-security': return ; case 'org-sharing': return ; + case 'org-labels': return ; + case 'org-calendars': return ; + case 'org-notices': return ; + case 'org-devices': return ; case 'plugins': return detail ? : ; case 'themes': return detail ? : ; case 'marketplace': return detail ? : ; diff --git a/app/(main)/admin/_pages/org-calendars.tsx b/app/(main)/admin/_pages/org-calendars.tsx new file mode 100644 index 000000000..b1abdc451 --- /dev/null +++ b/app/(main)/admin/_pages/org-calendars.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 { 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 ( + + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-devices.tsx b/app/(main)/admin/_pages/org-devices.tsx new file mode 100644 index 000000000..a7cf2834d --- /dev/null +++ b/app/(main)/admin/_pages/org-devices.tsx @@ -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 ( + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-labels.tsx b/app/(main)/admin/_pages/org-labels.tsx new file mode 100644 index 000000000..f60056ef5 --- /dev/null +++ b/app/(main)/admin/_pages/org-labels.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 { LabelsSection } from '@/components/admin/org/sections/labels-section'; + +export function OrgLabelsPage() { + const draft = useOrgPolicyDraft('org-labels'); + return ( + + + + ); +} diff --git a/app/(main)/admin/_pages/org-notices.tsx b/app/(main)/admin/_pages/org-notices.tsx new file mode 100644 index 000000000..f5c9c819d --- /dev/null +++ b/app/(main)/admin/_pages/org-notices.tsx @@ -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 ( + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-security.tsx b/app/(main)/admin/_pages/org-security.tsx index af778d1ee..5d8f65f55 100644 --- a/app/(main)/admin/_pages/org-security.tsx +++ b/app/(main)/admin/_pages/org-security.tsx @@ -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 ( + diff --git a/app/(main)/admin/_pages/org-templates.tsx b/app/(main)/admin/_pages/org-templates.tsx index bc63b0578..084f67d91 100644 --- a/app/(main)/admin/_pages/org-templates.tsx +++ b/app/(main)/admin/_pages/org-templates.tsx @@ -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 ( + ); } diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index 8a5074ba3..9b721d17f 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -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 = { '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, diff --git a/components/admin/org/sections/announcement-section.tsx b/components/admin/org/sections/announcement-section.tsx new file mode 100644 index 000000000..b6b3f392f --- /dev/null +++ b/components/admin/org/sections/announcement-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx new file mode 100644 index 000000000..82602702a --- /dev/null +++ b/components/admin/org/sections/company-calendars-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/devices-section.tsx b/components/admin/org/sections/devices-section.tsx new file mode 100644 index 000000000..bb4864de0 --- /dev/null +++ b/components/admin/org/sections/devices-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/free-busy-section.tsx b/components/admin/org/sections/free-busy-section.tsx new file mode 100644 index 000000000..a46bc30e9 --- /dev/null +++ b/components/admin/org/sections/free-busy-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx new file mode 100644 index 000000000..fd1cc1d3c --- /dev/null +++ b/components/admin/org/sections/labels-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx new file mode 100644 index 000000000..f53ae1cb1 --- /dev/null +++ b/components/admin/org/sections/offline-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/out-of-office-calendar-section.tsx b/components/admin/org/sections/out-of-office-calendar-section.tsx new file mode 100644 index 000000000..e64fcfcc8 --- /dev/null +++ b/components/admin/org/sections/out-of-office-calendar-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/phishing-report-section.tsx b/components/admin/org/sections/phishing-report-section.tsx new file mode 100644 index 000000000..5d1b47d85 --- /dev/null +++ b/components/admin/org/sections/phishing-report-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/retention-section.tsx b/components/admin/org/sections/retention-section.tsx new file mode 100644 index 000000000..de6b8f561 --- /dev/null +++ b/components/admin/org/sections/retention-section.tsx @@ -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 {null}; +} diff --git a/components/admin/org/sections/vacation-templates-section.tsx b/components/admin/org/sections/vacation-templates-section.tsx new file mode 100644 index 000000000..c974fef78 --- /dev/null +++ b/components/admin/org/sections/vacation-templates-section.tsx @@ -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 {null}; +} diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts new file mode 100644 index 000000000..76d0cc746 --- /dev/null +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -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 = {}) => ({ 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); + }); +}); diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index 5b336e453..e0eb282bc 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -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', diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index c61bcc89c..c6c22e93f 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -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; +/** 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; + /** 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(['labels', 'announcement']); + export type OrganisationSection = keyof OrganisationPolicy; export type DeepPartial = { @@ -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(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(value: unknown, max: number, build: (item: Record, id: string) => T | null): T[] | undefined { + if (!Array.isArray(value)) return undefined; + const seen = new Set(); + 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 | undefined { + if (!isRecord(value)) return undefined; + const out: Record = {}; + 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 = { 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 { const out = { ...base } as Record>; + const defaults = DEFAULT_ORGANISATION_POLICY as unknown as Record>; 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. */ diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index a65cabdee..745ee663b 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -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[] {