feat(auth): OIDC login UI + binding management + OIDC-only mode

- useAuthConfig fetches public /auth/config; LoginPage hides the
  password form when oidc_only and shows an SSO button when enabled.
- /oidc/callback route applies the returned JWT (sign-in) or shows the
  link result; oidc_error surfaced on LoginPage.
- UsersPage: hides password fields in OIDC-only mode; admin OIDC
  bind/unbind UI per user. Sidebar self-service "Link OIDC account"
  (non-OIDC_ONLY).
- Dockerfile ARG/ENV HARBORFORGE_OIDC_ONLY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-17 20:22:14 +01:00
parent aaf36a4f5c
commit 8f8d6d5465
9 changed files with 312 additions and 34 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'
import api from '@/services/api'
import { useAuth } from '@/hooks/useAuth'
import { useAuthConfig } from '@/hooks/useAuthConfig'
import type { User } from '@/types'
interface RoleOption {
@@ -16,8 +17,13 @@ interface ApiKeyPerms {
export default function UsersPage() {
const { user } = useAuth()
const { config: authCfg } = useAuthConfig()
const oidcOnly = authCfg.oidcOnly
const oidcEnabled = authCfg.oidcEnabled
const isAdmin = user?.is_admin === true
const [bindForm, setBindForm] = useState({ issuer: '', subject: '' })
const [users, setUsers] = useState<User[]>([])
const [roles, setRoles] = useState<RoleOption[]>([])
const [loading, setLoading] = useState(true)
@@ -105,17 +111,20 @@ export default function UsersPage() {
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) return
if (!createForm.username.trim() || !createForm.email.trim()) return
if (!oidcOnly && !createForm.password.trim()) return
setSaving(true)
setMessage('')
try {
const payload = {
const payload: Record<string, any> = {
username: createForm.username.trim(),
email: createForm.email.trim(),
full_name: createForm.full_name.trim() || null,
password: createForm.password,
role_id: createForm.role_id ? Number(createForm.role_id) : undefined,
}
if (!oidcOnly) {
payload.password = createForm.password
}
const { data } = await api.post<User>('/users', payload)
const guestRole = roles.find((r) => r.name === 'guest') ?? roles[0]
setCreateForm({
@@ -201,6 +210,42 @@ export default function UsersPage() {
}
}
const handleBindOidc = async () => {
if (!selectedUser) return
if (!bindForm.issuer.trim() || !bindForm.subject.trim()) return
setSaving(true)
setMessage('')
try {
await api.put(`/users/${selectedUser.id}/oidc-binding`, {
issuer: bindForm.issuer.trim(),
subject: bindForm.subject.trim(),
})
setBindForm({ issuer: '', subject: '' })
setMessage('OIDC identity bound successfully')
await fetchData()
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to bind OIDC identity')
} finally {
setSaving(false)
}
}
const handleUnbindOidc = async () => {
if (!selectedUser) return
if (!confirm(`Remove the OIDC binding for ${selectedUser.username}?`)) return
setSaving(true)
setMessage('')
try {
await api.delete(`/users/${selectedUser.id}/oidc-binding`)
setMessage('OIDC binding removed')
await fetchData()
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to remove OIDC binding')
} finally {
setSaving(false)
}
}
if (loading) return <div className="loading">Loading users...</div>
if (!isAdmin) {
@@ -251,10 +296,15 @@ export default function UsersPage() {
Full Name
<input value={createForm.full_name} onChange={(e) => setCreateForm({ ...createForm, full_name: e.target.value })} />
</label>
<label>
Password
<input type="password" value={createForm.password} onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} />
</label>
{!oidcOnly && (
<label>
Password
<input type="password" value={createForm.password} onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} />
</label>
)}
{oidcOnly && (
<p className="text-dim">OIDC-only mode: users are created without a password and sign in via a bound OIDC identity.</p>
)}
<label>
Role
<select value={createForm.role_id} onChange={(e) => setCreateForm({ ...createForm, role_id: e.target.value })}>
@@ -264,7 +314,7 @@ export default function UsersPage() {
))}
</select>
</label>
<button className="btn-primary" disabled={saving || !createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim() || !createForm.role_id} onClick={handleCreateUser}>
<button className="btn-primary" disabled={saving || !createForm.username.trim() || !createForm.email.trim() || (!oidcOnly && !createForm.password.trim()) || !createForm.role_id} onClick={handleCreateUser}>
{saving ? 'Saving...' : 'Create User'}
</button>
</div>
@@ -326,10 +376,12 @@ export default function UsersPage() {
Full Name
<input value={editForm.full_name} onChange={(e) => setEditForm({ ...editForm, full_name: e.target.value })} />
</label>
<label>
Reset Password
<input type="password" placeholder="Leave blank to keep current password" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
</label>
{!oidcOnly && (
<label>
Reset Password
<input type="password" placeholder="Leave blank to keep current password" value={editForm.password} onChange={(e) => setEditForm({ ...editForm, password: e.target.value })} />
</label>
)}
<div style={{ marginTop: '8px', padding: '12px', border: '1px solid var(--border)', borderRadius: '8px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>Role</div>
@@ -381,6 +433,36 @@ export default function UsersPage() {
)}
</div>
)}
{oidcEnabled && (
<div style={{ marginTop: '8px', padding: '12px', border: '1px solid var(--border)', borderRadius: '8px' }}>
<div style={{ fontWeight: 600, marginBottom: '10px' }}>OIDC Binding</div>
{selectedUser.oidc_subject ? (
<div style={{ marginBottom: 10 }}>
<div className="text-dim" style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>
issuer: {selectedUser.oidc_issuer || '—'}<br />
subject: {selectedUser.oidc_subject}
</div>
<button className="btn-danger" style={{ marginTop: 10 }} disabled={saving} onClick={handleUnbindOidc}>
Unbind OIDC identity
</button>
</div>
) : (
<div className="text-dim" style={{ marginBottom: 10 }}>No OIDC identity bound.</div>
)}
<label>
Issuer
<input value={bindForm.issuer} placeholder="https://idp.example.com" onChange={(e) => setBindForm({ ...bindForm, issuer: e.target.value })} />
</label>
<label>
Subject (sub)
<input value={bindForm.subject} placeholder="OIDC subject claim" onChange={(e) => setBindForm({ ...bindForm, subject: e.target.value })} />
</label>
<button className="btn-secondary" style={{ marginTop: 10 }} disabled={saving || !bindForm.issuer.trim() || !bindForm.subject.trim()} onClick={handleBindOidc}>
{selectedUser.oidc_subject ? 'Rebind OIDC identity' : 'Bind OIDC identity'}
</button>
</div>
)}
</div>
</>
) : (