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
+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>
);
}