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>
This commit is contained in:
h z
2026-05-17 21:05:40 +01:00
parent 782e42ac64
commit 73da3926e7
2 changed files with 26 additions and 10 deletions

View File

@@ -10,6 +10,7 @@ interface Settings {
redirect_uri: string | null redirect_uri: string | null
scopes: string | null scopes: string | null
post_login_redirect: string | null post_login_redirect: string | null
admin_role: string
oidc_only: boolean oidc_only: boolean
effective_enabled: boolean effective_enabled: boolean
source: string source: string
@@ -31,6 +32,7 @@ export default function OidcSettingsPage() {
redirect_uri: '', redirect_uri: '',
scopes: 'openid email profile', scopes: 'openid email profile',
post_login_redirect: '', post_login_redirect: '',
admin_role: 'admin',
}) })
useEffect(() => { useEffect(() => {
@@ -46,6 +48,7 @@ export default function OidcSettingsPage() {
redirect_uri: data.redirect_uri || '', redirect_uri: data.redirect_uri || '',
scopes: data.scopes || 'openid email profile', scopes: data.scopes || 'openid email profile',
post_login_redirect: data.post_login_redirect || '', 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')) .catch((e) => setMessage(e.response?.data?.detail || 'Failed to load OIDC settings'))
@@ -63,6 +66,7 @@ export default function OidcSettingsPage() {
redirect_uri: form.redirect_uri.trim(), redirect_uri: form.redirect_uri.trim(),
scopes: form.scopes.trim(), scopes: form.scopes.trim(),
post_login_redirect: form.post_login_redirect.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 if (form.client_secret) payload.client_secret = form.client_secret
const { data } = await api.put<Settings>('/auth/oidc/settings', payload) const { data } = await api.put<Settings>('/auth/oidc/settings', payload)
@@ -150,6 +154,14 @@ export default function OidcSettingsPage() {
Post-login redirect (frontend) 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 })} /> <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>
<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}> <button className="btn-primary" disabled={saving} onClick={save}>
{saving ? 'Saving...' : 'Save OIDC Settings'} {saving ? 'Saving...' : 'Save OIDC Settings'}
</button> </button>

View File

@@ -22,7 +22,7 @@ interface SetupForm {
oidc_redirect_uri: string oidc_redirect_uri: string
oidc_scopes: string oidc_scopes: string
oidc_post_login_redirect: string oidc_post_login_redirect: string
oidc_admin_subject: string oidc_admin_role: string
} }
const oidcOnly = getRuntimeOidcOnly() === true const oidcOnly = getRuntimeOidcOnly() === true
@@ -52,7 +52,7 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
oidc_redirect_uri: '', oidc_redirect_uri: '',
oidc_scopes: 'openid email profile', oidc_scopes: 'openid email profile',
oidc_post_login_redirect: '', oidc_post_login_redirect: '',
oidc_admin_subject: '', oidc_admin_role: 'admin',
}) })
const set = (key: keyof SetupForm, value: string | number | boolean) => const set = (key: keyof SetupForm, value: string | number | boolean) =>
@@ -85,8 +85,8 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
if (!form.oidc_client_id.trim()) return 'OIDC client ID is required' if (!form.oidc_client_id.trim()) return 'OIDC client ID is required'
if (!form.oidc_client_secret.trim()) return 'OIDC client secret is required' if (!form.oidc_client_secret.trim()) return 'OIDC client secret is required'
if (!form.oidc_redirect_uri.trim()) return 'OIDC redirect/callback URL is required' if (!form.oidc_redirect_uri.trim()) return 'OIDC redirect/callback URL is required'
if (oidcOnly && !form.oidc_admin_subject.trim()) { if (oidcOnly && !form.oidc_admin_role.trim()) {
return "In OIDC-only mode the admin's OIDC subject is required so the admin can sign in" return 'In OIDC-only mode the admin role is required so the admin can bootstrap'
} }
return '' return ''
} }
@@ -115,7 +115,7 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
redirect_uri: form.oidc_redirect_uri.trim(), redirect_uri: form.oidc_redirect_uri.trim(),
scopes: form.oidc_scopes.trim() || 'openid email profile', scopes: form.oidc_scopes.trim() || 'openid email profile',
post_login_redirect: form.oidc_post_login_redirect.trim() || undefined, post_login_redirect: form.oidc_post_login_redirect.trim() || undefined,
admin_subject: form.oidc_admin_subject.trim() || undefined, admin_role: form.oidc_admin_role.trim() || 'admin',
} }
} }
@@ -230,12 +230,16 @@ export default function SetupWizardPage({ initialWizardPort, onComplete }: Props
<label>Redirect / Callback URL <input value={form.oidc_redirect_uri} onChange={(e) => set('oidc_redirect_uri', e.target.value)} placeholder="https://hf-api.example.com/auth/oidc/callback" /></label> <label>Redirect / Callback URL <input value={form.oidc_redirect_uri} onChange={(e) => set('oidc_redirect_uri', e.target.value)} placeholder="https://hf-api.example.com/auth/oidc/callback" /></label>
<label>Scopes <input value={form.oidc_scopes} onChange={(e) => set('oidc_scopes', e.target.value)} /></label> <label>Scopes <input value={form.oidc_scopes} onChange={(e) => set('oidc_scopes', e.target.value)} /></label>
<label>Post-login redirect (frontend) <input value={form.oidc_post_login_redirect} onChange={(e) => set('oidc_post_login_redirect', e.target.value)} placeholder="https://hf.example.com/oidc/callback" /></label> <label>Post-login redirect (frontend) <input value={form.oidc_post_login_redirect} onChange={(e) => set('oidc_post_login_redirect', e.target.value)} placeholder="https://hf.example.com/oidc/callback" /></label>
{oidcOnly && ( <label>Admin role (bootstrap)
<label>Admin OIDC subject (sub) <input value={form.oidc_admin_role} onChange={(e) => set('oidc_admin_role', e.target.value)} placeholder="admin" />
<input value={form.oidc_admin_subject} onChange={(e) => set('oidc_admin_subject', e.target.value)} placeholder="the admin's `sub` claim at the IdP" /> </label>
</label>
)}
<p className="setup-hint">Register the Redirect / Callback URL above at your identity provider.</p> <p className="setup-hint">Register the Redirect / Callback URL above at your identity provider.</p>
{oidcOnly && (
<p className="setup-hint">
OIDC-only: before any admin is linked, the first IdP user whose token carries the
role above auto-connects to the HarborForge admin account. It then disables itself.
</p>
)}
</div> </div>
)} )}
<div className="setup-nav"> <div className="setup-nav">