auth-bundle-2 Phase 8: GUI auth surface (OIDC providers + group mappings + sessions + LoginPage IdP buttons + AuthState refactor + logout wiring)

Closes Phase 8 of cowork/auth-bundle-2-prompt.md. Every Bundle 2 endpoint
now has a permission-gated, data-testid-instrumented React surface.

Frontend changes
================

api/client.ts (Category H — AuthState refactor):
* fetchJSON now sends `credentials: 'include'` on every request so the
  HttpOnly session cookie + the JS-readable CSRF cookie ride along with
  Bearer-mode requests transparently. Mode is determined per call by
  what cookies are present, NOT by a state-machine — the same client
  works for Bearer-only deploys, session-only deploys, and the mixed
  upgrade path described in cowork/auth-bundles-index.md Category H.
* readCSRFCookie() + isStateChangingMethod() helpers auto-attach
  `X-CSRF-Token` to POST/PUT/PATCH/DELETE when the CSRF cookie exists.
  Bearer-only callers ride through unchanged (no CSRF cookie → no
  header → backend's CSRF middleware skips).
* AuthInfoResponse extended with optional `oidc_providers?:
  AuthInfoOIDCProvider[]` matching the Phase 6 server extension.
* New API helpers (1:1 with Phase 5 / 7.5 endpoints):
  - listOIDCProviders / createOIDCProvider / updateOIDCProvider /
    deleteOIDCProvider / refreshOIDCProvider
  - listGroupMappings / addGroupMapping / removeGroupMapping
  - listSessions(actorID?, actorType?) / revokeSession / logout
  - breakglassLogin / breakglassSetPassword / breakglassUnlock /
    breakglassRemove
  Permission gates fire server-side; the GUI predicates are UX only.

pages/auth/OIDCProvidersPage.tsx (NEW):
* Lists configured OIDC providers, gated on `auth.oidc.list`.
* Empty state + error state + loading state.
* Embedded Configure-Provider modal with form fields for name,
  issuer_url, client_id, client_secret, redirect_uri,
  groups_claim_path/format, fetch_userinfo, scopes. Modal hidden
  unless caller has `auth.oidc.create`.
* Unsaved-changes confirmation on cancel.

pages/auth/OIDCProviderDetailPage.tsx (NEW):
* Provider config dl + edit/delete/refresh action buttons.
* Edit and refresh require `auth.oidc.edit`. Delete requires
  `auth.oidc.delete`.
* Type-confirm-name delete dialog. Surfaces server's 409 Conflict
  ("ErrOIDCProviderInUse") inline so the operator knows to revoke
  the provider's active sessions first.
* Refresh discovery cache button → POST .../refresh → server re-runs
  RefreshKeys with the IdP-downgrade-attack defense from Phase 3.
* Group→role mappings link.

pages/auth/GroupMappingsPage.tsx (NEW):
* Per-provider group-claim → role-id mapping CRUD.
* Empty state explains the fail-closed semantics from Phase 3
  (no mappings ⇒ no users authenticate via this provider).
* Inline add form (group_name input + role_id select populated from
  `authListRoles`); add/remove gated on `auth.oidc.edit`.

pages/auth/SessionsPage.tsx (NEW):
* Default "My sessions" view available to anyone holding
  `auth.session.list`.
* "All actors (admin)" toggle exposed only when caller holds
  `auth.session.list.all`; renders an actor_id filter input that
  threads ?actor_id= through the GET.
* Self-pill marker on the caller's own rows.
* Revoke button is shown when (a) the row is the caller's own session
  (handler-side own-bypass) OR (b) caller holds `auth.session.revoke`.
* Confirms via window.confirm; surfaces revocation errors inline.

pages/LoginPage.tsx (MODIFIED):
* Fetches /v1/auth/info on mount; if `oidc_providers[]` is non-empty,
  renders one "Sign in with X" button per provider linking to the
  provider's `login_url` (the server-side handler in Phase 5 builds
  this URL with state + nonce + PKCE verifier sealed in the pre-login
  cookie; the GUI never touches those values).
* The API-key form remains as a fallback for Bearer-mode deploys and
  the Phase 7.5 break-glass path.
* All interactive elements carry data-testid:
  login-oidc-providers / login-oidc-button-{id} / login-api-key-form /
  login-api-key-input / login-api-key-submit.

components/AuthProvider.tsx (MODIFIED):
* logout() now also fires POST /auth/logout via the api/client helper
  before clearing local state. The endpoint is auth-exempt; the
  catch-and-swallow keeps the local logout flow working even if the
  cookie is already invalid (idempotent server-side as well).

components/Layout.tsx (MODIFIED):
* Two new nav entries under the Auth section: "OIDC Providers" + "Sessions".

main.tsx (MODIFIED):
* Four new routes:
  - /auth/oidc/providers
  - /auth/oidc/providers/:id
  - /auth/oidc/providers/:id/mappings
  - /auth/sessions

Vitest coverage
===============

Five new test files, 28 new test cases. Pattern matches Bundle 1
Phase 10's Vitest scaffold (vi.mock api/client, render with
QueryClient + MemoryRouter, authMe-driven permission shaping,
data-testid selectors).

* OIDCProvidersPage.test.tsx (5 tests): ErrorState w/o auth.oidc.list,
  empty state, list + create button render, hide-create-button
  without auth.oidc.create, submit-creates-via-API.
* OIDCProviderDetailPage.test.tsx (5 tests): ErrorState w/o list,
  full-perms render, hide edit/refresh/delete with only list,
  refresh button calls API, delete confirm-button stays disabled
  until typed text matches provider name.
* GroupMappingsPage.test.tsx (5 tests): ErrorState w/o list, empty
  fail-closed warning, mapping rows render, hide-form without
  auth.oidc.edit, submit-add-form-calls-API.
* SessionsPage.test.tsx (6 tests): ErrorState w/o list, own sessions
  + self-pill, hide All-actors toggle without list.all, show
  toggle with list.all, hide revoke on other-actor sessions without
  auth.session.revoke, click-revoke calls API after window.confirm.
* LoginPage.test.tsx (extended +2 tests): renders OIDC buttons when
  /auth/info reports providers; omits the OIDC block when none.

Verification
============

* `npx tsc --noEmit` — 0 errors.
* Vitest run across api/components/hooks/utils/auth/pages = 475 tests,
  all green.
* `npm run build` — green (980 KB bundle, no surprises vs Phase 7).
* No backend (Go) changes in this commit; Phase 5-7.5 surfaces
  consumed unchanged.

Not in this commit (deferred)
=============================

* "Test login flow" button on the provider detail page (prompt §Phase 8
  optional row). Requires a server-side test=true flag on the OIDC
  login handler — out of scope for the GUI commit.
* `web/src/__tests__/e2e/` Keycloak-via-testcontainers harness for the
  15 comprehensive flow checks. Tracked under Phase 10 of
  cowork/auth-bundle-2-prompt.md.
