Export what a legal hold keeps, from the Legal holds page #31

Merged
jcoffey-dev merged 1 commits from feature/hold-export into main 2026-09-28 04:48:10 +00:00
5 changed files with 508 additions and 10 deletions

No files matched your search

+227
View File
@@ -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 (
<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>
);
}
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<string[]>([]);
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('hold.export.title', 'Export {{name}}', { name: hold.name })}</DialogTitle>
<DialogDescription>
{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.',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-1 text-sm">
<div className="text-muted-foreground">
{t('hold.export.accounts', 'People (leave empty for everyone the hold covers)')}
</div>
<div className="flex flex-wrap items-center gap-1">
{accounts.map((id) => (
<AccountChip key={id} id={id} onRemove={() => setAccounts(accounts.filter((x) => x !== id))} />
))}
{schema && (
<ObjectPicker
schema={schema}
objectName="x:Account/User"
value=""
onChange={(id) => {
if (id && !accounts.includes(id)) setAccounts([...accounts, id]);
}}
placeholder={t('hold.addAccount', 'Add a person')}
/>
)}
</div>
<p className="text-xs text-muted-foreground">
{t('hold.export.accountsHint', 'Someone the hold doesn’t cover is left out of the file.')}
</p>
</div>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('hold.reason', 'Reason')}</span>
<Textarea
value={reason}
maxLength={500}
placeholder={t('hold.export.reasonHint', 'Why, for the audit log: a production request, a matter number…')}
onChange={(e) => setReason(e.target.value)}
/>
</label>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('hold.cancel', 'Cancel')}
</Button>
<Button onClick={() => void run()} disabled={busy || !reason.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileArchive className="mr-2 h-4 w-4" />}
{t('hold.export.go', 'Start the export')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ExportRow({ hold, exp }: { hold: LegalHold; exp: HoldExport }) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const download = async () => {
setBusy(true);
try {
await downloadExport(exp, exportFileName(hold.name, exp));
} catch (e) {
toast({
variant: 'destructive',
title: t('hold.export.downloadFailed', 'Couldn’t download the export'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-wrap items-start justify-between gap-2 border-t pt-2 text-sm first:border-t-0 first:pt-0">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-2">
{exp.status === 'running' && (
<Badge variant="outline">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
{t('hold.export.running', 'Collecting')}
</Badge>
)}
{exp.status === 'ready' && <Badge variant="secondary">{t('hold.export.ready', 'Ready')}</Badge>}
{exp.status === 'failed' && <Badge variant="destructive">{t('hold.export.failed', 'Failed')}</Badge>}
<span className="text-muted-foreground">
{t('hold.export.startedBy', '{{when}} by {{who}}', {
when: new Date(exp.createdAt).toLocaleString(),
who: exp.createdBy,
})}
</span>
</div>
<div className="text-muted-foreground">{exp.reason}</div>
{exp.accountIds.length > 0 && (
<div className="flex flex-wrap gap-1">
{exp.accountIds.map((id) => (
<AccountChip key={id} id={id} />
))}
</div>
)}
{exp.status === 'ready' && (
<div className="text-muted-foreground">
{[
exp.items === 1
? t('hold.export.oneItem', '1 item')
: t('hold.export.items', '{{n}} items', { n: String(exp.items) }),
formatSize(exp.size),
].join(' · ')}
{exp.sha256 && <div className="break-all font-mono text-xs">SHA-256 {exp.sha256}</div>}
</div>
)}
{exp.status === 'failed' && exp.error && <div className="text-destructive">{exp.error}</div>}
</div>
{exp.status === 'ready' && (
<Button variant="outline" size="sm" onClick={() => void download()} disabled={busy}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
{t('hold.export.download', 'Download')}
</Button>
)}
</div>
);
}
/** A hold's exports, newest first. */
export function ExportList({ hold, exports }: { hold: LegalHold; exports: HoldExport[] }) {
const { t } = useTranslation();
if (!exports.length) return null;
return (
<div className="space-y-2 rounded-lg bg-muted/40 p-3">
<div className="text-sm text-muted-foreground">{t('hold.export.heading', 'Exports')}</div>
{exports.map((exp) => (
<ExportRow key={exp.id} hold={hold} exp={exp} />
))}
<p className="text-xs text-muted-foreground">
{t(
'hold.export.expiry',
'Only the person who started an export can download it. The file is kept as a temporary upload (an hour, unless the upload time-to-live says otherwise); the record, with its checksum, stays.',
)}
</p>
</div>
);
}
+54 -10
View File
@@ -12,7 +12,7 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pencil, Scale, Unlock } from 'lucide-react';
import { FileArchive, Pencil, Scale, Unlock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/common/PageHeader';
@@ -20,6 +20,9 @@ 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';
import { fetchExports, type HoldExport } from './holdExport';
import { ExportDialog, ExportList } from './HoldExports';
import { usePollWhileRunning } from './usePollWhileRunning';
type Load = { kind: 'loading' } | { kind: 'ready'; holds: LegalHold[] } | { kind: 'error'; message: string };
@@ -34,12 +37,17 @@ export function LegalHoldsPage() {
const { t } = useTranslation();
const canCreate = useAccountStore((s) => s.hasPermission('sysLegalHoldCreate'));
const canUpdate = useAccountStore((s) => s.hasPermission('sysLegalHoldUpdate'));
const canExport = useAccountStore((s) => s.hasPermission('sysLegalHoldExport'));
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 [exporting, setExporting] = useState<LegalHold | null>(null);
const [exports, setExports] = useState<HoldExport[]>([]);
const [exportFetches, setExportFetches] = useState(0);
const refetch = useCallback(() => setFetches((n) => n + 1), []);
const refetchExports = useCallback(() => setExportFetches((n) => n + 1), []);
useEffect(() => {
const controller = new AbortController();
@@ -62,6 +70,21 @@ export function LegalHoldsPage() {
return () => controller.abort();
}, [fetches, t]);
useEffect(() => {
if (!canExport) return;
const controller = new AbortController();
fetchExports(controller.signal)
.then((list) => {
if (!controller.signal.aborted) setExports(list);
})
.catch(() => {
// An older server has no exports; the holds still show
});
return () => controller.abort();
}, [canExport, exportFetches]);
usePollWhileRunning(exports, refetchExports);
const done = () => {
setPlacing(false);
setEditing(null);
@@ -118,16 +141,26 @@ export function LegalHoldsPage() {
</div>
{hold.description && <div className="text-sm">{hold.description}</div>}
</div>
{canUpdate && (
{(canUpdate || canExport) && (
<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>
{canExport && (
<Button variant="outline" size="sm" onClick={() => setExporting(hold)}>
<FileArchive className="mr-2 h-4 w-4" />
{t('hold.export.action', 'Export…')}
</Button>
)}
{canUpdate && (
<Button variant="outline" size="sm" onClick={() => setEditing(hold)}>
<Pencil className="mr-2 h-4 w-4" />
{t('hold.widenAction', 'Widen…')}
</Button>
)}
{canUpdate && (
<Button variant="outline" size="sm" onClick={() => setReleasing(hold)}>
<Unlock className="mr-2 h-4 w-4" />
{t('hold.releaseAction', 'Release…')}
</Button>
)}
</div>
)}
</div>
@@ -152,6 +185,7 @@ export function LegalHoldsPage() {
</div>
</div>
</div>
<ExportList hold={hold} exports={exports.filter((e) => e.holdId === hold.id)} />
</div>
))}
@@ -180,6 +214,16 @@ export function LegalHoldsPage() {
{placing && <HoldDialog onClose={() => setPlacing(false)} onDone={done} />}
{editing && <HoldDialog existing={editing} onClose={() => setEditing(null)} onDone={done} />}
{exporting && (
<ExportDialog
hold={exporting}
onClose={() => setExporting(null)}
onStarted={() => {
setExporting(null);
refetchExports();
}}
/>
)}
{releasing && <ReleaseDialog hold={releasing} onClose={() => setReleasing(null)} onDone={done} />}
</div>
);
+72
View File
@@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const jmapRequest = vi.fn();
vi.mock('@/services/jmap/client', () => ({
fetchSession: vi.fn(),
getAccountId: () => 'admin',
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
}));
vi.mock('@/services/api', () => ({ apiFetch: vi.fn() }));
const { exportFileName, fetchExports, startExport } = await import('./holdExport');
beforeEach(() => jmapRequest.mockReset());
describe('hold exports', () => {
it('reads exports and settles unknown statuses as running', async () => {
jmapRequest.mockResolvedValue([
[
'inbuxa:HoldExport/get',
{
list: [
{
id: 'e1',
holdId: 'h',
status: 'ready',
blobId: 'b',
size: 10,
items: 2,
sha256: 'ab',
accountIds: ['x'],
},
{ id: 'e2', holdId: 'h', status: 'queued' },
],
},
],
]);
const list = await fetchExports();
expect(list[0]).toMatchObject({ id: 'e1', status: 'ready', blobId: 'b', items: 2, accountIds: ['x'] });
expect(list[1]).toMatchObject({ status: 'running', blobId: null, accountIds: [], size: 0 });
});
it('sends only the accounts asked for, and the reason trimmed', async () => {
jmapRequest.mockResolvedValue([['inbuxa:HoldExport/set', { created: { x: { id: 'e3' } } }]]);
expect(await startExport('h', [], ' production request ')).toBe('e3');
const [[, args]] = jmapRequest.mock.calls[0][0];
expect(args).toEqual({ accountId: 'admin', create: { x: { holdId: 'h', reason: 'production request' } } });
await startExport('h', ['a', 'b'], 'r');
expect(jmapRequest.mock.calls[1][0][0][1].create.x.accountIds).toEqual(['a', 'b']);
});
it('passes the server’s refusal on', async () => {
jmapRequest.mockResolvedValue([
['inbuxa:HoldExport/set', { notCreated: { x: { type: 'invalidProperties', description: 'Say why.' } } }],
]);
await expect(startExport('h', [], 'r')).rejects.toThrow('Say why.');
jmapRequest.mockResolvedValue([['error', { type: 'forbidden' }]]);
await expect(fetchExports()).rejects.toThrow('forbidden');
});
it('names the file after the case and the day', () => {
const exp = { id: 'e1', createdAt: '2026-09-28T10:00:00Z' } as Parameters<typeof exportFileName>[1];
expect(exportFileName('Matter 4411 / Acme', exp)).toBe('Matter-4411-Acme-2026-09-28-e1.zip');
expect(exportFileName('///', exp)).toBe('hold-2026-09-28-e1.zip');
});
});
+137
View File
@@ -0,0 +1,137 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: `inbuxa:HoldExport` over JMAP (audit-hold-lock spec, LH-12):
* collecting what a legal hold keeps as a ZIP, in the background. The file is
* the blob of whoever started it, so only they download it.
*/
import { apiFetch } from '@/services/api';
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
const CAPABILITY = 'urn:inbuxa:jmap';
const OBJECT = 'inbuxa:HoldExport';
export type ExportStatus = 'running' | 'ready' | 'failed';
export interface HoldExport {
id: string;
holdId: string;
accountIds: string[];
reason: string;
status: ExportStatus;
createdAt: string;
createdBy: string;
finishedAt: string | null;
blobId: string | null;
size: number;
items: number;
sha256: string | null;
error: string | null;
}
function fromServer(item: Record<string, unknown>): HoldExport {
const text = (v: unknown) => (typeof v === 'string' ? v : null);
const status = String(item.status);
return {
id: String(item.id),
holdId: String(item.holdId),
accountIds: Array.isArray(item.accountIds) ? item.accountIds.map(String) : [],
reason: text(item.reason) ?? '',
status: status === 'ready' || status === 'failed' ? status : 'running',
createdAt: text(item.createdAt) ?? '',
createdBy: text(item.createdBy) ?? '',
finishedAt: text(item.finishedAt),
blobId: text(item.blobId),
size: typeof item.size === 'number' ? item.size : 0,
items: typeof item.items === 'number' ? item.items : 0,
sha256: text(item.sha256),
error: text(item.error),
};
}
function failure(name: string | undefined, result: unknown): Error {
const r = result as { type?: string; description?: string } | undefined;
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
}
/** Every export, newest first. */
export async function fetchExports(signal?: AbortSignal): Promise<HoldExport[]> {
const responses = await jmapRequest(
[[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ids: null }, '0']],
signal,
[CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${OBJECT}/get`) throw failure(name, result);
return ((result as { list?: Record<string, unknown>[] }).list ?? []).map(fromServer);
}
/** Starts collecting what `holdId` keeps, for `accountIds` or all it covers. */
export async function startExport(holdId: string, accountIds: string[], reason: string): Promise<string> {
const create: Record<string, unknown> = { holdId, reason: reason.trim() };
if (accountIds.length) create.accountIds = accountIds;
const responses = await jmapRequest(
[[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), create: { x: create } }, '0']],
undefined,
[CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${OBJECT}/set`) throw failure(name, result);
const r = result as {
created?: { x?: { id: string } };
notCreated?: { x?: { description?: string; type?: string } };
};
if (!r.created?.x) throw new Error(r.notCreated?.x?.description ?? r.notCreated?.x?.type ?? 'Not started');
return r.created.x.id;
}
/** A name for the downloaded file. */
export function exportFileName(holdName: string, exp: HoldExport): string {
const slug = holdName.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'hold';
return `${slug}-${exp.createdAt.slice(0, 10)}-${exp.id}.zip`;
}
function pathOf(url: string): string {
try {
const u = new URL(url, 'http://placeholder');
return u.pathname + u.search;
} catch {
return url;
}
}
/** Downloads a ready export: the signed-in person's own blob. */
export async function downloadExport(exp: HoldExport, fileName: string): Promise<void> {
if (!exp.blobId) throw new Error('This export has no file.');
const session = await fetchSession();
const template =
typeof session.downloadUrl === 'string'
? session.downloadUrl
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
const url = template
.replace('{accountId}', encodeURIComponent(getAccountId('x:Account')))
.replace('{blobId}', encodeURIComponent(exp.blobId))
.replace('{name}', encodeURIComponent(fileName))
.replace('{type}', encodeURIComponent('application/zip'));
const response = await apiFetch(pathOf(url));
if (!response.ok) {
throw new Error(
response.status === 404 || response.status === 403
? `The file has expired, or it's ${exp.createdBy}'s to download. Start a new export.`
: `Download failed (${response.status})`,
);
}
const href = URL.createObjectURL(await response.blob());
const a = document.createElement('a');
a.href = href;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 10_000);
}
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react';
import type { HoldExport } from './holdExport';
/** Refetches every few seconds while any export is still collecting. */
export function usePollWhileRunning(exports: HoldExport[], refetch: () => void) {
const running = exports.some((e) => e.status === 'running');
useEffect(() => {
if (!running) return;
const timer = window.setInterval(refetch, 4000);
return () => window.clearInterval(timer);
}, [running, refetch]);
}