302 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|