From 21a0031c81d30835ce63c33fc7b18228b00d9d8e Mon Sep 17 00:00:00 2001 From: abhishekbhakat Date: Fri, 19 Dec 2025 20:28:27 +0530 Subject: [PATCH] feat: implement admin user management page with user listing, creation, deletion, and password reset functionality --- backend/src/handlers/users.rs | 41 +++ backend/src/main.rs | 1 + frontend/src/App.tsx | 2 + frontend/src/index.css | 64 +++++ frontend/src/pages/Admin.tsx | 432 +++++++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 12 + 6 files changed, 552 insertions(+) create mode 100644 frontend/src/pages/Admin.tsx diff --git a/backend/src/handlers/users.rs b/backend/src/handlers/users.rs index 82a1cef..020f395 100644 --- a/backend/src/handlers/users.rs +++ b/backend/src/handlers/users.rs @@ -328,6 +328,46 @@ pub async fn delete_user( Ok(StatusCode::NO_CONTENT) } +/// Request to reset a user's password. +#[derive(Deserialize)] +pub struct ResetPasswordRequest { + pub new_password: String, +} + +/// POST /api/users/:id/reset-password - Reset a user's password (admin only). +pub async fn reset_password( + State(db): State, + Path(id): Path, + Json(req): Json, +) -> Result { + // Find the user + let existing = user::Entity::find_by_id(id) + .one(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Hash new password + let password_hash = crate::auth::hash_password(&req.new_password) + .map_err(|e| { + tracing::error!("Password hashing failed: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let now = Utc::now().naive_utc(); + + let mut active: user::ActiveModel = existing.into(); + active.password_hash = Set(password_hash); + active.updated_at = Set(now); + + active + .update(&db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(StatusCode::OK) +} + /// GET /api/roles - List all roles. pub async fn list_roles( State(db): State, @@ -355,3 +395,4 @@ pub struct RoleResponse { pub name: String, pub description: Option, } + diff --git a/backend/src/main.rs b/backend/src/main.rs index 9c1b566..61c41a1 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -138,6 +138,7 @@ fn create_router(db: DatabaseConnection, config: &config::Config) -> Router { .route("/api/users/{id}", get(handlers::users::get_user) .put(handlers::users::update_user) .delete(handlers::users::delete_user)) + .route("/api/users/{id}/reset-password", post(handlers::users::reset_password)) // Entries API .route("/api/entries", post(handlers::entries::create_entry)) .route("/api/users/{user_id}/entries", get(handlers::entries::list_user_entries)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6932a09..cd84e1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/index.css b/frontend/src/index.css index 604b1fb..23eb461 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); } \ No newline at end of file diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..966e8a8 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -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([]) + const [currentUser, setCurrentUser] = useState<{ role: string } | null>(null) + const [showCreateModal, setShowCreateModal] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [resetPasswordUser, setResetPasswordUser] = useState(null) + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) + + // Create user form + const [newUser, setNewUser] = useState({ + 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
Loading...
+ } + + if (!currentUser || currentUser.role !== 'admin') { + return null + } + + return ( +
+
+ ← Back to Dashboard +
+

User Management

+ +
+
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Users Table */} +
+ + + + + + + + + + + + + {users.map(user => ( + + + + + + + + + ))} + +
IDUsernameNameRoleCreatedActions
{user.id}{user.username}{user.name || '—'} + + {user.role} + + {new Date(user.created_at).toLocaleDateString()} +
+ + + +
+
+
+ + {/* Create User Modal */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +

Create New User

+
+
+ + setNewUser({ ...newUser, username: e.target.value })} + required + /> +
+
+ + setNewUser({ ...newUser, password: e.target.value })} + required + /> +
+
+ + +
+
+ + +
+
+
+
+ )} + + {/* Edit User Modal */} + {editingUser && ( + setEditingUser(null)} + onSave={() => { setEditingUser(null); loadUsers(); }} + setMessage={setMessage} + /> + )} + + {/* Reset Password Modal */} + {resetPasswordUser && ( + setResetPasswordUser(null)} + onSave={() => { setResetPasswordUser(null); }} + setMessage={setMessage} + /> + )} +
+ ) +} + +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 ( +
+
e.stopPropagation()}> +

Edit User: {user.username}

+
+
+ + setName(e.target.value)} + placeholder="Full name" + /> +
+
+ + +
+
+
+
+ ) +} + +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 ( +
+
e.stopPropagation()}> +

Reset Password: {user.username}

+
+
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + /> +
+ {error &&
{error}
} +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 817e298..924001b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -93,6 +93,18 @@ export function Dashboard() { + {user.role === 'admin' && ( +
+

Admin

+

+ Manage users and system settings +

+ + Manage Users + +
+ )} +

Dashboard coming soon...