Compliance › Legal holds #30
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]}`;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user