mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:31:36 +00:00
191384c1d2
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 in72b54ce. 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 (shipped172b30b). 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
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
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>
|
||
);
|
||
}
|