From 50563f2b3d9780307d9bc94ef4328ea231a8e3b2 Mon Sep 17 00:00:00 2001 From: zhi Date: Fri, 20 Mar 2026 10:56:00 +0000 Subject: [PATCH] feat(users): add admin user management page --- src/App.tsx | 5 +- src/components/Sidebar.tsx | 5 +- src/pages/UsersPage.tsx | 305 +++++++++++++++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/pages/UsersPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 4aab74c..ae7218e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() {
} /> - } /> + } /> + } /> } /> } /> @@ -96,6 +98,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 7868417..2afdbd2 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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' }, ] diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx new file mode 100644 index 0000000..c34bd9d --- /dev/null +++ b/src/pages/UsersPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState('') + const [selectedId, setSelectedId] = useState(null) + + const [createForm, setCreateForm] = useState({ + 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('/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('/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(`/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
Loading users...
+ + if (!isAdmin) { + return ( +
+

👥 User Management

+

Admin access required.

+
+ ) + } + + return ( +
+
+
+

👥 User Management

+
Create, edit, activate, and remove HarborForge users.
+
+
+ + {message && ( +
+ {message} +
+ )} + +
+
+

Create User

+
+ + + + + + +
+ +

Users

+
+ {users.map((u) => ( + + ))} +
+
+ +
+ {selectedUser ? ( + <> +
+
+

{selectedUser.username}

+
Created at {new Date(selectedUser.created_at).toLocaleString()}
+
+
+ + +
+
+ +
+ + + + +
+ + +
+
+ + ) : ( +
No user selected.
+ )} +
+
+
+ ) +}