mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat: add the policy sections and admin pages for the enterprise features
This commit is contained in:
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user