From 2f381ff5f92ded1deb05069f92df5045e34acafe Mon Sep 17 00:00:00 2001 From: root Date: Wed, 4 Mar 2026 21:00:58 +0000 Subject: [PATCH] feat: user profile self-service + admin password reset - Add PUT /api/auth/profile for users to change their own email/password - Add POST /api/admin/users/:id/reset-password for admin password resets - Add updateProfileSchema and resetPasswordSchema validation (Zod) - Create Profile.jsx page with email change and password change forms - Add Reset Password button with inline form to ManageUsers in Admin panel - Add /profile route and Account nav link in Layout Co-Authored-By: Claude Opus 4.6 --- backend/src/routes/admin.js | 31 ++++++ backend/src/routes/auth.js | 51 +++++++++ backend/src/utils/validation.js | 17 +++ frontend/src/components/Layout.jsx | 3 +- frontend/src/main.jsx | 2 + frontend/src/pages/Admin.jsx | 52 +++++++++- frontend/src/pages/Profile.jsx | 159 +++++++++++++++++++++++++++++ 7 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/Profile.jsx diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d1780de..114ca29 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -7,6 +7,7 @@ const { createHomeownerSchema, updateHomeownerSchema, reportQuerySchema, + resetPasswordSchema, validateBody, validateQuery, } = require('../utils/validation'); @@ -860,4 +861,34 @@ router.get('/overtime', async (req, res) => { // ─────────────── GET /api/timesheets/overtime ─────────────── // (mounted at /api/timesheets/overtime in timesheets router) + +// ═══════════════════════════════════════════════════════════════ +// ADMIN PASSWORD RESET +// ═══════════════════════════════════════════════════════════════ + +// POST /api/admin/users/:id/reset-password +router.post('/users/:id/reset-password', validateBody(resetPasswordSchema), async (req, res) => { + try { + const { id } = req.params; + const { newPassword } = req.validated; + + const user = await req.prisma.user.findUnique({ where: { id } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const passwordHash = await bcrypt.hash(newPassword, 12); + + await req.prisma.user.update({ + where: { id }, + data: { passwordHash, refreshToken: null }, + }); + + res.json({ message: 'Password reset successfully' }); + } catch (err) { + console.error('Admin password reset error:', err); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 2082489..fe0817e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -12,6 +12,7 @@ const { loginSchema, registerSchema, refreshSchema, + updateProfileSchema, validateBody, } = require('../utils/validation'); @@ -207,4 +208,54 @@ router.post('/logout', authenticate, async (req, res) => { } }); + +// ─────────────── PUT /api/auth/profile ─────────────── +router.put('/profile', authenticate, validateBody(updateProfileSchema), async (req, res) => { + try { + const { currentPassword, newEmail, newPassword } = req.validated; + + const user = await req.prisma.user.findUnique({ where: { id: req.user.id } }); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const passwordValid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!passwordValid) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + const updateData = {}; + + if (newEmail) { + const email = newEmail.toLowerCase(); + if (email !== user.email) { + const existing = await req.prisma.user.findUnique({ where: { email } }); + if (existing) { + return res.status(409).json({ error: 'Email already in use' }); + } + updateData.email = email; + } + } + + if (newPassword) { + updateData.passwordHash = await bcrypt.hash(newPassword, 12); + } + + if (Object.keys(updateData).length === 0) { + return res.status(400).json({ error: 'No changes to apply' }); + } + + const updated = await req.prisma.user.update({ + where: { id: req.user.id }, + data: updateData, + select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true }, + }); + + res.json({ user: updated }); + } catch (err) { + console.error('Profile update error:', err); + res.status(500).json({ error: 'Failed to update profile' }); + } +}); + module.exports = router; diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js index d700311..4a9ed72 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -90,6 +90,21 @@ const reportQuerySchema = z.object({ status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(), }); + +// ──────────────────────────── Profile / Password Reset ──────────────────────────── + +const updateProfileSchema = z.object({ + currentPassword: z.string().min(1, "Current password is required").max(128), + newEmail: z.string().email("Invalid email address").max(255).optional(), + newPassword: z.string().min(8, "Password must be at least 8 characters").max(128).optional(), +}).refine((data) => data.newEmail || data.newPassword, { + message: "At least one of newEmail or newPassword must be provided", +}); + +const resetPasswordSchema = z.object({ + newPassword: z.string().min(8, "Password must be at least 8 characters").max(128), +}); + // ──────────────────────────── Helpers ──────────────────────────── /** @@ -142,6 +157,8 @@ module.exports = { createHomeownerSchema, updateHomeownerSchema, reportQuerySchema, + updateProfileSchema, + resetPasswordSchema, validateBody, validateQuery, }; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index ee3c0a6..e6ff438 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,7 +1,7 @@ import { Outlet, NavLink, useLocation } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import ThemeToggle from './ThemeToggle'; -import { Calendar, Clock, Shield, LogOut, Menu, X } from 'lucide-react'; +import { Calendar, Clock, Shield, LogOut, Menu, X, User } from 'lucide-react'; import { useState } from 'react'; export default function Layout() { @@ -12,6 +12,7 @@ export default function Layout() { const navItems = [ { to: '/', icon: Clock, label: 'Timesheet' }, { to: '/history', icon: Calendar, label: 'History' }, + { to: '/profile', icon: User, label: 'Account' }, ...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []), ]; diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 9cbc273..aa08be8 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -7,6 +7,7 @@ import Login from './pages/Login'; import Timesheet from './pages/Timesheet'; import History from './pages/History'; import Admin from './pages/Admin'; +import Profile from './pages/Profile'; import './index.css'; function ProtectedRoute({ children }) { @@ -29,6 +30,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index a457e30..1396c43 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import api from '../api/client'; import StatusBadge from '../components/StatusBadge'; -import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle } from 'lucide-react'; +import { Check, X, Users, Home, Clock, Loader2, Plus, UserPlus, Download, BarChart3, TrendingUp, AlertTriangle, Key } from 'lucide-react'; function TabButton({ active, onClick, icon: Icon, label, count }) { return ( @@ -197,6 +197,24 @@ function ManageUsers() { const [showForm, setShowForm] = useState(false); const [form, setForm] = useState({ name: '', email: '', password: '', role: 'employee' }); const [creating, setCreating] = useState(false); + const [resetUserId, setResetUserId] = useState(null); + const [resetPw, setResetPw] = useState(''); + const [resetting, setResetting] = useState(false); + + async function handleResetPassword(userId) { + if (resetPw.length < 8) { alert('Password must be at least 8 characters'); return; } + setResetting(true); + try { + await api.post(`/admin/users/${userId}/reset-password`, { newPassword: resetPw }); + alert('Password reset successfully'); + setResetUserId(null); + setResetPw(''); + } catch (err) { + alert(err.response?.data?.error || 'Failed to reset password'); + } finally { + setResetting(false); + } + } useEffect(() => { loadUsers(); }, []); @@ -277,6 +295,13 @@ function ManageUsers() { + {u.isActive === false ? ( + + + )} ))} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx new file mode 100644 index 0000000..aa551c0 --- /dev/null +++ b/frontend/src/pages/Profile.jsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import api from "../api/client"; +import { useAuth } from "../contexts/AuthContext"; +import { Mail, Lock, Loader2, CheckCircle, AlertCircle } from "lucide-react"; + +export default function Profile() { + const { user } = useAuth(); + + const [emailForm, setEmailForm] = useState({ currentPassword: "", newEmail: "" }); + const [pwForm, setPwForm] = useState({ currentPassword: "", newPassword: "", confirmPassword: "" }); + const [emailLoading, setEmailLoading] = useState(false); + const [pwLoading, setPwLoading] = useState(false); + const [emailMsg, setEmailMsg] = useState(null); + const [pwMsg, setPwMsg] = useState(null); + + async function handleEmailChange(e) { + e.preventDefault(); + setEmailMsg(null); + setEmailLoading(true); + try { + const res = await api.put("/auth/profile", { + currentPassword: emailForm.currentPassword, + newEmail: emailForm.newEmail, + }); + setEmailMsg({ type: "success", text: `Email updated to ${res.data.user.email}` }); + setEmailForm({ currentPassword: "", newEmail: "" }); + } catch (err) { + setEmailMsg({ type: "error", text: err.response?.data?.error || "Failed to update email" }); + } finally { + setEmailLoading(false); + } + } + + async function handlePasswordChange(e) { + e.preventDefault(); + setPwMsg(null); + if (pwForm.newPassword !== pwForm.confirmPassword) { + setPwMsg({ type: "error", text: "New passwords do not match" }); + return; + } + setPwLoading(true); + try { + await api.put("/auth/profile", { + currentPassword: pwForm.currentPassword, + newPassword: pwForm.newPassword, + }); + setPwMsg({ type: "success", text: "Password updated successfully" }); + setPwForm({ currentPassword: "", newPassword: "", confirmPassword: "" }); + } catch (err) { + setPwMsg({ type: "error", text: err.response?.data?.error || "Failed to update password" }); + } finally { + setPwLoading(false); + } + } + + const inputClass = + "w-full px-3 py-2.5 rounded-xl bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500"; + + return ( +
+
+

Account Settings

+

+ Signed in as {user?.name} · {user?.email} +

+
+ + {/* Change Email */} +
+
+ + Change Email +
+ {emailMsg && ( +
+ {emailMsg.type === "success" ? : } + {emailMsg.text} +
+ )} + setEmailForm({ ...emailForm, currentPassword: e.target.value })} + className={inputClass} + /> + setEmailForm({ ...emailForm, newEmail: e.target.value })} + className={inputClass} + /> +
+ +
+
+ + {/* Change Password */} +
+
+ + Change Password +
+ {pwMsg && ( +
+ {pwMsg.type === "success" ? : } + {pwMsg.text} +
+ )} + setPwForm({ ...pwForm, currentPassword: e.target.value })} + className={inputClass} + /> + setPwForm({ ...pwForm, newPassword: e.target.value })} + className={inputClass} + /> + setPwForm({ ...pwForm, confirmPassword: e.target.value })} + className={inputClass} + /> +
+ +
+
+
+ ); +}