Add OIDC admin config page with auto-signup toggle
Some checks failed
CI / build (push) Successful in 1m12s
CI / docker (push) Successful in 50s
CI / deploy (push) Failing after 2m10s

Backend: add autoSignup field to OidcConfig, ClickHouse schema, repository,
and admin controller. Gate OIDC login when auto-signup is disabled and user
is not pre-created (returns 403).

Frontend: add OIDC admin page with full CRUD (save/test/delete), role-gated
Admin nav link parsed from JWT, and matching design system styles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 13:56:02 +01:00
parent 377908cc61
commit 0c47ac9b1a
12 changed files with 802 additions and 14 deletions

View File

@@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export interface OidcConfigResponse {
configured: boolean;
enabled?: boolean;
issuerUri?: string;
clientId?: string;
clientSecretSet?: boolean;
rolesClaim?: string;
defaultRoles?: string[];
autoSignup?: boolean;
}
export interface OidcConfigRequest {
enabled: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
autoSignup: boolean;
}
interface TestResult {
status: string;
authorizationEndpoint: string;
}
async function adminFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (res.status === 204) return undefined as T;
const body = await res.json();
if (!res.ok) throw new Error(body.message || `Request failed (${res.status})`);
return body as T;
}
export function useOidcConfig() {
return useQuery<OidcConfigResponse>({
queryKey: ['admin', 'oidc'],
queryFn: () => adminFetch('/admin/oidc'),
});
}
export function useSaveOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: OidcConfigRequest) =>
adminFetch<OidcConfigResponse>('/admin/oidc', {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}
export function useTestOidcConnection() {
return useMutation({
mutationFn: () =>
adminFetch<TestResult>('/admin/oidc/test', { method: 'POST' }),
});
}
export function useDeleteOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: () =>
adminFetch<void>('/admin/oidc', { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}

View File

@@ -5,6 +5,7 @@ interface AuthState {
accessToken: string | null;
refreshToken: string | null;
username: string | null;
roles: string[];
isAuthenticated: boolean;
error: string | null;
loading: boolean;
@@ -13,6 +14,15 @@ interface AuthState {
logout: () => void;
}
function parseRolesFromJwt(token: string): string[] {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return Array.isArray(payload.roles) ? payload.roles : [];
} catch {
return [];
}
}
function loadTokens() {
return {
accessToken: localStorage.getItem('cameleer-access-token'),
@@ -39,6 +49,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accessToken: initial.accessToken,
refreshToken: initial.refreshToken,
username: initial.username,
roles: initial.accessToken ? parseRolesFromJwt(initial.accessToken) : [],
isAuthenticated: !!initial.accessToken,
error: null,
loading: false,
@@ -61,6 +72,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accessToken,
refreshToken,
username,
roles: parseRolesFromJwt(accessToken),
isAuthenticated: true,
loading: false,
});
@@ -88,6 +100,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
roles: parseRolesFromJwt(data.accessToken),
isAuthenticated: true,
});
return true;
@@ -102,6 +115,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accessToken: null,
refreshToken: null,
username: null,
roles: [],
isAuthenticated: false,
error: null,
});

View File

