diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f831926..da3a950 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -879,4 +879,34 @@ router.put('/users/:id/reset-password', validateIdParam, async (req, res) => { } }); + +// ═══════════════════════════════════════════════════════════════ +// 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 9b5ccaf..2b05bd6 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -13,6 +13,7 @@ const { loginSchema, registerSchema, refreshSchema, + updateProfileSchema, validateBody, } = require('../utils/validation'); @@ -249,4 +250,54 @@ router.put('/change-password', 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 bf99012..317b09d 100644 --- a/backend/src/utils/validation.js +++ b/backend/src/utils/validation.js @@ -142,6 +142,21 @@ const adminReportPdfQuerySchema = z.object({ weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'), }); + +// ──────────────────────────── 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 ──────────────────────────── /** @@ -211,6 +226,8 @@ module.exports = { adminTimesheetQuerySchema, adminOvertimeQuerySchema, adminReportPdfQuerySchema, + updateProfileSchema, + resetPasswordSchema, validateBody, validateQuery, validateIdParam, diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 5cbec19..0e154e4 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 7707a4f..cd411d8 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 OfflineBanner from './components/OfflineBanner'; import './index.css'; @@ -30,6 +31,7 @@ function AppRoutes() { }> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 31b12fb..bbd0b1e 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useAuth } from '../contexts/AuthContext'; 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 ( @@ -199,6 +199,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(); }, []); @@ -295,6 +313,13 @@ function ManageUsers() { > 🔑 Reset PW + {u.isActive === false ? ( + + + )} ))} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx new file mode 100644 index 0000000..d6fce91 --- /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} + /> +
+ +
+
+
+ ); +}