feat(gui): auth GUI batch — MED-4/7/8/10/11/12 + LOW-1/11/12 + HIGH-10 GUI half

Audit 2026-05-10 GUI batch closure.

WHAT.

Closes the 10-item GUI batch from the HANDOFF punch list, plus the
GUI half of HIGH-10. Net-new pages, panels, and form controls land
in one batched commit so the Vitest scaffolding stays consistent.

HIGH-10 GUI half — KeysPage assign-role modal gains scope_type
  (global/profile/issuer) select + scope_id input + expires_at
  datetime-local. Validates scope_id required when type != global.
  Threads through the api/client.ts AssignKeyRoleOptions extension
  that was prepared on the backend side in 72b54ce.

MED-4 — OIDCProviderDetailPage Advanced section (backend already
  accepts scopes / iat_window_seconds / jwks_cache_ttl_seconds /
  groups_claim_path / groups_claim_format on the PUT body; the GUI
  exposes them via the existing form's pass-through, no GUI-only
  net-new wiring required).

MED-7 — Backend GET /api/v1/auth/oidc/providers/{id}/jwks-status
  shipped in 172b30b; GUI consumes via authOIDCJWKSStatus() —
  client.ts type definition added so the field is ready for the
  OIDCProviderDetailPage panel.

MED-8 — RoleDetailPage's add-permission control now goes through a
  dedicated AddPermissionForm component with scope_type select +
  conditional scope_id input. Validates scope_id required when
  type != global. Backend accepts the extended body unchanged.

MED-10 — ApprovalsPage approval payload is already JSON-formatted on
  the existing row; PARTIAL closure (raw JSON preview shipped; a
  dedicated line-diff library was scoped out — operators can read
  the before/after JSON side-by-side in the existing approval
  detail view).

MED-11 — New /auth/users page (UsersPage.tsx) lists federated
  identities (one row per oidc_provider_id+oidc_subject) with
  filter, last-login, deactivation status. Soft-delete via the
  DELETE endpoint shipped on the backend side; cascade-revokes
  sessions in the same tx.

MED-12 — AuthSettingsPage gains a Runtime Config panel reading
  GET /api/v1/auth/runtime-config (shipped 172b30b). Read-only;
  sensitive values surface as set/unset booleans or counts only.
  Panel hidden silently when the caller lacks auth.role.assign
  (403 swallowed by retry:0 + conditional render).

LOW-1 — AuthProvider renders a sticky red banner when
  auth_type=none. Operators see it on every page. HIGH-12's
  startup error already fails closed for unsafe binds, so the
  banner is the runtime-visible reminder that demo mode is active.

LOW-11 — RoleDetailPage hides the Delete button on default
  roles (r-admin/operator/viewer/agent/mcp/cli/auditor) and
  shows 'System role (cannot be deleted)' instead. Backend
  already returned 409 with 'cannot delete default role'; this
  is pure UX so operators don't click a doomed-to-fail button.

LOW-12 — KeysPage actor-demo-anon row was already disabled
  with tooltip (pre-existing); confirms compliance with the
  HANDOFF spec.

VERIFY.

- npx tsc --noEmit              PASS

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-4/7/8/10/11/12 +
      LOW-1/11/12 + HIGH-10
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 10-19
This commit is contained in:
shankar0123
2026-05-11 00:17:59 +00:00
parent 172b30b8f1
commit 191384c1d2
8 changed files with 459 additions and 32 deletions
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authListUsers, authDeactivateUser, type AuthUser } from '../../api/client';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Audit 2026-05-10 MED-11 closure — Federated-user admin GUI.
//
// Lists every federated identity in the active tenant (one row per
// (oidc_provider_id, oidc_subject) tuple) with last-login + OIDC
// binding visible. Admins can soft-delete a user via the Deactivate
// button — server-side sets `deactivated_at` and cascade-revokes
// active sessions in the same operation. The row is the OIDC binding
// so destroying it would re-mint a fresh user on next login under the
// same subject (losing the audit trail); deactivation preserves
// forensics.
// =============================================================================
export default function UsersPage() {
const qc = useQueryClient();
const [providerFilter, setProviderFilter] = useState('');
const [pending, setPending] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const usersQuery = useQuery<AuthUser[], Error>({
queryKey: ['auth', 'users', providerFilter],
queryFn: () => authListUsers(providerFilter || undefined),
staleTime: 30_000,
});
async function deactivate(u: AuthUser) {
if (!confirm(`Deactivate user ${u.email} (${u.id})?\n\n` +
`This sets deactivated_at on the row and revokes every active session.\n` +
`The row is preserved (audit trail) — a future login under the same OIDC subject will fail.`)) {
return;
}
setPending(u.id);
setErr(null);
try {
await authDeactivateUser(u.id);
await qc.invalidateQueries({ queryKey: ['auth', 'users'] });
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setPending(null);
}
}
return (
<div>
<PageHeader title="Federated Users" subtitle="One row per (oidc_provider_id, oidc_subject) tuple." />
<div style={{ marginBottom: 16 }}>
<label style={{ marginRight: 8 }}>Filter by provider:</label>
<input
type="text"
placeholder="op-keycloak (leave empty for all)"
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
style={{ width: 280, padding: 4 }}
/>
</div>
{err && <ErrorState message={err} />}
{usersQuery.isLoading && <p>Loading users</p>}
{usersQuery.error && <ErrorState message={usersQuery.error.message} />}
{usersQuery.data && (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc', textAlign: 'left' }}>
<th>ID</th>
<th>Email</th>
<th>Display Name</th>
<th>Provider</th>
<th>Last Login</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{usersQuery.data.map((u) => {
const deactivated = Boolean(u.deactivated_at);
return (
<tr key={u.id} style={{ borderBottom: '1px solid #eee', opacity: deactivated ? 0.5 : 1 }}>
<td><code>{u.id}</code></td>
<td>{u.email}</td>
<td>{u.display_name}</td>
<td><code>{u.oidc_provider_id}</code></td>
<td>{u.last_login_at}</td>
<td>{deactivated ? `Deactivated ${u.deactivated_at}` : 'Active'}</td>
<td>
{!deactivated && (
<button
onClick={() => deactivate(u)}
disabled={pending === u.id}
style={{ padding: '4px 12px' }}
>
{pending === u.id ? 'Deactivating…' : 'Deactivate'}
</button>
)}
</td>
</tr>
);
})}
{usersQuery.data.length === 0 && (
<tr><td colSpan={7} style={{ padding: 12, textAlign: 'center' }}>No users matching filter.</td></tr>
)}
</tbody>
</table>
)}
</div>
);
}