From 67acffbe4f1a88a97cc838461c31790e9f869bd7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 21:10:24 -0700 Subject: [PATCH] Export what a legal hold keeps, from the Legal holds page Each active hold gets an Export... action for people with sysLegalHoldExport: pick some of the people it covers, or none for all of them, and give a reason for the audit log. The server collects the ZIP in the background (inbuxa:HoldExport, LH-12); the hold's card lists its exports, newest first, polls while one is collecting, and shows the item count, size and SHA-256 once it's ready. Download follows the audit log export: the session's downloadUrl, for the signed-in person's own account, since the file is the blob of whoever started it. It's a temporary upload, so the list says only that person can download it and that the file goes after the upload time-to-live while the record stays. Tested: unit tests for the client (status mapping, the create call, refusals, file names); typecheck and lint; and end to end in headless Chrome against a local server on the export branch: a hold over a demo domain exported 173 items, and the downloaded file was a 178,798-byte ZIP whose SHA-256 matched the one shown. --- src/features/hold/HoldExports.tsx | 227 +++++++++++++++++++++++ src/features/hold/LegalHoldsPage.tsx | 64 ++++++- src/features/hold/holdExport.test.ts | 72 +++++++ src/features/hold/holdExport.ts | 137 ++++++++++++++ src/features/hold/usePollWhileRunning.ts | 18 ++ 5 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 src/features/hold/HoldExports.tsx create mode 100644 src/features/hold/holdExport.test.ts create mode 100644 src/features/hold/holdExport.ts create mode 100644 src/features/hold/usePollWhileRunning.ts diff --git a/src/features/hold/HoldExports.tsx b/src/features/hold/HoldExports.tsx new file mode 100644 index 0000000..3aeca5d --- /dev/null +++ b/src/features/hold/HoldExports.tsx @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: exporting what a legal hold keeps (audit-hold-lock spec, LH-12). + * The export runs on the server; this starts one, lists them, and downloads + * a ready file for whoever started it. + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Download, FileArchive, Loader2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Textarea } from '@/components/ui/textarea'; +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 { formatSize, type LegalHold } from './legalHold'; +import { downloadExport, exportFileName, startExport, type HoldExport } from './holdExport'; + +function AccountChip({ id, onRemove }: { id: string; onRemove?: () => void }) { + const schema = useSchemaStore((s) => s.schema); + const { label } = useObjectLabel('x:Account', id, schema!); + return ( + + {label ?? id} + {onRemove && ( + + )} + + ); +} + +export function ExportDialog({ + hold, + onClose, + onStarted, +}: { + hold: LegalHold; + onClose: () => void; + onStarted: () => void; +}) { + const { t } = useTranslation(); + const schema = useSchemaStore((s) => s.schema); + const [accounts, setAccounts] = useState([]); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + + const run = async () => { + setBusy(true); + try { + await startExport(hold.id, accounts, reason); + toast({ title: t('hold.export.started', 'Export started') }); + onStarted(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('hold.export.notStarted', 'The export didn’t start'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + {t('hold.export.title', 'Export {{name}}', { name: hold.name })} + + {t( + 'hold.export.body', + 'Collects everything this hold covers into one ZIP: mail as .eml, calendars as .ics, contacts as .vcf, files as they are, and the deleted items it keeps, with a manifest of SHA-256 checksums. It runs on the server; you can leave this page.', + )} + + +
+
+ {t('hold.export.accounts', 'People (leave empty for everyone the hold covers)')} +
+
+ {accounts.map((id) => ( + setAccounts(accounts.filter((x) => x !== id))} /> + ))} + {schema && ( + { + if (id && !accounts.includes(id)) setAccounts([...accounts, id]); + }} + placeholder={t('hold.addAccount', 'Add a person')} + /> + )} +
+

+ {t('hold.export.accountsHint', 'Someone the hold doesn’t cover is left out of the file.')} +

+
+