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

@@ -80,7 +80,8 @@ public class OidcConfigAdminController {
request.clientId() != null ? request.clientId() : "",
clientSecret,
request.rolesClaim() != null ? request.rolesClaim() : "realm_access.roles",
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER")
request.defaultRoles() != null ? request.defaultRoles() : List.of("VIEWER"),
request.autoSignup()
);
configRepository.save(config);
@@ -134,6 +135,7 @@ public class OidcConfigAdminController {
map.put("clientSecretSet", !config.clientSecret().isBlank());
map.put("rolesClaim", config.rolesClaim());
map.put("defaultRoles", config.defaultRoles());
map.put("autoSignup", config.autoSignup());
return map;
}
@@ -143,6 +145,7 @@ public class OidcConfigAdminController {
String clientId,
String clientSecret,
String rolesClaim,
List<String> defaultRoles
List<String> defaultRoles,
boolean autoSignup
) {}
}

View File

@@ -90,8 +90,15 @@ public class OidcAuthController {
String issuerHost = URI.create(config.get().issuerUri()).getHost();
String provider = "oidc:" + issuerHost;
// Check auto-signup gate: if disabled, user must already exist
Optional<UserInfo> existingUser = userRepository.findById(userId);
if (!config.get().autoSignup() && existingUser.isEmpty()) {
return ResponseEntity.status(403)
.body(Map.of("message", "Account not provisioned. Contact your administrator."));
}
// Resolve roles: DB override > OIDC claim > default
List<String> roles = resolveRoles(userId, oidcUser.roles(), config.get());
List<String> roles = resolveRoles(existingUser, oidcUser.roles(), config.get());
userRepository.upsert(new UserInfo(
userId, provider, oidcUser.email(), oidcUser.name(), roles, Instant.now()));
@@ -110,8 +117,7 @@ public class OidcAuthController {
}
}
private List<String> resolveRoles(String userId, List<String> oidcRoles, OidcConfig config) {
Optional<UserInfo> existing = userRepository.findById(userId);
private List<String> resolveRoles(Optional<UserInfo> existing, List<String> oidcRoles, OidcConfig config) {
if (existing.isPresent() && !existing.get().roles().isEmpty()) {
return existing.get().roles();
}

View File

@@ -73,7 +73,8 @@ public class SecurityBeanConfig {
envOidc.getClientId(),
envOidc.getClientSecret() != null ? envOidc.getClientSecret() : "",
envOidc.getRolesClaim(),
envOidc.getDefaultRoles()
envOidc.getDefaultRoles(),
true
);
configRepository.save(config);
log.info("OIDC config seeded from environment variables: issuer={}", envOidc.getIssuerUri());

View File

@@ -27,7 +27,7 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
@Override
public Optional<OidcConfig> find() {
List<OidcConfig> results = jdbc.query(
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles "
"SELECT enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup "
+ "FROM oidc_config FINAL WHERE config_id = 'default'",
this::mapRow
);
@@ -37,14 +37,15 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
@Override
public void save(OidcConfig config) {
jdbc.update(
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, updated_at) "
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
"INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, roles_claim, default_roles, auto_signup, updated_at) "
+ "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, now64(3, 'UTC'))",
config.enabled(),
config.issuerUri(),
config.clientId(),
config.clientSecret(),
config.rolesClaim(),
config.defaultRoles().toArray(new String[0])
config.defaultRoles().toArray(new String[0]),
config.autoSignup()
);
}
@@ -61,7 +62,8 @@ public class ClickHouseOidcConfigRepository implements OidcConfigRepository {
rs.getString("client_id"),
rs.getString("client_secret"),
rs.getString("roles_claim"),
Arrays.asList(rolesArray)
Arrays.asList(rolesArray),
rs.getBoolean("auto_signup")
);
}
}

View File

@@ -11,6 +11,7 @@ import java.util.List;
* @param clientSecret OAuth2 client secret (stored server-side only)
* @param rolesClaim dot-separated path to roles in the id_token (e.g. {@code realm_access.roles})
* @param defaultRoles fallback roles for new users with no OIDC role claim
* @param autoSignup whether new OIDC users are automatically created on first login
*/
public record OidcConfig(
boolean enabled,
@@ -18,9 +19,10 @@ public record OidcConfig(
String clientId,
String clientSecret,
String rolesClaim,
List<String> defaultRoles
List<String> defaultRoles,
boolean autoSignup
) {
public static OidcConfig disabled() {
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"));
return new OidcConfig(false, "", "", "", "realm_access.roles", List.of("VIEWER"), true);
}
}

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS oidc_config (
client_secret String DEFAULT '',
roles_claim String DEFAULT 'realm_access.roles',
default_roles Array(LowCardinality(String)),
auto_signup Bool DEFAULT true,
updated_at DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC')
) ENGINE = ReplacingMergeTree(updated_at)
ORDER BY (config_id);

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 /> },
],
},
],