Compliance › Legal holds #30

Merged
jcoffey-dev merged 1 commits from feature/legal-holds into main 2026-09-28 03:00:44 +00:00
7 changed files with 965 additions and 0 deletions

No files matched your search

+12
View File
@@ -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 <AccountLocksPage />;
}
if (componentName === 'LegalHolds') {
return <LegalHoldsPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
@@ -202,6 +213,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (viewName === 'x:Account/User') {
return (
<div className="space-y-4">
<HeldNotice accountId={id} />
<AccountLockBanner accountId={id} />
{page}
</div>
+57
View File
@@ -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<LegalHold[]>([]);
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 (
<div className="flex items-start gap-3 rounded-xl border border-primary/40 bg-primary/5 p-4 text-sm">
<Scale className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div className="space-y-1">
<p className="font-medium">
{t('hold.heldTitle', 'Held: nothing in this account can be destroyed, and deleting it keeps its data.')}
</p>
<p className="text-muted-foreground">
{holds.map((h) => (h.reference ? `${h.name} (${h.reference})` : h.name)).join(', ')}
</p>
</div>
</div>
);
}
+361
View File
@@ -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<ScopeKind, string> = {
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<ScopeKind, string> = {
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 (
<span className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs">
{label ?? id}
{onRemove && (
<button type="button" onClick={onRemove} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
)}
</span>
);
}
/** 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 <span className="text-sm">{t('hold.wholeServer', 'Every account on the server')}</span>;
const kinds: ScopeKind[] = ['accounts', 'groups', 'domains', 'tenants'];
return (
<div className="flex flex-wrap gap-1">
{kinds.flatMap((kind) => scope[kind].map((id) => <ScopeChip key={`${kind}-${id}`} kind={kind} id={id} />))}
</div>
);
}
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 (
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={scope.server}
disabled={locked?.server}
onCheckedChange={(v) => onChange({ ...scope, server: v === true })}
/>
{t('hold.wholeServer', 'Every account on the server')}
</label>
{!scope.server &&
schema &&
rows.map(({ kind, label, placeholder }) => (
<div key={kind} className="space-y-1 text-sm">
<div className="text-muted-foreground">{label}</div>
<div className="flex flex-wrap items-center gap-1">
{scope[kind].map((id) => (
<ScopeChip
key={id}
kind={kind}
id={id}
onRemove={
locked?.[kind].includes(id)
? undefined
: () => onChange({ ...scope, [kind]: scope[kind].filter((x) => x !== id) })
}
/>
))}
<ObjectPicker
schema={schema}
objectName={PICKER_VIEW[kind]}
value=""
onChange={(id) => {
if (id && !scope[kind].includes(id)) onChange({ ...scope, [kind]: [...scope[kind], id] });
}}
placeholder={placeholder}
/>
</div>
</div>
))}
<p className="text-xs text-muted-foreground">
{t(
'hold.scopeHint',
'Domains, groups and tenants count as they are now: an account added later is held, and one that leaves stays held.',
)}
</p>
</div>
);
}
function ReasonField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.reason', 'Reason')}</span>
<Textarea
value={value}
maxLength={500}
placeholder={t('hold.reasonHint', 'Why, for the audit log: counsel’s letter, a matter number…')}
onChange={(e) => onChange(e.target.value)}
/>
</label>
);
}
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<HoldDraft>(() => 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<HoldDraft>) => 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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{existing
? t('hold.editTitle', 'Change {{name}}', { name: existing.name })
: t('hold.placeTitle', 'Place a legal hold')}
</DialogTitle>
<DialogDescription>
{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.',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.name', 'Case name')}</span>
<Input value={draft.name} maxLength={500} onChange={(e) => patch({ name: e.target.value })} />
</label>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.reference', 'Reference (optional)')}</span>
<Input
value={draft.reference}
maxLength={500}
placeholder={t('hold.referenceHint', 'Matter or ticket number')}
onChange={(e) => patch({ reference: e.target.value })}
/>
</label>
</div>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.description', 'Description (optional)')}</span>
<Textarea
value={draft.description}
maxLength={500}
onChange={(e) => patch({ description: e.target.value })}
/>
</label>
<div className="space-y-2">
<div className="text-sm font-medium">{t('hold.covers', 'What it covers')}</div>
<ScopeEditor scope={draft.scope} locked={existing?.scope} onChange={(scope) => patch({ scope })} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium">{t('hold.range', 'Dates (optional, UTC)')}</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.from', 'From')}</span>
<Input type="date" value={draft.from} onChange={(e) => patch({ from: e.target.value })} />
</label>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.to', 'To')}</span>
<Input type="date" value={draft.to} onChange={(e) => patch({ to: e.target.value })} />
</label>
</div>
<p className="text-xs text-muted-foreground">
{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.',
)}
</p>
</div>
{problem && <p className="text-sm text-destructive">{problem}</p>}
<ReasonField value={reason} onChange={setReason} />
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('hold.cancel', 'Cancel')}
</Button>
<Button onClick={() => void save()} disabled={busy || !ready}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : !existing && <Scale className="mr-2 h-4 w-4" />}
{existing ? t('hold.save', 'Save') : t('hold.placeGo', 'Place hold')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('hold.releaseTitle', 'Release {{name}}', { name: hold.name })}</DialogTitle>
<DialogDescription>
{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.',
)}
</DialogDescription>
</DialogHeader>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">
{t('hold.typeName', 'Type the case name to confirm: {{name}}', { name: hold.name })}
</span>
<Input value={typed} onChange={(e) => setTyped(e.target.value)} />
</label>
<ReasonField value={reason} onChange={setReason} />
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('hold.cancel', 'Cancel')}
</Button>
<Button variant="destructive" onClick={() => void run()} disabled={busy || !ready}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unlock className="mr-2 h-4 w-4" />}
{t('hold.releaseGo', 'Release')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+186
View File
@@ -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, string>) => 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<Load>({ kind: 'loading' });
const [fetches, setFetches] = useState(0);
const [placing, setPlacing] = useState(false);
const [editing, setEditing] = useState<LegalHold | null>(null);
const [releasing, setReleasing] = useState<LegalHold | null>(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 (
<div className="mx-auto max-w-5xl space-y-5">
<PageHeader
icon="scale"
title={t('hold.title', 'Legal holds')}
subtitle={t(
'hold.subtitle',
'Nothing a hold covers can be destroyed, by anyone, until it is released: deletions are kept with no expiry and deleted accounts keep their data. People aren’t told.',
)}
actions={
canCreate && (
<Button onClick={() => setPlacing(true)}>
<Scale className="mr-2 h-4 w-4" />
{t('hold.placeAction', 'Place a hold…')}
</Button>
)
}
/>
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
)}
{load.kind === 'ready' && active.length === 0 && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{t('hold.none', 'No hold is in place.')}
</div>
)}
{active.map((hold) => (
<div key={hold.id} className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2 font-medium">
<Scale className="h-4 w-4 text-primary" />
{hold.name}
{hold.reference && <Badge variant="outline">{hold.reference}</Badge>}
</div>
<div className="text-sm text-muted-foreground">
{t('hold.placedBy', 'Placed {{when}} by {{who}}', {
when: new Date(hold.placedAt).toLocaleString(),
who: hold.placedBy,
})}
</div>
{hold.description && <div className="text-sm">{hold.description}</div>}
</div>
{canUpdate && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setEditing(hold)}>
<Pencil className="mr-2 h-4 w-4" />
{t('hold.widenAction', 'Widen…')}
</Button>
<Button variant="outline" size="sm" onClick={() => setReleasing(hold)}>
<Unlock className="mr-2 h-4 w-4" />
{t('hold.releaseAction', 'Release…')}
</Button>
</div>
)}
</div>
<div className="grid gap-3 text-sm sm:grid-cols-2">
<div className="space-y-1">
<div className="text-muted-foreground">{t('hold.covers', 'What it covers')}</div>
<ScopeSummary scope={hold.scope} />
<div className="text-muted-foreground">{rangeText(t, hold)}</div>
</div>
<div className="space-y-1">
<div className="text-muted-foreground">{t('hold.keeps', 'What it keeps now')}</div>
<div>
{[
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(' · ')}
</div>
</div>
</div>
</div>
))}
{released.length > 0 && (
<div className="space-y-2">
<h2 className="text-sm font-medium text-muted-foreground">{t('hold.releasedHeading', 'Released')}</h2>
{released.map((hold) => (
<div key={hold.id} className="rounded-xl border p-4 text-sm text-muted-foreground">
<div className="flex flex-wrap items-center gap-2 font-medium text-foreground">
{hold.name}
{hold.reference && <Badge variant="outline">{hold.reference}</Badge>}
</div>
<div>
{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 ?? '',
})}
</div>
</div>
))}
</div>
)}
{placing && <HoldDialog onClose={() => setPlacing(false)} onDone={done} />}
{editing && <HoldDialog existing={editing} onClose={() => setEditing(null)} onDone={done} />}
{releasing && <ReleaseDialog hold={releasing} onClose={() => setReleasing(null)} onDone={done} />}
</div>
);
}
+81
View File
@@ -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> = {}): HoldDraft => ({
name: 'Matter 4411',
reference: '',
description: '',
scope: { ...EMPTY_SCOPE, accounts: ['b'] },
from: '',
to: '',
...patch,
});
const hold = (patch: Partial<LegalHold> = {}): 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');
});
});
+263
View File
@@ -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<string, Record<string, { description?: string; type?: string }> | 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<string, unknown>): LegalHold {
const scope = (item.scope ?? {}) as Record<string, unknown>;
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<string, unknown>, signal?: AbortSignal): Promise<LegalHold[]> {
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<string, unknown>[] }).list ?? []).map(fromServer);
}
/** Every hold, released ones included, with what each keeps (LH-9). */
export function fetchHolds(signal?: AbortSignal): Promise<LegalHold[]> {
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<LegalHold[]> {
return get({ coveringAccount: accountId, properties: ['id', 'name', 'reference'] }, signal);
}
function scopeToServer(scope: HoldScope): Record<string, unknown> {
return {
server: scope.server,
accounts: scope.accounts,
groups: scope.groups,
domains: scope.domains,
tenants: scope.tenants,
};
}
function draftToServer(draft: HoldDraft): Record<string, unknown> {
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<string, unknown>, bucket: string, key: string): Promise<string | undefined> {
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<string, { id?: string }> }).created?.[key]?.id;
}
export async function placeHold(draft: HoldDraft, reason: string): Promise<string | undefined> {
return set({ create: { h: { ...draftToServer(draft), reason: reason.trim() } } }, 'notCreated', 'h');
}
export async function updateHold(id: string, draft: HoldDraft, reason: string): Promise<void> {
await set({ reason: reason.trim(), update: { [id]: draftToServer(draft) } }, 'notUpdated', id);
}
export async function releaseHold(id: string, reason: string): Promise<void> {
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]}`;
}
+5
View File
@@ -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 };