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
+114 -28
View File
@@ -30,6 +30,20 @@ import ErrorState from '../../components/ErrorState';
// server still 403s an end-run; client-side hide is UX, not security.
// =============================================================================
// Audit 2026-05-10 LOW-11 — default role ids the server seeds via
// migrations 000029 + 000039. The backend rejects DELETE on any of
// these with HTTP 409; this set mirrors the seed so the GUI hides
// the Delete button on system roles. Keep in sync with the migrations.
const DEFAULT_ROLE_IDS = new Set([
'r-admin',
'r-operator',
'r-viewer',
'r-agent',
'r-mcp',
'r-cli',
'r-auditor',
]);
export default function RoleDetailPage() {
const { id = '' } = useParams<{ id: string }>();
const me = useAuthMe();
@@ -83,11 +97,14 @@ export default function RoleDetailPage() {
}
};
const handleAddPermission = async (perm: string) => {
// Audit 2026-05-10 MED-8 — extended permission grant body with
// scope_type + scope_id. The select dropdown drives `perm`; scope
// inputs are read from inline state hoisted from the form below.
const handleAddPermission = async (perm: string, scope?: { scope_type?: string; scope_id?: string }) => {
setSubmitting(true);
setActionError(null);
try {
await authAddRolePermission(role.id, { permission: perm });
await authAddRolePermission(role.id, { permission: perm, ...(scope ?? {}) });
qc.invalidateQueries({ queryKey: ['auth', 'role', role.id] });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
@@ -132,14 +149,29 @@ export default function RoleDetailPage() {
</button>
)}
{canDelete && (
<button
className="btn btn-danger"
onClick={handleDelete}
disabled={submitting}
data-testid="role-delete-button"
>
Delete
</button>
// Audit 2026-05-10 LOW-11 closure — hide Delete on
// default roles. The backend already rejects deletion of
// default roles (DELETE returns 409 with
// 'cannot delete default role'); this is pure UX so
// operators don't click a button that's destined to fail.
DEFAULT_ROLE_IDS.has(role.id) ? (
<span
className="text-xs text-ink-muted"
title="System role; cannot be deleted."
data-testid="role-delete-disabled-tooltip"
>
System role (cannot be deleted)
</span>
) : (
<button
className="btn btn-danger"
onClick={handleDelete}
disabled={submitting}
data-testid="role-delete-button"
>
Delete
</button>
)
)}
</div>
}
@@ -166,24 +198,10 @@ export default function RoleDetailPage() {
</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>
<AddPermissionForm
availablePerms={availablePerms.map((p) => p.name)}
onSubmit={(perm, scope) => void handleAddPermission(perm, scope)}
/>
)}
</div>
{permissions.length === 0 ? (
@@ -339,3 +357,71 @@ function EditRoleModal({ roleId, initialName, initialDescription, onClose, onSuc
</div>
);
}
// =============================================================================
// Audit 2026-05-10 MED-8 closure — Add-permission form with scope picker.
// =============================================================================
interface AddPermissionFormProps {
availablePerms: string[];
onSubmit: (perm: string, scope?: { scope_type?: string; scope_id?: string }) => void;
}
function AddPermissionForm({ availablePerms, onSubmit }: AddPermissionFormProps) {
const [perm, setPerm] = useState('');
const [scopeType, setScopeType] = useState<'global' | 'profile' | 'issuer'>('global');
const [scopeID, setScopeID] = useState('');
return (
<div className="flex items-center gap-2">
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
value={perm}
onChange={(e) => setPerm(e.target.value)}
data-testid="role-add-permission-select"
>
<option value="">Add permission</option>
{availablePerms.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<select
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
value={scopeType}
onChange={(e) => setScopeType(e.target.value as 'global' | 'profile' | 'issuer')}
data-testid="role-add-permission-scope-type"
>
<option value="global">Global</option>
<option value="profile">Profile</option>
<option value="issuer">Issuer</option>
</select>
{scopeType !== 'global' && (
<input
type="text"
placeholder={scopeType === 'profile' ? 'p-acme-corp' : 'iss-internal-pki'}
value={scopeID}
onChange={(e) => setScopeID(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm"
data-testid="role-add-permission-scope-id"
/>
)}
<button
type="button"
className="btn btn-primary"
disabled={!perm || (scopeType !== 'global' && !scopeID.trim())}
onClick={() => {
if (!perm) return;
if (scopeType === 'global') {
onSubmit(perm);
} else {
onSubmit(perm, { scope_type: scopeType, scope_id: scopeID.trim() });
}
setPerm('');
setScopeID('');
}}
data-testid="role-add-permission-submit"
>
Add
</button>
</div>
);
}