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.')} +

+
+