From 06d8e42f2461c3c69ab4cc43b566e8211a01072d Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sat, 6 Jun 2026 12:25:43 +1000 Subject: [PATCH] Add owner incident deletion UI --- CHANGELOG.md | 2 + README.md | 2 +- docs/api-client.md | 20 +++ src/App.test.tsx | 256 +++++++++++++++++++++++++++ src/api/client.test.ts | 80 +++++++++ src/api/client.ts | 74 +++++++- src/api/schemas.test.ts | 49 +++++ src/api/schemas.ts | 23 +++ src/routes/incidents/$incidentId.tsx | 133 +++++++++++++- 9 files changed, 636 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e4165..1bcfe7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added owner incident deletion status and request UI for the authenticated + incident detail route. - Added an authenticated account profile route with safe account metadata and password-change handling. - Implemented an explicit browser-cookie auth client mode with in-memory CSRF diff --git a/README.md b/README.md index 36f88e6..8b53e5d 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ The current bootstrap includes: - contact public-key metadata views - sharing-grant metadata views - wrapped-key metadata views +- owner incident deletion status and request UI - safe loading, empty, and error states - visible prototype and emergency-reliance warnings - frontend CI for typecheck, lint, unit tests, build, and Playwright smoke tests @@ -139,7 +140,6 @@ Planned authenticated incident-review work includes: - stream and chunk metadata review - viewer-token creation and revocation UI - encrypted bundle download affordances with clear warnings -- deletion request/status UI for account-owned incidents, if backend support is available - mode, capture-profile, escalation-policy, sharing-state, deletion-state, and retention metadata display - safe empty/error/loading states for all incident views diff --git a/docs/api-client.md b/docs/api-client.md index 2286405..abae27e 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -40,6 +40,8 @@ From current `open-proofline/server` docs and route registration: - `POST /v1/incidents` - `GET /v1/incidents` - `GET /v1/incidents/{incident_id}` +- `POST /v1/incidents/{incident_id}/deletion` +- `GET /v1/incidents/{incident_id}/deletion` - `GET /v1/contact-public-keys` - `GET /v1/contact-public-keys/{public_key_id}` - `GET /v1/incidents/{incident_id}/sharing-grants` @@ -75,6 +77,24 @@ Incident detail parsing keeps browser state focused on public-safe metadata. If backend chunk responses include private `stored_path` values for upload or storage internals, the frontend schema does not retain those fields. +Owner-scoped incident deletion uses the server's authenticated deletion routes: + +- `GET /v1/incidents/{incident_id}/deletion` +- `POST /v1/incidents/{incident_id}/deletion` + +The client treats `404 incident_deletion_not_found` from the status route as +"no deletion request" and keeps other ownership, missing-incident, and backend +errors generic in the UI. Deletion requests use a fixed non-sensitive +`account_delete` reason code. Open incidents require explicit user confirmation +before the client sends `allow_open: true`. + +Deletion status parsing keeps only the server's non-sensitive status fields: +decision and incident identifiers, source, reason code, `allow_open`, state, +item count, optional safe error code, and timestamps. It does not retain +deletion item paths, object keys, request bodies, plaintext, raw keys, +wrapped-key ciphertext, token hashes, private deployment details, or user safety +narrative. + Wrapped-key parsing does not retain `wrapped_key_ciphertext` in frontend state. The current server may return ciphertext on authenticated wrapped-key routes, but this metadata-review prototype keeps only wrapped-key identifiers, grant and diff --git a/src/App.test.tsx b/src/App.test.tsx index fb79993..df648ed 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -600,6 +600,237 @@ test("renders authenticated mock incident detail metadata sections", async () => expect(screen.getByText("No key delivery")).toBeInTheDocument(); }); +test("renders existing incident deletion status without private deletion internals", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + deletion_state: "deletion_pending", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json({ + deletion: { + decision_id: "del_live", + incident_id: "inc_live", + source: "account_request", + reason_code: "account_delete", + actor_account_id: "acct_live", + allow_open: false, + state: "deletion_pending", + item_count: 2, + requested_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:01:00Z", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + wrapped_key_ciphertext: "wrapped-ciphertext", + }, + }), + ), + ...emptyIncidentDetailHandlers("inc_live"), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Deletion request" }), + ).toBeInTheDocument(); + expect(await screen.findAllByText("deletion pending")).not.toHaveLength(0); + expect(screen.getByText("account_delete")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Request deletion" }), + ).toBeNull(); + expect(screen.queryByText("incidents/inc_live/private.enc")).toBeNull(); + expect(screen.queryByText("private/object/key")).toBeNull(); + expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); +}); + +test("requires confirmation before requesting deletion for an open incident", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + const deletionRequest = vi.fn(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "open", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.post("*/v1/incidents/inc_live/deletion", async ({ request }) => { + deletionRequest(); + await expect(request.json()).resolves.toEqual({ + reason_code: "account_delete", + allow_open: true, + }); + return HttpResponse.json( + { + deletion: { + decision_id: "del_live", + incident_id: "inc_live", + source: "account_request", + reason_code: "account_delete", + allow_open: true, + state: "deletion_pending", + item_count: 0, + requested_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + }, + }, + { status: 202 }, + ); + }), + ...emptyIncidentDetailHandlers("inc_live"), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + const button = await screen.findByRole("button", { + name: "Request deletion", + }); + expect(button).toBeDisabled(); + fireEvent.click( + screen.getByRole("checkbox", { + name: "Confirm this open incident should be submitted for deletion.", + }), + ); + expect(button).toBeEnabled(); + fireEvent.click(button); + + expect(await screen.findAllByText("deletion pending")).not.toHaveLength(0); + expect(deletionRequest).toHaveBeenCalledTimes(1); +}); + +test("shows generic deletion status and request errors", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "open", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "unavailable", + message: "backend private deletion detail", + }, + }, + { status: 503 }, + ), + ), + ...emptyIncidentDetailHandlers("inc_live"), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + expect( + await screen.findByText("Deletion status could not be loaded."), + ).toBeInTheDocument(); + expect(screen.queryByText("backend private deletion detail")).toBeNull(); +}); + +test("shows a generic deletion request failure for missing or unowned incidents", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "open", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.post("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "forbidden", + message: "private ownership detail", + }, + }, + { status: 403 }, + ), + ), + ...emptyIncidentDetailHandlers("inc_live"), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + fireEvent.click( + await screen.findByRole("checkbox", { + name: "Confirm this open incident should be submitted for deletion.", + }), + ); + fireEvent.click(screen.getByRole("button", { name: "Request deletion" })); + + expect( + await screen.findByText("Deletion request could not be completed."), + ).toBeInTheDocument(); + expect(screen.queryByText("private ownership detail")).toBeNull(); +}); + test("redirects unauthenticated account profile visits to login", async () => { renderRoute("/account"); @@ -1049,6 +1280,17 @@ test("shows generic dependent metadata errors on incident detail", async () => { checkins: [], }), ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), http.get("*/v1/contact-public-keys", () => HttpResponse.json({ error: { code: "unavailable" } }, { status: 503 }), ), @@ -1114,3 +1356,17 @@ function saveTestSession(mode: "mock" | "live", username: string) { authMode: "bearer", }); } + +function emptyIncidentDetailHandlers(incidentId: string) { + return [ + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ contact_public_keys: [] }), + ), + http.get(`*/v1/incidents/${incidentId}/sharing-grants`, () => + HttpResponse.json({ sharing_grants: [] }), + ), + http.get(`*/v1/incidents/${incidentId}/wrapped-keys`, () => + HttpResponse.json({ wrapped_keys: [] }), + ), + ]; +} diff --git a/src/api/client.test.ts b/src/api/client.test.ts index dfcfdfc..cc7d4e2 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -572,3 +572,83 @@ test("parses live owned incident list responses without retaining private fields expect("plaintext" in incident).toBe(false); expect("raw_key" in incident).toBe(false); }); + +test("returns null when no incident deletion request exists", async () => { + server.use( + http.get("*/v1/incidents/inc_live/deletion", ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + return HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + await expect(client.readIncidentDeletion("inc_live")).resolves.toBeNull(); +}); + +test("requests live incident deletion with a safe reason code", async () => { + server.use( + http.post("*/v1/incidents/inc_live/deletion", async ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + reason_code: "account_delete", + allow_open: true, + }); + return HttpResponse.json( + { + deletion: { + decision_id: "del_live", + incident_id: "inc_live", + source: "account_request", + reason_code: "account_delete", + actor_account_id: "acct_live", + allow_open: true, + state: "deletion_pending", + item_count: 2, + requested_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + wrapped_key_ciphertext: "wrapped-ciphertext", + }, + }, + { status: 202 }, + ); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + const deletion = await client.requestIncidentDeletion("inc_live", { + reasonCode: "account_delete", + allowOpen: true, + }); + + expect(deletion).toMatchObject({ + decision_id: "del_live", + incident_id: "inc_live", + state: "deletion_pending", + item_count: 2, + }); + expect("stored_path" in deletion).toBe(false); + expect("object_key" in deletion).toBe(false); + expect("wrapped_key_ciphertext" in deletion).toBe(false); +}); diff --git a/src/api/client.ts b/src/api/client.ts index d98c033..f73e9d4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,6 +3,7 @@ import { contactPublicKeyResponseSchema, contactPublicKeysResponseSchema, incidentDetailSchema, + incidentDeletionResponseSchema, incidentsResponseSchema, emailVerificationResponseSchema, loginResponseSchema, @@ -19,6 +20,7 @@ import { type EmailVerificationResponse, type Incident, type IncidentDetail, + type IncidentDeletionStatus, type LoginResponse, type RegistrationAcceptedResponse, type SharingGrant, @@ -26,7 +28,7 @@ import { type WebLoginResponse, type WrappedKey, } from "./schemas"; -import { apiErrorFromResponse } from "./errors"; +import { ApiError, apiErrorFromResponse } from "./errors"; export type ClientMode = "mock" | "live"; @@ -52,6 +54,11 @@ type ChangePasswordRequest = { newPassword: string; }; +type RequestIncidentDeletionRequest = { + reasonCode?: string; + allowOpen: boolean; +}; + type RequestOptions = { includeAuth?: boolean; includeCredentials?: boolean; @@ -77,6 +84,8 @@ export const prooflineQueryKeys = { account: (sessionId: string) => ["account", sessionId] as const, incidents: ["incidents"] as const, incident: (incidentId: string) => ["incident", incidentId] as const, + incidentDeletion: (incidentId: string) => + ["incident-deletion", incidentId] as const, contactPublicKeys: ["contact-public-keys"] as const, sharingGrants: (incidentId: string) => ["sharing-grants", incidentId] as const, @@ -417,6 +426,69 @@ export class ProoflineApiClient { ); } + async readIncidentDeletion( + incidentId: string, + ): Promise { + if (this.mode === "mock") { + return null; + } + + try { + return incidentDeletionResponseSchema.parse( + await this.request( + `/v1/incidents/${encodeURIComponent(incidentId)}/deletion`, + ), + ).deletion; + } catch (error) { + if ( + error instanceof ApiError && + error.status === 404 && + error.code === "incident_deletion_not_found" + ) { + return null; + } + throw error; + } + } + + async requestIncidentDeletion( + incidentId: string, + request: RequestIncidentDeletionRequest, + ): Promise { + if (this.mode === "mock") { + const now = new Date().toISOString(); + return { + decision_id: `del_${incidentId}`, + incident_id: incidentId, + source: "account_request", + reason_code: request.reasonCode, + allow_open: request.allowOpen, + state: "deletion_pending", + item_count: 0, + requested_at: now, + updated_at: now, + }; + } + + const body = + request.reasonCode === undefined + ? { allow_open: request.allowOpen } + : { + reason_code: request.reasonCode, + allow_open: request.allowOpen, + }; + + return incidentDeletionResponseSchema.parse( + await this.request( + `/v1/incidents/${encodeURIComponent(incidentId)}/deletion`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + ).deletion; + } + async listContactPublicKeys(): Promise { if (this.mode === "mock") { return mockContactPublicKeys; diff --git a/src/api/schemas.test.ts b/src/api/schemas.test.ts index 4e6d9dd..be01004 100644 --- a/src/api/schemas.test.ts +++ b/src/api/schemas.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "vitest"; import { + incidentDeletionResponseSchema, incidentDetailSchema, incidentsResponseSchema, wrappedKeyResponseSchema, @@ -105,6 +106,54 @@ test("incident detail parsing drops private chunk storage paths", () => { expect("stored_path" in chunk).toBe(false); }); +test("incident deletion status parsing drops deletion item internals", () => { + const parsed = incidentDeletionResponseSchema.parse({ + deletion: { + decision_id: "del_test", + incident_id: "inc_test", + source: "account_request", + reason_code: "account_delete", + actor_account_id: "acct_test", + allow_open: true, + state: "deletion_pending", + item_count: 2, + error_code: "blob_delete_failed", + requested_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:01:00Z", + started_at: "2026-06-01T00:00:30Z", + completed_at: "2026-06-01T00:01:00Z", + stored_path: "incidents/inc_test/private.enc", + object_key: "private/object/key", + token_hash: "token-hash", + request_body: "private request", + plaintext: "private plaintext", + raw_key: "raw-key", + wrapped_key_ciphertext: "wrapped-ciphertext", + user_safety_narrative: "private narrative", + }, + }); + + expect(parsed.deletion).toMatchObject({ + decision_id: "del_test", + incident_id: "inc_test", + source: "account_request", + reason_code: "account_delete", + actor_account_id: "acct_test", + allow_open: true, + state: "deletion_pending", + item_count: 2, + error_code: "blob_delete_failed", + }); + expect("stored_path" in parsed.deletion).toBe(false); + expect("object_key" in parsed.deletion).toBe(false); + expect("token_hash" in parsed.deletion).toBe(false); + expect("request_body" in parsed.deletion).toBe(false); + expect("plaintext" in parsed.deletion).toBe(false); + expect("raw_key" in parsed.deletion).toBe(false); + expect("wrapped_key_ciphertext" in parsed.deletion).toBe(false); + expect("user_safety_narrative" in parsed.deletion).toBe(false); +}); + const wrappedKeyFixture = { wrapped_key_id: "wkey_test", owner_account_id: "acct_test", diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 48eed61..c77e837 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -133,6 +133,26 @@ export const incidentDetailSchema = z.object({ checkins: z.array(checkinSchema).default([]), }); +export const incidentDeletionStatusSchema = z.object({ + decision_id: z.string(), + incident_id: z.string(), + source: z.string(), + reason_code: z.string().optional(), + actor_account_id: z.string().optional(), + allow_open: z.boolean(), + state: z.string(), + item_count: z.number(), + error_code: z.string().optional(), + requested_at: z.string(), + updated_at: z.string(), + started_at: z.string().optional(), + completed_at: z.string().optional(), +}); + +export const incidentDeletionResponseSchema = z.object({ + deletion: incidentDeletionStatusSchema, +}); + export const contactPublicKeySchema = z.object({ public_key_id: z.string(), owner_account_id: z.string().optional(), @@ -221,6 +241,9 @@ export type Session = z.infer; export type Incident = z.infer; export type IncidentsResponse = z.infer; export type IncidentDetail = z.infer; +export type IncidentDeletionStatus = z.infer< + typeof incidentDeletionStatusSchema +>; export type Stream = z.infer; export type Chunk = z.infer; export type ContactPublicKey = z.infer; diff --git a/src/routes/incidents/$incidentId.tsx b/src/routes/incidents/$incidentId.tsx index 9a1cbf5..9a0355f 100644 --- a/src/routes/incidents/$incidentId.tsx +++ b/src/routes/incidents/$incidentId.tsx @@ -1,7 +1,10 @@ +import { useState } from "react"; import { Navigate, createRoute, useParams } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { prooflineQueryKeys } from "../../api/client"; +import type { IncidentDeletionStatus } from "../../api/schemas"; import { useAuth } from "../../auth/use-auth"; +import { Button } from "../../components/catalyst/button"; import { EmptyState } from "../../components/proofline/EmptyState"; import { ContentSection, @@ -16,12 +19,19 @@ import { rootRoute } from "../__root"; function IncidentDetailPage() { const { incidentId } = useParams({ from: "/incidents/$incidentId" }); const { isAuthenticated, apiClient } = useAuth(); + const queryClient = useQueryClient(); + const [confirmedOpenDeletion, setConfirmedOpenDeletion] = useState(false); const incident = useQuery({ queryKey: prooflineQueryKeys.incident(incidentId), queryFn: () => apiClient.readIncident(incidentId), enabled: isAuthenticated, }); + const deletion = useQuery({ + queryKey: prooflineQueryKeys.incidentDeletion(incidentId), + queryFn: () => apiClient.readIncidentDeletion(incidentId), + enabled: isAuthenticated, + }); const contacts = useQuery({ queryKey: prooflineQueryKeys.contactPublicKeys, queryFn: () => apiClient.listContactPublicKeys(), @@ -37,6 +47,30 @@ function IncidentDetailPage() { queryFn: () => apiClient.listWrappedKeys(incidentId), enabled: isAuthenticated, }); + const requestDeletion = useMutation({ + mutationFn: () => { + const detail = incident.data; + if (!detail) { + throw new Error("incident detail is not loaded"); + } + return apiClient.requestIncidentDeletion(incidentId, { + reasonCode: "account_delete", + allowOpen: detail.incident.status === "open", + }); + }, + onSuccess: (status) => { + queryClient.setQueryData( + prooflineQueryKeys.incidentDeletion(incidentId), + status, + ); + void queryClient.invalidateQueries({ + queryKey: prooflineQueryKeys.incident(incidentId), + }); + void queryClient.invalidateQueries({ + queryKey: prooflineQueryKeys.incidents, + }); + }, + }); if (!isAuthenticated) { return ; @@ -62,6 +96,9 @@ function IncidentDetailPage() { } const detail = incident.data; + const isOpenIncident = detail.incident.status === "open"; + const requestDisabled = + requestDeletion.isPending || (isOpenIncident && !confirmedOpenDeletion); return (
@@ -111,6 +148,71 @@ function IncidentDetailPage() { /> + + ) : ( + "No request" + ) + } + > + {deletion.isLoading ? ( +

+ Loading deletion status. +

+ ) : deletion.isError ? ( + Deletion status could not be loaded. + ) : deletion.data ? ( + + ) : ( +
+

+ Request server-side deletion for this owned incident. This does + not expose storage paths, object keys, request bodies, plaintext, + raw keys, or wrapped-key ciphertext. +

+ {isOpenIncident ? ( + + This incident is open. Confirm that it should be placed into + deletion before sending the request. + + ) : null} + {isOpenIncident ? ( + + ) : null} + {requestDeletion.isError ? ( + + Deletion request could not be completed. + + ) : null} + +
+ )} +
+ {detail.streams.length ? (
@@ -258,6 +360,35 @@ function IncidentDetailPage() { ); } +function IncidentDeletionStatusView({ + status, +}: { + status: IncidentDeletionStatus; +}) { + return ( +
+ + + Deletion status is server-controlled metadata. This app does not expose + deletion item paths or private storage details. + +
+ ); +} + function MetadataError({ children }: { children: React.ReactNode }) { return (