feat(users): add admin user management page

This commit is contained in:
zhi
2026-03-20 10:56:00 +00:00
parent caad6be048
commit 50563f2b3d
3 changed files with 313 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import RoleEditorPage from '@/pages/RoleEditorPage'
import MonitorPage from '@/pages/MonitorPage'
import ProposesPage from '@/pages/ProposesPage'
import ProposeDetailPage from '@/pages/ProposeDetailPage'
import UsersPage from '@/pages/UsersPage'
import axios from 'axios'
const WIZARD_PORT = Number(import.meta.env.VITE_WIZARD_PORT) || 18080
@@ -69,7 +70,8 @@ export default function App() {
<main className="main-content">
<Routes>
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/login" element={<LoginPage onLogin={login} />} />
<Route path="*" element={<Navigate to="/monitor" />} />
</Routes>
@@ -96,6 +98,7 @@ export default function App() {
<Route path="/proposes/:id" element={<ProposeDetailPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/roles" element={<RoleEditorPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>

View File

@@ -35,7 +35,10 @@ export default function Sidebar({ user, onLogout }: Props) {
{ to: '/proposes', icon: '💡', label: 'Proposes' },
{ to: '/notifications', icon: '🔔', label: 'Notifications' + (unreadCount > 0 ? ' (' + unreadCount + ')' : '') },
{ to: '/monitor', icon: '📡', label: 'Monitor' },
...(user.is_admin ? [{ to: '/roles', icon: '🔐', label: 'Roles' }] : []),
...(user.is_admin ? [
{ to: '/users', icon: '👥', label: 'Users' },
{ to: '/roles', icon: '🔐', label: 'Roles' },
] : []),
] : [
{ to: '/monitor', icon: '📡', label: 'Monitor' },
]

305
src/pages/UsersPage.tsx Normal file
View File

@@ -0,0 +1,305 @@
import { useEffect, useMemo, useState } from 'react'
import api from '@/services/api'
import { useAuth } from '@/hooks/useAuth'
import type { User } from '@/types'
interface UserCreatePayload {
username: string
email: string
full_name: string
password: string
is_admin: boolean
}
interface UserUpdatePayload {
email?: string
full_name?: string | null
password?: string
is_admin?: boolean
is_active?: boolean
}
export default function UsersPage() {
const { user } = useAuth()
const isAdmin = user?.is_admin === true
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState('')
const [selectedId, setSelectedId] = useState<number | null>(null)
const [createForm, setCreateForm] = useState<UserCreatePayload>({
username: '',
email: '',
full_name: '',
password: '',
is_admin: false,
})
const [editForm, setEditForm] = useState({
email: '',
full_name: '',
password: '',
is_admin: false,
is_active: true,
})
const selectedUser = useMemo(
() => users.find((u) => u.id === selectedId) ?? null,
[users, selectedId],
)
useEffect(() => {
if (!isAdmin) {
setLoading(false)
return
}
fetchUsers()
}, [isAdmin])
useEffect(() => {
if (!selectedUser) return
setEditForm({
email: selectedUser.email,
full_name: selectedUser.full_name || '',
password: '',
is_admin: selectedUser.is_admin,
is_active: selectedUser.is_active,
})
}, [selectedUser])
const fetchUsers = async () => {
try {
const { data } = await api.get<User[]>('/users')
setUsers(data)
if (!selectedId && data.length > 0) {
setSelectedId(data[0].id)
} else if (selectedId && !data.some((u) => u.id === selectedId)) {
setSelectedId(data[0]?.id ?? null)
}
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to load users')
} finally {
setLoading(false)
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) return
setSaving(true)
setMessage('')
try {
const payload = {
username: createForm.username.trim(),
email: createForm.email.trim(),
full_name: createForm.full_name.trim() || null,
password: createForm.password,
is_admin: createForm.is_admin,
}
const { data } = await api.post<User>('/users', payload)
setCreateForm({ username: '', email: '', full_name: '', password: '', is_admin: false })
setMessage('User created successfully')
await fetchUsers()
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: UserUpdatePayload = {
email: editForm.email.trim(),
full_name: editForm.full_name.trim() || null,
is_admin: editForm.is_admin,
is_active: editForm.is_active,
}
if (editForm.password.trim()) {
payload.password = editForm.password
}
await api.patch<User>(`/users/${selectedUser.id}`, payload)
setMessage('User updated successfully')
await fetchUsers()
setEditForm((prev) => ({ ...prev, password: '' }))
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to update user')
} 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 fetchUsers()
} catch (err: any) {
setMessage(err.response?.data?.detail || 'Failed to delete user')
} 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, edit, activate, and remove HarborForge users.</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>
<label>
Password
<input type="password" value={createForm.password} onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} />
</label>
<label className="filter-check">
<input type="checkbox" checked={createForm.is_admin} onChange={(e) => setCreateForm({ ...createForm, is_admin: e.target.checked })} />
Admin user
</label>
<button className="btn-primary" disabled={saving || !createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()} 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' }}>
{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} 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>
<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={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label className="filter-check">
<input
type="checkbox"
checked={editForm.is_admin}
disabled={user?.id === selectedUser.id}
onChange={(e) => setEditForm({ ...editForm, is_admin: e.target.checked })}
/>
Admin user
</label>
<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>
</div>
</>
) : (
<div className="empty">No user selected.</div>
)}
</div>
</div>
</div>
)
}