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
+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>
);
}