feat: implement admin user management page with user listing, creation, deletion, and password reset functionality

This commit is contained in:
2025-12-19 20:28:27 +05:30
parent c26c74ebdb
commit 21a0031c81
6 changed files with 552 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ import { LoginPage } from './pages/Login'
import { SignupPage } from './pages/Signup'
import { Dashboard } from './pages/Dashboard'
import { ProfilePage } from './pages/Profile'
import { AdminPage } from './pages/Admin'
function App() {
return (
@@ -11,6 +12,7 @@ function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/" element={<Dashboard />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -322,4 +322,68 @@ select.input {
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: var(--space-sm) var(--space-md);
text-align: left;
border-bottom: 1px solid var(--border);
}
.table th {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary);
}
.table tr:hover {
background-color: var(--bg-secondary);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-lg);
width: 100%;
max-width: 400px;
}
.modal h2 {
margin-bottom: var(--space-lg);
}
/* Button variants */
.btn-sm {
padding: var(--space-xs) var(--space-sm);
font-size: 12px;
}
.btn-danger {
background-color: transparent;
color: var(--indicator-critical);
border: 1px solid var(--indicator-critical);
}
.btn-danger:hover {
background-color: color-mix(in srgb, var(--indicator-critical) 10%, transparent);
}

View File

@@ -0,0 +1,432 @@
import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
interface User {
id: number
username: string
name: string | null
role: string
height_cm: number | null
blood_type: string | null
birthdate: string | null
smoking: boolean | null
alcohol: boolean | null
diet: string | null
created_at: string
}
interface NewUser {
username: string
password: string
role_name: string
}
// Admin username from config (cannot be deleted or have password reset)
const CONFIG_ADMIN_USERNAME = 'admin'
export function AdminPage() {
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [users, setUsers] = useState<User[]>([])
const [currentUser, setCurrentUser] = useState<{ role: string } | null>(null)
const [showCreateModal, setShowCreateModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [resetPasswordUser, setResetPasswordUser] = useState<User | null>(null)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
// Create user form
const [newUser, setNewUser] = useState<NewUser>({
username: '',
password: '',
role_name: 'user',
})
useEffect(() => {
// Check if admin
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (!data.user) {
navigate('/login')
return
}
if (data.user.role !== 'admin') {
navigate('/')
return
}
setCurrentUser(data.user)
loadUsers()
})
.catch(() => navigate('/login'))
}, [navigate])
const loadUsers = async () => {
try {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) {
const data = await res.json()
setUsers(data)
}
} catch {
setMessage({ type: 'error', text: 'Failed to load users' })
} finally {
setLoading(false)
}
}
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault()
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(newUser),
})
if (res.ok) {
setMessage({ type: 'success', text: 'User created successfully' })
setShowCreateModal(false)
setNewUser({ username: '', password: '', role_name: 'user' })
loadUsers()
} else {
setMessage({ type: 'error', text: 'Failed to create user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
}
}
const handleDeleteUser = async (id: number, username: string) => {
if (!confirm(`Delete user "${username}"?`)) return
try {
const res = await fetch(`/api/users/${id}`, {
method: 'DELETE',
credentials: 'include',
})
if (res.ok) {
setMessage({ type: 'success', text: 'User deleted' })
loadUsers()
} else {
setMessage({ type: 'error', text: 'Failed to delete user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
}
}
// Check if user is the config admin (cannot delete or reset password)
const isConfigAdmin = (user: User) => user.username === CONFIG_ADMIN_USERNAME
if (loading) {
return <div style={{ padding: 'var(--space-lg)' }}>Loading...</div>
}
if (!currentUser || currentUser.role !== 'admin') {
return null
}
return (
<div style={{ padding: 'var(--space-lg)', maxWidth: '1000px', margin: '0 auto' }}>
<header style={{ marginBottom: 'var(--space-xl)' }}>
<Link to="/" className="text-secondary text-sm"> Back to Dashboard</Link>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 'var(--space-sm)' }}>
<h1>User Management</h1>
<button className="btn btn-primary" onClick={() => setShowCreateModal(true)}>
+ New User
</button>
</div>
</header>
{message && (
<div className={message.type === 'success' ? 'success-message' : 'error-message'}>
{message.text}
</div>
)}
{/* Users Table */}
<div className="card">
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Name</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.name || '—'}</td>
<td>
<span className={`indicator indicator-${user.role === 'admin' ? 'warning' : 'info'}`}>
{user.role}
</span>
</td>
<td className="text-secondary text-sm">{new Date(user.created_at).toLocaleDateString()}</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => setEditingUser(user)}
>
Edit
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => setResetPasswordUser(user)}
disabled={isConfigAdmin(user)}
title={isConfigAdmin(user) ? 'Admin password is set in config' : 'Reset password'}
>
Reset Password
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteUser(user.id, user.username)}
disabled={isConfigAdmin(user)}
title={isConfigAdmin(user) ? 'Cannot delete config admin' : 'Delete user'}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create User Modal */}
{showCreateModal && (
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Create New User</h2>
<form onSubmit={handleCreateUser}>
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
className="input"
value={newUser.username}
onChange={e => setNewUser({ ...newUser, username: e.target.value })}
required
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
className="input"
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
required
/>
</div>
<div className="form-group">
<label htmlFor="role">Role</label>
<select
id="role"
className="input"
value={newUser.role_name}
onChange={e => setNewUser({ ...newUser, role_name: e.target.value })}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
<button type="submit" className="btn btn-primary">Create</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowCreateModal(false)}>Cancel</button>
</div>
</form>
</div>
</div>
)}
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
onClose={() => setEditingUser(null)}
onSave={() => { setEditingUser(null); loadUsers(); }}
setMessage={setMessage}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onSave={() => { setResetPasswordUser(null); }}
setMessage={setMessage}
/>
)}
</div>
)
}
interface EditUserModalProps {
user: User
onClose: () => void
onSave: () => void
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
}
function EditUserModal({ user, onClose, onSave, setMessage }: EditUserModalProps) {
const [name, setName] = useState(user.name || '')
const [saving, setSaving] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const res = await fetch(`/api/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: name || null }),
})
if (res.ok) {
setMessage({ type: 'success', text: 'User updated' })
onSave()
} else {
setMessage({ type: 'error', text: 'Failed to update user' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Edit User: {user.username}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="edit-name">Display Name</label>
<input
id="edit-name"
type="text"
className="input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Full name"
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
</div>
)
}
interface ResetPasswordModalProps {
user: User
onClose: () => void
onSave: () => void
setMessage: (msg: { type: 'success' | 'error'; text: string } | null) => void
}
function ResetPasswordModal({ user, onClose, onSave, setMessage }: ResetPasswordModalProps) {
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (newPassword.length < 4) {
setError('Password must be at least 4 characters')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match')
return
}
setSaving(true)
try {
const res = await fetch(`/api/users/${user.id}/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ new_password: newPassword }),
})
if (res.ok) {
setMessage({ type: 'success', text: `Password reset for ${user.username}` })
onSave()
} else {
setMessage({ type: 'error', text: 'Failed to reset password' })
}
} catch {
setMessage({ type: 'error', text: 'Network error' })
} finally {
setSaving(false)
}
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>Reset Password: {user.username}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="new-password">New Password</label>
<input
id="new-password"
type="password"
className="input"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Enter new password"
required
/>
</div>
<div className="form-group">
<label htmlFor="confirm-password">Confirm Password</label>
<input
id="confirm-password"
type="password"
className="input"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginTop: 'var(--space-lg)' }}>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Resetting...' : 'Reset Password'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -93,6 +93,18 @@ export function Dashboard() {
</div>
</div>
{user.role === 'admin' && (
<div className="card" style={{ marginBottom: 'var(--space-lg)' }}>
<h3 style={{ marginBottom: 'var(--space-sm)' }}>Admin</h3>
<p className="text-secondary text-sm" style={{ marginBottom: 'var(--space-md)' }}>
Manage users and system settings
</p>
<Link to="/admin" className="btn btn-secondary">
Manage Users
</Link>
</div>
)}
<p className="text-secondary text-sm">
Dashboard coming soon...
</p>