diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx
index 5c41649..2b5b88b 100644
--- a/src/components/layout/MainContent.tsx
+++ b/src/components/layout/MainContent.tsx
@@ -66,6 +66,14 @@ const AccountLocksPage = lazyFeature(
() => import('@/features/lock/AccountLocksPage'),
(m) => m.AccountLocksPage,
);
+const LegalHoldsPage = lazyFeature(
+ () => import('@/features/hold/LegalHoldsPage'),
+ (m) => m.LegalHoldsPage,
+);
+const HeldNotice = lazyFeature(
+ () => import('@/features/hold/HeldNotice'),
+ (m) => m.HeldNotice,
+);
const AccountLockBanner = lazyFeature(
() => import('@/features/lock/AccountLockBanner'),
(m) => m.AccountLockBanner,
@@ -140,6 +148,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'AccountLocks') {
return ;
}
+ if (componentName === 'LegalHolds') {
+ return ;
+ }
return (
Unknown component: {componentName}
@@ -202,6 +213,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (viewName === 'x:Account/User') {
return (
diff --git a/src/features/hold/HeldNotice.tsx b/src/features/hold/HeldNotice.tsx
new file mode 100644
index 0000000..8d47bdf
--- /dev/null
+++ b/src/features/hold/HeldNotice.tsx
@@ -0,0 +1,57 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: on a person's account page, the legal holds covering it, by any
+ * route (audit-hold-lock spec, LH-14). Shown only to those who may see
+ * holds; everyone else, the account's owner included, sees nothing.
+ */
+
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Scale } from 'lucide-react';
+import { useAccountStore } from '@/stores/accountStore';
+import { fetchHoldsOn, HOLD_CHANGED, type LegalHold } from './legalHold';
+
+export function HeldNotice({ accountId }: { accountId: string }) {
+ const { t } = useTranslation();
+ const canGet = useAccountStore((s) => s.hasPermission('sysLegalHoldGet'));
+ const [holds, setHolds] = useState
([]);
+ const [fetches, setFetches] = useState(0);
+
+ useEffect(() => {
+ const refetch = () => setFetches((n) => n + 1);
+ window.addEventListener(HOLD_CHANGED, refetch);
+ return () => window.removeEventListener(HOLD_CHANGED, refetch);
+ }, []);
+
+ useEffect(() => {
+ if (!canGet) return;
+ const controller = new AbortController();
+ fetchHoldsOn(accountId, controller.signal)
+ .then((list) => {
+ if (!controller.signal.aborted) setHolds(list);
+ })
+ // An older server without holds: say nothing
+ .catch(() => undefined);
+ return () => controller.abort();
+ }, [accountId, canGet, fetches]);
+
+ if (!canGet || holds.length === 0) return null;
+ return (
+
+
+
+
+ {t('hold.heldTitle', 'Held: nothing in this account can be destroyed, and deleting it keeps its data.')}
+
+
+ {holds.map((h) => (h.reference ? `${h.name} (${h.reference})` : h.name)).join(', ')}
+
+
+
+ );
+}
diff --git a/src/features/hold/HoldDialogs.tsx b/src/features/hold/HoldDialogs.tsx
new file mode 100644
index 0000000..66cc9d6
--- /dev/null
+++ b/src/features/hold/HoldDialogs.tsx
@@ -0,0 +1,361 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: placing, widening and releasing a legal hold (audit-hold-lock
+ * spec, LH-1, LH-3, LH-10). Every change needs a reason (AU-12); a release
+ * also needs the case name typed, since it lets things expire.
+ */
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader2, Scale, Unlock, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Textarea } from '@/components/ui/textarea';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { ObjectPicker } from '@/components/common/ObjectPicker';
+import { useObjectLabel } from '@/lib/objectOptions';
+import { useSchemaStore } from '@/stores/schemaStore';
+import { toast } from '@/hooks/use-toast';
+import {
+ draftProblem,
+ EMPTY_SCOPE,
+ narrowingProblem,
+ placeHold,
+ releaseHold,
+ updateHold,
+ type HoldDraft,
+ type HoldScope,
+ type LegalHold,
+} from './legalHold';
+
+type ScopeKind = 'accounts' | 'groups' | 'domains' | 'tenants';
+
+const PICKER_VIEW: Record = {
+ accounts: 'x:Account/User',
+ groups: 'x:Account/Group',
+ domains: 'x:Domain',
+ tenants: 'x:Tenant',
+};
+
+/** The object each kind's ids are labelled from. */
+const LABEL_OBJECT: Record = {
+ accounts: 'x:Account',
+ groups: 'x:Account',
+ domains: 'x:Domain',
+ tenants: 'x:Tenant',
+};
+
+function ScopeChip({ kind, id, onRemove }: { kind: ScopeKind; id: string; onRemove?: () => void }) {
+ const schema = useSchemaStore((s) => s.schema);
+ const { label } = useObjectLabel(LABEL_OBJECT[kind], id, schema!);
+ return (
+
+ {label ?? id}
+ {onRemove && (
+
+
+
+ )}
+
+ );
+}
+
+/** A hold's scope, read-only: the chips, or "the whole server". */
+export function ScopeSummary({ scope }: { scope: HoldScope }) {
+ const { t } = useTranslation();
+ if (scope.server) return {t('hold.wholeServer', 'Every account on the server')} ;
+ const kinds: ScopeKind[] = ['accounts', 'groups', 'domains', 'tenants'];
+ return (
+
+ {kinds.flatMap((kind) => scope[kind].map((id) => ))}
+
+ );
+}
+
+function ScopeEditor({
+ scope,
+ locked,
+ onChange,
+}: {
+ scope: HoldScope;
+ /** Entries already held, which can't be taken out (LH-3). */
+ locked?: HoldScope;
+ onChange: (s: HoldScope) => void;
+}) {
+ const { t } = useTranslation();
+ const schema = useSchemaStore((s) => s.schema);
+ const rows: { kind: ScopeKind; label: string; placeholder: string }[] = [
+ { kind: 'accounts', label: t('hold.scope.accounts', 'People'), placeholder: t('hold.addAccount', 'Add a person') },
+ { kind: 'groups', label: t('hold.scope.groups', 'Groups'), placeholder: t('hold.addGroup', 'Add a group') },
+ { kind: 'domains', label: t('hold.scope.domains', 'Domains'), placeholder: t('hold.addDomain', 'Add a domain') },
+ { kind: 'tenants', label: t('hold.scope.tenants', 'Tenants'), placeholder: t('hold.addTenant', 'Add a tenant') },
+ ];
+ return (
+
+
+ onChange({ ...scope, server: v === true })}
+ />
+ {t('hold.wholeServer', 'Every account on the server')}
+
+ {!scope.server &&
+ schema &&
+ rows.map(({ kind, label, placeholder }) => (
+
+
{label}
+
+ {scope[kind].map((id) => (
+ onChange({ ...scope, [kind]: scope[kind].filter((x) => x !== id) })
+ }
+ />
+ ))}
+ {
+ if (id && !scope[kind].includes(id)) onChange({ ...scope, [kind]: [...scope[kind], id] });
+ }}
+ placeholder={placeholder}
+ />
+
+
+ ))}
+
+ {t(
+ 'hold.scopeHint',
+ 'Domains, groups and tenants count as they are now: an account added later is held, and one that leaves stays held.',
+ )}
+
+
+ );
+}
+
+function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const { t } = useTranslation();
+ return (
+
+ {t('hold.reason', 'Reason')}
+
+ );
+}
+
+function toDraft(hold?: LegalHold): HoldDraft {
+ return {
+ name: hold?.name ?? '',
+ reference: hold?.reference ?? '',
+ description: hold?.description ?? '',
+ scope: hold ? { ...hold.scope } : { ...EMPTY_SCOPE },
+ from: hold?.from ?? '',
+ to: hold?.to ?? '',
+ };
+}
+
+export function HoldDialog({
+ existing,
+ onClose,
+ onDone,
+}: {
+ existing?: LegalHold;
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const { t } = useTranslation();
+ const [draft, setDraft] = useState(() => toDraft(existing));
+ const [reason, setReason] = useState('');
+ const [busy, setBusy] = useState(false);
+ const problem = draftProblem(draft) ?? (existing ? narrowingProblem(existing, draft) : null);
+ const ready = !problem && reason.trim().length > 0;
+ const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p }));
+
+ const save = async () => {
+ setBusy(true);
+ try {
+ if (existing) await updateHold(existing.id, draft, reason);
+ else await placeHold(draft, reason);
+ toast({
+ title: existing
+ ? t('hold.updated', 'Hold changed')
+ : t('hold.placed', 'Hold placed. What it covers can no longer be destroyed.'),
+ });
+ onDone();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: existing ? t('hold.updateFailed', 'Nothing changed') : t('hold.placeFailed', 'The hold wasn’t placed'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ !open && onClose()}>
+
+
+
+ {existing
+ ? t('hold.editTitle', 'Change {{name}}', { name: existing.name })
+ : t('hold.placeTitle', 'Place a legal hold')}
+
+
+ {existing
+ ? t(
+ 'hold.editBody',
+ 'A hold can grow, never shrink: more accounts, or a wider date range. To hold less, release it and place a new one.',
+ )
+ : t(
+ 'hold.placeBody',
+ 'Nothing it covers can be destroyed: deletions are kept in the archive with no expiry, by any user, delegate or administrator, and deleted accounts keep their data. People aren’t told.',
+ )}
+
+
+
+
+
+ {t('hold.name', 'Case name')}
+ patch({ name: e.target.value })} />
+
+
+ {t('hold.reference', 'Reference (optional)')}
+ patch({ reference: e.target.value })}
+ />
+
+
+
+ {t('hold.description', 'Description (optional)')}
+
+
+
{t('hold.covers', 'What it covers')}
+
patch({ scope })} />
+
+
+
{t('hold.range', 'Dates (optional, UTC)')}
+
+
+ {t('hold.from', 'From')}
+ patch({ from: e.target.value })} />
+
+
+ {t('hold.to', 'To')}
+ patch({ to: e.target.value })} />
+
+
+
+ {t(
+ 'hold.rangeHint',
+ 'Leave both empty to hold everything. With dates, mail counts by when it arrived and events by when they start; contacts, files and filter scripts are held whole. Without an end, mail still to come is held too.',
+ )}
+
+
+ {problem &&
{problem}
}
+
+
+
+
+ {t('hold.cancel', 'Cancel')}
+
+ void save()} disabled={busy || !ready}>
+ {busy ? : !existing && }
+ {existing ? t('hold.save', 'Save') : t('hold.placeGo', 'Place hold')}
+
+
+
+
+ );
+}
+
+export function ReleaseDialog({ hold, onClose, onDone }: { hold: LegalHold; onClose: () => void; onDone: () => void }) {
+ const { t } = useTranslation();
+ const [typed, setTyped] = useState('');
+ const [reason, setReason] = useState('');
+ const [busy, setBusy] = useState(false);
+ const ready = typed.trim() === hold.name.trim() && reason.trim().length > 0;
+
+ const run = async () => {
+ setBusy(true);
+ try {
+ await releaseHold(hold.id, reason);
+ toast({ title: t('hold.released', 'Hold released') });
+ onDone();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: t('hold.releaseFailed', 'The hold is still in place'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+ !open && onClose()}>
+
+
+ {t('hold.releaseTitle', 'Release {{name}}', { name: hold.name })}
+
+ {t(
+ 'hold.releaseBody',
+ 'What only this hold kept gets its normal deadline back, and never less than 30 days from now, so a mistaken release can be undone by placing a new hold. Anything another hold covers stays held. A released hold stays listed, and can’t be put back.',
+ )}
+
+
+
+
+ {t('hold.typeName', 'Type the case name to confirm: {{name}}', { name: hold.name })}
+
+ setTyped(e.target.value)} />
+
+
+
+
+ {t('hold.cancel', 'Cancel')}
+
+ void run()} disabled={busy || !ready}>
+ {busy ? : }
+ {t('hold.releaseGo', 'Release')}
+
+
+
+
+ );
+}
diff --git a/src/features/hold/LegalHoldsPage.tsx b/src/features/hold/LegalHoldsPage.tsx
new file mode 100644
index 0000000..e9d44d4
--- /dev/null
+++ b/src/features/hold/LegalHoldsPage.tsx
@@ -0,0 +1,186 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: Management › Compliance › Legal holds (audit-hold-lock spec, LH-1
+ * to LH-14). Active holds with what each covers and keeps; released ones
+ * stay listed, read-only, for the audit trail.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Pencil, Scale, Unlock } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import { PageHeader } from '@/components/common/PageHeader';
+import { LoadingFallback } from '@/components/common/LoadingFallback';
+import { useAccountStore } from '@/stores/accountStore';
+import { fetchHolds, formatSize, HOLD_CHANGED, HoldsUnavailable, type LegalHold } from './legalHold';
+import { HoldDialog, ReleaseDialog, ScopeSummary } from './HoldDialogs';
+
+type Load = { kind: 'loading' } | { kind: 'ready'; holds: LegalHold[] } | { kind: 'error'; message: string };
+
+function rangeText(t: (k: string, f: string, o?: Record) => string, hold: LegalHold): string {
+ if (!hold.from && !hold.to) return t('hold.allDates', 'Everything, whatever its date');
+ if (hold.from && hold.to) return t('hold.between', '{{from}} to {{to}}', { from: hold.from, to: hold.to });
+ if (hold.from) return t('hold.since', 'From {{from}}, and mail still to come', { from: hold.from });
+ return t('hold.until', 'Up to {{to}}', { to: hold.to });
+}
+
+export function LegalHoldsPage() {
+ const { t } = useTranslation();
+ const canCreate = useAccountStore((s) => s.hasPermission('sysLegalHoldCreate'));
+ const canUpdate = useAccountStore((s) => s.hasPermission('sysLegalHoldUpdate'));
+ const [load, setLoad] = useState({ kind: 'loading' });
+ const [fetches, setFetches] = useState(0);
+ const [placing, setPlacing] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [releasing, setReleasing] = useState(null);
+ const refetch = useCallback(() => setFetches((n) => n + 1), []);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ fetchHolds(controller.signal)
+ .then((holds) => {
+ if (!controller.signal.aborted) setLoad({ kind: 'ready', holds });
+ })
+ .catch((e: unknown) => {
+ if (controller.signal.aborted) return;
+ setLoad({
+ kind: 'error',
+ message:
+ e instanceof HoldsUnavailable
+ ? t('hold.unavailable', 'This server can’t place legal holds.')
+ : e instanceof Error
+ ? e.message
+ : String(e),
+ });
+ });
+ return () => controller.abort();
+ }, [fetches, t]);
+
+ const done = () => {
+ setPlacing(false);
+ setEditing(null);
+ setReleasing(null);
+ window.dispatchEvent(new Event(HOLD_CHANGED));
+ refetch();
+ };
+
+ const active = load.kind === 'ready' ? load.holds.filter((h) => !h.released) : [];
+ const released = load.kind === 'ready' ? load.holds.filter((h) => h.released) : [];
+
+ return (
+
+
setPlacing(true)}>
+
+ {t('hold.placeAction', 'Place a hold…')}
+
+ )
+ }
+ />
+
+ {load.kind === 'loading' && }
+ {load.kind === 'error' && (
+ {load.message}
+ )}
+ {load.kind === 'ready' && active.length === 0 && (
+
+ {t('hold.none', 'No hold is in place.')}
+
+ )}
+ {active.map((hold) => (
+
+
+
+
+
+ {hold.name}
+ {hold.reference && {hold.reference} }
+
+
+ {t('hold.placedBy', 'Placed {{when}} by {{who}}', {
+ when: new Date(hold.placedAt).toLocaleString(),
+ who: hold.placedBy,
+ })}
+
+ {hold.description &&
{hold.description}
}
+
+ {canUpdate && (
+
+
setEditing(hold)}>
+
+ {t('hold.widenAction', 'Widen…')}
+
+
setReleasing(hold)}>
+
+ {t('hold.releaseAction', 'Release…')}
+
+
+ )}
+
+
+
+
{t('hold.covers', 'What it covers')}
+
+
{rangeText(t, hold)}
+
+
+
{t('hold.keeps', 'What it keeps now')}
+
+ {[
+ hold.accountsCovered === 1
+ ? t('hold.oneAccount', '1 account')
+ : t('hold.accounts', '{{n}} accounts', { n: String(hold.accountsCovered ?? 0) }),
+ hold.itemsHeld === 1
+ ? t('hold.oneItem', '1 deleted item kept')
+ : t('hold.items', '{{n}} deleted items kept', { n: String(hold.itemsHeld ?? 0) }),
+ formatSize(hold.sizeHeld),
+ ].join(' · ')}
+
+
+
+
+ ))}
+
+ {released.length > 0 && (
+
+
{t('hold.releasedHeading', 'Released')}
+ {released.map((hold) => (
+
+
+ {hold.name}
+ {hold.reference && {hold.reference} }
+
+
+ {t('hold.releasedBy', 'Placed {{placed}} by {{placedBy}}; released {{when}} by {{who}}: {{why}}', {
+ placed: new Date(hold.placedAt).toLocaleDateString(),
+ placedBy: hold.placedBy,
+ when: hold.releasedAt ? new Date(hold.releasedAt).toLocaleString() : '',
+ who: hold.releasedBy ?? '',
+ why: hold.releaseReason ?? '',
+ })}
+
+
+ ))}
+
+ )}
+
+ {placing && setPlacing(false)} onDone={done} />}
+ {editing && setEditing(null)} onDone={done} />}
+ {releasing && setReleasing(null)} onDone={done} />}
+
+ );
+}
diff --git a/src/features/hold/legalHold.test.ts b/src/features/hold/legalHold.test.ts
new file mode 100644
index 0000000..f7e428a
--- /dev/null
+++ b/src/features/hold/legalHold.test.ts
@@ -0,0 +1,81 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ dayFromServer,
+ draftProblem,
+ EMPTY_SCOPE,
+ formatSize,
+ fromToServer,
+ narrowingProblem,
+ toToServer,
+ type HoldDraft,
+ type LegalHold,
+} from './legalHold';
+
+const draft = (patch: Partial = {}): HoldDraft => ({
+ name: 'Matter 4411',
+ reference: '',
+ description: '',
+ scope: { ...EMPTY_SCOPE, accounts: ['b'] },
+ from: '',
+ to: '',
+ ...patch,
+});
+
+const hold = (patch: Partial = {}): LegalHold => ({
+ id: 'a',
+ name: 'Matter 4411',
+ reference: '',
+ description: '',
+ scope: { ...EMPTY_SCOPE, accounts: ['b'], domains: ['d'] },
+ from: '2026-01-01',
+ to: '2026-06-30',
+ placedAt: '2026-09-27T00:00:00Z',
+ placedBy: 'admin',
+ released: false,
+ releasedAt: null,
+ releasedBy: null,
+ releaseReason: null,
+ ...patch,
+});
+
+describe('legal hold drafts', () => {
+ it('needs a name, a scope and a forward range', () => {
+ expect(draftProblem(draft())).toBeNull();
+ expect(draftProblem(draft({ name: ' ' }))).toMatch(/name/);
+ expect(draftProblem(draft({ scope: { ...EMPTY_SCOPE } }))).toMatch(/covers/);
+ expect(draftProblem(draft({ scope: { ...EMPTY_SCOPE, server: true } }))).toBeNull();
+ expect(draftProblem(draft({ from: '2026-02-01', to: '2026-01-01' }))).toMatch(/after/);
+ });
+
+ it('only widens (LH-3)', () => {
+ const current = hold();
+ const same = { ...draft(), scope: { ...current.scope }, from: current.from, to: current.to };
+ expect(narrowingProblem(current, same)).toBeNull();
+ expect(narrowingProblem(current, { ...same, from: '2025-01-01', to: '' })).toBeNull();
+ expect(narrowingProblem(current, { ...same, from: '2026-03-01' })).toMatch(/widened/);
+ expect(narrowingProblem(current, { ...same, to: '2026-05-01' })).toMatch(/widened/);
+ expect(narrowingProblem(current, { ...same, scope: { ...current.scope, domains: [] } })).toMatch(/taken out/);
+ expect(narrowingProblem(hold({ from: '', to: '' }), { ...same, from: '2026-01-01' })).toMatch(/widened/);
+ });
+
+ it('sends whole UTC days', () => {
+ expect(fromToServer('2026-01-01')).toBe('2026-01-01T00:00:00Z');
+ expect(toToServer('2026-06-30')).toBe('2026-06-30T23:59:59Z');
+ expect(fromToServer('')).toBeNull();
+ expect(dayFromServer('2026-06-30T23:59:59Z')).toBe('2026-06-30');
+ expect(dayFromServer(null)).toBe('');
+ });
+
+ it('shows sizes as people read them', () => {
+ expect(formatSize(0)).toBe('0 B');
+ expect(formatSize(512)).toBe('512 B');
+ expect(formatSize(1536)).toBe('1.5 KB');
+ expect(formatSize(20 * 1024 * 1024)).toBe('20 MB');
+ });
+});
diff --git a/src/features/hold/legalHold.ts b/src/features/hold/legalHold.ts
new file mode 100644
index 0000000..0d7e4d0
--- /dev/null
+++ b/src/features/hold/legalHold.ts
@@ -0,0 +1,263 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: `inbuxa:LegalHold` over JMAP (audit-hold-lock spec, LH-1 to
+ * LH-14). A hold names a case and what it covers; its range and scope only
+ * widen, releasing it is final, and it is never deleted. Every change says
+ * why (AU-12). Only server administrators reach it (LH-13).
+ */
+
+import { getAccountId, jmapRequest } from '@/services/jmap/client';
+
+const CAPABILITY = 'urn:inbuxa:jmap';
+const OBJECT = 'inbuxa:LegalHold';
+
+/** Fired on window after a hold changes, so every view of holds refreshes. */
+export const HOLD_CHANGED = 'inbuxa:hold-changed';
+
+export interface HoldScope {
+ server: boolean;
+ accounts: string[];
+ groups: string[];
+ domains: string[];
+ tenants: string[];
+}
+
+export interface LegalHold {
+ id: string;
+ name: string;
+ reference: string;
+ description: string;
+ scope: HoldScope;
+ /** `yyyy-mm-dd`, UTC; empty for an open start. */
+ from: string;
+ /** `yyyy-mm-dd`, UTC; empty for an open end. */
+ to: string;
+ placedAt: string;
+ placedBy: string;
+ released: boolean;
+ releasedAt: string | null;
+ releasedBy: string | null;
+ releaseReason: string | null;
+ /** What it keeps (LH-9), when asked for. */
+ accountsCovered?: number;
+ itemsHeld?: number;
+ sizeHeld?: number;
+}
+
+/** A hold's editable parts, as the form holds them. */
+export interface HoldDraft {
+ name: string;
+ reference: string;
+ description: string;
+ scope: HoldScope;
+ from: string;
+ to: string;
+}
+
+export const EMPTY_SCOPE: HoldScope = { server: false, accounts: [], groups: [], domains: [], tenants: [] };
+
+/** The server doesn't offer holds (an older server). */
+export class HoldsUnavailable extends Error {
+ constructor(reason: string) {
+ super(`Legal holds unavailable: ${reason}`);
+ }
+}
+
+function methodError(name: string | undefined, result: unknown): Error {
+ const r = result as { type?: string; description?: string } | undefined;
+ if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new HoldsUnavailable(r.type);
+ return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
+}
+
+function setError(result: unknown, bucket: string, key: string): Error | null {
+ const failed = (result as Record | undefined>)[
+ bucket
+ ]?.[key];
+ return failed ? new Error(failed.description ?? failed.type ?? 'Not saved') : null;
+}
+
+/** A day as the form holds it, as the start of that day in UTC. */
+export function fromToServer(day: string): string | null {
+ return day ? `${day}T00:00:00Z` : null;
+}
+
+/** A day as the form holds it, as the end of that day in UTC. */
+export function toToServer(day: string): string | null {
+ return day ? `${day}T23:59:59Z` : null;
+}
+
+/** A server date as the form's `yyyy-mm-dd`, UTC. */
+export function dayFromServer(value: unknown): string {
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
+}
+
+const ids = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v) => typeof v === 'string') : []);
+const text = (value: unknown): string => (typeof value === 'string' ? value : '');
+const count = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
+
+function fromServer(item: Record): LegalHold {
+ const scope = (item.scope ?? {}) as Record;
+ return {
+ id: text(item.id),
+ name: text(item.name),
+ reference: text(item.reference),
+ description: text(item.description),
+ scope: {
+ server: scope.server === true,
+ accounts: ids(scope.accounts),
+ groups: ids(scope.groups),
+ domains: ids(scope.domains),
+ tenants: ids(scope.tenants),
+ },
+ from: dayFromServer(item.from),
+ to: dayFromServer(item.to),
+ placedAt: text(item.placedAt),
+ placedBy: text(item.placedBy),
+ released: item.released === true,
+ releasedAt: typeof item.releasedAt === 'string' ? item.releasedAt : null,
+ releasedBy: typeof item.releasedBy === 'string' ? item.releasedBy : null,
+ releaseReason: typeof item.releaseReason === 'string' ? item.releaseReason : null,
+ accountsCovered: count(item.accountsCovered),
+ itemsHeld: count(item.itemsHeld),
+ sizeHeld: count(item.sizeHeld),
+ };
+}
+
+const BASE_PROPERTIES = [
+ 'id',
+ 'name',
+ 'reference',
+ 'description',
+ 'scope',
+ 'from',
+ 'to',
+ 'placedAt',
+ 'placedBy',
+ 'released',
+ 'releasedAt',
+ 'releasedBy',
+ 'releaseReason',
+];
+const SUMMARY_PROPERTIES = ['accountsCovered', 'itemsHeld', 'sizeHeld'];
+
+async function get(args: Record, signal?: AbortSignal): Promise {
+ const responses = await jmapRequest(
+ [[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ...args }, '0']],
+ signal,
+ [CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/get`) throw methodError(name, result);
+ return ((result as { list?: Record[] }).list ?? []).map(fromServer);
+}
+
+/** Every hold, released ones included, with what each keeps (LH-9). */
+export function fetchHolds(signal?: AbortSignal): Promise {
+ return get({ ids: null, properties: [...BASE_PROPERTIES, ...SUMMARY_PROPERTIES] }, signal);
+}
+
+/** The active holds covering one account, by any route (LH-14). */
+export function fetchHoldsOn(accountId: string, signal?: AbortSignal): Promise {
+ return get({ coveringAccount: accountId, properties: ['id', 'name', 'reference'] }, signal);
+}
+
+function scopeToServer(scope: HoldScope): Record {
+ return {
+ server: scope.server,
+ accounts: scope.accounts,
+ groups: scope.groups,
+ domains: scope.domains,
+ tenants: scope.tenants,
+ };
+}
+
+function draftToServer(draft: HoldDraft): Record {
+ return {
+ name: draft.name.trim(),
+ reference: draft.reference.trim() || null,
+ description: draft.description.trim() || null,
+ scope: scopeToServer(draft.scope),
+ from: fromToServer(draft.from),
+ to: toToServer(draft.to),
+ };
+}
+
+/** What's wrong with a draft before it's sent, or null. */
+export function draftProblem(draft: HoldDraft): string | null {
+ if (!draft.name.trim()) return 'Give the case a name.';
+ const s = draft.scope;
+ if (!s.server && !s.accounts.length && !s.groups.length && !s.domains.length && !s.tenants.length) {
+ return 'Choose what the hold covers.';
+ }
+ if (draft.from && draft.to && draft.from > draft.to) return 'The range starts after it ends.';
+ return null;
+}
+
+/**
+ * What an edit would take away, which the server refuses (LH-3): a narrower
+ * range or anything out of the scope. Checked here so the form can say so
+ * before sending.
+ */
+export function narrowingProblem(current: LegalHold, draft: HoldDraft): string | null {
+ const from = (old: string, next: string) => (old ? !!next && next > old : !!next);
+ const to = (old: string, next: string) => (old ? !!next && next < old : !!next);
+ if (from(current.from, draft.from) || to(current.to, draft.to)) {
+ return 'The range can only be widened. To hold less, release this hold and place a new one.';
+ }
+ const kept = (old: string[], next: string[]) => old.every((id) => next.includes(id));
+ const s = current.scope;
+ const d = draft.scope;
+ if (
+ (s.server && !d.server) ||
+ !kept(s.accounts, d.accounts) ||
+ !kept(s.groups, d.groups) ||
+ !kept(s.domains, d.domains) ||
+ !kept(s.tenants, d.tenants)
+ ) {
+ return 'Nothing can be taken out of a hold. To hold less, release it and place a new one.';
+ }
+ return null;
+}
+
+async function set(args: Record, bucket: string, key: string): Promise {
+ const responses = await jmapRequest(
+ [[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), ...args }, '0']],
+ undefined,
+ [CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/set`) throw methodError(name, result);
+ const failed = setError(result, bucket, key);
+ if (failed) throw failed;
+ return (result as { created?: Record }).created?.[key]?.id;
+}
+
+export async function placeHold(draft: HoldDraft, reason: string): Promise {
+ return set({ create: { h: { ...draftToServer(draft), reason: reason.trim() } } }, 'notCreated', 'h');
+}
+
+export async function updateHold(id: string, draft: HoldDraft, reason: string): Promise {
+ await set({ reason: reason.trim(), update: { [id]: draftToServer(draft) } }, 'notUpdated', id);
+}
+
+export async function releaseHold(id: string, reason: string): Promise {
+ await set({ reason: reason.trim(), update: { [id]: { released: true } } }, 'notUpdated', id);
+}
+
+/** Bytes, as people read them. */
+export function formatSize(bytes: number | undefined): string {
+ if (!bytes) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < units.length - 1) {
+ value /= 1024;
+ unit += 1;
+ }
+ return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
+}
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
index beec0f6..f2d7d7f 100644
--- a/src/lib/layout.ts
+++ b/src/lib/layout.ts
@@ -81,6 +81,11 @@ function checkSpecialLink(
return { visible: canGet ? canGet('sysAudit') : true, enterprise: false };
}
+ // inbuxa: legal holds, for server administrators who may see them (LH-13).
+ if (viewName === 'CustomComponent/LegalHolds') {
+ return { visible: canGet ? canGet('sysLegalHold') : true, enterprise: false };
+ }
+
// inbuxa: locked accounts, for whoever may see them (AL-12).
if (viewName === 'CustomComponent/AccountLocks') {
return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false };