Files
HarborForge.Frontend/src/pages/UsersPage.tsx

306 lines
10 KiB
TypeScript

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>
)
}