feat: add OIDC Config admin page
This commit is contained in:
65
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
65
src/pages/Admin/OidcConfig/OidcConfig.module.css
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
.page {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggleRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.noRoles {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRoleRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roleInput {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
191
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
191
src/pages/Admin/OidcConfig/OidcConfig.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { AdminLayout } from '../Admin'
|
||||||
|
import { Button } from '../../../design-system/primitives/Button/Button'
|
||||||
|
import { Input } from '../../../design-system/primitives/Input/Input'
|
||||||
|
import { Toggle } from '../../../design-system/primitives/Toggle/Toggle'
|
||||||
|
import { FormField } from '../../../design-system/primitives/FormField/FormField'
|
||||||
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
|
import styles from './OidcConfig.module.css'
|
||||||
|
|
||||||
|
interface OidcFormData {
|
||||||
|
enabled: boolean
|
||||||
|
autoSignup: boolean
|
||||||
|
issuerUri: string
|
||||||
|
clientId: string
|
||||||
|
clientSecret: string
|
||||||
|
rolesClaim: string
|
||||||
|
displayNameClaim: string
|
||||||
|
defaultRoles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_DATA: OidcFormData = {
|
||||||
|
enabled: true,
|
||||||
|
autoSignup: true,
|
||||||
|
issuerUri: 'https://keycloak.example.com/realms/cameleer',
|
||||||
|
clientId: 'cameleer-app',
|
||||||
|
clientSecret: '••••••••••••',
|
||||||
|
rolesClaim: 'realm_access.roles',
|
||||||
|
displayNameClaim: 'name',
|
||||||
|
defaultRoles: ['USER', 'VIEWER'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OidcConfig() {
|
||||||
|
const [form, setForm] = useState<OidcFormData>(INITIAL_DATA)
|
||||||
|
const [newRole, setNewRole] = useState('')
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
|
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRole() {
|
||||||
|
const role = newRole.trim().toUpperCase()
|
||||||
|
if (role && !form.defaultRoles.includes(role)) {
|
||||||
|
update('defaultRoles', [...form.defaultRoles, role])
|
||||||
|
setNewRole('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRole(role: string) {
|
||||||
|
update('defaultRoles', form.defaultRoles.filter((r) => r !== role))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTest() {
|
||||||
|
toast({ title: 'Connection test', description: 'OIDC provider responded successfully.', variant: 'info' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
setForm({ ...INITIAL_DATA, enabled: false, issuerUri: '', clientId: '', clientSecret: '', defaultRoles: [] })
|
||||||
|
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="OIDC Configuration">
|
||||||
|
<div className={styles.page}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h2 className={styles.title}>OIDC Configuration</h2>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri}>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Behavior</SectionHeader>
|
||||||
|
<div className={styles.toggleRow}>
|
||||||
|
<Toggle
|
||||||
|
label="Enabled"
|
||||||
|
checked={form.enabled}
|
||||||
|
onChange={(e) => update('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.toggleRow}>
|
||||||
|
<Toggle
|
||||||
|
label="Auto Sign-Up"
|
||||||
|
checked={form.autoSignup}
|
||||||
|
onChange={(e) => update('autoSignup', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.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={form.issuerUri}
|
||||||
|
onChange={(e) => update('issuerUri', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Client ID" htmlFor="client-id">
|
||||||
|
<Input
|
||||||
|
id="client-id"
|
||||||
|
value={form.clientId}
|
||||||
|
onChange={(e) => update('clientId', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Client Secret" htmlFor="client-secret">
|
||||||
|
<Input
|
||||||
|
id="client-secret"
|
||||||
|
type="password"
|
||||||
|
value={form.clientSecret}
|
||||||
|
onChange={(e) => update('clientSecret', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Claim Mapping</SectionHeader>
|
||||||
|
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||||
|
<Input
|
||||||
|
id="roles-claim"
|
||||||
|
value={form.rolesClaim}
|
||||||
|
onChange={(e) => update('rolesClaim', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||||
|
<Input
|
||||||
|
id="name-claim"
|
||||||
|
value={form.displayNameClaim}
|
||||||
|
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<SectionHeader>Default Roles</SectionHeader>
|
||||||
|
<div className={styles.tagList}>
|
||||||
|
{form.defaultRoles.map((role) => (
|
||||||
|
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||||||
|
))}
|
||||||
|
{form.defaultRoles.length === 0 && (
|
||||||
|
<span className={styles.noRoles}>No default roles configured</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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={styles.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"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user