mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:51:30 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user