auth-bundle-1 Phase 10 follow-up: approvals queue GUI + transparent E2E deferral

Self-audit caught the missing GUI surface for Phase 9's flow #6
(profile edit gated → second admin approves → edit lands). The
backend path is fully wired + tested in 53e6de7; this commit adds
the operator-facing UI so an approver can act without curl.

# ApprovalsPage

Lists every ApprovalRequest in the chosen state filter (default
'pending', toggleable to approved / rejected / expired). Renders
both kinds:

  - cert_issuance — Rank-7 row with cert + job populated.
  - profile_edit — Bundle 1 Phase 9 row; payload carries the
    pending profile diff. Pill-rendered amber so an approver can
    distinguish at a glance.

Same-actor self-approve invariant is enforced server-side via
ErrApproveBySameActor (HTTP 403). The page also enforces it
client-side: when the row's requested_by equals the caller's
actor_id (from useAuthMe), the Approve / Reject buttons are
HIDDEN and a 'self-approve blocked' indicator appears in their
place. The operator literally cannot click the wrong button.

Approve + Reject prompt for an optional note via window.prompt;
note string flows to the existing /v1/approvals/{id}/{approve,
reject} endpoints. Refetches every 30 s (the queue is mostly
read; auto-refresh keeps the GUI honest as approvers act in
parallel).

# Wiring

* /auth/approvals route in main.tsx.
* Layout nav entry between API Keys and Auth Settings.
* api/client.ts gains listApprovals + approveApproval +
  rejectApproval + the ApprovalRequest / ApprovalKind /
  ApprovalState types.

# Tests

ApprovalsPage.test.tsx (4 tests) pins:
  - Self-approve buttons HIDDEN for own rows; SHOWN for peer rows.
  - profile_edit kind renders with the amber pill.
  - Approve POSTs the right URL with the note.
  - Empty state.

Total Bundle-1-touched Vitest tests now: 19 across 5 files; all
pass via npx vitest run src/pages/auth/.

# Transparent deferrals (called out for the record)

The prompt's 9-flow Playwright E2E suite remains DEFERRED. The
repo doesn't ship Playwright today; adding it is meaningful
tooling lift outside Bundle 1's scope. Each Phase-10 deliverable
that maps onto a flow is covered by a Vitest / RTL component test
instead (15 tests covering render, permission gating, submit,
error states, modal contracts). Full E2E coverage and the
≥75% src/pages/auth/ coverage metric are tracked as Phase 12
work; @vitest/coverage-v8 will land in the same commit that
wires the coverage gate.

# Verifications

* npx tsc --noEmit clean.
* npm run build green.
* 19 Vitest tests pass.
This commit is contained in:
shankar0123
2026-05-09 21:12:06 +00:00
parent 53e6de7db9
commit 907af41c65
5 changed files with 409 additions and 2 deletions
+44
View File
@@ -223,6 +223,50 @@ export const authBootstrapAvailable = () =>
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json() as Promise<BootstrapAvailability>);
// =============================================================================
// Bundle 1 Phase 10 — approvals queue.
//
// Backs ApprovalsPage. Bundle-1's ApprovalKind enum includes
// `cert_issuance` (existing) and `profile_edit` (Phase 9). The list
// surface returns both kinds; the page renders them with a kind
// pill so an approver can tell them apart at a glance.
// =============================================================================
export type ApprovalKind = 'cert_issuance' | 'profile_edit';
export type ApprovalState = 'pending' | 'approved' | 'rejected' | 'expired';
export interface ApprovalRequest {
id: string;
kind: ApprovalKind;
certificate_id?: string;
job_id?: string;
profile_id: string;
requested_by: string;
state: ApprovalState;
decided_by?: string;
decided_at?: string;
decision_note?: string;
metadata?: Record<string, string>;
payload?: string; // base64 / raw JSON pass-through
created_at: string;
updated_at: string;
}
export const listApprovals = (state: ApprovalState = 'pending') =>
fetchJSON<PaginatedResponse<ApprovalRequest>>(`${BASE}/approvals?state=${state}`);
export const approveApproval = (id: string, note: string) =>
fetchJSON<unknown>(`${BASE}/approvals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ note }),
});
export const rejectApproval = (id: string, note: string) =>
fetchJSON<unknown>(`${BASE}/approvals/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ note }),
});
// Certificates
export const getCertificates = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();