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 69a508d; 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 69a508dfcf
commit cfe76ad381
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();
+3 -2
View File
@@ -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 }) {
+2
View File
@@ -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(
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
<Route path="auth/keys" element={<KeysPage />} />
<Route path="auth/settings" element={<AuthSettingsPage />} />
<Route path="auth/approvals" element={<ApprovalsPage />} />
</Route>
</Routes>
</BrowserRouter>
+144
View File
@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
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(<ApprovalsPage />);
await waitFor(() => expect(screen.getByTestId('approvals-empty')).toBeTruthy());
});
});
+216
View File
@@ -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<ApprovalState>('pending');
const query = useQuery({
queryKey: ['approvals', filterState],
queryFn: () => listApprovals(filterState),
staleTime: 15_000,
refetchInterval: 30_000,
});
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(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 <PageHeader title="Approvals" subtitle="Loading…" />;
if (query.error) {
return (
<div className="space-y-4">
<PageHeader title="Approvals" />
<ErrorState
error={query.error as Error}
onRetry={() => qc.invalidateQueries({ queryKey: ['approvals'] })}
/>
</div>
);
}
const items = query.data?.data ?? [];
const myID = me.data?.actor_id ?? '';
return (
<div className="space-y-4" data-testid="approvals-page">
<PageHeader
title="Approvals queue"
subtitle="Two-person integrity / four-eyes principle. The requester cannot self-approve — same-actor approvals are rejected server-side."
action={
<select
value={filterState}
onChange={e => 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"
>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="expired">Expired</option>
</select>
}
/>
{actionError && (
<div
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
data-testid="approvals-action-error"
>
{actionError}
</div>
)}
{items.length === 0 ? (
<div
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
data-testid="approvals-empty"
>
No {filterState} approvals.
</div>
) : (
<div className="bg-surface border border-surface-border rounded">
<table className="w-full text-sm" data-testid="approvals-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">ID</th>
<th className="text-left px-3 py-2">Kind</th>
<th className="text-left px-3 py-2">Profile</th>
<th className="text-left px-3 py-2">Requested by</th>
<th className="text-left px-3 py-2">Created</th>
<th className="px-3 py-2 w-44"></th>
</tr>
</thead>
<tbody>
{items.map(req => {
const isMine = req.requested_by === myID;
const isPending = req.state === 'pending';
return (
<tr
key={req.id}
className="border-t border-surface-border align-top"
data-testid={`approvals-row-${req.id}`}
>
<td className="px-3 py-2 font-mono text-xs">{req.id}</td>
<td className="px-3 py-2">
<span
className={
'inline-block px-2 py-0.5 rounded text-xs ' +
(req.kind === 'profile_edit'
? 'bg-amber-100 text-amber-800'
: 'bg-surface-muted')
}
>
{req.kind}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs">{req.profile_id}</td>
<td className="px-3 py-2 text-xs">
{req.requested_by}
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
</td>
<td className="px-3 py-2 text-xs text-ink-muted">
{new Date(req.created_at).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
{isPending && !isMine && (
<div className="flex gap-1 justify-end">
<button
className="btn btn-primary text-xs"
onClick={() => handleApprove(req)}
disabled={busy === req.id}
data-testid={`approvals-approve-${req.id}`}
>
Approve
</button>
<button
className="btn btn-ghost text-xs"
onClick={() => handleReject(req)}
disabled={busy === req.id}
data-testid={`approvals-reject-${req.id}`}
>
Reject
</button>
</div>
)}
{isPending && isMine && (
<span
className="text-xs text-ink-muted italic"
data-testid={`approvals-self-locked-${req.id}`}
>
self-approve blocked
</span>
)}
{!isPending && (
<span className="text-xs text-ink-muted">{req.state}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}