Files
certctl/web/src/components/AuthProvider.tsx
T
shankar0123 191384c1d2 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
2026-05-11 00:17:59 +00:00

165 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { getAuthInfo, checkAuth, setApiKey, logout as apiLogout } from '../api/client';
interface AuthState {
loading: boolean;
authRequired: boolean;
authenticated: boolean;
authType: string;
// M-003: named-key identity + admin flag surfaced from /auth/check so admin-
// only GUI affordances (e.g., bulk-revoke) can be hidden from non-admin
// callers. These are UX hints — authorization remains enforced server-side.
user: string;
admin: boolean;
login: (key: string) => Promise<void>;
logout: () => void;
error: string | null;
}
const AuthContext = createContext<AuthState>({
loading: true,
authRequired: false,
authenticated: false,
authType: 'none',
user: '',
admin: false,
login: async () => {},
logout: () => {},
error: null,
});
export function useAuth() {
return useContext(AuthContext);
}
export default function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
const [authRequired, setAuthRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [authType, setAuthType] = useState('none');
const [user, setUser] = useState('');
const [admin, setAdmin] = useState(false);
const [error, setError] = useState<string | null>(null);
// Check if server requires auth on mount
useEffect(() => {
getAuthInfo()
.then((info) => {
setAuthType(info.auth_type);
setAuthRequired(info.required);
if (!info.required) {
// CERTCTL_AUTH_TYPE=none: the server treats every caller as
// anonymous with admin=false. Mirror that locally so gated
// affordances stay hidden.
setAuthenticated(true);
setUser('');
setAdmin(false);
}
})
.catch(() => {
// If auth/info fails, assume no auth required (server may be old version)
setAuthenticated(true);
setUser('');
setAdmin(false);
})
.finally(() => setLoading(false));
}, []);
// Listen for 401 events from the API client.
//
// Audit 2026-05-10 HIGH-8 — the API client now attaches a cause
// category to the event detail (parsed from the WWW-Authenticate
// header). When a cause is recognised, redirect to
// /login?session_expired=<cause> so the LoginPage renders OIDC-aware
// re-login wording instead of the generic "session expired" + API-key
// copy. Cookie-mode (OIDC) and Bearer-mode (API-key) callers share
// the same wire shape; the LoginPage banner is purely UX.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ cause?: string }>).detail;
const cause = detail?.cause || '';
setAuthenticated(false);
setApiKey(null);
setUser('');
setAdmin(false);
// Generic copy; the LoginPage will overlay a cause-specific
// banner when ?session_expired=<cause> is present.
setError('Session expired. Please re-enter your API key.');
// Forward the cause to the LoginPage. window.location is used
// (not React Router's navigate) because this listener fires
// outside any route component's render and we want a hard
// navigation that clears any stale state.
if (cause && cause !== 'invalid_token' &&
window.location.pathname !== '/login') {
const params = new URLSearchParams({ session_expired: cause });
window.location.href = '/login?' + params.toString();
}
};
window.addEventListener('certctl:auth-required', handler);
return () => window.removeEventListener('certctl:auth-required', handler);
}, []);
const login = useCallback(async (key: string) => {
setError(null);
try {
// /auth/check returns {status, user, admin}. Capture user + admin so the
// GUI can hide admin-only affordances (bulk revoke, etc.).
const resp = await checkAuth(key);
setApiKey(key);
setAuthenticated(true);
setUser(resp.user ?? '');
setAdmin(Boolean(resp.admin));
} catch {
setError('Invalid API key');
throw new Error('Invalid API key');
}
}, []);
const logout = useCallback(() => {
// Bundle 2 Phase 8 — fire POST /auth/logout so the server can revoke the
// session row + clear the HttpOnly session cookie. The API logout helper
// sends `credentials: 'include'`. Errors are swallowed (the user's intent
// is still to be logged out locally; e.g. cookie already expired).
void apiLogout().catch(() => undefined);
setApiKey(null);
setAuthenticated(false);
setUser('');
setAdmin(false);
setError(null);
}, []);
return (
<AuthContext.Provider value={{ loading, authRequired, authenticated, authType, user, admin, login, logout, error }}>
{/*
Audit 2026-05-10 LOW-1 closure — demo-mode banner. When the
server reports auth_type=none, every caller is the anonymous
admin. Rendering a sticky red banner above the layout makes
sure operators see this on every page; HIGH-12's startup
check already fails closed for unsafe binds (0.0.0.0 / ::
without CERTCTL_DEMO_MODE_ACK=true), so reaching this banner
means the operator either ran on loopback or acknowledged
the bypass — but the GUI still surfaces the state plainly.
*/}
{authType === 'none' && !loading && (
<div
data-testid="demo-mode-banner"
role="alert"
style={{
background: '#b91c1c',
color: '#fff',
padding: '8px 16px',
fontSize: 13,
fontWeight: 600,
textAlign: 'center',
}}
>
Demo mode active (CERTCTL_AUTH_TYPE=none). Every caller is anonymous admin.
Production deployments MUST set CERTCTL_AUTH_TYPE=api-key or oidc.
</div>
)}
{children}
</AuthContext.Provider>
);
}