feat: ExportEngagementButton + exports API client
Add split-button dropdown [Export ▼] on EngagementDetailPage that downloads engagement as Markdown, CSV, or PDF via GET /api/engagements/<id>/export?format=md|csv|pdf. Both halves open the dropdown (no default left-click action). RBAC-gated with canEditEngagements (admin + redteam only). Loading state per item, toast on error, click-outside + Escape close. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
56
frontend/src/api/exports.ts
Normal file
56
frontend/src/api/exports.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export type ExportFormat = 'md' | 'csv' | 'pdf';
|
||||
|
||||
export function parseContentDispositionFilename(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = header.match(/filename="([^"]+)"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function parseBlobError(err: unknown): Promise<string> {
|
||||
if (axios.isAxiosError(err) && err.response?.data instanceof Blob) {
|
||||
try {
|
||||
const text = await (err.response.data as Blob).text();
|
||||
const parsed = JSON.parse(text) as { error?: string };
|
||||
if (parsed.error) return parsed.error;
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Export failed';
|
||||
}
|
||||
|
||||
export async function downloadEngagementExport(
|
||||
engagementId: number,
|
||||
format: ExportFormat,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await apiClient.get(`/engagements/${engagementId}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
let filename = parseContentDispositionFilename(
|
||||
response.headers['content-disposition'] as string | undefined,
|
||||
);
|
||||
if (!filename) {
|
||||
const ext = format === 'md' ? 'md' : format === 'csv' ? 'csv' : 'pdf';
|
||||
filename = `engagement-${engagementId}.${ext}`;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const message = await parseBlobError(err);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user