From 100441bdeb2ed484814c5b23d6ee295b4af593ef Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 8 Jun 2026 18:04:49 +0200 Subject: [PATCH] feat: ExportEngagementButton + exports API client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add split-button dropdown [Export ▼] on EngagementDetailPage that downloads engagement as Markdown, CSV, or PDF via GET /api/engagements//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 --- frontend/src/api/exports.ts | 56 ++++++++++ .../src/components/ExportEngagementButton.tsx | 100 ++++++++++++++++++ frontend/src/pages/EngagementDetailPage.tsx | 10 +- 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/exports.ts create mode 100644 frontend/src/components/ExportEngagementButton.tsx diff --git a/frontend/src/api/exports.ts b/frontend/src/api/exports.ts new file mode 100644 index 0000000..5feca4c --- /dev/null +++ b/frontend/src/api/exports.ts @@ -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 { + 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 { + 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); + } +} diff --git a/frontend/src/components/ExportEngagementButton.tsx b/frontend/src/components/ExportEngagementButton.tsx new file mode 100644 index 0000000..9eecfb4 --- /dev/null +++ b/frontend/src/components/ExportEngagementButton.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, Download, Loader2 } from 'lucide-react'; +import { downloadEngagementExport, type ExportFormat } from '@/api/exports'; +import { useToast } from '@/hooks/useToast'; + +interface ExportEngagementButtonProps { + engagementId: number; +} + +const FORMATS: { label: string; value: ExportFormat }[] = [ + { label: 'Markdown', value: 'md' }, + { label: 'CSV', value: 'csv' }, + { label: 'PDF', value: 'pdf' }, +]; + +export function ExportEngagementButton({ engagementId }: ExportEngagementButtonProps): JSX.Element { + const { push } = useToast(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: PointerEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const handleDownload = async (format: ExportFormat) => { + setLoading(format); + try { + await downloadEngagementExport(engagementId, format); + setOpen(false); + } catch (err) { + push(err instanceof Error ? err.message : 'Export failed', 'error'); + } finally { + setLoading(null); + } + }; + + return ( +
+
+ + +
+ + {open ? ( +
+ {FORMATS.map(({ label, value }) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/pages/EngagementDetailPage.tsx b/frontend/src/pages/EngagementDetailPage.tsx index cf44312..cf02d32 100644 --- a/frontend/src/pages/EngagementDetailPage.tsx +++ b/frontend/src/pages/EngagementDetailPage.tsx @@ -6,6 +6,7 @@ import { LoadingState } from '@/components/LoadingState'; import { ErrorState } from '@/components/ErrorState'; import { StatusBadge } from '@/components/StatusBadge'; import { SimulationList } from '@/components/SimulationList'; +import { ExportEngagementButton } from '@/components/ExportEngagementButton'; export function EngagementDetailPage(): JSX.Element { const { id } = useParams<{ id: string }>(); @@ -43,9 +44,12 @@ export function EngagementDetailPage(): JSX.Element { {canEditEngagements ? ( - - Edit - +
+ + + Edit + +
) : null}