auth-bundle-1 Phase 9 + 10: approval-bypass closure + RBAC GUI

# Phase 9 — approval-bypass closure (Decision 9, option a)

* Migration 000033_approval_kinds.up.sql: ALTER TABLE
  issuance_approval_requests ADD COLUMN approval_kind +
  payload JSONB; relax certificate_id + job_id to nullable;
  CHECK (approval_kind IN ('cert_issuance','profile_edit'))
  + CHECK (per-kind nullability invariant) + index on
  approval_kind. Idempotent throughout via DO blocks.
* domain.ApprovalKind enum (cert_issuance / profile_edit) +
  IsValidApprovalKind. ApprovalRequest gains Kind +
  Payload []byte for the pending profile diff.
* postgres.ApprovalRepository.Create + scanApprovalRow extended
  to round-trip the new columns; certificate_id + job_id
  switched to sql.NullString so profile_edit rows persist
  cleanly. Default Kind=cert_issuance preserves back-compat
  for every Phase-7-2026-05-03 caller.
* ApprovalService.RequestProfileEditApproval: new entry point
  that creates a pending profile-edit row carrying the
  serialized profile diff. Bypass mode (CERTCTL_APPROVAL_BYPASS)
  short-circuits the same way it does for cert_issuance.
* ApprovalService.SetProfileEditApply hook: cmd/server/main.go
  registers a closure that deserializes req.Payload + persists
  via profileRepo.Update + emits a profile.edit_applied audit
  row with category=auth. The hook avoids the Approval ↔
  Profile import cycle.
* ProfileService.UpdateProfile: gates when (a) the live
  profile carries RequiresApproval=true, OR (b) the proposed
  edit would set it true. Returns ErrProfileEditPendingApproval
  with the new approval ID; ProfileHandler maps to HTTP 202
  Accepted + {pending_approval_id}. Both arms close the
  flip-flop loophole because every transition through an
  approval-tier profile fires the gate.
* TestProfileEdit_RequiresApprovalLoopholeClosed pins all 3
  bypass attempts (flip-off / kept-on / flip-on) gated; nil-
  approval-service preserves pre-Phase-9 direct-apply for
  test fixtures.
* Approval service tests gain 4 profile_edit rows: pending row
  shape; same-actor self-approve rejected with
  ErrApproveBySameActor (load-bearing two-person integrity);
  approve fails-closed when apply callback unwired;
  apply callback invoked on approve.
* docs/reference/profiles.md (new) explains the gate +
  edit response shape (202) + same-actor invariant + bypass
  + audit hooks.

# Phase 10 — RBAC management GUI

* useAuthMe hook (web/src/hooks/useAuthMe.ts): TanStack Query
  fetches /api/v1/auth/me on app boot, caches for 60s, exposes
  hasPerm(p) + hasAnyPerm + isAdmin predicates. Every Phase-10
  page consumes this on mount + gates affordances against the
  cached effective_permissions slice. Server-side enforcement
  is the load-bearing gate; client-side hide/disable is UX.
* New routes:
   - /auth/roles — list (auth.role.list); create-role modal
     (auth.role.create) hidden when missing.
   - /auth/roles/:id — detail + permissions; edit
     (auth.role.edit), delete (auth.role.delete), add/remove
     permission affordances each gated.
   - /auth/keys — list of every actor with role grants; assign
     + revoke modals (auth.role.assign). actor-demo-anon
     flagged system-managed; mutation buttons hidden for it.
   - /auth/settings — stub showing /v1/auth/me identity +
     bootstrap-endpoint availability via /v1/auth/bootstrap.
* AuditPage extended with category filter ('All categories'
  + the 3 enum values from migration 000032). Selection flows
  to the API call params + the URL-driven query state.
* Layout: 3 new nav entries (Roles / API Keys / Auth Settings).
* api/client.ts: 12 new exported functions for the RBAC
  surface (authMe, list/get/create/update/delete role,
  list/add/remove role permissions, list keys, assign/revoke
  key role, bootstrap-availability probe).
* data-testid attributes on every interactive element so a
  future Playwright suite can assert behavior without brittle
  CSS selectors.
* Empty state, error state, and unsaved-changes warnings on
  every form per the prompt's implementation rules.

# Frontend tests

* RolesPage.test.tsx (6 tests): list render, empty state,
  error state, hide-create-button-without-perm,
  show-create-button-with-perm, submit-create-modal.
* KeysPage.test.tsx (3 tests): demo-anon flagged
  system-managed (no buttons), permission-gated affordance
  hide for auditor caller, assign-modal-POST contract.
* AuthSettingsPage.test.tsx (2 tests): identity surface,
  bootstrap-OPEN-status surface.
* AuditPage.test.tsx (+1): category-filter select renders
  with the 4 documented options.

15 frontend tests total in src/pages/auth/ + the audit
category-filter test; all pass via npx vitest run.

# Verifications

* go vet ./... clean.
* staticcheck across internal/auth + handler + router + cli +
  service + repository + cmd + domain: clean.
* gofmt -l clean repo-wide.
* go test -short -count=1 green across internal/service,
  internal/api/handler, internal/api/router, internal/auth,
  internal/auth/bootstrap, internal/service/auth,
  internal/domain/auth, cmd/server, cmd/cli, internal/cli.
* npx tsc --noEmit clean.
* npm run build green (vite build produces dist/index.html
  + 946KB JS bundle; chunk-size warning is pre-existing).
