Files
HarborForge.Frontend/src/pages/OidcSettingsPage.tsx
hzhang 73da3926e7 feat(auth): admin_role config; drop manual admin-subject from wizard
OIDC settings page + setup wizard now configure the bootstrap admin
role instead of a hand-typed OIDC subject. The OIDC-only admin link is
handled automatically by the backend admin-role auto-connect on first
sign-in (explained inline in both the wizard and settings page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:05:40 +01:00

172 lines
6.5 KiB
TypeScript

import { useEffect, useState } from 'react'
import api from '@/services/api'
import { useAuth } from '@/hooks/useAuth'
interface Settings {
enabled: boolean
issuer: string | null
client_id: string | null
has_client_secret: boolean
redirect_uri: string | null
scopes: string | null
post_login_redirect: string | null
admin_role: string
oidc_only: boolean
effective_enabled: boolean
source: string
}
export default function OidcSettingsPage() {
const { user } = useAuth()
const isAdmin = user?.is_admin === true
const [loaded, setLoaded] = useState<Settings | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [form, setForm] = useState({
enabled: false,
issuer: '',
client_id: '',
client_secret: '',
redirect_uri: '',
scopes: 'openid email profile',
post_login_redirect: '',
admin_role: 'admin',
})
useEffect(() => {
if (!isAdmin) { setLoading(false); return }
api.get<Settings>('/auth/oidc/settings')
.then(({ data }) => {
setLoaded(data)
setForm({
enabled: data.enabled,
issuer: data.issuer || '',
client_id: data.client_id || '',
client_secret: '',
redirect_uri: data.redirect_uri || '',
scopes: data.scopes || 'openid email profile',
post_login_redirect: data.post_login_redirect || '',
admin_role: data.admin_role || 'admin',
})
})
.catch((e) => setMessage(e.response?.data?.detail || 'Failed to load OIDC settings'))
.finally(() => setLoading(false))
}, [isAdmin])
const save = async () => {
setSaving(true)
setMessage('')
try {
const payload: Record<string, any> = {
enabled: form.enabled,
issuer: form.issuer.trim(),
client_id: form.client_id.trim(),
redirect_uri: form.redirect_uri.trim(),
scopes: form.scopes.trim(),
post_login_redirect: form.post_login_redirect.trim(),
admin_role: form.admin_role.trim() || 'admin',
}
if (form.client_secret) payload.client_secret = form.client_secret
const { data } = await api.put<Settings>('/auth/oidc/settings', payload)
setLoaded(data)
setForm((f) => ({ ...f, client_secret: '' }))
setMessage('OIDC settings saved successfully')
} catch (e: any) {
setMessage(e.response?.data?.detail || 'Failed to save OIDC settings')
} finally {
setSaving(false)
}
}
if (loading) return <div className="loading">Loading OIDC settings...</div>
if (!isAdmin) {
return (
<div className="section">
<h2>🔐 OIDC Settings</h2>
<p className="empty">Admin access required.</p>
</div>
)
}
const callbackHint = form.redirect_uri.trim() || loaded?.redirect_uri || '(set the Redirect / Callback URL below)'
return (
<div className="section">
<div className="page-header">
<div>
<h2>🔐 OIDC Settings</h2>
<div className="text-dim">Configure the OpenID Connect provider. Saved values override environment defaults.</div>
</div>
</div>
{message && (
<div style={{
padding: '10px 12px', marginBottom: 16, borderRadius: 8,
background: message.includes('success') ? 'rgba(70,180,135,.14)' : 'rgba(226,85,60,.14)',
border: `1px solid ${message.includes('success') ? 'rgba(70,180,135,.4)' : 'rgba(226,85,60,.4)'}`,
}}>{message}</div>
)}
<div className="monitor-card" style={{ marginBottom: 16 }}>
<div className="monitor-card-header">
<div style={{ fontWeight: 600 }}>Status</div>
<span className={'badge ' + (loaded?.effective_enabled ? 'status-online' : 'status-offline')}>
{loaded?.effective_enabled ? 'OIDC active' : 'OIDC inactive'}
</span>
</div>
<div className="monitor-metrics">
config source: <b>{loaded?.source}</b> · OIDC-only mode (deploy env): <b>{loaded?.oidc_only ? 'on' : 'off'}</b>
</div>
<div style={{ marginTop: 8 }}>
<div className="text-dim">Register this Redirect / Callback URL at your identity provider:</div>
<code style={{ display: 'block', marginTop: 6, wordBreak: 'break-all' }}>{callbackHint}</code>
</div>
</div>
<div className="task-create-form" style={{ maxWidth: 640 }}>
<label className="filter-check">
<input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} />
Enable OIDC sign-in
</label>
<label>
Issuer (OIDC source)
<input placeholder="https://idp.example.com" value={form.issuer} onChange={(e) => setForm({ ...form, issuer: e.target.value })} />
</label>
<label>
Client ID
<input value={form.client_id} onChange={(e) => setForm({ ...form, client_id: e.target.value })} />
</label>
<label>
Client Secret
<input type="password" placeholder={loaded?.has_client_secret ? '•••••• (leave blank to keep current)' : 'client secret'} value={form.client_secret} onChange={(e) => setForm({ ...form, client_secret: e.target.value })} />
</label>
<label>
Redirect / Callback URL
<input placeholder="https://hf-api.example.com/auth/oidc/callback" value={form.redirect_uri} onChange={(e) => setForm({ ...form, redirect_uri: e.target.value })} />
</label>
<label>
Scopes
<input value={form.scopes} onChange={(e) => setForm({ ...form, scopes: e.target.value })} />
</label>
<label>
Post-login redirect (frontend)
<input placeholder="https://hf.example.com/oidc/callback" value={form.post_login_redirect} onChange={(e) => setForm({ ...form, post_login_redirect: e.target.value })} />
</label>
<label>
Admin role (bootstrap)
<input placeholder="admin" value={form.admin_role} onChange={(e) => setForm({ ...form, admin_role: e.target.value })} />
</label>
<p className="text-dim">
OIDC-only bootstrap: before any admin is linked, an IdP user whose token carries this role
auto-connects to the HarborForge admin account on first sign-in. Disables itself once an admin is bound.
</p>
<button className="btn-primary" disabled={saving} onClick={save}>
{saving ? 'Saving...' : 'Save OIDC Settings'}
</button>
</div>
</div>
)
}