@@ -5,7 +5,7 @@ import styles from './TopNav.module.css';
export function TopNav() {
const { theme, toggle } = useThemeStore();
const { username, logout } = useAuthStore();
const { username, roles, logout } = useAuthStore();
return (
<nav className={styles.topnav}>
@@ -23,6 +23,13 @@ export function TopNav() {
Transactions
</NavLink>
</li>
{roles.includes('ADMIN') && (
<li>
<NavLink to="/admin/oidc" className={({ isActive }) => isActive ? styles.navLinkActive : styles.navLink}>
Admin
</NavLink>
</li>
)}
</ul>
<div className={styles.navRight}>

View File

@@ -0,0 +1,335 @@
.page {
max-width: 640px;
margin: 0 auto;
padding: 32px 16px;
}
.pageTitle {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.subtitle {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 24px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 32px;
}
.toggleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--border-subtle);
}
.toggleInfo {
flex: 1;
margin-right: 16px;
}
.toggleLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.toggleDesc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.4;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: var(--text-muted);
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.toggleOn {
background: var(--amber);
border-color: var(--amber);
}
.toggleOn::after {
transform: translateX(20px);
background: #0a0e17;
}
.field {
margin-top: 16px;
}
.label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 6px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
font-style: italic;
}
.input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 99px;
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.tagRemove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
}
.tagRemove:hover {
color: var(--rose);
}
.tagInput {
display: flex;
gap: 8px;
}
.tagInput .input {
flex: 1;
}
.tagAddBtn {
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.tagAddBtn:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border-subtle);
}
.btnPrimary {
padding: 10px 24px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btnPrimary:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnOutline {
padding: 10px 24px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btnOutline:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.btnOutline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnDanger {
margin-left: auto;
padding: 10px 24px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-family: var(--font-body);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btnDanger:hover {
background: var(--rose-glow);
}
.btnDanger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirmBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 12px 16px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}
.confirmBar button {
font-size: 13px;
cursor: pointer;
}
.confirmActions {
display: flex;
gap: 8px;
}
.successMsg {
margin-top: 16px;
padding: 10px 12px;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--green);
}
.errorMsg {
margin-top: 16px;
padding: 10px 12px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
background: var(--bg-raised);
border-radius: var(--radius-sm);
height: 20px;
margin-bottom: 12px;
}
.skeletonWide {
composes: skeleton;
width: 100%;
height: 40px;
}
.skeletonMedium {
composes: skeleton;
width: 60%;
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}

View File

@@ -0,0 +1,335 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import {
useOidcConfig,
useSaveOidcConfig,
useTestOidcConnection,
useDeleteOidcConfig,
type OidcConfigRequest,
} from '../../api/queries/oidc-admin';
import styles from './OidcAdminPage.module.css';
interface FormData {
enabled: boolean;
autoSignup: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
}
const emptyForm: FormData = {
enabled: false,
autoSignup: true,
issuerUri: '',
clientId: '',
clientSecret: '',
rolesClaim: 'realm_access.roles',
defaultRoles: ['VIEWER'],
};
export function OidcAdminPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <OidcAdminForm />;
}
function OidcAdminForm() {
const { data, isLoading } = useOidcConfig();
const saveMutation = useSaveOidcConfig();
const testMutation = useTestOidcConnection();
const deleteMutation = useDeleteOidcConfig();
const [form, setForm] = useState<FormData>(emptyForm);
const [secretTouched, setSecretTouched] = useState(false);
const [newRole, setNewRole] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const statusTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (!data) return;
if (data.configured) {
setForm({
enabled: data.enabled ?? false,
autoSignup: data.autoSignup ?? true,
issuerUri: data.issuerUri ?? '',
clientId: data.clientId ?? '',
clientSecret: '',
rolesClaim: data.rolesClaim ?? 'realm_access.roles',
defaultRoles: data.defaultRoles ?? ['VIEWER'],
});
setSecretTouched(false);
} else {
setForm(emptyForm);
}
}, [data]);
function showStatus(type: 'success' | 'error', message: string) {
setStatus({ type, message });
clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setStatus(null), 5000);
}
function updateField<K extends keyof FormData>(key: K, value: FormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
const payload: OidcConfigRequest = {
...form,
clientSecret: secretTouched ? form.clientSecret : '********',
};
try {
await saveMutation.mutateAsync(payload);
showStatus('success', 'Configuration saved.');
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Failed to save.');
}
}
async function handleTest() {
try {
const result = await testMutation.mutateAsync();
showStatus('success', `Provider reachable. Authorization endpoint: ${result.authorizationEndpoint}`);
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Test failed.');
}
}
async function handleDelete() {
try {
await deleteMutation.mutateAsync();
setForm(emptyForm);
setSecretTouched(false);
setShowDeleteConfirm(false);
showStatus('success', 'Configuration deleted.');
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Failed to delete.');
}
}
function addRole() {
const role = newRole.trim().toUpperCase();
if (role && !form.defaultRoles.includes(role)) {
updateField('defaultRoles', [...form.defaultRoles, role]);
}
setNewRole('');
}
function removeRole(role: string) {
updateField('defaultRoles', form.defaultRoles.filter((r) => r !== role));
}
if (isLoading) {
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
<p className={styles.subtitle}>Configure external identity provider</p>
<div className={styles.card}>
<div className={styles.skeletonWide} />
<div className={styles.skeletonMedium} />
<div className={styles.skeletonWide} />
<div className={styles.skeletonWide} />
<div className={styles.skeletonMedium} />
</div>
</div>
);
}
const isConfigured = data?.configured ?? false;
return (
<div className={styles.page}>
<h1 className={styles.pageTitle}>OIDC Configuration</h1>
<p className={styles.subtitle}>Configure external identity provider</p>
<div className={styles.card}>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Enabled</div>
<div className={styles.toggleDesc}>
Allow users to sign in with the configured OIDC identity provider
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
onClick={() => updateField('enabled', !form.enabled)}
aria-label="Toggle OIDC enabled"
/>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Auto Sign-Up</div>
<div className={styles.toggleDesc}>
Automatically create accounts for new OIDC users. When disabled, an admin must
pre-create the user before they can sign in.
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
onClick={() => updateField('autoSignup', !form.autoSignup)}
aria-label="Toggle auto sign-up"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Issuer URI</label>
<input
className={styles.input}
type="url"
value={form.issuerUri}
onChange={(e) => updateField('issuerUri', e.target.value)}
placeholder="https://auth.example.com/realms/main"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client ID</label>
<input
className={styles.input}
type="text"
value={form.clientId}
onChange={(e) => updateField('clientId', e.target.value)}
placeholder="cameleer3"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client Secret</label>
<input
className={styles.input}
type="password"
value={form.clientSecret}
onChange={(e) => {
updateField('clientSecret', e.target.value);
setSecretTouched(true);
}}
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Roles Claim</label>
<input
className={styles.input}
type="text"
value={form.rolesClaim}
onChange={(e) => updateField('rolesClaim', e.target.value)}
placeholder="realm_access.roles"
/>
<div className={styles.hint}>
Dot-separated path to roles array in the ID token
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Default Roles</label>
<div className={styles.tags}>
{form.defaultRoles.map((role) => (
<span key={role} className={styles.tag}>
{role}
<button
type="button"
className={styles.tagRemove}
onClick={() => removeRole(role)}
aria-label={`Remove ${role}`}
>
&#x2715;
</button>
</span>
))}
</div>
<div className={styles.tagInput}>
<input
className={styles.input}
type="text"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addRole();
}
}}
placeholder="Add role..."
/>
<button type="button" className={styles.tagAddBtn} onClick={addRole}>
Add
</button>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className={styles.btnOutline}
onClick={handleTest}
disabled={!isConfigured || testMutation.isPending}
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
<button
type="button"
className={styles.btnDanger}
onClick={() => setShowDeleteConfirm(true)}
disabled={!isConfigured || deleteMutation.isPending}
>
Delete
</button>
</div>
{showDeleteConfirm && (
<div className={styles.confirmBar}>
<span>Delete OIDC configuration? This cannot be undone.</span>
<div className={styles.confirmActions}>
<button
type="button"
className={styles.btnOutline}
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</button>
<button
type="button"
className={styles.btnDanger}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Confirm'}
</button>
</div>
</div>
)}
{status && (
<div className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.message}
</div>
)}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { AppShell } from './components/layout/AppShell';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { LoginPage } from './auth/LoginPage';
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
export const router = createBrowserRouter([
{
@@ -17,6 +18,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to="/executions" replace /> },
{ path: 'executions', element: <ExecutionExplorer /> },
{ path: 'admin/oidc', element: <OidcAdminPage /> },
],
},
],