This commit is contained in:
shankar0123
2026-05-10 07:23:41 +00:00
parent 1d01c87663
commit 9143003e95
14 changed files with 2170 additions and 7 deletions
+232 -2
View File
@@ -55,10 +55,55 @@ function authHeaders(): Record<string, string> {
return headers;
}
// Bundle 2 Phase 8 — read the certctl_csrf cookie value (set by the
// OIDC-callback / break-glass-login flows; JS-readable by design so
// the GUI can echo it into the X-CSRF-Token header on every state-
// changing request). Returns empty string when the cookie isn't set
// (Bearer-mode deployments don't need CSRF; the server's middleware
// short-circuits CSRF for Bearer-authenticated requests).
function readCSRFCookie(): string {
if (typeof document === 'undefined' || !document.cookie) return '';
for (const part of document.cookie.split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === 'certctl_csrf') {
return decodeURIComponent(rest.join('='));
}
}
return '';
}
// isStateChangingMethod mirrors the server-side
// internal/auth/session/middleware.go::isStateChangingMethod predicate.
// State-changing requests get the X-CSRF-Token header auto-attached
// when in session-cookie mode; safe methods don't need it.
function isStateChangingMethod(method?: string): boolean {
switch ((method || 'GET').toUpperCase()) {
case 'POST':
case 'PUT':
case 'DELETE':
case 'PATCH':
return true;
default:
return false;
}
}
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
// Bundle 2 Phase 8 — credentials:'include' lets the certctl_session
// cookie ride along on every request. Bearer-mode deployments work
// unchanged (the cookie just isn't there). Auto-attach X-CSRF-Token
// header on state-changing methods when the cookie is present.
const headers: Record<string, string> = { ...authHeaders(), ...(init?.headers as Record<string, string> | undefined) };
if (isStateChangingMethod(init?.method)) {
const csrf = readCSRFCookie();
if (csrf && !headers['X-CSRF-Token']) {
headers['X-CSRF-Token'] = csrf;
}
}
const res = await fetch(url, {
headers: { ...authHeaders(), ...init?.headers },
...init,
credentials: 'include',
headers, // intentional: spread init first, then override headers with the merged map (init.headers already merged into `headers` above)
});
if (res.status === 401) {
// Trigger re-auth
@@ -81,9 +126,27 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
}
// Auth
//
// Bundle 2 Phase 6 / Category E — /auth/info now optionally returns
// the list of configured OIDC providers (id + display_name + login_url)
// when the server has any configured. The Login page renders the
// "Sign in with X" buttons from this list; older servers (pre-Phase-6)
// just return {auth_type, required} and the GUI falls back to the
// API-key form. Both shapes are valid; oidc_providers is an
// optional field on the wire.
export interface AuthInfoOIDCProvider {
id: string;
display_name: string;
login_url: string;
}
export interface AuthInfoResponse {
auth_type: string;
required: boolean;
oidc_providers?: AuthInfoOIDCProvider[];
}
export const getAuthInfo = () =>
fetch(`${BASE}/auth/info`, { headers: { 'Content-Type': 'application/json' } })
.then(r => r.json() as Promise<{ auth_type: string; required: boolean }>);
.then(r => r.json() as Promise<AuthInfoResponse>);
// AuthCheckResponse mirrors the /auth/check handler payload. Post-M-003 it
// surfaces `user` (named-key identity) and `admin` (named-key admin flag) so
@@ -223,6 +286,173 @@ export const authBootstrapAvailable = () =>
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json() as Promise<BootstrapAvailability>);
// =============================================================================
// Bundle 2 Phase 8 — OIDC providers + group mappings + sessions +
// break-glass admin API surface. Backs:
// - LoginPage (OIDC provider buttons + breakglass form)
// - OIDCProvidersPage + OIDCProviderDetailPage
// - GroupMappingsPage
// - SessionsPage (own + admin)
// - ProfilePage session-list panel
//
// Every function maps 1:1 to a Phase 5 / Phase 7.5 server endpoint;
// permission gates fire server-side, the GUI's permission-aware
// renders are a UX layer on top.
// =============================================================================
export interface OIDCProvider {
id: string;
tenant_id: string;
name: string;
issuer_url: string;
client_id: string;
redirect_uri: string;
groups_claim_path: string;
groups_claim_format: string;
fetch_userinfo: boolean;
scopes: string[];
allowed_email_domains?: string[];
iat_window_seconds: number;
jwks_cache_ttl_seconds: number;
created_at: string;
updated_at: string;
}
export interface OIDCProviderRequest {
name: string;
issuer_url: string;
client_id: string;
client_secret?: string; // sent on create + rotate; omitted on edit-without-rotate
redirect_uri: string;
groups_claim_path?: string;
groups_claim_format?: string;
fetch_userinfo?: boolean;
scopes?: string[];
allowed_email_domains?: string[];
iat_window_seconds?: number;
jwks_cache_ttl_seconds?: number;
}
export interface GroupRoleMapping {
id: string;
provider_id: string;
group_name: string;
role_id: string;
tenant_id: string;
created_at: string;
}
export interface SessionInfo {
id: string;
actor_id: string;
actor_type: string;
ip_address?: string;
user_agent?: string;
created_at: string;
last_seen_at: string;
idle_expires_at: string;
absolute_expires_at: string;
revoked: boolean;
}
// OIDC provider CRUD (auth.oidc.list / .create / .edit / .delete).
export const listOIDCProviders = () =>
fetchJSON<{ providers: OIDCProvider[] }>(`${BASE}/auth/oidc/providers`);
export const createOIDCProvider = (req: OIDCProviderRequest) =>
fetchJSON<OIDCProvider>(`${BASE}/auth/oidc/providers`, {
method: 'POST',
body: JSON.stringify(req),
});
export const updateOIDCProvider = (id: string, req: OIDCProviderRequest) =>
fetchJSON<OIDCProvider>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(req),
});
export const deleteOIDCProvider = (id: string) =>
fetchJSON<void>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
export const refreshOIDCProvider = (id: string) =>
fetchJSON<{ refreshed: boolean }>(`${BASE}/auth/oidc/providers/${encodeURIComponent(id)}/refresh`, {
method: 'POST',
});
// Group→role mapping CRUD (auth.oidc.list / .edit).
export const listGroupMappings = (providerID: string) =>
fetchJSON<{ mappings: GroupRoleMapping[] }>(
`${BASE}/auth/oidc/group-mappings?provider_id=${encodeURIComponent(providerID)}`,
);
export const addGroupMapping = (providerID: string, groupName: string, roleID: string) =>
fetchJSON<GroupRoleMapping>(`${BASE}/auth/oidc/group-mappings`, {
method: 'POST',
body: JSON.stringify({ provider_id: providerID, group_name: groupName, role_id: roleID }),
});
export const removeGroupMapping = (id: string) =>
fetchJSON<void>(`${BASE}/auth/oidc/group-mappings/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
// Session list + revoke. The GET also accepts ?actor_id=<other>
// for the admin all-actors view (auth.session.list.all gated server-
// side; see internal/api/router::router.go).
export const listSessions = (actorID?: string, actorType?: string) => {
const q = actorID ? `?actor_id=${encodeURIComponent(actorID)}${actorType ? '&actor_type=' + encodeURIComponent(actorType) : ''}` : '';
return fetchJSON<{ sessions: SessionInfo[] }>(`${BASE}/auth/sessions${q}`);
};
export const revokeSession = (sessionID: string) =>
fetchJSON<void>(`${BASE}/auth/sessions/${encodeURIComponent(sessionID)}`, {
method: 'DELETE',
});
// Logout — POST /auth/logout. Auth-exempt (the handler accepts the
// caller's session cookie OR a missing cookie; both 204).
export const logout = () =>
fetch(`/auth/logout`, { method: 'POST', credentials: 'include' }).then(r => {
if (!r.ok && r.status !== 204) throw new Error(`logout failed: ${r.status}`);
});
// =============================================================================
// Bundle 2 Phase 7.5 — break-glass admin surface. The login endpoint
// is auth-exempt; the admin endpoints require auth.breakglass.admin.
// All four endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false
// (surface invisibility).
// =============================================================================
export const breakglassLogin = (actorID: string, password: string) =>
fetch(`/auth/breakglass/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actor_id: actorID, password }),
}).then(async r => {
if (r.status === 204) return;
if (r.status === 404) throw new Error('break-glass admin not enabled on this server');
if (!r.ok) throw new Error('invalid credentials');
});
export const breakglassSetPassword = (targetActorID: string, password: string) =>
fetchJSON<{ actor_id: string; created_at: string }>(`${BASE}/auth/breakglass/credentials`, {
method: 'POST',
body: JSON.stringify({ actor_id: targetActorID, password }),
});
export const breakglassUnlock = (targetActorID: string) =>
fetchJSON<void>(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}/unlock`, {
method: 'POST',
});
export const breakglassRemove = (targetActorID: string) =>
fetchJSON<void>(`${BASE}/auth/breakglass/credentials/${encodeURIComponent(targetActorID)}`, {
method: 'DELETE',
});
// =============================================================================
// Bundle 1 Phase 10 — approvals queue.
//
+6 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { getAuthInfo, checkAuth, setApiKey } from '../api/client';
import { getAuthInfo, checkAuth, setApiKey, logout as apiLogout } from '../api/client';
interface AuthState {
loading: boolean;
@@ -96,6 +96,11 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}, []);
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('');
+3
View File
@@ -27,6 +27,9 @@ const nav = [
{ 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).
// Bundle 2 Phase 8 — OIDC + Sessions.
{ to: '/auth/oidc/providers', label: 'OIDC Providers', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4' },
{ to: '/auth/sessions', label: 'Sessions', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
{ 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/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
+10
View File
@@ -41,6 +41,11 @@ import RoleDetailPage from './pages/auth/RoleDetailPage';
import KeysPage from './pages/auth/KeysPage';
import AuthSettingsPage from './pages/auth/AuthSettingsPage';
import ApprovalsPage from './pages/auth/ApprovalsPage';
// Bundle 2 Phase 8 — OIDC + session management pages.
import OIDCProvidersPage from './pages/auth/OIDCProvidersPage';
import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage';
import GroupMappingsPage from './pages/auth/GroupMappingsPage';
import SessionsPage from './pages/auth/SessionsPage';
import './index.css';
const queryClient = new QueryClient({
@@ -117,6 +122,11 @@ createRoot(document.getElementById('root')!).render(
cached effective_permissions slice. Server-side
enforcement is the load-bearing layer; client-side
hide/disable is UX. */}
{/* Bundle 2 Phase 8 — OIDC + session management surface. */}
<Route path="auth/oidc/providers" element={<OIDCProvidersPage />} />
<Route path="auth/oidc/providers/:id" element={<OIDCProviderDetailPage />} />
<Route path="auth/oidc/providers/:id/mappings" element={<GroupMappingsPage />} />
<Route path="auth/sessions" element={<SessionsPage />} />
<Route path="auth/roles" element={<RolesPage />} />
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
<Route path="auth/keys" element={<KeysPage />} />
+49
View File
@@ -19,6 +19,11 @@ import type { ReactNode } from 'react';
// 1. The login form renders.
// 2. An auth error containing a literal <script> tag does NOT execute.
// 3. The literal payload text appears as escaped content.
//
// Bundle 2 Phase 8 add:
// 4. When /auth/info returns oidc_providers[], a "Sign in with X" button
// renders per provider linking to the provider's login_url.
// 5. When /auth/info returns no providers, the OIDC block does NOT render.
// -----------------------------------------------------------------------------
const xssError = '<script data-xss="login-error">window.__xss_pwned__=1;</script>';
@@ -38,7 +43,12 @@ vi.mock('../components/AuthProvider', () => ({
}),
}));
vi.mock('../api/client', () => ({
getAuthInfo: vi.fn(),
}));
import LoginPage from './LoginPage';
import * as client from '../api/client';
function renderWithRouter(ui: ReactNode) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
@@ -50,6 +60,11 @@ describe('LoginPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => {
cleanup();
mockError = null;
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
// Default: no providers configured.
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'api-key',
required: true,
});
});
it('renders the login form', () => {
@@ -92,4 +107,38 @@ describe('LoginPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => {
expect(screen.getByRole('button', { name: /Sign In/i })).toBeDisabled();
});
});
it('renders OIDC "Sign in with X" buttons when /auth/info returns providers (Bundle 2 Phase 8)', async () => {
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'api-key',
required: true,
oidc_providers: [
{ id: 'op-okta', display_name: 'Okta', login_url: '/auth/oidc/login?provider_id=op-okta' },
{ id: 'op-google', display_name: 'Google', login_url: '/auth/oidc/login?provider_id=op-google' },
],
});
renderWithRouter(<LoginPage />);
await waitFor(() => {
expect(screen.getByTestId('login-oidc-providers')).toBeTruthy();
});
const oktaBtn = screen.getByTestId('login-oidc-button-op-okta') as HTMLAnchorElement;
expect(oktaBtn.href).toContain('/auth/oidc/login?provider_id=op-okta');
expect(oktaBtn.textContent).toContain('Okta');
const googleBtn = screen.getByTestId('login-oidc-button-op-google') as HTMLAnchorElement;
expect(googleBtn.textContent).toContain('Google');
// API-key form remains as fallback.
expect(screen.getByTestId('login-api-key-form')).toBeTruthy();
});
it('omits the OIDC block when /auth/info returns no providers (Bundle 2 Phase 8)', async () => {
vi.mocked(client.getAuthInfo).mockResolvedValue({
auth_type: 'api-key',
required: true,
});
renderWithRouter(<LoginPage />);
await waitFor(() => {
expect(screen.getByTestId('login-api-key-form')).toBeTruthy();
});
expect(screen.queryByTestId('login-oidc-providers')).toBeNull();
});
});
+65 -4
View File
@@ -1,14 +1,42 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useAuth } from '../components/AuthProvider';
import { getAuthInfo, type AuthInfoOIDCProvider } from '../api/client';
// =============================================================================
// LoginPage — Bundle 2 Phase 8 / multi-mode entry surface.
//
// Pre-Bundle-2: API-key-only sign-in form.
// Post-Bundle-2: when `/auth/info` reports `oidc_providers[]`, the
// page renders one "Sign in with X" button per provider; clicking
// navigates to the provider's `login_url` (which 302s through the
// IdP and back to /auth/oidc/callback). The API-key form remains as
// a fallback for Bearer-mode deployments + the break-glass path.
// =============================================================================
export default function LoginPage() {
const { login, error: authError } = useAuth();
const [key, setKey] = useState('');
const [submitting, setSubmitting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [providers, setProviders] = useState<AuthInfoOIDCProvider[]>([]);
const error = localError || authError;
// On mount, fetch /auth/info and extract any configured OIDC
// providers so we can render the "Sign in with X" buttons. Errors
// are non-fatal — fall back to the API-key form.
useEffect(() => {
getAuthInfo()
.then(info => {
if (info.oidc_providers && info.oidc_providers.length > 0) {
setProviders(info.oidc_providers);
}
})
.catch(() => {
// Server may be pre-Phase-6; ignore.
});
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!key.trim()) return;
@@ -31,7 +59,35 @@ export default function LoginPage() {
<p className="text-sm text-ink-muted uppercase tracking-wider">Certificate Control Plane</p>
</div>
<form onSubmit={handleSubmit} className="bg-surface border border-surface-border rounded p-6 space-y-4 shadow-sm">
{providers.length > 0 && (
<div
className="bg-surface border border-surface-border rounded p-6 space-y-3 shadow-sm mb-4"
data-testid="login-oidc-providers"
>
<p className="text-sm font-medium text-ink-muted text-center">Sign in with your identity provider</p>
{providers.map(p => (
<a
key={p.id}
href={p.login_url}
className="block w-full text-center bg-brand-400 hover:bg-brand-500 text-white py-2.5 text-sm font-medium rounded transition-colors"
data-testid={`login-oidc-button-${p.id}`}
>
Sign in with {p.display_name}
</a>
))}
</div>
)}
<form
onSubmit={handleSubmit}
className="bg-surface border border-surface-border rounded p-6 space-y-4 shadow-sm"
data-testid="login-api-key-form"
>
{providers.length > 0 && (
<p className="text-xs text-ink-muted text-center pb-2 border-b border-surface-border">
or sign in with API key
</p>
)}
<div>
<label htmlFor="api-key" className="block text-sm font-medium text-ink-muted mb-1.5">
API Key
@@ -42,13 +98,17 @@ export default function LoginPage() {
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="Enter your API key"
autoFocus
autoFocus={providers.length === 0}
className="w-full bg-white border border-surface-border rounded px-3 py-2.5 text-sm text-ink placeholder-ink-faint focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20"
data-testid="login-api-key-input"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700">
<div
className="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm text-red-700"
data-testid="login-error"
>
{error}
</div>
)}
@@ -57,6 +117,7 @@ export default function LoginPage() {
type="submit"
disabled={submitting || !key.trim()}
className="w-full bg-brand-400 hover:bg-brand-500 text-white py-2.5 text-sm font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="login-api-key-submit"
>
{submitting ? 'Verifying...' : 'Sign In'}
</button>
@@ -0,0 +1,167 @@
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, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
// Bundle 2 Phase 8 — GroupMappingsPage tests. Pins:
// - 403 ErrorState when caller lacks auth.oidc.list.
// - Empty mapping list renders the fail-closed-warning empty state.
// - Mapping list renders one row per mapping.
// - Add form HIDDEN without auth.oidc.edit.
// - Add form SHOWN with auth.oidc.edit + submission calls addGroupMapping.
vi.mock('../../api/client', () => ({
listGroupMappings: vi.fn(),
addGroupMapping: vi.fn(),
removeGroupMapping: vi.fn(),
authListRoles: vi.fn(),
authMe: vi.fn(),
}));
import GroupMappingsPage from './GroupMappingsPage';
import * as client from '../../api/client';
function renderRoute(ui: ReactNode, path = '/auth/oidc/providers/op-okta/mappings') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/auth/oidc/providers/:id/mappings" element={ui} />
</Routes>
</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' },
];
const sampleMappings = [
{
id: 'gm-1',
provider_id: 'op-okta',
group_name: 'engineers',
role_id: 'r-admin',
tenant_id: 't-default',
created_at: '2026-05-10T00:00:00Z',
},
];
describe('GroupMappingsPage', () => {
it('renders ErrorState when caller lacks auth.oidc.list', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
renderRoute(<GroupMappingsPage />);
await waitFor(() => {
expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy();
});
});
it('renders empty fail-closed warning when no mappings configured', async () => {
vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: [] });
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderRoute(<GroupMappingsPage />);
await waitFor(() => {
expect(screen.getByTestId('group-mappings-empty')).toBeTruthy();
});
});
it('renders mapping rows from listGroupMappings', async () => {
vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: sampleMappings });
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<GroupMappingsPage />);
await waitFor(() => {
expect(screen.getByTestId('group-mapping-row-gm-1')).toBeTruthy();
});
expect(screen.getByText('engineers')).toBeTruthy();
expect(screen.getByText('r-admin')).toBeTruthy();
expect(screen.getByTestId('group-mapping-remove-gm-1')).toBeTruthy();
});
it('hides the add form when caller lacks auth.oidc.edit', async () => {
vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: sampleMappings });
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-viewer',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderRoute(<GroupMappingsPage />);
await waitFor(() => {
expect(screen.getByTestId('group-mapping-row-gm-1')).toBeTruthy();
});
expect(screen.queryByTestId('group-mappings-add-form')).toBeNull();
// Remove button is also hidden in row when caller lacks edit.
expect(screen.queryByTestId('group-mapping-remove-gm-1')).toBeNull();
});
it('submitting the add form calls addGroupMapping', async () => {
vi.mocked(client.listGroupMappings).mockResolvedValue({ mappings: [] });
vi.mocked(client.authListRoles).mockResolvedValue(sampleRoles);
vi.mocked(client.addGroupMapping).mockResolvedValue(sampleMappings[0]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<GroupMappingsPage />);
await waitFor(() => {
expect(screen.getByTestId('group-mappings-add-form')).toBeTruthy();
});
fireEvent.change(screen.getByTestId('group-mappings-group-name-input'), {
target: { value: 'engineers' },
});
fireEvent.change(screen.getByTestId('group-mappings-role-select'), {
target: { value: 'r-admin' },
});
fireEvent.click(screen.getByTestId('group-mappings-add-button'));
await waitFor(() => {
expect(client.addGroupMapping).toHaveBeenCalledWith('op-okta', 'engineers', 'r-admin');
});
});
});
+227
View File
@@ -0,0 +1,227 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
listGroupMappings,
addGroupMapping,
removeGroupMapping,
authListRoles,
type GroupRoleMapping,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 2 Phase 8 — GroupMappingsPage.
//
// Per-OIDC-provider group→role mappings. The OIDC service consults the
// list at HandleCallback time (Phase 3) to translate IdP-supplied
// group claims into role IDs that get attached to the post-login
// session. Empty mapping list ⇒ no users can authenticate via this
// provider (fail-closed); operators add at least one mapping before
// rolling out OIDC.
//
// Routes:
// /auth/oidc/providers/{id}/mappings — this page.
// API:
// GET /api/v1/auth/oidc/group-mappings?provider_id={id}
// POST /api/v1/auth/oidc/group-mappings
// DELETE /api/v1/auth/oidc/group-mappings/{id}
// Permissions: auth.oidc.list (page) + auth.oidc.edit (add/remove).
// =============================================================================
export default function GroupMappingsPage() {
const { id: providerID } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const { hasPerm } = useAuthMe();
const canList = hasPerm('auth.oidc.list');
const canEdit = hasPerm('auth.oidc.edit');
const [groupName, setGroupName] = useState('');
const [roleID, setRoleID] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data, isLoading, error: loadErr } = useQuery({
queryKey: ['group-mappings', providerID],
queryFn: () => listGroupMappings(providerID || ''),
enabled: canList && !!providerID,
});
const { data: rolesData } = useQuery({
queryKey: ['auth-roles'],
queryFn: authListRoles,
enabled: canEdit,
});
if (!canList) {
return (
<div className="p-8">
<PageHeader title="Group → role mappings" subtitle="" />
<ErrorState error={new Error('You need the auth.oidc.list permission to view mappings.')} />
</div>
);
}
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
if (!groupName.trim() || !roleID || !providerID) return;
setSubmitting(true);
setError(null);
try {
await addGroupMapping(providerID, groupName.trim(), roleID);
setGroupName('');
setRoleID('');
queryClient.invalidateQueries({ queryKey: ['group-mappings', providerID] });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const handleRemove = async (mappingID: string, displayName: string) => {
if (!window.confirm(`Remove the mapping for "${displayName}"?`)) return;
try {
await removeGroupMapping(mappingID);
queryClient.invalidateQueries({ queryKey: ['group-mappings', providerID] });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<div className="p-8 space-y-6">
<PageHeader
title="Group → role mappings"
subtitle={`Provider · ${providerID}`}
action={
<Link
to={`/auth/oidc/providers/${encodeURIComponent(providerID || '')}`}
className="text-sm text-brand-600 hover:underline"
>
Provider
</Link>
}
/>
{error && (
<div
className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
data-testid="group-mappings-error"
>
{error}
</div>
)}
{canEdit && (
<form
onSubmit={handleAdd}
className="bg-surface border border-surface-border rounded p-4 space-y-3"
data-testid="group-mappings-add-form"
>
<h2 className="text-sm font-semibold text-ink">Add mapping</h2>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-ink mb-1">IdP group name</label>
<input
value={groupName}
onChange={e => setGroupName(e.target.value)}
placeholder="engineers"
className="w-full px-2 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="group-mappings-group-name-input"
/>
</div>
<div>
<label className="block text-xs font-medium text-ink mb-1">certctl role</label>
<select
value={roleID}
onChange={e => setRoleID(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="group-mappings-role-select"
>
<option value="">Select role</option>
{(rolesData || []).map(r => (
<option key={r.id} value={r.id}>
{r.name} ({r.id})
</option>
))}
</select>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={submitting || !groupName.trim() || !roleID}
className="w-full px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
data-testid="group-mappings-add-button"
>
{submitting ? 'Adding…' : 'Add mapping'}
</button>
</div>
</div>
</form>
)}
{isLoading && (
<div className="text-sm text-ink-muted" data-testid="group-mappings-loading">
Loading mappings
</div>
)}
{loadErr && <ErrorState error={loadErr instanceof Error ? loadErr : new Error(String(loadErr))} />}
{data && data.mappings.length === 0 && (
<div
className="bg-surface border border-surface-border rounded p-6 text-center"
data-testid="group-mappings-empty"
>
<p className="text-ink-muted text-sm">
No mappings configured for this provider. Until at least one mapping exists, OIDC logins
via this provider fail closed (no roles 401 to the user).
</p>
</div>
)}
{data && data.mappings.length > 0 && (
<div className="bg-surface border border-surface-border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-page border-b border-surface-border">
<tr>
<th className="text-left px-4 py-2 font-medium text-ink">IdP group</th>
<th className="text-left px-4 py-2 font-medium text-ink">certctl role</th>
<th className="text-left px-4 py-2 font-medium text-ink">Created</th>
<th className="text-right px-4 py-2 font-medium text-ink">Actions</th>
</tr>
</thead>
<tbody>
{data.mappings.map((m: GroupRoleMapping) => (
<tr
key={m.id}
className="border-b border-surface-border hover:bg-page"
data-testid={`group-mapping-row-${m.id}`}
>
<td className="px-4 py-2 font-mono text-xs">{m.group_name}</td>
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
<td className="px-4 py-2 text-ink-muted">
{m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'}
</td>
<td className="px-4 py-2 text-right">
{canEdit && (
<button
onClick={() => handleRemove(m.id, m.group_name)}
className="text-xs text-red-600 hover:underline"
data-testid={`group-mapping-remove-${m.id}`}
>
Remove
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,178 @@
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, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
// Bundle 2 Phase 8 — OIDCProviderDetailPage tests. Pins:
// - 403 ErrorState when caller lacks auth.oidc.list.
// - "Edit"/"Refresh"/"Delete" buttons HIDDEN without their respective perms.
// - "Edit"/"Refresh"/"Delete" buttons SHOWN when perms present.
// - Refresh button calls refreshOIDCProvider.
// - Delete confirmation flow + button enabled only when typed text matches.
vi.mock('../../api/client', () => ({
listOIDCProviders: vi.fn(),
updateOIDCProvider: vi.fn(),
deleteOIDCProvider: vi.fn(),
refreshOIDCProvider: vi.fn(),
authMe: vi.fn(),
}));
import OIDCProviderDetailPage from './OIDCProviderDetailPage';
import * as client from '../../api/client';
function renderRoute(ui: ReactNode, path = '/auth/oidc/providers/op-okta') {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/auth/oidc/providers/:id" element={ui} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
const sampleProvider = {
id: 'op-okta',
tenant_id: 't-default',
name: 'Okta',
issuer_url: 'https://example.okta.com',
client_id: 'certctl',
redirect_uri: 'https://certctl.example.com/auth/oidc/callback',
groups_claim_path: 'groups',
groups_claim_format: 'string-array',
fetch_userinfo: false,
scopes: ['openid'],
iat_window_seconds: 300,
jwks_cache_ttl_seconds: 3600,
created_at: '2026-05-10T00:00:00Z',
updated_at: '2026-05-10T00:00:00Z',
};
describe('OIDCProviderDetailPage', () => {
it('renders ErrorState when caller lacks auth.oidc.list', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy();
});
});
it('renders provider config and edit/delete/refresh buttons with full perms', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
{ permission: 'auth.oidc.delete', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy();
});
expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy();
expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy();
expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy();
// The provider's issuer_url renders in the dl.
expect(screen.getAllByText('https://example.okta.com').length).toBeGreaterThan(0);
});
it('hides edit/refresh/delete when caller has only auth.oidc.list', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-viewer',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-mappings-link')).toBeTruthy();
});
expect(screen.queryByTestId('oidc-provider-edit-button')).toBeNull();
expect(screen.queryByTestId('oidc-provider-refresh-button')).toBeNull();
expect(screen.queryByTestId('oidc-provider-delete-button')).toBeNull();
});
it('refresh button calls refreshOIDCProvider', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.refreshOIDCProvider).mockResolvedValue({ refreshed: true });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-refresh-button')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('oidc-provider-refresh-button'));
await waitFor(() => {
expect(client.refreshOIDCProvider).toHaveBeenCalledWith('op-okta');
});
});
it('delete confirm button stays disabled until typed text matches provider name', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.delete', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-delete-button')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('oidc-provider-delete-button'));
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-delete-confirm')).toBeTruthy();
});
const confirmBtn = screen.getByTestId('oidc-provider-delete-confirm-button') as HTMLButtonElement;
expect(confirmBtn.disabled).toBe(true);
fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), {
target: { value: 'Wrong' },
});
expect(confirmBtn.disabled).toBe(true);
fireEvent.change(screen.getByTestId('oidc-provider-delete-confirm-input'), {
target: { value: 'Okta' },
});
expect(confirmBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,367 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
listOIDCProviders,
updateOIDCProvider,
deleteOIDCProvider,
refreshOIDCProvider,
type OIDCProvider,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 2 Phase 8 — OIDCProviderDetailPage.
//
// One row per provider — edit (PUT), delete (DELETE), and refresh
// discovery cache (POST .../refresh). Edit modal shares the create-
// modal field set; the client_secret field is OPTIONAL on edit (empty
// preserves the existing ciphertext on the server). Delete is gated
// behind a typed-confirmation dialog AND surfaces 409 Conflict (the
// server's ErrOIDCProviderInUse) as a non-destructive error so the
// operator knows to revoke active sessions first. Refresh discovery
// cache fires the server's RefreshKeys → re-runs the IdP downgrade-
// attack defense AND re-fetches JWKS; common operator action when an
// IdP rotates keys mid-day.
//
// Permission gates: the page itself requires auth.oidc.list. Edit
// and refresh require auth.oidc.edit. Delete requires
// auth.oidc.delete. Mappings link is rendered for any caller with
// auth.oidc.list.
// =============================================================================
export default function OIDCProviderDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { hasPerm } = useAuthMe();
const canList = hasPerm('auth.oidc.list');
const canEdit = hasPerm('auth.oidc.edit');
const canDelete = hasPerm('auth.oidc.delete');
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editIssuerURL, setEditIssuerURL] = useState('');
const [editClientID, setEditClientID] = useState('');
const [editClientSecret, setEditClientSecret] = useState('');
const [editRedirectURI, setEditRedirectURI] = useState('');
const [editFetchUserinfo, setEditFetchUserinfo] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const { data, isLoading, error: loadErr } = useQuery({
queryKey: ['oidc-providers'],
queryFn: listOIDCProviders,
enabled: canList,
});
if (!canList) {
return (
<div className="p-8">
<PageHeader title="OIDC provider" subtitle="Identity provider configuration" />
<ErrorState error={new Error("You need the auth.oidc.list permission to view OIDC providers.")} />
</div>
);
}
const provider: OIDCProvider | undefined = data?.providers.find(p => p.id === id);
if (isLoading) {
return <div className="p-8 text-sm text-ink-muted" data-testid="oidc-provider-detail-loading">Loading</div>;
}
if (loadErr || !provider) {
return (
<div className="p-8">
<PageHeader title="OIDC provider" subtitle="Identity provider configuration" />
<ErrorState error={loadErr instanceof Error ? loadErr : new Error("Provider not found")} />
<Link to="/auth/oidc/providers" className="text-sm text-brand-600 hover:underline">
Back to providers
</Link>
</div>
);
}
const startEdit = () => {
setEditName(provider.name);
setEditIssuerURL(provider.issuer_url);
setEditClientID(provider.client_id);
setEditClientSecret('');
setEditRedirectURI(provider.redirect_uri);
setEditFetchUserinfo(provider.fetch_userinfo || false);
setError(null);
setSuccess(null);
setEditing(true);
};
const cancelEdit = () => {
setEditing(false);
setError(null);
};
const saveEdit = async () => {
setSubmitting(true);
setError(null);
setSuccess(null);
try {
const req: Parameters<typeof updateOIDCProvider>[1] = {
name: editName,
issuer_url: editIssuerURL,
client_id: editClientID,
redirect_uri: editRedirectURI,
groups_claim_path: provider.groups_claim_path,
groups_claim_format: provider.groups_claim_format,
fetch_userinfo: editFetchUserinfo,
scopes: provider.scopes,
iat_window_seconds: provider.iat_window_seconds,
jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds,
};
if (editClientSecret) req.client_secret = editClientSecret;
await updateOIDCProvider(provider.id, req);
setSuccess('Provider updated');
setEditing(false);
queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const doRefresh = async () => {
setSubmitting(true);
setError(null);
setSuccess(null);
try {
await refreshOIDCProvider(provider.id);
setSuccess('Discovery + JWKS refreshed; IdP downgrade defense re-run');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
const doDelete = async () => {
setSubmitting(true);
setError(null);
try {
await deleteOIDCProvider(provider.id);
navigate('/auth/oidc/providers');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
};
return (
<div className="p-8 space-y-6">
<PageHeader
title={provider.name}
subtitle={`OIDC provider · ${provider.id}`}
action={
<Link to="/auth/oidc/providers" className="text-sm text-brand-600 hover:underline">
All providers
</Link>
}
/>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700" data-testid="oidc-provider-detail-error">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-50 border border-green-200 rounded text-sm text-green-700" data-testid="oidc-provider-detail-success">
{success}
</div>
)}
<div className="bg-surface border border-surface-border rounded p-5 space-y-4">
<h2 className="text-base font-semibold text-ink">Configuration</h2>
{!editing ? (
<dl className="grid grid-cols-3 gap-y-2 text-sm">
<dt className="text-ink-muted col-span-1">Issuer URL</dt>
<dd className="col-span-2 font-mono text-xs">{provider.issuer_url}</dd>
<dt className="text-ink-muted col-span-1">Client ID</dt>
<dd className="col-span-2 font-mono text-xs">{provider.client_id}</dd>
<dt className="text-ink-muted col-span-1">Redirect URI</dt>
<dd className="col-span-2 font-mono text-xs">{provider.redirect_uri}</dd>
<dt className="text-ink-muted col-span-1">Groups claim</dt>
<dd className="col-span-2 font-mono text-xs">
{provider.groups_claim_path} ({provider.groups_claim_format})
</dd>
<dt className="text-ink-muted col-span-1">Userinfo fallback</dt>
<dd className="col-span-2">{provider.fetch_userinfo ? 'enabled' : 'disabled'}</dd>
<dt className="text-ink-muted col-span-1">Scopes</dt>
<dd className="col-span-2 font-mono text-xs">{(provider.scopes || []).join(', ')}</dd>
<dt className="text-ink-muted col-span-1">IAT window</dt>
<dd className="col-span-2">{provider.iat_window_seconds}s</dd>
</dl>
) : (
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Display name</label>
<input
value={editName}
onChange={e => setEditName(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-name"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Issuer URL</label>
<input
value={editIssuerURL}
onChange={e => setEditIssuerURL(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-issuer-url"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Client ID</label>
<input
value={editClientID}
onChange={e => setEditClientID(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-client-id"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">
Client secret (leave blank to keep current)
</label>
<input
type="password"
value={editClientSecret}
onChange={e => setEditClientSecret(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-client-secret"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Redirect URI</label>
<input
value={editRedirectURI}
onChange={e => setEditRedirectURI(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-redirect-uri"
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={editFetchUserinfo}
onChange={e => setEditFetchUserinfo(e.target.checked)}
data-testid="oidc-provider-edit-fetch-userinfo"
/>
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
</label>
</div>
)}
</div>
<div className="bg-surface border border-surface-border rounded p-5 space-y-3">
<h2 className="text-base font-semibold text-ink">Actions</h2>
<div className="flex flex-wrap gap-2">
{canEdit && !editing && (
<button
onClick={startEdit}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-provider-edit-button"
>
Edit
</button>
)}
{editing && (
<>
<button
onClick={saveEdit}
disabled={submitting}
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
data-testid="oidc-provider-save-button"
>
{submitting ? 'Saving…' : 'Save'}
</button>
<button
onClick={cancelEdit}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-provider-cancel-edit-button"
>
Cancel
</button>
</>
)}
{canEdit && (
<button
onClick={doRefresh}
disabled={submitting}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink disabled:opacity-50"
data-testid="oidc-provider-refresh-button"
title="Re-fetch IdP discovery doc + JWKS; re-runs IdP downgrade defense"
>
Refresh discovery cache
</button>
)}
<Link
to={`/auth/oidc/providers/${encodeURIComponent(provider.id)}/mappings`}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-provider-mappings-link"
>
Group role mappings
</Link>
{canDelete && !confirmDelete && (
<button
onClick={() => setConfirmDelete(true)}
className="ml-auto px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700"
data-testid="oidc-provider-delete-button"
>
Delete
</button>
)}
</div>
{confirmDelete && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800" data-testid="oidc-provider-delete-confirm">
<p className="mb-2">
Type <span className="font-mono font-semibold">{provider.name}</span> to confirm deletion.
Deletion is refused (HTTP 409) when any user has authenticated via this provider; revoke
their sessions first.
</p>
<div className="flex gap-2">
<input
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
className="flex-1 px-2 py-1 text-sm border border-red-300 rounded bg-white"
data-testid="oidc-provider-delete-confirm-input"
/>
<button
onClick={doDelete}
disabled={submitting || deleteConfirmText !== provider.name}
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50"
data-testid="oidc-provider-delete-confirm-button"
>
{submitting ? 'Deleting…' : 'Delete provider'}
</button>
<button
onClick={() => {
setConfirmDelete(false);
setDeleteConfirmText('');
}}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-provider-delete-cancel-button"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,167 @@
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 2 Phase 8 — OIDCProvidersPage tests. Pins:
// - Page 403's (renders ErrorState) when caller lacks auth.oidc.list.
// - Empty state renders when no providers.
// - List renders + name links to detail page.
// - "Configure provider" button HIDDEN without auth.oidc.create.
// - "Configure provider" button SHOWN with auth.oidc.create + submit
// calls createOIDCProvider.
vi.mock('../../api/client', () => ({
listOIDCProviders: vi.fn(),
createOIDCProvider: vi.fn(),
authMe: vi.fn(),
}));
import OIDCProvidersPage from './OIDCProvidersPage';
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 sample = [
{
id: 'op-okta',
tenant_id: 't-default',
name: 'Okta',
issuer_url: 'https://example.okta.com',
client_id: 'certctl',
redirect_uri: 'https://certctl.example.com/auth/oidc/callback',
groups_claim_path: 'groups',
groups_claim_format: 'string-array',
fetch_userinfo: false,
scopes: ['openid'],
iat_window_seconds: 300,
jwks_cache_ttl_seconds: 3600,
created_at: '2026-05-10T00:00:00Z',
updated_at: '2026-05-10T00:00:00Z',
},
];
describe('OIDCProvidersPage', () => {
it('renders ErrorState when caller lacks auth.oidc.list', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
renderWithProviders(<OIDCProvidersPage />);
await waitFor(() => {
expect(screen.queryByText(/auth\.oidc\.list/)).toBeTruthy();
});
});
it('renders empty state when no providers configured', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderWithProviders(<OIDCProvidersPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-providers-empty')).toBeTruthy();
});
});
it('renders list + create button when caller has auth.oidc.create', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: sample });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.create', scope_type: 'global' },
],
});
renderWithProviders(<OIDCProvidersPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-row-op-okta')).toBeTruthy();
});
expect(screen.getByTestId('oidc-providers-create-button')).toBeTruthy();
expect(screen.getByText('Okta')).toBeTruthy();
});
it('hides create button without auth.oidc.create', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: sample });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-viewer',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }],
});
renderWithProviders(<OIDCProvidersPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-provider-row-op-okta')).toBeTruthy();
});
expect(screen.queryByTestId('oidc-providers-create-button')).toBeNull();
});
it('submits the create modal via createOIDCProvider', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [] });
vi.mocked(client.createOIDCProvider).mockResolvedValue(sample[0]);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.create', scope_type: 'global' },
],
});
renderWithProviders(<OIDCProvidersPage />);
await waitFor(() => {
expect(screen.getByTestId('oidc-providers-create-button')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('oidc-providers-create-button'));
await waitFor(() => {
expect(screen.getByTestId('create-oidc-provider-modal')).toBeTruthy();
});
fireEvent.change(screen.getByTestId('oidc-provider-name-input'), { target: { value: 'Okta' } });
fireEvent.change(screen.getByTestId('oidc-provider-issuer-url-input'), {
target: { value: 'https://example.okta.com' },
});
fireEvent.change(screen.getByTestId('oidc-provider-client-id-input'), { target: { value: 'certctl' } });
fireEvent.change(screen.getByTestId('oidc-provider-client-secret-input'), {
target: { value: 'super-secret' },
});
fireEvent.change(screen.getByTestId('oidc-provider-redirect-uri-input'), {
target: { value: 'https://certctl.example.com/auth/oidc/callback' },
});
fireEvent.click(screen.getByTestId('create-oidc-provider-submit'));
await waitFor(() => {
expect(client.createOIDCProvider).toHaveBeenCalledTimes(1);
});
});
});
+318
View File
@@ -0,0 +1,318 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
listOIDCProviders,
createOIDCProvider,
type OIDCProvider,
type OIDCProviderRequest,
} from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 2 Phase 8 — OIDCProvidersPage.
//
// Lists every configured OIDC identity provider in the tenant. Each
// row shows id, name, issuer URL, client_id, and a deep-link to the
// provider detail page.
//
// Render-time permission gating:
// - Page itself requires auth.oidc.list; non-holders see an
// ErrorState directing them to ask an admin.
// - "Configure provider" button is HIDDEN unless the caller holds
// auth.oidc.create (server-side enforcement is still load-bearing).
//
// data-testid attributes flag every interactive element so the future
// E2E suite can assert behaviour without brittle CSS selectors. Same
// pattern as Bundle 1's RolesPage.
// =============================================================================
interface CreateProviderModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModalProps) {
const [form, setForm] = useState<OIDCProviderRequest>({
name: '',
issuer_url: '',
client_id: '',
client_secret: '',
redirect_uri: '',
groups_claim_path: 'groups',
groups_claim_format: 'string-array',
fetch_userinfo: false,
scopes: ['openid', 'profile', 'email'],
iat_window_seconds: 300,
jwks_cache_ttl_seconds: 3600,
});
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
if (!isOpen) return null;
const update = <K extends keyof OIDCProviderRequest>(k: K, v: OIDCProviderRequest[K]) => {
setForm(prev => ({ ...prev, [k]: v }));
setDirty(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim() || !form.issuer_url.trim() || !form.client_id.trim() || !form.client_secret) return;
setSubmitting(true);
setError(null);
try {
await createOIDCProvider(form);
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;
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-lg shadow-xl max-h-[90vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
data-testid="create-oidc-provider-modal"
>
<h2 className="text-lg font-semibold text-ink mb-4">Configure OIDC provider</h2>
{error && (
<div
className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
data-testid="create-oidc-provider-error"
>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Display name *</label>
<input
value={form.name}
onChange={e => update('name', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
required
data-testid="oidc-provider-name-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Issuer URL *</label>
<input
type="url"
value={form.issuer_url}
onChange={e => update('issuer_url', e.target.value)}
placeholder="https://idp.example.com/realm/main"
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
required
data-testid="oidc-provider-issuer-url-input"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Client ID *</label>
<input
value={form.client_id}
onChange={e => update('client_id', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
required
data-testid="oidc-provider-client-id-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Client secret *</label>
<input
type="password"
value={form.client_secret}
onChange={e => update('client_secret', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
required
data-testid="oidc-provider-client-secret-input"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Redirect URI *</label>
<input
type="url"
value={form.redirect_uri}
onChange={e => update('redirect_uri', e.target.value)}
placeholder="https://certctl.example.com/auth/oidc/callback"
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
required
data-testid="oidc-provider-redirect-uri-input"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">Groups claim path</label>
<input
value={form.groups_claim_path}
onChange={e => update('groups_claim_path', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-groups-claim-path-input"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Groups claim format</label>
<select
value={form.groups_claim_format}
onChange={e => update('groups_claim_format', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-groups-claim-format-select"
>
<option value="string-array">string-array</option>
<option value="json-path">json-path</option>
</select>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-ink">
<input
type="checkbox"
checked={form.fetch_userinfo || false}
onChange={e => update('fetch_userinfo', e.target.checked)}
data-testid="oidc-provider-fetch-userinfo-checkbox"
/>
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
</label>
<div className="flex justify-end gap-2 pt-3">
<button
type="button"
onClick={handleClose}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="create-oidc-provider-cancel"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
data-testid="create-oidc-provider-submit"
>
{submitting ? 'Creating…' : 'Create provider'}
</button>
</div>
</form>
</div>
</div>
);
}
export default function OIDCProvidersPage() {
const { hasPerm } = useAuthMe();
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const canList = hasPerm('auth.oidc.list');
const canCreate = hasPerm('auth.oidc.create');
const { data, isLoading, error } = useQuery({
queryKey: ['oidc-providers'],
queryFn: listOIDCProviders,
enabled: canList,
});
if (!canList) {
return (
<div className="p-8">
<PageHeader title="OIDC providers" subtitle="Identity provider configuration" />
<ErrorState error={new Error("You need the auth.oidc.list permission to view OIDC providers. Ask an administrator to grant the permission to your role.")} />
</div>
);
}
return (
<div className="p-8">
<PageHeader
title="OIDC providers"
subtitle="Identity provider configuration"
action={
canCreate && (
<button
onClick={() => setShowCreate(true)}
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700"
data-testid="oidc-providers-create-button"
>
Configure provider
</button>
)
}
/>
{isLoading && (
<div className="text-sm text-ink-muted" data-testid="oidc-providers-loading">
Loading providers
</div>
)}
{error && <ErrorState error={error instanceof Error ? error : new Error(String(error))} />}
{data && data.providers.length === 0 && (
<div className="bg-surface border border-surface-border rounded p-6 text-center" data-testid="oidc-providers-empty">
<p className="text-ink-muted text-sm">
No OIDC providers configured.{' '}
{canCreate ? 'Click "Configure provider" to add one.' : 'Ask an administrator to configure one.'}
</p>
</div>
)}
{data && data.providers.length > 0 && (
<div className="bg-surface border border-surface-border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-page border-b border-surface-border">
<tr>
<th className="text-left px-4 py-2 font-medium text-ink">Name</th>
<th className="text-left px-4 py-2 font-medium text-ink">Issuer URL</th>
<th className="text-left px-4 py-2 font-medium text-ink">Client ID</th>
<th className="text-left px-4 py-2 font-medium text-ink">Created</th>
</tr>
</thead>
<tbody>
{data.providers.map((p: OIDCProvider) => (
<tr key={p.id} className="border-b border-surface-border hover:bg-page" data-testid={`oidc-provider-row-${p.id}`}>
<td className="px-4 py-2">
<Link
to={`/auth/oidc/providers/${encodeURIComponent(p.id)}`}
className="text-brand-600 hover:underline"
data-testid={`oidc-provider-link-${p.id}`}
>
{p.name}
</Link>
</td>
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.issuer_url}</td>
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.client_id}</td>
<td className="px-4 py-2 text-ink-muted">
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<CreateProviderModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {
setShowCreate(false);
queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
}}
/>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
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 2 Phase 8 — SessionsPage tests. Pins:
// - 403 ErrorState when caller lacks auth.session.list.
// - "Self" view renders the caller's sessions + self-pill on own row.
// - "All actors (admin)" toggle HIDDEN without auth.session.list.all.
// - "All actors (admin)" toggle SHOWN with auth.session.list.all.
// - Revoke button SHOWN for own session even without auth.session.revoke.
// - Revoke click calls revokeSession (after window.confirm).
vi.mock('../../api/client', () => ({
listSessions: vi.fn(),
revokeSession: vi.fn(),
authMe: vi.fn(),
}));
import SessionsPage from './SessionsPage';
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 ownSession = {
id: 'sess-own',
actor_id: 'u-alice',
actor_type: 'User',
ip_address: '10.0.0.1',
user_agent: 'curl/8',
created_at: '2026-05-10T00:00:00Z',
last_seen_at: '2026-05-10T01:00:00Z',
idle_expires_at: '2026-05-10T02:00:00Z',
absolute_expires_at: '2026-05-11T00:00:00Z',
revoked: false,
};
const otherSession = {
id: 'sess-other',
actor_id: 'u-bob',
actor_type: 'User',
ip_address: '10.0.0.2',
user_agent: 'firefox',
created_at: '2026-05-10T00:00:00Z',
last_seen_at: '2026-05-10T01:00:00Z',
idle_expires_at: '2026-05-10T02:00:00Z',
absolute_expires_at: '2026-05-11T00:00:00Z',
revoked: false,
};
describe('SessionsPage', () => {
it('renders ErrorState when caller lacks auth.session.list', async () => {
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-x',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: [],
effective_permissions: [],
});
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.queryByText(/auth\.session\.list/)).toBeTruthy();
});
});
it('renders own sessions with self-pill on caller row', async () => {
vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-alice',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }],
});
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.getByTestId('session-row-sess-own')).toBeTruthy();
});
expect(screen.getByTestId('session-self-pill-sess-own')).toBeTruthy();
// own session always shows revoke (own-bypass) regardless of auth.session.revoke.
expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy();
});
it('hides "All actors" toggle when caller lacks auth.session.list.all', async () => {
vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-alice',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }],
});
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.getByTestId('session-row-sess-own')).toBeTruthy();
});
expect(screen.getByTestId('sessions-view-self')).toBeTruthy();
expect(screen.queryByTestId('sessions-view-all')).toBeNull();
});
it('shows "All actors" toggle when caller has auth.session.list.all', async () => {
vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.session.list', scope_type: 'global' },
{ permission: 'auth.session.list.all', scope_type: 'global' },
],
});
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.getByTestId('sessions-view-all')).toBeTruthy();
});
});
it('hides revoke button on other-actor sessions without auth.session.revoke', async () => {
vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession, otherSession] });
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-alice',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }],
});
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.getByTestId('session-row-sess-other')).toBeTruthy();
});
expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy();
expect(screen.queryByTestId('session-revoke-sess-other')).toBeNull();
});
it('clicking revoke calls revokeSession after window.confirm', async () => {
vi.mocked(client.listSessions).mockResolvedValue({ sessions: [ownSession] });
vi.mocked(client.revokeSession).mockResolvedValue(undefined);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-alice',
actor_type: 'User',
tenant_id: 't-default',
admin: false,
roles: ['r-viewer'],
effective_permissions: [{ permission: 'auth.session.list', scope_type: 'global' }],
});
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithProviders(<SessionsPage />);
await waitFor(() => {
expect(screen.getByTestId('session-revoke-sess-own')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('session-revoke-sess-own'));
await waitFor(() => {
expect(client.revokeSession).toHaveBeenCalledWith('sess-own');
});
confirmSpy.mockRestore();
});
});
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { listSessions, revokeSession, type SessionInfo } from '../../api/client';
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
// =============================================================================
// Bundle 2 Phase 8 — SessionsPage.
//
// Renders the caller's active sessions by default. When the caller
// holds auth.session.list.all, an "All actors" toggle exposes the
// admin view (every active session in the tenant).
//
// Routes:
// /auth/sessions — admin all-actors view + own sessions toggle.
// API:
// GET /api/v1/auth/sessions (own; auth.session.list)
// GET /api/v1/auth/sessions?actor_id=<other> (admin; auth.session.list.all)
// DELETE /api/v1/auth/sessions/{id} (own bypass + auth.session.revoke)
//
// Permission gating: page itself requires auth.session.list. Switch
// to all-actors view requires auth.session.list.all. Revoke action
// is shown for: (a) the caller's own sessions (own-bypass at the
// handler), AND (b) any session when caller holds auth.session.revoke.
// Server-side enforcement is the load-bearing layer; client-side
// hide is UX.
// =============================================================================
type ViewMode = 'self' | 'all';
export default function SessionsPage() {
const { data: me, hasPerm } = useAuthMe();
const queryClient = useQueryClient();
const canList = hasPerm('auth.session.list');
const canListAll = hasPerm('auth.session.list.all');
const canRevokeAny = hasPerm('auth.session.revoke');
const [view, setView] = useState<ViewMode>('self');
const [filterActorID, setFilterActorID] = useState('');
const [error, setError] = useState<string | null>(null);
// Effective actor_id query param when in admin view.
const effectiveActorID = view === 'all' ? filterActorID.trim() : '';
const { data, isLoading, error: loadErr } = useQuery({
queryKey: ['sessions', view, effectiveActorID],
queryFn: () =>
effectiveActorID ? listSessions(effectiveActorID, 'User') : listSessions(),
enabled: canList,
});
if (!canList) {
return (
<div className="p-8">
<PageHeader title="Sessions" subtitle="Active session management" />
<ErrorState error={new Error('You need the auth.session.list permission to view sessions.')} />
</div>
);
}
const handleRevoke = async (s: SessionInfo) => {
if (!window.confirm(`Revoke session ${s.id} for ${s.actor_id}? They will be logged out.`)) return;
try {
await revokeSession(s.id);
queryClient.invalidateQueries({ queryKey: ['sessions'] });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const callerActorID = me?.actor_id || '';
return (
<div className="p-8 space-y-6">
<PageHeader title="Sessions" subtitle="Active session management" />
{error && (
<div
className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
data-testid="sessions-page-error"
>
{error}
</div>
)}
<div className="flex gap-2 items-center">
<button
onClick={() => setView('self')}
className={
view === 'self'
? 'px-3 py-1.5 text-sm bg-brand-600 text-white rounded'
: 'px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink'
}
data-testid="sessions-view-self"
>
My sessions
</button>
{canListAll && (
<button
onClick={() => setView('all')}
className={
view === 'all'
? 'px-3 py-1.5 text-sm bg-brand-600 text-white rounded'
: 'px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink'
}
data-testid="sessions-view-all"
>
All actors (admin)
</button>
)}
{view === 'all' && (
<input
value={filterActorID}
onChange={e => setFilterActorID(e.target.value)}
placeholder="Filter by actor_id (e.g. u-alice)"
className="ml-2 flex-1 px-2 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="sessions-actor-id-filter"
/>
)}
</div>
{isLoading && (
<div className="text-sm text-ink-muted" data-testid="sessions-loading">
Loading sessions
</div>
)}
{loadErr && <ErrorState error={loadErr instanceof Error ? loadErr : new Error(String(loadErr))} />}
{data && data.sessions && data.sessions.length === 0 && (
<div
className="bg-surface border border-surface-border rounded p-6 text-center"
data-testid="sessions-empty"
>
<p className="text-ink-muted text-sm">No active sessions.</p>
</div>
)}
{data && data.sessions && data.sessions.length > 0 && (
<div className="bg-surface border border-surface-border rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-page border-b border-surface-border">
<tr>
<th className="text-left px-4 py-2 font-medium text-ink">Session ID</th>
<th className="text-left px-4 py-2 font-medium text-ink">Actor</th>
<th className="text-left px-4 py-2 font-medium text-ink">IP</th>
<th className="text-left px-4 py-2 font-medium text-ink">Last seen</th>
<th className="text-left px-4 py-2 font-medium text-ink">Absolute expiry</th>
<th className="text-right px-4 py-2 font-medium text-ink">Actions</th>
</tr>
</thead>
<tbody>
{data.sessions.map((s: SessionInfo) => {
const isOwn = s.actor_id === callerActorID;
const showRevoke = isOwn || canRevokeAny;
return (
<tr
key={s.id}
className="border-b border-surface-border hover:bg-page"
data-testid={`session-row-${s.id}`}
>
<td className="px-4 py-2 font-mono text-xs">{s.id}</td>
<td className="px-4 py-2">
<span className="font-mono text-xs">{s.actor_id}</span>
<span className="ml-1 text-ink-muted">({s.actor_type})</span>
{isOwn && (
<span
className="ml-2 inline-block px-1.5 py-0.5 text-[10px] rounded bg-brand-50 text-brand-700"
data-testid={`session-self-pill-${s.id}`}
>
you
</span>
)}
</td>
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
<td className="px-4 py-2 text-ink-muted">
{s.last_seen_at ? new Date(s.last_seen_at).toLocaleString() : '—'}
</td>
<td className="px-4 py-2 text-ink-muted">
{s.absolute_expires_at ? new Date(s.absolute_expires_at).toLocaleString() : '—'}
</td>
<td className="px-4 py-2 text-right">
{showRevoke && (
<button
onClick={() => handleRevoke(s)}
className="text-xs text-red-600 hover:underline"
data-testid={`session-revoke-${s.id}`}
>
Revoke
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}