Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions docs/api-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
256 changes: 256 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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 }),
),
Expand Down Expand Up @@ -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: [] }),
),
];
}
Loading