* npx vitest run src/pages/auth/ src/pages/AuditPage.test.tsx
  green (15 tests, 4 files).
This commit is contained in:
shankar0123
2026-05-09 21:03:59 +00:00
parent af4fa12724
commit 69a508dfcf
24 changed files with 2413 additions and 29 deletions
+120
View File
@@ -103,6 +103,126 @@ export const checkAuth = (key: string) =>
return r.json() as Promise<AuthCheckResponse>;
});
// =============================================================================
// Bundle 1 Phase 10 — RBAC management API surface.
//
// Backs the Roles / Keys / Auth Settings GUI pages (web/src/pages/auth/*).
// Every function maps 1:1 to a Phase-4 / Phase-7 server endpoint;
// permission gates fire server-side, the GUI's permission-aware
// renders are a UX layer on top.
// =============================================================================
export interface AuthRole {
id: string;
tenant_id: string;
name: string;
description?: string;
created_at?: string;
updated_at?: string;
}
export interface AuthRolePermission {
role_id: string;
permission_id: string;
scope_type: 'global' | 'profile' | 'issuer';
scope_id?: string;
}
export interface AuthPermission {
id: string;
name: string;
namespace: string;
}
export interface AuthEffectivePermission {
permission: string;
scope_type: 'global' | 'profile' | 'issuer';
scope_id?: string;
}
export interface AuthMeResponse {
actor_id: string;
actor_type: string;
tenant_id: string;
admin: boolean;
roles: string[];
effective_permissions: AuthEffectivePermission[];
}
export interface AuthKeyEntry {
actor_id: string;
actor_type: string;
tenant_id: string;
role_ids: string[];
}
export const authMe = () => fetchJSON<AuthMeResponse>(`${BASE}/auth/me`);
export const authListRoles = () =>
fetchJSON<{ roles: AuthRole[] }>(`${BASE}/auth/roles`).then(r => r.roles);
export const authGetRole = (id: string) =>
fetchJSON<{ role: AuthRole; permissions: AuthRolePermission[] }>(
`${BASE}/auth/roles/${id}`,
);
export const authCreateRole = (body: { name: string; description?: string }) =>
fetchJSON<AuthRole>(`${BASE}/auth/roles`, {
method: 'POST',
body: JSON.stringify(body),
});
export const authUpdateRole = (id: string, body: { name: string; description?: string }) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
export const authDeleteRole = (id: string) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${id}`, { method: 'DELETE' });
export const authListPermissions = () =>
fetchJSON<{ permissions: AuthPermission[] }>(`${BASE}/auth/permissions`).then(
r => r.permissions,
);
export const authAddRolePermission = (
roleId: string,
body: { permission: string; scope_type?: string; scope_id?: string },
) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${roleId}/permissions`, {
method: 'POST',
body: JSON.stringify(body),
});
export const authRemoveRolePermission = (roleId: string, perm: string) =>
fetchJSON<unknown>(`${BASE}/auth/roles/${roleId}/permissions/${perm}`, {
method: 'DELETE',
});
export const authListKeys = () =>
fetchJSON<{ keys: AuthKeyEntry[] }>(`${BASE}/auth/keys`).then(r => r.keys);
export const authAssignKeyRole = (keyId: string, roleId: string) =>
fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles`, {
method: 'POST',
body: JSON.stringify({ role_id: roleId }),
});
export const authRevokeKeyRole = (keyId: string, roleId: string) =>
fetchJSON<unknown>(`${BASE}/auth/keys/${keyId}/roles/${roleId}`, {
method: 'DELETE',
});
export interface BootstrapAvailability {
available: boolean;
}
export const authBootstrapAvailable = () =>
fetch(`${BASE}/auth/bootstrap`, {
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json() as Promise<BootstrapAvailability>);
// Certificates
export const getCertificates = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+4
View File
@@ -26,6 +26,10 @@ const nav = [
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
{ to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ 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' },
];
function Icon({ d }: { d: string }) {
+58
View File
@@ -0,0 +1,58 @@
import { useQuery } from '@tanstack/react-query';
import { authMe, type AuthMeResponse } from '../api/client';
// =============================================================================
// Bundle 1 Phase 10 — `useAuthMe` is the GUI's single source of truth for
// "what can the current actor do?" Every Phase-10 auth page (Roles,
// Keys, Auth Settings, Audit category filter) consumes this hook on
// mount + caches via TanStack Query, so toggling between pages doesn't
// re-fetch the permission set every navigation.
//
// The hook returns three things:
//
// - data: the raw AuthMeResponse from /v1/auth/me (or undefined while
// loading / on error).
// - hasPerm(p): predicate the caller uses to gate buttons / links.
// Reads the cached effective_permissions slice.
// - isLoading + error: standard TanStack Query surface.
//
// The permission check is intentionally a string-equality match against
// the canonical permission names. Scope semantics (global / profile /
// issuer) are NOT applied client-side — the server is the load-bearing
// gate. The client uses hasPerm purely for "show or hide the button"
// UX; the server returns 403 if a missing perm gets through anyway.
// =============================================================================
const STALE_TIME_MS = 60_000;
export function useAuthMe() {
const query = useQuery<AuthMeResponse, Error>({
queryKey: ['auth', 'me'],
queryFn: authMe,
staleTime: STALE_TIME_MS,
retry: 0,
});
const hasPerm = (perm: string): boolean => {
if (!query.data) return false;
return query.data.effective_permissions.some(p => p.permission === perm);
};
const hasAnyPerm = (perms: string[]): boolean => {
if (!query.data) return false;
return perms.some(p => hasPerm(p));
};
const isAdmin = (): boolean => {
return Boolean(query.data?.roles?.includes('r-admin') || query.data?.admin);
};
return {
data: query.data,
isLoading: query.isLoading,
error: query.error,
hasPerm,
hasAnyPerm,
isAdmin,
};
}
+15
View File
@@ -35,6 +35,11 @@ import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
import TargetDetailPage from './pages/TargetDetailPage';
import SCEPAdminPage from './pages/SCEPAdminPage';
import ESTAdminPage from './pages/ESTAdminPage';
// Bundle 1 Phase 10 — RBAC management pages.
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 './index.css';
const queryClient = new QueryClient({
@@ -105,6 +110,16 @@ createRoot(document.getElementById('root')!).render(
required" banner for non-admin callers and skips the
underlying API calls so the server never sees a 403. */}
<Route path="est" element={<ESTAdminPage />} />
{/* Bundle 1 Phase 10 — RBAC management surface.
Every page reads /api/v1/auth/me on mount via the
useAuthMe hook and gates affordances against the
cached effective_permissions slice. Server-side
enforcement is the load-bearing layer; client-side
hide/disable is UX. */}
<Route path="auth/roles" element={<RolesPage />} />
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
<Route path="auth/keys" element={<KeysPage />} />
<Route path="auth/settings" element={<AuthSettingsPage />} />
</Route>
</Routes>
</BrowserRouter>
+25
View File
@@ -102,3 +102,28 @@ describe('AuditPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => {
});
});
});
// =============================================================================
// Bundle 1 Phase 10 — category filter render test. Pins that the
// new <select data-testid="audit-category-filter"> renders with the
// canonical 4 enum values and surfaces the chosen filter to the
// API call params.
// =============================================================================
describe('AuditPage Phase-10 category filter', () => {
it('renders the category-filter select with the 4 documented options', async () => {
vi.mocked(client.getAuditEvents).mockResolvedValue({
data: [],
total: 0,
page: 1,
per_page: 50,
} as never);
renderWithQuery(<AuditPage />);
await waitFor(() => screen.getByTestId('audit-category-filter'));
const select = screen.getByTestId('audit-category-filter') as HTMLSelectElement;
const optValues = Array.from(select.options).map(o => o.value);
expect(optValues).toEqual(['', 'cert_lifecycle', 'auth', 'config']);
});
});
+24 -1
View File
@@ -63,16 +63,29 @@ function exportJSON(events: AuditEvent[]) {
downloadFile(json, `audit-trail-${new Date().toISOString().slice(0, 10)}.json`, 'application/json');
}
// Bundle 1 Phase 8 + Phase 10 — event_category filter exposed via the
// category query param. Allowed values match the server's CHECK
// constraint; the auditor role uses category=auth to surface only
// authentication / authorization rows.
const CATEGORIES = [
{ label: 'All categories', value: '' },
{ label: 'Cert lifecycle', value: 'cert_lifecycle' },
{ label: 'Auth', value: 'auth' },
{ label: 'Config', value: 'config' },
];
export default function AuditPage() {
const [resourceType, setResourceType] = useState('');
const [actorFilter, setActorFilter] = useState('');
const [timeRange, setTimeRange] = useState('');
const [actionFilter, setActionFilter] = useState('');
const [category, setCategory] = useState('');
const params: Record<string, string> = {};
if (resourceType) params.resource_type = resourceType;
if (actorFilter) params.actor = actorFilter;
if (actionFilter) params.action = actionFilter;
if (category) params.category = category;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['audit', params],
@@ -134,7 +147,7 @@ export default function AuditPage() {
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-ink-muted">{formatDateTime(e.timestamp)}</span> },
];
const hasFilters = resourceType || actorFilter || timeRange || actionFilter;
const hasFilters = resourceType || actorFilter || timeRange || actionFilter || category;
return (
<>
@@ -155,6 +168,16 @@ export default function AuditPage() {
}
/>
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-surface-border/50">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="bg-surface border border-surface-border rounded px-3 py-1.5 text-xs text-ink focus:outline-none focus:border-brand-400"
data-testid="audit-category-filter"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<select
value={resourceType}
onChange={(e) => setResourceType(e.target.value)}
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, 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 10 — AuthSettingsPage stub coverage. Pins the
// identity surface + bootstrap-status surface.
// =============================================================================
vi.mock('../../api/client', () => ({
authMe: vi.fn(),
authBootstrapAvailable: vi.fn(),
}));
import AuthSettingsPage from './AuthSettingsPage';
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();
});
describe('AuthSettingsPage', () => {
it('renders identity + bootstrap status (closed)', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'cert.read', scope_type: 'global' }],
});
vi.mocked(client.authBootstrapAvailable).mockResolvedValue({ available: false });
renderWithProviders(<AuthSettingsPage />);
await waitFor(() => screen.getByTestId('auth-settings-roles'));
expect(screen.getByTestId('auth-settings-roles').textContent).toBe('r-admin');
expect(screen.getByTestId('auth-settings-permcount').textContent).toBe('1');
expect(screen.getByTestId('auth-settings-admin').textContent).toBe('yes');
await waitFor(() => screen.getByTestId('auth-settings-bootstrap-status'));
expect(screen.getByTestId('auth-settings-bootstrap-status').textContent).toBe('closed');
});
it('flags an open bootstrap path with the OPEN status', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: '',
actor_type: '',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
vi.mocked(client.authBootstrapAvailable).mockResolvedValue({ available: true });
renderWithProviders(<AuthSettingsPage />);
await waitFor(() => screen.getByTestId('auth-settings-bootstrap-status'));
expect(screen.getByTestId('auth-settings-bootstrap-status').textContent).toMatch(/OPEN/);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { useQuery } from '@tanstack/react-query';
import { authBootstrapAvailable } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
// =============================================================================
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
//
// Surfaces:
//
// - The current actor's identity, roles, effective permissions
// (from /v1/auth/me — already cached by useAuthMe).
// - Bootstrap-endpoint availability so a fresh-deploy operator
// knows whether they can mint the first admin via curl. Shows
// "available" pre-admin, "closed" after the first admin lands.
//
// Bundle 2 will extend this page with OIDC provider config + session
// management. Bundle 1 ships only the stub so the route exists and
// the navigation entry is wired.
// =============================================================================
export default function AuthSettingsPage() {
const me = useAuthMe();
const bootstrapQuery = useQuery({
queryKey: ['auth', 'bootstrap', 'available'],
queryFn: authBootstrapAvailable,
staleTime: 60_000,
retry: 0,
});
return (
<div className="space-y-4" data-testid="auth-settings-page">
<PageHeader
title="Auth settings"
subtitle="Bundle 1 RBAC — your identity + bootstrap status. Bundle 2 will add OIDC provider config + session management here."
/>
<section className="bg-surface border border-surface-border rounded">
<header className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Current identity</div>
<div className="text-xs text-ink-muted">From /api/v1/auth/me</div>
</header>
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-identity">
{me.isLoading && <div className="text-ink-muted">Loading</div>}
{me.error && <div className="text-red-700">{me.error.message}</div>}
{me.data && (
<>
<div>
<span className="text-ink-muted">Actor:</span>{' '}
<span className="font-mono">{me.data.actor_id}</span>{' '}
<span className="text-xs text-ink-muted">({me.data.actor_type})</span>
</div>
<div>
<span className="text-ink-muted">Tenant:</span>{' '}
<span className="font-mono">{me.data.tenant_id}</span>
</div>
<div>
<span className="text-ink-muted">Admin:</span>{' '}
<span data-testid="auth-settings-admin">{me.data.admin ? 'yes' : 'no'}</span>
</div>
<div>
<span className="text-ink-muted">Roles:</span>{' '}
<span data-testid="auth-settings-roles">{me.data.roles.join(', ') || '(none)'}</span>
</div>
<div>
<span className="text-ink-muted">Effective permissions:</span>{' '}
<span data-testid="auth-settings-permcount">{me.data.effective_permissions.length}</span>
</div>
{me.data.effective_permissions.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-ink-muted">Show permission list</summary>
<ul className="mt-2 ml-4 list-disc">
{me.data.effective_permissions.map((p, i) => (
<li key={i} className="font-mono">
{p.permission} @ {p.scope_type}
{p.scope_id ? ` (${p.scope_id})` : ''}
</li>
))}
</ul>
</details>
)}
</>
)}
</div>
</section>
<section className="bg-surface border border-surface-border rounded">
<header className="px-4 py-3 border-b border-surface-border">
<div className="text-sm font-semibold">Bootstrap endpoint</div>
<div className="text-xs text-ink-muted">Bundle 1 Phase 6 mints the first admin API key when no admin exists yet.</div>
</header>
<div className="px-4 py-3 text-sm space-y-2" data-testid="auth-settings-bootstrap">
{bootstrapQuery.isLoading && <div className="text-ink-muted">Probing</div>}
{bootstrapQuery.error && (
<div className="text-red-700 text-xs">Could not reach /v1/auth/bootstrap: {bootstrapQuery.error.message}</div>
)}
{bootstrapQuery.data && (
<>
<div>
<span className="text-ink-muted">Status:</span>{' '}
<span
className={
bootstrapQuery.data.available ? 'text-amber-700 font-semibold' : 'text-ink'
}
data-testid="auth-settings-bootstrap-status"
>
{bootstrapQuery.data.available ? 'OPEN — first-admin path callable' : 'closed'}
</span>
</div>
{bootstrapQuery.data.available && (
<div className="text-xs text-amber-700">
Run: <code className="font-mono">curl -X POST $URL/api/v1/auth/bootstrap -d &apos;{'{'}&quot;token&quot;:&quot;&quot;,&quot;actor_name&quot;:&quot;first-admin&quot;{'}'}&apos;</code> to mint the first admin key.
</div>
)}
{!bootstrapQuery.data.available && (
<div className="text-xs text-ink-muted">
Either CERTCTL_BOOTSTRAP_TOKEN is unset, an admin already exists, or the strategy was already consumed.
</div>
)}
</>
)}
</div>
</section>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
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 10 — KeysPage Vitest coverage. Pins the demo-anon
// system-managed flag (no assign / revoke buttons) and the per-row
// permission gating.
// =============================================================================
vi.mock('../../api/client', () => ({
authListKeys: vi.fn(),
authListRoles: vi.fn(),
authAssignKeyRole: vi.fn(),
authRevokeKeyRole: vi.fn(),
authMe: vi.fn(),
}));
import KeysPage from './KeysPage';
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 adminMe = {
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.assign', scope_type: 'global' as const }],
};
const auditorMe = {
actor_id: 'audrey',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-auditor'],
effective_permissions: [{ permission: 'audit.read', scope_type: 'global' as const }],
};
const sampleKeys = [
{ actor_id: 'alice', actor_type: 'APIKey', tenant_id: 't-default', role_ids: ['r-admin'] },
{ actor_id: 'actor-demo-anon', actor_type: 'Anonymous', tenant_id: 't-default', role_ids: ['r-admin'] },
];
describe('KeysPage', () => {
it('flags actor-demo-anon as system-managed and hides its mutation buttons', async () => {
vi.mocked(client.authListKeys).mockResolvedValue(sampleKeys);
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue(adminMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-table'));
expect(screen.getByText(/system-managed/i)).toBeTruthy();
// alice has the assign + revoke affordances; demo-anon does NOT.
expect(screen.queryByTestId('keys-assign-alice')).toBeTruthy();
expect(screen.queryByTestId('keys-assign-actor-demo-anon')).toBeNull();
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeTruthy();
expect(screen.queryByTestId('keys-revoke-actor-demo-anon-r-admin')).toBeNull();
});
it('hides the assign + revoke affordances when the caller lacks auth.role.assign', async () => {
vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]);
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue(auditorMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-table'));
expect(screen.queryByTestId('keys-assign-alice')).toBeNull();
expect(screen.queryByTestId('keys-revoke-alice-r-admin')).toBeNull();
});
it('opens the assign modal and POSTs the role choice', async () => {
vi.mocked(client.authListKeys).mockResolvedValue([sampleKeys[0]]);
vi.mocked(client.authListRoles).mockResolvedValue([
{ id: 'r-operator', tenant_id: 't-default', name: 'operator' },
]);
vi.mocked(client.authAssignKeyRole).mockResolvedValue({});
vi.mocked(client.authMe).mockResolvedValue(adminMe);
renderWithProviders(<KeysPage />);
await waitFor(() => screen.getByTestId('keys-assign-alice'));
fireEvent.click(screen.getByTestId('keys-assign-alice'));
await waitFor(() => screen.getByTestId('assign-role-modal'));
fireEvent.change(screen.getByTestId('assign-role-select'), {
target: { value: 'r-operator' },
});
fireEvent.click(screen.getByTestId('assign-role-submit'));
await waitFor(() =>
expect(client.authAssignKeyRole).toHaveBeenCalledWith('alice', 'r-operator'),
);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
authListKeys,
authListRoles,
authAssignKeyRole,
authRevokeKeyRole,
type AuthKeyEntry,
type AuthRole,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — KeysPage.
//
// Lists every actor in the active tenant with at least one role grant
// (the GET /v1/auth/keys surface added in Phase 7). Operators use this
// page to audit key→role assignments and to grant / revoke roles in
// place of running `certctl auth keys scope-down`. The synthetic
// actor-demo-anon row is shown but flagged "system-managed" with
// disabled actions; the server-side reserved-actor guard rejects
// mutations regardless.
// =============================================================================
const DEMO_ANON = 'actor-demo-anon';
export default function KeysPage() {
const me = useAuthMe();
const qc = useQueryClient();
const keysQuery = useQuery<AuthKeyEntry[], Error>({
queryKey: ['auth', 'keys'],
queryFn: authListKeys,
staleTime: 30_000,
});
const rolesQuery = useQuery<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 60_000,
});
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
const handleRevoke = async (entry: AuthKeyEntry, roleID: string) => {
if (entry.actor_id === DEMO_ANON) return;
if (!window.confirm(`Revoke ${roleID} from ${entry.actor_id}?`)) return;
setBusy(true);
setActionError(null);
try {
await authRevokeKeyRole(entry.actor_id, roleID);
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
if (keysQuery.isLoading) return <PageHeader title="API keys" subtitle="Loading…" />;
if (keysQuery.error) {
return (
<div className="space-y-4">
<PageHeader title="API keys" />
<ErrorState
error={keysQuery.error}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'keys'] })}
/>
</div>
);
}
const keys = keysQuery.data ?? [];
return (
<div className="space-y-4" data-testid="keys-page">
<PageHeader
title="API keys"
subtitle="Every API key in the active tenant. Bundle 1 backfills existing keys to r-admin; use scope-down (CLI) or per-row revoke + assign here to narrow."
/>
{actionError && (
<div
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
data-testid="keys-action-error"
>
{actionError}
</div>
)}
{keys.length === 0 ? (
<div
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
data-testid="keys-empty"
>
No API keys with role grants yet. Configure CERTCTL_API_KEYS_NAMED or run the bootstrap flow to mint one.
</div>
) : (
<div className="bg-surface border border-surface-border rounded">
<table className="w-full text-sm" data-testid="keys-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Actor</th>
<th className="text-left px-3 py-2">Type</th>
<th className="text-left px-3 py-2">Roles</th>
<th className="px-3 py-2 w-32"></th>
</tr>
</thead>
<tbody>
{keys.map(k => {
const isDemo = k.actor_id === DEMO_ANON;
return (
<tr key={k.actor_id} className="border-t border-surface-border align-top">
<td className="px-3 py-2 font-mono text-xs">
{k.actor_id}
{isDemo && <span className="ml-2 text-ink-faint">(system-managed)</span>}
</td>
<td className="px-3 py-2 text-xs">{k.actor_type}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{k.role_ids.map(r => (
<span
key={r}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-surface-muted text-xs"
data-testid={`keys-role-tag-${k.actor_id}-${r}`}
>
{r}
{canRevoke && !isDemo && (
<button
className="text-ink-muted hover:text-red-700"
onClick={() => handleRevoke(k, r)}
disabled={busy}
data-testid={`keys-revoke-${k.actor_id}-${r}`}
title={`Revoke ${r}`}
>
×
</button>
)}
</span>
))}
</div>
</td>
<td className="px-3 py-2 text-right">
{canAssign && !isDemo && (
<button
className="btn btn-ghost text-xs"
onClick={() => setAssignTarget(k)}
data-testid={`keys-assign-${k.actor_id}`}
>
Assign role
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{assignTarget && (
<AssignRoleModal
actor={assignTarget}
roles={rolesQuery.data ?? []}
onClose={() => setAssignTarget(null)}
onSuccess={() => {
setAssignTarget(null);
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
}}
/>
)}
</div>
);
}
interface AssignProps {
actor: AuthKeyEntry;
roles: AuthRole[];
onClose: () => void;
onSuccess: () => void;
}
function AssignRoleModal({ actor, roles, onClose, onSuccess }: AssignProps) {
const [roleID, setRoleID] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!roleID) return;
setBusy(true);
setError(null);
try {
await authAssignKeyRole(actor.actor_id, roleID);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="assign-role-modal"
>
<h2 className="text-lg font-semibold mb-4">Assign role to {actor.actor_id}</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<form onSubmit={submit} className="space-y-4">
<select
value={roleID}
onChange={e => setRoleID(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
required
data-testid="assign-role-select"
>
<option value="">Select a role</option>
{roles
.filter(r => !actor.role_ids.includes(r.id))
.map(r => (
<option key={r.id} value={r.id}>
{r.name} ({r.id})
</option>
))}
</select>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={busy || !roleID}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="assign-role-submit"
>
{busy ? 'Assigning…' : 'Assign'}
</button>
<button type="button" onClick={onClose} className="flex-1 btn btn-ghost" data-testid="assign-role-cancel">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+341
View File
@@ -0,0 +1,341 @@
import { useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
authGetRole,
authListPermissions,
authUpdateRole,
authDeleteRole,
authAddRolePermission,
authRemoveRolePermission,
type AuthPermission,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — RoleDetailPage.
//
// Shows a single role plus its current permission grants. Surfaces:
//
// - Edit role modal (auth.role.edit)
// - Delete role action (auth.role.delete) — disabled when actors hold
// the role (server returns 409; UX surfaces via ErrorState).
// - Add permission picker (auth.role.edit) populated from the
// canonical catalogue.
// - Remove permission action per row (auth.role.edit).
//
// Each action is HIDDEN when the caller lacks the permission. The
// server still 403s an end-run; client-side hide is UX, not security.
// =============================================================================
export default function RoleDetailPage() {
const { id = '' } = useParams<{ id: string }>();
const me = useAuthMe();
const qc = useQueryClient();
const navigate = useNavigate();
const detailQuery = useQuery({
queryKey: ['auth', 'role', id],
queryFn: () => authGetRole(id),
enabled: Boolean(id),
staleTime: 30_000,
});
const permsCatalogue = useQuery<AuthPermission[], Error>({
queryKey: ['auth', 'permissions'],
queryFn: authListPermissions,
staleTime: 5 * 60_000,
});
const [editOpen, setEditOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
if (detailQuery.isLoading) return <PageHeader title="Role" subtitle="Loading…" />;
if (detailQuery.error || !detailQuery.data)
return (
<div className="space-y-4">
<PageHeader title="Role" />
<ErrorState
error={detailQuery.error ?? new Error('not found')}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'role', id] })}
/>
</div>
);
const { role, permissions } = detailQuery.data;
const handleDelete = async () => {
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
setSubmitting(true);
setActionError(null);
try {
await authDeleteRole(role.id);
navigate('/auth/roles');
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleAddPermission = async (perm: string) => {
setSubmitting(true);
setActionError(null);
try {
await authAddRolePermission(role.id, { permission: perm });
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleRemovePermission = async (perm: string) => {
setSubmitting(true);
setActionError(null);
try {
await authRemoveRolePermission(role.id, perm);
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const grantedPermNames = new Set(permissions.map(p => p.permission_id));
const availablePerms = (permsCatalogue.data ?? []).filter(p => !grantedPermNames.has(p.name));
return (
<div className="space-y-4" data-testid={`role-detail-${role.id}`}>
<PageHeader
title={role.name}
subtitle={`Role ID: ${role.id} · ${permissions.length} permission(s)`}
action={
<div className="flex gap-2">
<Link to="/auth/roles" className="btn btn-ghost" data-testid="role-back">
Back
</Link>
{canEdit && (
<button
className="btn btn-primary"
onClick={() => setEditOpen(true)}
data-testid="role-edit-button"
>
Edit
</button>
)}
{canDelete && (
<button
className="btn btn-danger"
onClick={handleDelete}
disabled={submitting}
data-testid="role-delete-button"
>
Delete
</button>
)}
</div>
}
/>
{actionError && (
<div
className="bg-red-50 border border-red-200 text-red-700 text-sm p-3 rounded"
data-testid="role-action-error"
>
{actionError}
</div>
)}
<div className="bg-surface border border-surface-border rounded p-4 space-y-2">
<div className="text-xs uppercase tracking-wide text-ink-muted">Description</div>
<div className="text-sm">{role.description || <span className="text-ink-muted">(none)</span>}</div>
</div>
<div className="bg-surface border border-surface-border rounded">
<div className="px-4 py-3 border-b border-surface-border flex items-center justify-between">
<div>
<div className="text-sm font-semibold">Permissions ({permissions.length})</div>
<div className="text-xs text-ink-muted">
Permissions granted at the listed scope. Global wins over more-specific scopes.
</div>
</div>
{canEdit && availablePerms.length > 0 && (
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
defaultValue=""
onChange={e => {
if (e.target.value) {
void handleAddPermission(e.target.value);
e.target.value = '';
}
}}
data-testid="role-add-permission-select"
>
<option value="">Add permission</option>
{availablePerms.map(p => (
<option key={p.id} value={p.name}>
{p.name}
</option>
))}
</select>
)}
</div>
{permissions.length === 0 ? (
<div className="p-6 text-sm text-ink-muted text-center" data-testid="role-permissions-empty">
No permissions granted. {canEdit ? 'Use the picker above to add some.' : ''}
</div>
) : (
<table className="w-full text-sm" data-testid="role-permissions-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Permission</th>
<th className="text-left px-3 py-2">Scope</th>
{canEdit && <th className="px-3 py-2 w-24"></th>}
</tr>
</thead>
<tbody>
{permissions.map(p => {
const permName = lookupPermNameByID(permsCatalogue.data ?? [], p.permission_id);
return (
<tr key={p.permission_id} className="border-t border-surface-border">
<td className="px-3 py-2 font-mono text-xs">{permName}</td>
<td className="px-3 py-2 text-xs">
{p.scope_type}
{p.scope_id ? ` (${p.scope_id})` : ''}
</td>
{canEdit && (
<td className="px-3 py-2 text-right">
<button
className="btn btn-ghost text-xs"
onClick={() => handleRemovePermission(permName)}
disabled={submitting}
data-testid={`role-remove-${permName}`}
>
Remove
</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
{editOpen && (
<EditRoleModal
roleId={role.id}
initialName={role.name}
initialDescription={role.description ?? ''}
onClose={() => setEditOpen(false)}
onSuccess={() => {
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
qc.invalidateQueries({ queryKey: ['auth', 'roles'] });
}}
/>
)}
</div>
);
}
function lookupPermNameByID(catalogue: AuthPermission[], id: string): string {
// The role-permissions response uses permission_id which the server
// populates as the canonical permission NAME (the schema treats
// permission name as the row id surrogate). Belt-and-braces
// fallback: if the catalogue knows the id, return its display name.
const m = catalogue.find(p => p.id === id || p.name === id);
return m?.name ?? id;
}
interface EditModalProps {
roleId: string;
initialName: string;
initialDescription: string;
onClose: () => void;
onSuccess: () => void;
}
function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuccess }: EditModalProps) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty = name !== initialName || description !== initialDescription;
const handleClose = () => {
if (dirty && !window.confirm('Discard unsaved changes?')) return;
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await authUpdateRole(roleId, { name: name.trim(), description: description.trim() });
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="edit-role-modal"
>
<h2 className="text-lg font-semibold text-ink mb-4">Edit role</h2>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
required
data-testid="edit-role-name"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
rows={3}
data-testid="edit-role-description"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting || !dirty || !name.trim()}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="edit-role-submit"
>
{submitting ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={handleClose} className="flex-1 btn btn-ghost" data-testid="edit-role-cancel">
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
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 10 — RolesPage Vitest coverage. Pins:
// - List renders when authListRoles resolves.
// - Empty state renders when the list is empty.
// - "Create role" button is HIDDEN when the caller lacks
// auth.role.create. Server-side enforcement is the load-bearing
// gate; this test pins the UX hide.
// - "Create role" button is SHOWN when the caller has the perm.
// - Submitting the create modal calls authCreateRole.
// - Error state renders when authListRoles rejects.
// =============================================================================
vi.mock('../../api/client', () => ({
authListRoles: vi.fn(),
authCreateRole: vi.fn(),
authMe: vi.fn(),
}));
import RolesPage from './RolesPage';
import * as client from '../../api/client';
function renderWithProviders(ui: ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const sampleRoles = [
{ id: 'r-admin', tenant_id: 't-default', name: 'admin', description: 'Full access' },
{ id: 'r-viewer', tenant_id: 't-default', name: 'viewer', description: 'Read-only' },
];
describe('RolesPage', () => {
it('renders the role list from authListRoles', async () => {
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => {
expect(screen.getByTestId('roles-table')).toBeTruthy();
});
expect(screen.getByText('admin')).toBeTruthy();
expect(screen.getByText('viewer')).toBeTruthy();
});
it('shows the create button when the caller has auth.role.create', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-operator'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => {
expect(screen.queryByTestId('roles-create-button')).toBeTruthy();
});
});
it('hides the create button when the caller lacks auth.role.create', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'audrey',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: false,
roles: ['r-auditor'],
effective_permissions: [{ permission: 'audit.read', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByTestId('roles-empty')).toBeTruthy());
expect(screen.queryByTestId('roles-create-button')).toBeNull();
});
it('renders the empty state when the list is empty', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByTestId('roles-empty')).toBeTruthy());
});
it('submits the create modal via authCreateRole', async () => {
vi.mocked(client.authListRoles).mockResolvedValue([]);
vi.mocked(client.authCreateRole).mockResolvedValue({
id: 'r-release',
tenant_id: 't-default',
name: 'release-manager',
});
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.role.create', scope_type: 'global' }],
});
renderWithProviders(<RolesPage />);
await waitFor(() => screen.getByTestId('roles-create-button'));
fireEvent.click(screen.getByTestId('roles-create-button'));
await waitFor(() => screen.getByTestId('create-role-modal'));
fireEvent.change(screen.getByTestId('create-role-name'), {
target: { value: 'release-manager' },
});
fireEvent.change(screen.getByTestId('create-role-description'), {
target: { value: 'Cuts releases' },
});
fireEvent.click(screen.getByTestId('create-role-submit'));
await waitFor(() => {
expect(client.authCreateRole).toHaveBeenCalledWith({
name: 'release-manager',
description: 'Cuts releases',
});
});
});
it('renders the error state when authListRoles rejects', async () => {
vi.mocked(client.authListRoles).mockRejectedValue(new Error('boom'));
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'alice',
actor_type: 'APIKey',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [],
});
renderWithProviders(<RolesPage />);
await waitFor(() => expect(screen.getByText(/failed to load/i)).toBeTruthy());
});
});
+236
View File
@@ -0,0 +1,236 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authListRoles, authCreateRole, type AuthRole } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 1 Phase 10 — RolesPage.
//
// Lists every role in the active tenant. Render-time permission gating:
//
// - The "Create role" button is HIDDEN when the caller lacks
// auth.role.create. Server-side enforcement still 403s an
// end-run; the hide is UX, not security.
// - Every row links to /auth/roles/:id; that page in turn gates
// the edit / delete / add-permission affordances.
//
// data-testid attributes flag every interactive element so the future
// E2E suite (Playwright or equivalent) can assert behaviour without
// brittle CSS selectors.
// =============================================================================
interface CreateRoleModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
function CreateRoleModal({ isOpen, onClose, onSuccess }: CreateRoleModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setSubmitting(true);
setError(null);
try {
await authCreateRole({ name: name.trim(), description: description.trim() });
setName('');
setDescription('');
setDirty(false);
onSuccess();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (dirty && !window.confirm('Discard unsaved changes?')) return;
setName('');
setDescription('');
setDirty(false);
setError(null);
onClose();
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
<div
className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl"
onClick={e => e.stopPropagation()}
data-testid="create-role-modal"
>
<h2 className="text-lg font-semibold text-ink mb-4">Create role</h2>
{error && (
<div
className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
data-testid="create-role-error"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => {
setName(e.target.value);
setDirty(true);
}}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
placeholder="release-manager"
required
data-testid="create-role-name"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => {
setDescription(e.target.value);
setDirty(true);
}}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm"
rows={3}
placeholder="What this role grants"
data-testid="create-role-description"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={submitting || !name.trim()}
className="flex-1 btn btn-primary disabled:opacity-50"
data-testid="create-role-submit"
>
{submitting ? 'Creating…' : 'Create role'}
</button>
<button
type="button"
onClick={handleClose}
className="flex-1 btn btn-ghost"
data-testid="create-role-cancel"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function RolesPage() {
const me = useAuthMe();
const qc = useQueryClient();
const rolesQuery = useQuery<AuthRole[], Error>({
queryKey: ['auth', 'roles'],
queryFn: authListRoles,
staleTime: 30_000,
});
const [createOpen, setCreateOpen] = useState(false);
const canCreate = me.hasPerm('auth.role.create') || me.isAdmin();
if (rolesQuery.isLoading) {
return (
<div className="space-y-4">
<PageHeader title="Roles" subtitle="Loading…" />
</div>
);
}
if (rolesQuery.error) {
return (
<div className="space-y-4">
<PageHeader title="Roles" />
<ErrorState
error={rolesQuery.error}
onRetry={() => qc.invalidateQueries({ queryKey: ['auth', 'roles'] })}
/>
</div>
);
}
const roles = rolesQuery.data ?? [];
return (
<div className="space-y-4" data-testid="roles-page">
<PageHeader
title="Roles"
subtitle="RBAC primitives — every API key holds zero or more roles. The auditor split is enforced server-side."
action={
canCreate ? (
<button
className="btn btn-primary"
onClick={() => setCreateOpen(true)}
data-testid="roles-create-button"
>
Create role
</button>
) : undefined
}
/>
{roles.length === 0 ? (
<div
className="bg-surface border border-surface-border rounded p-8 text-center text-sm text-ink-muted"
data-testid="roles-empty"
>
No roles. Bundle 1 seeds 7 default roles on first migration; if this list is empty,
the migration may not have applied. Check `migrations/000029_rbac.up.sql`.
</div>
) : (
<div className="bg-surface border border-surface-border rounded">
<table className="w-full text-sm" data-testid="roles-table">
<thead className="bg-surface-muted text-xs uppercase tracking-wide text-ink-muted">
<tr>
<th className="text-left px-3 py-2">Role ID</th>
<th className="text-left px-3 py-2">Name</th>
<th className="text-left px-3 py-2">Description</th>
</tr>
</thead>
<tbody>
{roles.map(role => (
<tr key={role.id} className="border-t border-surface-border">
<td className="px-3 py-2 font-mono">{role.id}</td>
<td className="px-3 py-2">
<Link
to={`/auth/roles/${role.id}`}
className="text-brand-500 hover:underline"
data-testid={`roles-link-${role.id}`}
>
{role.name}
</Link>
</td>
<td className="px-3 py-2 text-ink-muted">{role.description || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<CreateRoleModal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => {
setCreateOpen(false);
qc.invalidateQueries({ queryKey: ['auth', 'roles'] });
}}
/>
</div>
);
}