feat(auth): admin OIDC settings page
New admin page /settings/oidc to configure the OIDC provider (issuer, client id/secret, redirect/callback URL, scopes, post-login redirect). Prominently shows the callback URL to register at the IdP, current status/source, and the read-only deploy-level OIDC-only flag. Secret is write-only (blank = keep). Sidebar entry for admins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import CalendarPage from '@/pages/CalendarPage'
|
|||||||
import SupportDetailPage from '@/pages/SupportDetailPage'
|
import SupportDetailPage from '@/pages/SupportDetailPage'
|
||||||
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
import MeetingDetailPage from '@/pages/MeetingDetailPage'
|
||||||
import OidcCallbackPage from '@/pages/OidcCallbackPage'
|
import OidcCallbackPage from '@/pages/OidcCallbackPage'
|
||||||
|
import OidcSettingsPage from '@/pages/OidcSettingsPage'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const getStoredWizardPort = (): number | null => {
|
const getStoredWizardPort = (): number | null => {
|
||||||
@@ -135,6 +136,7 @@ export default function App() {
|
|||||||
<Route path="/roles" element={<RoleEditorPage />} />
|
<Route path="/roles" element={<RoleEditorPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/monitor" element={<MonitorPage />} />
|
<Route path="/monitor" element={<MonitorPage />} />
|
||||||
|
<Route path="/settings/oidc" element={<OidcSettingsPage />} />
|
||||||
<Route path="/oidc/callback" element={<OidcCallbackPage onToken={loginWithToken} />} />
|
<Route path="/oidc/callback" element={<OidcCallbackPage onToken={loginWithToken} />} />
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default function Sidebar({ user, onLogout }: Props) {
|
|||||||
...(user.is_admin ? [
|
...(user.is_admin ? [
|
||||||
{ to: '/users', icon: '👥', label: 'Users' },
|
{ to: '/users', icon: '👥', label: 'Users' },
|
||||||
{ to: '/roles', icon: '🔐', label: 'Roles' },
|
{ to: '/roles', icon: '🔐', label: 'Roles' },
|
||||||
|
{ to: '/settings/oidc', icon: '🪪', label: 'OIDC' },
|
||||||
] : []),
|
] : []),
|
||||||
] : [
|
] : [
|
||||||
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
{ to: '/monitor', icon: '📡', label: 'Monitor' },
|
||||||
|
|||||||
159
src/pages/OidcSettingsPage.tsx
Normal file
159
src/pages/OidcSettingsPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
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
|
||||||
|
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: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
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 || '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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(),
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<button className="btn-primary" disabled={saving} onClick={save}>
|
||||||
|
{saving ? 'Saving...' : 'Save OIDC Settings'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user