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:
@@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user