Files
cameleer-server/ui/src/pages/Admin/OidcConfigPage.tsx
2026-04-09 18:43:46 +02:00

302 lines
11 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog,
} from '@cameleer/design-system';
import { useToast } from '@cameleer/design-system';
import { PageLoader } from '../../components/PageLoader';
import { adminFetch } from '../../api/queries/admin/admin-api';
import styles from './OidcConfigPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
interface OidcFormData {
enabled: boolean;
autoSignup: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
displayNameClaim: string;
userIdClaim: string;
defaultRoles: string[];
audience: string;
additionalScopes: string[];
}
const EMPTY_CONFIG: OidcFormData = {
enabled: false,
autoSignup: true,
issuerUri: '',
clientId: '',
clientSecret: '',
rolesClaim: 'roles',
displayNameClaim: 'name',
userIdClaim: 'sub',
defaultRoles: ['VIEWER'],
audience: '',
additionalScopes: [],
};
export default function OidcConfigPage() {
const [form, setForm] = useState<OidcFormData | null>(null);
const [editing, setEditing] = useState(false);
const [formDraft, setFormDraft] = useState<OidcFormData | null>(null);
const [newRole, setNewRole] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const { toast } = useToast();
useEffect(() => {
adminFetch<Partial<OidcFormData> & { configured?: boolean }>('/oidc')
.then((data) => setForm({
enabled: data.enabled ?? false,
autoSignup: data.autoSignup ?? true,
issuerUri: data.issuerUri ?? '',
clientId: data.clientId ?? '',
clientSecret: data.clientSecret ?? '',
rolesClaim: data.rolesClaim ?? 'roles',
displayNameClaim: data.displayNameClaim ?? 'name',
userIdClaim: data.userIdClaim ?? 'sub',
defaultRoles: data.defaultRoles ?? ['VIEWER'],
audience: (data as any).audience ?? '',
additionalScopes: (data as any).additionalScopes ?? [],
}))
.catch(() => setForm(EMPTY_CONFIG));
}, []);
// The display values come from formDraft when editing, form otherwise
const current = editing ? formDraft : form;
function startEditing() {
setFormDraft(form ? { ...form } : null);
setEditing(true);
}
function cancelEditing() {
setFormDraft(null);
setEditing(false);
setNewRole('');
}
function updateDraft<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
setFormDraft((prev) => prev ? { ...prev, [key]: value } : prev);
}
function addRole() {
if (!current) return;
const role = newRole.trim().toUpperCase();
if (role && !(current.defaultRoles || []).includes(role)) {
updateDraft('defaultRoles', [...(current.defaultRoles || []), role]);
setNewRole('');
}
}
function removeRole(role: string) {
if (!current) return;
updateDraft('defaultRoles', (current.defaultRoles || []).filter((r) => r !== role));
}
async function handleSave() {
if (!formDraft) return;
setSaving(true);
try {
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(formDraft) });
setForm({ ...formDraft });
setFormDraft(null);
setEditing(false);
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
} catch (e: any) {
toast({ title: 'Failed to save OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 });
} finally {
setSaving(false);
}
}
async function handleTest() {
if (!form) return;
setTesting(true);
try {
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
} catch (e: any) {
toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 });
} finally {
setTesting(false);
}
}
async function handleDelete() {
setDeleteOpen(false);
try {
await adminFetch('/oidc', { method: 'DELETE' });
setForm(EMPTY_CONFIG);
setFormDraft(null);
setEditing(false);
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
} catch (e: any) {
toast({ title: 'Failed to delete OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 });
}
}
if (!form) return <PageLoader />;
return (
<div className={styles.page}>
<div className={styles.toolbar}>
{editing ? (
<>
<Button size="sm" variant="ghost" onClick={cancelEditing}>Cancel</Button>
<Button size="sm" variant="primary" onClick={handleSave} loading={saving}>Save</Button>
</>
) : (
<>
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<Button size="sm" variant="secondary" onClick={startEditing}>Edit</Button>
</>
)}
</div>
<section className={sectionStyles.section}>
<SectionHeader>Behavior</SectionHeader>
<div className={styles.toggleRow}>
<Toggle
label="Enabled"
checked={current?.enabled ?? false}
onChange={(e) => updateDraft('enabled', e.target.checked)}
disabled={!editing}
/>
</div>
<div className={styles.toggleRow}>
<Toggle
label="Auto Sign-Up"
checked={current?.autoSignup ?? true}
onChange={(e) => updateDraft('autoSignup', e.target.checked)}
disabled={!editing}
/>
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
</div>
</section>
<section className={sectionStyles.section}>
<SectionHeader>Provider Settings</SectionHeader>
<FormField label="Issuer URI" htmlFor="issuer">
<Input
id="issuer"
type="url"
placeholder="https://idp.example.com/realms/my-realm"
value={current?.issuerUri ?? ''}
onChange={(e) => updateDraft('issuerUri', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="Client ID" htmlFor="client-id">
<Input
id="client-id"
value={current?.clientId ?? ''}
onChange={(e) => updateDraft('clientId', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="Client Secret" htmlFor="client-secret">
<Input
id="client-secret"
type="password"
value={current?.clientSecret ?? ''}
onChange={(e) => updateDraft('clientSecret', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="Audience / API Resource" htmlFor="audience" hint="RFC 8707 resource indicator sent in the authorization request">
<Input
id="audience"
placeholder="https://api.example.com"
value={current?.audience ?? ''}
onChange={(e) => updateDraft('audience', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="Additional Scopes" htmlFor="additional-scopes" hint="Extra scopes to request beyond openid email profile (comma-separated)">
<Input
id="additional-scopes"
placeholder="urn:scope:organizations, urn:scope:roles"
value={(current?.additionalScopes || []).join(', ')}
onChange={(e) => updateDraft('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
disabled={!editing}
/>
</FormField>
</section>
<section className={sectionStyles.section}>
<SectionHeader>Claim Mapping</SectionHeader>
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the access token or ID token">
<Input
id="roles-claim"
value={current?.rolesClaim ?? ''}
onChange={(e) => updateDraft('rolesClaim', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="User ID Claim" htmlFor="userid-claim" hint="Claim used as unique user identifier (default: sub)">
<Input
id="userid-claim"
value={current?.userIdClaim ?? ''}
onChange={(e) => updateDraft('userIdClaim', e.target.value)}
disabled={!editing}
/>
</FormField>
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
<Input
id="name-claim"
value={current?.displayNameClaim ?? ''}
onChange={(e) => updateDraft('displayNameClaim', e.target.value)}
disabled={!editing}
/>
</FormField>
</section>
<section className={sectionStyles.section}>
<SectionHeader>Default Roles</SectionHeader>
<div className={styles.tagList}>
{(current?.defaultRoles || []).map((role) => (
<Tag key={role} label={role} color="primary" onRemove={editing ? () => removeRole(role) : undefined} />
))}
{(current?.defaultRoles || []).length === 0 && (
<span className={styles.noRoles}>No default roles configured</span>
)}
</div>
{editing && (
<div className={styles.addRoleRow}>
<Input
placeholder="Add role..."
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
className={styles.roleInput}
/>
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
Add
</Button>
</div>
)}
</section>
<section className={sectionStyles.section}>
<SectionHeader>Danger Zone</SectionHeader>
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
Delete OIDC Configuration
</Button>
<ConfirmDialog
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDelete}
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
confirmText="delete oidc"
loading={saving}
/>
</section>
</div>
);
}