- 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>
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
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 {
|
|
id: number
|
|
name: string
|
|
description?: string | null
|
|
}
|
|
|
|
interface ApiKeyPerms {
|
|
can_reset_self: boolean
|
|
can_reset_any: boolean
|
|
}
|
|
|
|
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)
|
|
const [saving, setSaving] = useState(false)
|
|
const [message, setMessage] = useState('')
|
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
const [apikeyPerms, setApikeyPerms] = useState<ApiKeyPerms>({ can_reset_self: false, can_reset_any: false })
|
|
const [generatedApiKey, setGeneratedApiKey] = useState<string | null>(null)
|
|
|
|
const [createForm, setCreateForm] = useState({
|
|
username: '',
|
|
email: '',
|
|
full_name: '',
|
|
password: '',
|
|
role_id: '',
|
|
})
|
|
|
|
const [editForm, setEditForm] = useState({
|
|
email: '',
|
|
full_name: '',
|
|
password: '',
|
|
role_id: '',
|
|
is_active: true,
|
|
})
|
|
|
|
const selectedUser = useMemo(
|
|
() => users.find((u) => u.id === selectedId) ?? null,
|
|
[users, selectedId],
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isAdmin) {
|
|
setLoading(false)
|
|
return
|
|
}
|
|
fetchData()
|
|
}, [isAdmin])
|
|
|
|
useEffect(() => {
|
|
if (!selectedUser) return
|
|
setGeneratedApiKey(null)
|
|
setEditForm({
|
|
email: selectedUser.email,
|
|
full_name: selectedUser.full_name || '',
|
|
password: '',
|
|
role_id: selectedUser.role_id ? String(selectedUser.role_id) : '',
|
|
is_active: selectedUser.is_active,
|
|
})
|
|
}, [selectedUser])
|
|
|
|
useEffect(() => {
|
|
if (!createForm.role_id && roles.length > 0) {
|
|
const guestRole = roles.find((r) => r.name === 'guest') ?? roles[0]
|
|
if (guestRole) {
|
|
setCreateForm((prev) => ({ ...prev, role_id: String(guestRole.id) }))
|
|
}
|
|
}
|
|
}, [roles, createForm.role_id])
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const [usersRes, rolesRes, apikeyRes] = await Promise.all([
|
|
api.get<User[]>('/users'),
|
|
api.get<RoleOption[]>('/roles'),
|
|
api.get<ApiKeyPerms>('/auth/me/apikey-permissions').catch(() => ({ data: { can_reset_self: false, can_reset_any: false } })),
|
|
])
|
|
setApikeyPerms(apikeyRes.data)
|
|
const assignableRoles = rolesRes.data
|
|
.filter((role) => role.name !== 'admin')
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
|
|
setUsers(usersRes.data)
|
|
setRoles(assignableRoles)
|
|
|
|
if (!selectedId && usersRes.data.length > 0) {
|
|
setSelectedId(usersRes.data[0].id)
|
|
} else if (selectedId && !usersRes.data.some((u) => u.id === selectedId)) {
|
|
setSelectedId(usersRes.data[0]?.id ?? null)
|
|
}
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to load user management data')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCreateUser = async () => {
|
|
if (!createForm.username.trim() || !createForm.email.trim()) return
|
|
if (!oidcOnly && !createForm.password.trim()) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const payload: Record<string, any> = {
|
|
username: createForm.username.trim(),
|
|
email: createForm.email.trim(),
|
|
full_name: createForm.full_name.trim() || null,
|
|
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({
|
|
username: '',
|
|
email: '',
|
|
full_name: '',
|
|
password: '',
|
|
role_id: guestRole ? String(guestRole.id) : '',
|
|
})
|
|
setMessage('User created successfully')
|
|
await fetchData()
|
|
setSelectedId(data.id)
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to create user')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleSaveUser = async () => {
|
|
if (!selectedUser) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
const payload: Record<string, any> = {
|
|
email: editForm.email.trim(),
|
|
full_name: editForm.full_name.trim() || null,
|
|
is_active: editForm.is_active,
|
|
}
|
|
if (!selectedUser.is_admin) {
|
|
payload.role_id = editForm.role_id ? Number(editForm.role_id) : undefined
|
|
}
|
|
if (editForm.password.trim()) {
|
|
payload.password = editForm.password
|
|
}
|
|
await api.patch<User>(`/users/${selectedUser.id}`, payload)
|
|
setMessage('User updated successfully')
|
|
await fetchData()
|
|
setEditForm((prev) => ({ ...prev, password: '' }))
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to update user')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const canResetApiKey = (targetUser: User) => {
|
|
if (apikeyPerms.can_reset_any) return true
|
|
if (apikeyPerms.can_reset_self && targetUser.id === user?.id) return true
|
|
return false
|
|
}
|
|
|
|
const handleResetApiKey = async () => {
|
|
if (!selectedUser) return
|
|
if (!confirm(`Reset API key for ${selectedUser.username}? The old key will be deactivated.`)) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
setGeneratedApiKey(null)
|
|
try {
|
|
const { data } = await api.post(`/users/${selectedUser.id}/reset-apikey`)
|
|
setGeneratedApiKey(data.api_key)
|
|
setMessage('API key reset successfully. Copy it now — it will not be shown again.')
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to reset API key')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDeleteUser = async () => {
|
|
if (!selectedUser) return
|
|
if (!confirm(`Delete user ${selectedUser.username}? This cannot be undone.`)) return
|
|
setSaving(true)
|
|
setMessage('')
|
|
try {
|
|
await api.delete(`/users/${selectedUser.id}`)
|
|
setMessage('User deleted successfully')
|
|
await fetchData()
|
|
} catch (err: any) {
|
|
setMessage(err.response?.data?.detail || 'Failed to delete user')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return (
|
|
<div className="section">
|
|
<h2>👥 User Management</h2>
|
|
<p className="empty">Admin access required.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="section">
|
|
<div className="page-header">
|
|
<div>
|
|
<h2>👥 User Management</h2>
|
|
<div className="text-dim">Create accounts, assign one non-admin role, and manage activation state.</div>
|
|
</div>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
style={{
|
|
padding: '10px 12px',
|
|
marginBottom: '16px',
|
|
borderRadius: '8px',
|
|
background: message.toLowerCase().includes('successfully') ? 'rgba(16,185,129,.12)' : 'rgba(239,68,68,.12)',
|
|
border: `1px solid ${message.toLowerCase().includes('successfully') ? 'rgba(16,185,129,.35)' : 'rgba(239,68,68,.35)'}`,
|
|
}}
|
|
>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '320px 1fr', gap: '20px', alignItems: 'start' }}>
|
|
<div className="monitor-card">
|
|
<h3 style={{ marginBottom: 12 }}>Create User</h3>
|
|
<div className="task-create-form">
|
|
<label>
|
|
Username
|
|
<input value={createForm.username} onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })} />
|
|
</label>
|
|
<label>
|
|
Email
|
|
<input value={createForm.email} onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })} />
|
|
</label>
|
|
<label>
|
|
Full Name
|
|
<input value={createForm.full_name} onChange={(e) => setCreateForm({ ...createForm, full_name: 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 })}>
|
|
<option value="" disabled>Select role</option>
|
|
{roles.map((role) => (
|
|
<option key={role.id} value={String(role.id)}>{role.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<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>
|
|
|
|
<h3 style={{ marginTop: 24, marginBottom: 12 }}>Users</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{users.map((u) => (
|
|
<button
|
|
key={u.id}
|
|
onClick={() => setSelectedId(u.id)}
|
|
className="btn-secondary"
|
|
style={{
|
|
textAlign: 'left',
|
|
padding: '12px',
|
|
background: selectedId === u.id ? 'var(--bg-hover)' : 'transparent',
|
|
borderColor: selectedId === u.id ? 'var(--accent)' : 'var(--border)',
|
|
}}
|
|
>
|
|
<div style={{ fontWeight: 600 }}>{u.username}</div>
|
|
<div className="text-dim">{u.email}</div>
|
|
<div style={{ display: 'flex', gap: '6px', marginTop: '6px', flexWrap: 'wrap' }}>
|
|
<span className="badge">{u.role_name || 'unassigned'}</span>
|
|
{u.is_admin && <span className="badge">admin</span>}
|
|
<span className={'badge ' + (u.is_active ? 'status-online' : 'status-offline')}>{u.is_active ? 'active' : 'inactive'}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="monitor-card">
|
|
{selectedUser ? (
|
|
<>
|
|
<div className="page-header" style={{ marginBottom: 12 }}>
|
|
<div>
|
|
<h3 style={{ marginBottom: 6 }}>{selectedUser.username}</h3>
|
|
<div className="text-dim">Created at {new Date(selectedUser.created_at).toLocaleString()}</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<button className="btn-primary" disabled={saving} onClick={handleSaveUser}>
|
|
{saving ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
<button className="btn-danger" disabled={saving || user?.id === selectedUser.id || selectedUser.is_admin || selectedUser.username === 'acc-mgr'} onClick={handleDeleteUser}>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="task-create-form">
|
|
<label>
|
|
Username
|
|
<input value={selectedUser.username} disabled />
|
|
</label>
|
|
<label>
|
|
Email
|
|
<input value={editForm.email} onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} />
|
|
</label>
|
|
<label>
|
|
Full Name
|
|
<input value={editForm.full_name} onChange={(e) => setEditForm({ ...editForm, full_name: 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>
|
|
{selectedUser.is_admin ? (
|
|
<div className="text-dim">{selectedUser.role_name || 'admin'} (admin accounts are managed outside this screen)</div>
|
|
) : (
|
|
<label>
|
|
Account Role
|
|
<select value={editForm.role_id} onChange={(e) => setEditForm({ ...editForm, role_id: e.target.value })}>
|
|
<option value="" disabled>Select role</option>
|
|
{roles.map((role) => (
|
|
<option key={role.id} value={String(role.id)}>{role.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
|
<label className="filter-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={editForm.is_active}
|
|
disabled={user?.id === selectedUser.id}
|
|
onChange={(e) => setEditForm({ ...editForm, is_active: e.target.checked })}
|
|
/>
|
|
Active
|
|
</label>
|
|
</div>
|
|
|
|
{canResetApiKey(selectedUser) && (
|
|
<div style={{ marginTop: '8px', padding: '12px', border: '1px solid var(--border)', borderRadius: '8px' }}>
|
|
<div style={{ fontWeight: 600, marginBottom: '10px' }}>API Key</div>
|
|
<button className="btn-secondary" disabled={saving} onClick={handleResetApiKey}>
|
|
🔑 Reset API Key
|
|
</button>
|
|
{generatedApiKey && (
|
|
<div style={{ marginTop: 10, padding: '10px', background: 'rgba(16,185,129,.08)', border: '1px solid rgba(16,185,129,.35)', borderRadius: '6px', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '13px' }}>
|
|
<div style={{ marginBottom: 6, fontWeight: 600, fontFamily: 'inherit' }}>New API Key (copy now!):</div>
|
|
<code>{generatedApiKey}</code>
|
|
<button
|
|
className="btn-sm"
|
|
style={{ marginLeft: 8 }}
|
|
onClick={() => { navigator.clipboard.writeText(generatedApiKey); setMessage('API key copied to clipboard') }}
|
|
>
|
|
📋 Copy
|
|
</button>
|
|
</div>
|
|
)}
|
|
</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>
|
|
</>
|
|
) : (
|
|
<div className="empty">No user selected.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|