'use client'; import { 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 = { '': 'None', Personal: 'Personal', Private: 'Private', 'Company-Confidential': 'Company-Confidential (Outlook: Confidential)', }; /** The VS-NfD warning, here and in the docs. */ function VsNfdWarning() { return (
); } 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) => onChange({ ...label, ...patch }); const name = label.name || 'New label'; return (
  • {!label.name.trim() &&

    A label without a name is not saved. Give it one.

    } {open && (
    set({ name: e.target.value })} />
    set({ color: e.target.value.toLowerCase() })} /> {label.color}
    set({ description: e.target.value })} />
    set({ marking: e.target.value.replace(/["\\]/g, '') })} />

    Empty uses the name. Received mail is matched by this text.

    Outlook shows it above the message.

    set({ subjectPrefix: e.target.value })} />

    Added in front of the subject when the mail goes out, once.

    {extraHeader && (
    set({ extraHeaderValue: e.target.value })} />
    )}
    )}
  • ); } /** * 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 }) { const id = useId(); const inScope = !!draft.scope; const scopeOwn = inScope && KEYS.some((key) => draft.orgValue('labels', key).own); const editing = !inScope || scopeOwn; const value = (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 = (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. // A new stored value (a load, a scope switch) replaces the text during // render: an effect would run after mount and could wipe what was typed. const [headerText, setHeaderText] = useState(extraHeader); const [headerFrom, setHeaderFrom] = useState(extraHeader); if (headerFrom !== extraHeader) { setHeaderFrom(extraHeader); setHeaderText(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 (
    {inScope && (
    {scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'} {scopeOwn ? ( ) : ( )}
    )} {editing ? ( <>
    Replaces the list below.
    {list.length === 0 ? (

    No labels. The composer shows no label picker.

    ) : (
      {list.map((label, index) => ( change(index, l)} onMove={(delta) => move(index, delta)} onRemove={() => remove(index)} /> ))}
    )}
    {list.length >= MAX_LABELS && At most {MAX_LABELS} labels.}
    set('required', v)} testId="org-labels-required" />
    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 && (

    A header name has no spaces or colons. Bulwark writes this one itself, or the mail server won't take it.

    )}
    ) : (
    {everyone.list.length === 0 ? (

    Everyone has no labels.

    ) : (
      {everyone.list.map((l) => (
    • ))}
    )}
    )}

    Calendar invitations, read receipts, phishing reports and unsubscribe mails go out without a label.{' '} How labels work

    ); }