mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 '{'{'}"token":"…","actor_name":"first-admin"{'}'}'</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>
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user