From fde94980bf3734b5c48f2724bd76406380c4b600 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 18:47:42 -0700 Subject: [PATCH] Compliance > Legal holds Place a legal hold on people, groups, domains, tenants or the whole server, optionally for a date range; widen it (the dialog refuses anything that would take something out); release it by typing the case name and giving a reason. Each hold shows what it covers and what it keeps now: accounts, deleted items and their size. Released holds stay listed. A person's account page says which holds cover it, to those who may see holds only (audit-hold-lock spec, LH-1 to LH-14). --- src/components/layout/MainContent.tsx | 12 + src/features/hold/HeldNotice.tsx | 57 ++++ src/features/hold/HoldDialogs.tsx | 361 ++++++++++++++++++++++++++ src/features/hold/LegalHoldsPage.tsx | 186 +++++++++++++ src/features/hold/legalHold.test.ts | 81 ++++++ src/features/hold/legalHold.ts | 263 +++++++++++++++++++ src/lib/layout.ts | 5 + 7 files changed, 965 insertions(+) create mode 100644 src/features/hold/HeldNotice.tsx create mode 100644 src/features/hold/HoldDialogs.tsx create mode 100644 src/features/hold/LegalHoldsPage.tsx create mode 100644 src/features/hold/legalHold.test.ts create mode 100644 src/features/hold/legalHold.ts 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 (
+ {page}
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 ( +
+ + {!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 ( +