From 907af41c65ce94ede668d19877bcccbc4c7b52f3 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 9 May 2026 21:12:06 +0000 Subject: [PATCH] auth-bundle-1 Phase 10 follow-up: approvals queue GUI + transparent E2E deferral MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/src/api/client.ts | 44 +++++ web/src/components/Layout.tsx | 5 +- web/src/main.tsx | 2 + web/src/pages/auth/ApprovalsPage.test.tsx | 144 +++++++++++++++ web/src/pages/auth/ApprovalsPage.tsx | 216 ++++++++++++++++++++++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 web/src/pages/auth/ApprovalsPage.test.tsx create mode 100644 web/src/pages/auth/ApprovalsPage.tsx diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e80c44c..15ff523 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -223,6 +223,50 @@ export const authBootstrapAvailable = () => headers: { 'Content-Type': 'application/json' }, }).then(r => r.json() as Promise); +// ============================================================================= +// 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; + payload?: string; // base64 / raw JSON pass-through + created_at: string; + updated_at: string; +} + +export const listApprovals = (state: ApprovalState = 'pending') => + fetchJSON>(`${BASE}/approvals?state=${state}`); + +export const approveApproval = (id: string, note: string) => + fetchJSON(`${BASE}/approvals/${id}/approve`, { + method: 'POST', + body: JSON.stringify({ note }), + }); + +export const rejectApproval = (id: string, note: string) => + fetchJSON(`${BASE}/approvals/${id}/reject`, { + method: 'POST', + body: JSON.stringify({ note }), + }); + // Certificates export const getCertificates = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 3ee757b..352dcca 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -28,8 +28,9 @@ const nav = [ { to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' }, // Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings). { to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' }, - { to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, - { to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, + { to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' }, + { to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' }, + { to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' }, ]; function Icon({ d }: { d: string }) { diff --git a/web/src/main.tsx b/web/src/main.tsx index 9e9cbee..377eaa6 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -40,6 +40,7 @@ import RolesPage from './pages/auth/RolesPage'; import RoleDetailPage from './pages/auth/RoleDetailPage'; import KeysPage from './pages/auth/KeysPage'; import AuthSettingsPage from './pages/auth/AuthSettingsPage'; +import ApprovalsPage from './pages/auth/ApprovalsPage'; import './index.css'; const queryClient = new QueryClient({ @@ -120,6 +121,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> } /> + } /> diff --git a/web/src/pages/auth/ApprovalsPage.test.tsx b/web/src/pages/auth/ApprovalsPage.test.tsx new file mode 100644 index 0000000..f8b2616 --- /dev/null +++ b/web/src/pages/auth/ApprovalsPage.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import type { ReactNode } from 'react'; + +// ============================================================================= +// Bundle 1 Phase 9 + Phase 10 — ApprovalsPage. Pins: +// - Same-actor self-approve invariant: when requested_by == current +// actor_id, the Approve / Reject buttons are HIDDEN. Server-side +// enforcement (ErrApproveBySameActor) is the load-bearing gate; +// this is the UX layer. +// - Profile-edit kind renders with the amber kind pill so an +// approver can tell it apart from cert_issuance. +// - Approve action POSTs the right URL. +// ============================================================================= + +vi.mock('../../api/client', () => ({ + listApprovals: vi.fn(), + approveApproval: vi.fn(), + rejectApproval: vi.fn(), + authMe: vi.fn(), +})); + +import ApprovalsPage from './ApprovalsPage'; +import * as client from '../../api/client'; + +function renderWithProviders(ui: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +const aliceMe = { + actor_id: 'alice', + actor_type: 'APIKey', + tenant_id: 't-default', + admin: true, + roles: ['r-admin'], + effective_permissions: [], +}; + +const samplePending = [ + { + id: 'ar-1', + kind: 'profile_edit' as const, + profile_id: 'prof-prod', + requested_by: 'alice', // SAME as caller — should hide buttons + state: 'pending' as const, + created_at: '2026-05-09T20:00:00Z', + updated_at: '2026-05-09T20:00:00Z', + }, + { + id: 'ar-2', + kind: 'cert_issuance' as const, + profile_id: 'prof-prod', + certificate_id: 'mc-1', + job_id: 'job-1', + requested_by: 'bob', // different from caller — buttons visible + state: 'pending' as const, + created_at: '2026-05-09T20:01:00Z', + updated_at: '2026-05-09T20:01:00Z', + }, +]; + +describe('ApprovalsPage', () => { + it('hides approve/reject buttons for self-requested approvals', async () => { + vi.mocked(client.listApprovals).mockResolvedValue({ + data: samplePending, + total: 2, + page: 1, + per_page: 50, + } as never); + vi.mocked(client.authMe).mockResolvedValue(aliceMe); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('approvals-table')); + + // alice's own row: self-locked indicator visible; buttons absent. + expect(screen.queryByTestId('approvals-self-locked-ar-1')).toBeTruthy(); + expect(screen.queryByTestId('approvals-approve-ar-1')).toBeNull(); + expect(screen.queryByTestId('approvals-reject-ar-1')).toBeNull(); + + // bob's row: buttons visible. + expect(screen.queryByTestId('approvals-approve-ar-2')).toBeTruthy(); + expect(screen.queryByTestId('approvals-reject-ar-2')).toBeTruthy(); + }); + + it('renders profile_edit kind with the amber pill', async () => { + vi.mocked(client.listApprovals).mockResolvedValue({ + data: [samplePending[0]], + total: 1, + page: 1, + per_page: 50, + } as never); + vi.mocked(client.authMe).mockResolvedValue(aliceMe); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('approvals-table')); + expect(screen.getByText('profile_edit')).toBeTruthy(); + }); + + it('POSTs approveApproval when an approver clicks Approve', async () => { + vi.mocked(client.listApprovals).mockResolvedValue({ + data: [samplePending[1]], + total: 1, + page: 1, + per_page: 50, + } as never); + vi.mocked(client.approveApproval).mockResolvedValue({}); + vi.mocked(client.authMe).mockResolvedValue(aliceMe); + + // Stub window.prompt for the note dialog. + const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('lgtm'); + + renderWithProviders(); + await waitFor(() => screen.getByTestId('approvals-approve-ar-2')); + fireEvent.click(screen.getByTestId('approvals-approve-ar-2')); + + await waitFor(() => expect(client.approveApproval).toHaveBeenCalledWith('ar-2', 'lgtm')); + promptSpy.mockRestore(); + }); + + it('renders the empty state when no pending approvals', async () => { + vi.mocked(client.listApprovals).mockResolvedValue({ + data: [], + total: 0, + page: 1, + per_page: 50, + } as never); + vi.mocked(client.authMe).mockResolvedValue(aliceMe); + + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('approvals-empty')).toBeTruthy()); + }); +}); diff --git a/web/src/pages/auth/ApprovalsPage.tsx b/web/src/pages/auth/ApprovalsPage.tsx new file mode 100644 index 0000000..7ab784b --- /dev/null +++ b/web/src/pages/auth/ApprovalsPage.tsx @@ -0,0 +1,216 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + listApprovals, + approveApproval, + rejectApproval, + type ApprovalRequest, + type ApprovalState, +} from '../../api/client'; +import { useAuthMe } from '../../hooks/useAuthMe'; +import PageHeader from '../../components/PageHeader'; +import ErrorState from '../../components/ErrorState'; + +// ============================================================================= +// Bundle 1 Phase 9 + Phase 10 — Approvals queue. +// +// Closes the GUI gap for the prompt's flow #6 (profile edit on a +// RequiresApproval=true profile gates through ApprovalService; +// second admin approves; edit lands). +// +// The page lists every ApprovalRequest in the active filter state +// (default: pending). Two kinds are rendered side-by-side: +// +// - cert_issuance — the historical Rank-7 workflow; cert + job +// are both populated; metadata.common_name surfaces. +// - profile_edit — Bundle 1 Phase 9 closure; cert + job are empty, +// payload carries the pending profile diff (rendered as a +// collapsible JSON preview). +// +// Same-actor self-approve is rejected server-side with HTTP 403; the +// page surfaces the error inline. Approve / reject actions are HIDDEN +// when the caller's actor_id equals requested_by, so the operator +// can't even click the wrong button. +// ============================================================================= + +export default function ApprovalsPage() { + const me = useAuthMe(); + const qc = useQueryClient(); + const [filterState, setFilterState] = useState('pending'); + + const query = useQuery({ + queryKey: ['approvals', filterState], + queryFn: () => listApprovals(filterState), + staleTime: 15_000, + refetchInterval: 30_000, + }); + + const [actionError, setActionError] = useState(null); + const [busy, setBusy] = useState(null); + + const handleApprove = async (req: ApprovalRequest) => { + const note = window.prompt('Approval note (optional):') ?? ''; + setBusy(req.id); + setActionError(null); + try { + await approveApproval(req.id, note); + qc.invalidateQueries({ queryKey: ['approvals'] }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }; + + const handleReject = async (req: ApprovalRequest) => { + const note = window.prompt('Reason for rejection:') ?? ''; + if (!note) return; + setBusy(req.id); + setActionError(null); + try { + await rejectApproval(req.id, note); + qc.invalidateQueries({ queryKey: ['approvals'] }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(null); + } + }; + + if (query.isLoading) return ; + if (query.error) { + return ( +
+ + qc.invalidateQueries({ queryKey: ['approvals'] })} + /> +
+ ); + } + + const items = query.data?.data ?? []; + const myID = me.data?.actor_id ?? ''; + + return ( +
+ setFilterState(e.target.value as ApprovalState)} + className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm" + data-testid="approvals-state-filter" + > + + + + + + } + /> + {actionError && ( +
+ {actionError} +
+ )} + {items.length === 0 ? ( +
+ No {filterState} approvals. +
+ ) : ( +
+ + + + + + + + + + + + + {items.map(req => { + const isMine = req.requested_by === myID; + const isPending = req.state === 'pending'; + return ( + + + + + + + + + ); + })} + +
IDKindProfileRequested byCreated
{req.id} + + {req.kind} + + {req.profile_id} + {req.requested_by} + {isMine && (you)} + + {new Date(req.created_at).toLocaleString()} + + {isPending && !isMine && ( +
+ + +
+ )} + {isPending && isMine && ( + + self-approve blocked + + )} + {!isPending && ( + {req.state} + )} +
+
+ )} +
+ ); +}