mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: let admins define classification labels with presets
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
// Organisation > Classification: presets, the list editor, default and
|
||||
// required, the extra header, the VS-NfD warning, and a scope that has its
|
||||
// own scheme as a whole (labels merge as one section).
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { LabelsSection } from '../sections/labels-section';
|
||||
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
|
||||
import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy';
|
||||
import { resolvePolicy } from '@/lib/admin/policy-scopes';
|
||||
import { DEFAULT_POLICY } from '@/lib/admin/types';
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: {},
|
||||
scopes: [{ id: 'gov', label: 'Gov', match: { domains: ['gov.example'] }, policy: {} }],
|
||||
};
|
||||
|
||||
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
|
||||
function Harness() {
|
||||
const draft = useOrgPolicyDraft('test');
|
||||
latest = draft;
|
||||
if (draft.loading) return null;
|
||||
return <LabelsSection draft={draft} />;
|
||||
}
|
||||
|
||||
async function renderSection() {
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(screen.getByTestId('org-labels-vsnfd-warning')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
latest = null;
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Classification section', () => {
|
||||
it('says plainly that a label does not make mail meet the VS-NfD rules', async () => {
|
||||
await renderSection();
|
||||
const warning = screen.getByTestId('org-labels-vsnfd-warning').textContent ?? '';
|
||||
expect(warning).toContain('VSA § 50, § 55');
|
||||
expect(warning).toContain('BSI-approved encryption');
|
||||
expect(warning).toContain('are not approved products');
|
||||
});
|
||||
|
||||
it('starts from the generic preset and saves it with the default', async () => {
|
||||
await renderSection();
|
||||
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'generic' } });
|
||||
expect(screen.getAllByTestId('org-label-item')).toHaveLength(4);
|
||||
fireEvent.click(screen.getByTestId('org-labels-required'));
|
||||
await act(async () => { await latest!.save(); });
|
||||
const labels = (puts[0].organisation as { labels: { list: Array<{ id: string; subjectPrefix: string }>; defaultId: string; required: boolean } }).labels;
|
||||
expect(labels.list.map((l) => l.id)).toEqual(['public', 'internal', 'confidential', 'strictly-confidential']);
|
||||
expect(labels.defaultId).toBe('internal');
|
||||
expect(labels.required).toBe(true);
|
||||
// What the server keeps of it is the same.
|
||||
expect(sanitizeOrganisationPolicy(puts[0].organisation).labels.list[2].subjectPrefix).toBe('[Confidential]');
|
||||
});
|
||||
|
||||
it('offers no grade above VS-NfD in the German preset', async () => {
|
||||
await renderSection();
|
||||
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
|
||||
const names = latest!.orgValue('labels', 'list').value.map((l) => l.name);
|
||||
expect(names).toEqual(['OFFEN', 'VS-NfD']);
|
||||
expect(latest!.orgValue('labels', 'list').value[1].marking).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
|
||||
});
|
||||
|
||||
it('clears the default and the requirement with the last label', async () => {
|
||||
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true } } };
|
||||
await renderSection();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove A' }));
|
||||
expect(latest!.orgValue('labels', 'defaultId').value).toBe('');
|
||||
expect(latest!.orgValue('labels', 'required').value).toBe(false);
|
||||
});
|
||||
|
||||
it('stores the extra header name only when it is a header Bulwark doesn’t write', async () => {
|
||||
await renderSection();
|
||||
const field = screen.getByTestId('org-labels-extra-header');
|
||||
fireEvent.change(field, { target: { value: 'Sensitivity' } });
|
||||
expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes('not a header Bulwark writes itself'))).toBe(true);
|
||||
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('');
|
||||
fireEvent.change(field, { target: { value: 'X-Classification' } });
|
||||
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('X-Classification');
|
||||
});
|
||||
|
||||
it('gives a scope its own scheme as a whole, and back', async () => {
|
||||
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true, extraHeader: 'X-Class' } } };
|
||||
await renderSection();
|
||||
act(() => latest!.setScopeId('gov'));
|
||||
expect(screen.getByTestId('org-labels-inherited').textContent).toContain('A');
|
||||
fireEvent.click(screen.getByTestId('org-labels-own'));
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toEqual({
|
||||
list: [{ id: 'a', name: 'A', color: '#000000', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }],
|
||||
defaultId: 'a', required: true, extraHeader: 'X-Class',
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
|
||||
const resolved = resolvePolicy({ ...DEFAULT_POLICY, ...(latest!.policy as object) } as never, { email: 'x@gov.example', groups: [] }).policy;
|
||||
expect(resolved.organisation!.labels.list.map((l) => l.id)).toEqual(['offen', 'vs-nfd']);
|
||||
expect(resolved.organisation!.labels.defaultId).toBe('offen');
|
||||
expect(resolved.organisation!.labels.extraHeader).toBe('X-Class');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use Everyone/ }));
|
||||
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,338 @@
|
||||
'use client';
|
||||
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { useEffect, useId, useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Plus, RotateCcw, Trash2 } from '@/components/icons';
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DEFAULT_ORGANISATION_POLICY,
|
||||
LABEL_SENSITIVITIES,
|
||||
isLabelExtraHeaderName,
|
||||
type ClassificationLabel,
|
||||
type LabelSensitivity,
|
||||
type OrganisationPolicy,
|
||||
} from '@/lib/admin/organisation-policy';
|
||||
import { LABEL_PRESETS } from '@/lib/classification/presets';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
type Labels = OrganisationPolicy['labels'];
|
||||
const KEYS = ['list', 'defaultId', 'required', 'extraHeader'] as const;
|
||||
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/classification-labels';
|
||||
/** Same cap as the sanitiser. */
|
||||
const MAX_LABELS = 50;
|
||||
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
|
||||
const SENSITIVITY_NAMES: Record<LabelSensitivity, string> = {
|
||||
'': 'None',
|
||||
Personal: 'Personal',
|
||||
Private: 'Private',
|
||||
'Company-Confidential': 'Company-Confidential (Outlook: Confidential)',
|
||||
};
|
||||
|
||||
/** The VS-NfD warning, here and in the docs. */
|
||||
function VsNfdWarning() {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200" data-testid="org-labels-vsnfd-warning">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
A label only marks the message. It doesn't make mail meet the VS-NfD handling rules (VSA § 50, § 55): that
|
||||
needs approved IT and BSI-approved encryption. Bulwark's S/MIME and PGP plugins and Stalwart are not
|
||||
approved products. A received label can be forged by anyone: Bulwark shows it, it never relaxes anything because of it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelEditor({ label, index, count, extraHeader, onChange, onMove, onRemove }: {
|
||||
label: ClassificationLabel;
|
||||
index: number;
|
||||
count: number;
|
||||
extraHeader: string;
|
||||
onChange: (label: ClassificationLabel) => void;
|
||||
onMove: (delta: -1 | 1) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(!label.name);
|
||||
const set = (patch: Partial<ClassificationLabel>) => onChange({ ...label, ...patch });
|
||||
const name = label.name || 'New label';
|
||||
return (
|
||||
<li className="rounded-md border border-border" data-testid="org-label-item">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
||||
<span className="h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: label.color }} aria-hidden="true" />
|
||||
<span className={cn('truncate font-medium', !label.name && 'text-muted-foreground')}>{name}</span>
|
||||
{label.subjectPrefix && <span className="hidden truncate text-xs text-muted-foreground sm:inline">{label.subjectPrefix}</span>}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" disabled={index === 0} onClick={() => onMove(-1)} aria-label={`Move ${name} up`}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" disabled={index === count - 1} onClick={() => onMove(1)} aria-label={`Move ${name} down`}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 px-2 hover:text-red-600" aria-label={`Remove ${name}`}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!label.name.trim() && <p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A label without a name is not saved. Give it one.</p>}
|
||||
{open && (
|
||||
<div className="grid grid-cols-1 gap-3 border-t border-border p-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
|
||||
<input id={`${id}-name`} data-testid="org-label-name" className={inputClass} value={label.name} maxLength={100} onChange={(e) => set({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-color`} className={labelClass}>Colour</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id={`${id}-color`} type="color" className="h-9 w-12 shrink-0 cursor-pointer rounded border border-input bg-background" value={label.color} onChange={(e) => set({ color: e.target.value.toLowerCase() })} />
|
||||
<span className="font-mono text-xs text-muted-foreground">{label.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor={`${id}-description`} className={labelClass}>Description</label>
|
||||
<input id={`${id}-description`} className={inputClass} value={label.description} maxLength={1000} placeholder="Shown under the label in the composer's picker" onChange={(e) => set({ description: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-marking`} className={labelClass}>Marking (SIO-Label header)</label>
|
||||
<input id={`${id}-marking`} data-testid="org-label-marking" className={inputClass} value={label.marking} maxLength={200} placeholder={label.name.toUpperCase() || 'CONFIDENTIAL'} onChange={(e) => set({ marking: e.target.value.replace(/["\\]/g, '') })} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">Empty uses the name. Received mail is matched by this text.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-sensitivity`} className={labelClass}>Sensitivity header</label>
|
||||
<select id={`${id}-sensitivity`} data-testid="org-label-sensitivity" className={inputClass} value={label.sensitivity} onChange={(e) => set({ sensitivity: e.target.value as LabelSensitivity })}>
|
||||
{LABEL_SENSITIVITIES.map((value) => <option key={value} value={value}>{SENSITIVITY_NAMES[value]}</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Outlook shows it above the message.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-prefix`} className={labelClass}>Subject prefix</label>
|
||||
<input id={`${id}-prefix`} data-testid="org-label-prefix" className={inputClass} value={label.subjectPrefix} maxLength={60} placeholder="[Confidential]" onChange={(e) => set({ subjectPrefix: e.target.value })} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">Added in front of the subject when the mail goes out, once.</p>
|
||||
</div>
|
||||
{extraHeader && (
|
||||
<div>
|
||||
<label htmlFor={`${id}-extra`} className={labelClass}>{extraHeader} value</label>
|
||||
<input id={`${id}-extra`} data-testid="org-label-extra" className={inputClass} value={label.extraHeaderValue} maxLength={500} onChange={(e) => set({ extraHeaderValue: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder from the enterprise-pack foundation, replaced by package A:
|
||||
* Classification labels, their default, and whether a label is required.
|
||||
* Organisation > Classification (enterprise pack item 3): the labels users
|
||||
* pick in the composer (policy.json `organisation.labels`), the default,
|
||||
* whether a label is required, and an extra header for gateways. A scope
|
||||
* that sets labels replaces Everyone's scheme as a whole, so the scope
|
||||
* either uses Everyone's labels or has all of its own.
|
||||
*/
|
||||
export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
return <SettingsSection id="labels" title="Classification labels" description="Not set up yet.">{null}</SettingsSection>;
|
||||
const id = useId();
|
||||
const inScope = !!draft.scope;
|
||||
const scopeOwn = inScope && KEYS.some((key) => draft.orgValue('labels', key).own);
|
||||
const editing = !inScope || scopeOwn;
|
||||
const value = <K extends keyof Labels>(key: K): Labels[K] => {
|
||||
const v = draft.orgValue('labels', key);
|
||||
if (!inScope) return v.value;
|
||||
if (!scopeOwn) return v.everyone;
|
||||
return v.own ? v.value : DEFAULT_ORGANISATION_POLICY.labels[key];
|
||||
};
|
||||
const everyone = {
|
||||
list: draft.orgValue('labels', 'list').everyone,
|
||||
};
|
||||
const list = value('list');
|
||||
const defaultId = value('defaultId');
|
||||
const required = value('required');
|
||||
const extraHeader = value('extraHeader');
|
||||
|
||||
const set = <K extends keyof Labels>(key: K, next: Labels[K]) => draft.setOrgValue('labels', key, next);
|
||||
const setAll = (labels: Labels) => { for (const key of KEYS) draft.setOrgValue('labels', key, labels[key] as never); };
|
||||
const useOwn = () => setAll(JSON.parse(JSON.stringify({
|
||||
list: everyone.list,
|
||||
defaultId: draft.orgValue('labels', 'defaultId').everyone,
|
||||
required: draft.orgValue('labels', 'required').everyone,
|
||||
extraHeader: draft.orgValue('labels', 'extraHeader').everyone,
|
||||
})) as Labels);
|
||||
const useEveryone = () => { for (const key of KEYS) draft.clearOrgValue('labels', key); };
|
||||
|
||||
const setList = (next: ClassificationLabel[]) => {
|
||||
set('list', next);
|
||||
if (defaultId && !next.some((l) => l.id === defaultId)) set('defaultId', '');
|
||||
if (next.length === 0 && required) set('required', false);
|
||||
};
|
||||
const add = () => setList([...list, { id: generateUUID().slice(0, 12), name: '', color: '#6b7280', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }]);
|
||||
const change = (index: number, label: ClassificationLabel) => setList(list.map((l, i) => (i === index ? label : l)));
|
||||
const remove = (index: number) => setList(list.filter((_, i) => i !== index));
|
||||
const move = (index: number, delta: -1 | 1) => {
|
||||
const next = [...list];
|
||||
const [item] = next.splice(index, 1);
|
||||
next.splice(index + delta, 0, item);
|
||||
setList(next);
|
||||
};
|
||||
const applyPreset = (presetId: string) => {
|
||||
const preset = LABEL_PRESETS.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
set('list', JSON.parse(JSON.stringify(preset.list)));
|
||||
set('defaultId', preset.defaultId);
|
||||
};
|
||||
|
||||
// The extra header name is typed freely and stored once it is a valid name.
|
||||
const [headerText, setHeaderText] = useState(extraHeader);
|
||||
useEffect(() => { setHeaderText(extraHeader); }, [extraHeader]);
|
||||
const headerInvalid = headerText.trim() !== '' && !isLabelExtraHeaderName(headerText);
|
||||
const onHeader = (text: string) => {
|
||||
setHeaderText(text);
|
||||
const next = text.trim();
|
||||
if (next === '' || isLabelExtraHeaderName(next)) set('extraHeader', next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="labels"
|
||||
title="Classification labels"
|
||||
description="Users pick a label in the composer. Bulwark writes it into the SIO-Label header (RFC 7444), the Sensitivity header Outlook reads when the label sets one, and your extra header, and adds the subject prefix once. Received mail with a known label shows it. No labels turns this off."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<VsNfdWarning />
|
||||
|
||||
{inScope && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm" data-testid="org-labels-scope">
|
||||
<span>{scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'}</span>
|
||||
{scopeOwn ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={useEveryone}>
|
||||
<RotateCcw className="me-1 h-3.5 w-3.5" /> Use Everyone's labels
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={useOwn} data-testid="org-labels-own">
|
||||
Set labels for this scope
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label htmlFor={`${id}-preset`} className="text-sm text-muted-foreground">Start from a preset</label>
|
||||
<select
|
||||
id={`${id}-preset`}
|
||||
data-testid="org-labels-preset"
|
||||
className="h-9 max-w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
value=""
|
||||
onChange={(e) => applyPreset(e.target.value)}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{LABEL_PRESETS.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">Replaces the list below.</span>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="org-labels-empty">No labels. The composer shows no label picker.</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="org-labels-list">
|
||||
{list.map((label, index) => (
|
||||
<LabelEditor
|
||||
key={label.id}
|
||||
label={label}
|
||||
index={index}
|
||||
count={list.length}
|
||||
extraHeader={extraHeader}
|
||||
onChange={(l) => change(index, l)}
|
||||
onMove={(delta) => move(index, delta)}
|
||||
onRemove={() => remove(index)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={add} disabled={list.length >= MAX_LABELS} data-testid="org-labels-add">
|
||||
<Plus className="me-1 h-4 w-4" /> Add label
|
||||
</Button>
|
||||
{list.length >= MAX_LABELS && <span className="text-xs text-muted-foreground">At most {MAX_LABELS} labels.</span>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SettingItem label="Default label" description="New messages start with it. Replies and forwards keep the original's label.">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-56"
|
||||
data-testid="org-labels-default"
|
||||
value={defaultId}
|
||||
disabled={list.length === 0}
|
||||
onChange={(e) => set('defaultId', e.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{list.filter((l) => l.name.trim()).map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Require a label"
|
||||
description="Sending asks for a label until one is picked. A quick reply without one opens the full composer."
|
||||
>
|
||||
<ToggleSwitch checked={required} disabled={list.length === 0} onChange={(v) => set('required', v)} testId="org-labels-required" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Extra header"
|
||||
description="A header of your own each label can set, for a mail gateway or DLP (for example X-Classification, or msip_labels for Microsoft Purview). Empty writes none."
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<input
|
||||
type="text"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder="X-Classification"
|
||||
value={headerText}
|
||||
onChange={(e) => onHeader(e.target.value)}
|
||||
aria-invalid={headerInvalid || undefined}
|
||||
data-testid="org-labels-extra-header"
|
||||
className={cn(inputClass, 'sm:w-56', headerInvalid && 'border-red-500')}
|
||||
/>
|
||||
{headerInvalid && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400" role="alert">
|
||||
Letters, digits and dashes only, and not a header Bulwark writes itself.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div data-testid="org-labels-inherited">
|
||||
{everyone.list.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everyone has no labels.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{everyone.list.map((l) => (
|
||||
<li key={l.id} className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: l.color }} aria-hidden="true" />
|
||||
{l.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Calendar invitations, read receipts, phishing reports and unsubscribe mails go out without a label.{' '}
|
||||
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
How labels work
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isLabelExtraHeaderName,
|
||||
DEFAULT_ORGANISATION_POLICY,
|
||||
MIN_FEED_REFRESH_MINUTES,
|
||||
mergeOrganisation,
|
||||
@@ -58,6 +59,12 @@ describe('enterprise sections: defaults and sanitising', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the header Bulwark keeps the label of a draft in', () => {
|
||||
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe('');
|
||||
expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false);
|
||||
expect(isLabelExtraHeaderName('X-Classification')).toBe(true);
|
||||
});
|
||||
|
||||
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: ' ' },
|
||||
|
||||
@@ -287,7 +287,7 @@ 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',
|
||||
'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'importance', 'x-priority', 'disposition-notification-to',
|
||||
]);
|
||||
const MAX_LABELS = 50;
|
||||
const MAX_FEEDS = 50;
|
||||
@@ -367,6 +367,12 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */
|
||||
export function isLabelExtraHeaderName(value: string): boolean {
|
||||
const v = value.trim();
|
||||
return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase());
|
||||
}
|
||||
|
||||
function headerName(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const v = value.trim();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The two label schemes the admin page offers to start from (enterprise
|
||||
* pack item 3). Markings follow RFC 7444 (SIO-Label), Sensitivity values
|
||||
* RFC 2156 (what Outlook shows).
|
||||
*
|
||||
* The German public-sector preset stops at VS-NfD on purpose: higher grades
|
||||
* (VS-VERTRAULICH and up) need IT approved for them (VSA § 50 Abs. 4) and
|
||||
* must not be offered in a mail client that isn't. Even VS-NfD needs
|
||||
* approved IT and BSI-approved encryption outside an approved network
|
||||
* (§ 50, § 55); the label only marks the message.
|
||||
*/
|
||||
|
||||
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
|
||||
|
||||
export interface LabelPreset {
|
||||
id: 'generic' | 'german-public-sector';
|
||||
name: string;
|
||||
defaultId: string;
|
||||
list: ClassificationLabel[];
|
||||
}
|
||||
|
||||
const label = (l: Partial<ClassificationLabel> & Pick<ClassificationLabel, 'id' | 'name' | 'color' | 'marking'>): ClassificationLabel => ({
|
||||
description: '',
|
||||
sensitivity: '',
|
||||
subjectPrefix: '',
|
||||
extraHeaderValue: '',
|
||||
...l,
|
||||
});
|
||||
|
||||
export const LABEL_PRESETS: LabelPreset[] = [
|
||||
{
|
||||
id: 'generic',
|
||||
name: 'Generic: Public / Internal / Confidential / Strictly confidential',
|
||||
defaultId: 'internal',
|
||||
list: [
|
||||
label({ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: 'Can be shared with anyone.', extraHeaderValue: 'Public' }),
|
||||
label({ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: 'For people in the organisation and those working with it.', extraHeaderValue: 'Internal' }),
|
||||
label({
|
||||
id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', sensitivity: 'Company-Confidential',
|
||||
subjectPrefix: '[Confidential]', description: 'Only for the people it is meant for.', extraHeaderValue: 'Confidential',
|
||||
}),
|
||||
label({
|
||||
id: 'strictly-confidential', name: 'Strictly confidential', color: '#dc2626', marking: 'STRICTLY CONFIDENTIAL',
|
||||
sensitivity: 'Company-Confidential', subjectPrefix: '[Strictly confidential]',
|
||||
description: 'Only for the named recipients. Don’t forward.', extraHeaderValue: 'Strictly confidential',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'german-public-sector',
|
||||
name: 'German public sector: OFFEN / VS-NfD',
|
||||
defaultId: 'offen',
|
||||
list: [
|
||||
label({ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: 'Nicht eingestuft.', extraHeaderValue: 'OFFEN' }),
|
||||
label({
|
||||
id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', sensitivity: 'Company-Confidential',
|
||||
subjectPrefix: 'VS-NfD',
|
||||
description: 'Verschlusssache – nur für den Dienstgebrauch. Nur nach der VSA behandeln und versenden.',
|
||||
extraHeaderValue: 'VS-NfD',
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user