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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const {
|
|||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
refreshSchema,
|
refreshSchema,
|
||||||
|
updateProfileSchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
} = require('../utils/validation');
|
} = 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ const adminReportPdfQuerySchema = z.object({
|
|||||||
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
|
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 ────────────────────────────
|
// ──────────────────────────── Helpers ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,6 +226,8 @@ module.exports = {
|
|||||||
adminTimesheetQuerySchema,
|
adminTimesheetQuerySchema,
|
||||||
adminOvertimeQuerySchema,
|
adminOvertimeQuerySchema,
|
||||||
adminReportPdfQuerySchema,
|
adminReportPdfQuerySchema,
|
||||||
|
updateProfileSchema,
|
||||||
|
resetPasswordSchema,
|
||||||
validateBody,
|
validateBody,
|
||||||
validateQuery,
|
validateQuery,
|
||||||
validateIdParam,
|
validateIdParam,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
import { Outlet, NavLink, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ThemeToggle from './ThemeToggle';
|
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';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -12,6 +12,7 @@ export default function Layout() {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Clock, label: 'Timesheet' },
|
{ to: '/', icon: Clock, label: 'Timesheet' },
|
||||||
{ to: '/history', icon: Calendar, label: 'History' },
|
{ to: '/history', icon: Calendar, label: 'History' },
|
||||||
|
{ to: '/profile', icon: User, label: 'Account' },
|
||||||
...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []),
|
...(isAdmin ? [{ to: '/admin', icon: Shield, label: 'Admin' }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Login from './pages/Login';
|
|||||||
import Timesheet from './pages/Timesheet';
|
import Timesheet from './pages/Timesheet';
|
||||||
import History from './pages/History';
|
import History from './pages/History';
|
||||||
import Admin from './pages/Admin';
|
import Admin from './pages/Admin';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
import OfflineBanner from './components/OfflineBanner';
|
import OfflineBanner from './components/OfflineBanner';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ function AppRoutes() {
|
|||||||
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||||
<Route index element={<Timesheet />} />
|
<Route index element={<Timesheet />} />
|
||||||
<Route path="history" element={<History />} />
|
<Route path="history" element={<History />} />
|
||||||
|
<Route path="profile" element={<Profile />} />
|
||||||
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
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 }) {
|
function TabButton({ active, onClick, icon: Icon, label, count }) {
|
||||||
return (
|
return (
|
||||||
@@ -199,6 +199,24 @@ function ManageUsers() {
|
|||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'employee' });
|
const [form, setForm] = useState({ name: '', email: '', password: '', role: 'employee' });
|
||||||
const [creating, setCreating] = useState(false);
|
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(); }, []);
|
useEffect(() => { loadUsers(); }, []);
|
||||||
|
|
||||||
@@ -295,6 +313,13 @@ function ManageUsers() {
|
|||||||
>
|
>
|
||||||
🔑 Reset PW
|
🔑 Reset PW
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setResetUserId(resetUserId === u.id ? null : u.id); setResetPw(''); }}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-amber-500 hover:bg-amber-50 dark:hover:bg-amber-500/10 transition-all"
|
||||||
|
title="Reset Password"
|
||||||
|
>
|
||||||
|
<Key size={14} />
|
||||||
|
</button>
|
||||||
{u.isActive === false ? (
|
{u.isActive === false ? (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -320,6 +345,31 @@ function ManageUsers() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{resetUserId === u.id && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-800 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="New password (min 8 chars)"
|
||||||
|
value={resetPw}
|
||||||
|
onChange={(e) => setResetPw(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
className="flex-1 px-3 py-2 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-sm text-gray-900 dark:text-white"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleResetPassword(u.id)}
|
||||||
|
disabled={resetting}
|
||||||
|
className="px-3 py-2 rounded-xl bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{resetting ? 'Resetting...' : 'Reset'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setResetUserId(null); setResetPw(''); }}
|
||||||
|
className="px-3 py-2 rounded-xl text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-6 max-w-lg mx-auto">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">Account Settings</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Signed in as <span className="font-medium text-gray-700 dark:text-gray-300">{user?.name}</span> · {user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Change Email */}
|
||||||
|
<form onSubmit={handleEmailChange} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-900 dark:text-white font-medium">
|
||||||
|
<Mail size={18} className="text-sky-500" />
|
||||||
|
Change Email
|
||||||
|
</div>
|
||||||
|
{emailMsg && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${emailMsg.type === 'success' ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400'}`}>
|
||||||
|
{emailMsg.type === 'success' ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
|
{emailMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Current password"
|
||||||
|
value={emailForm.currentPassword}
|
||||||
|
onChange={(e) => setEmailForm({ ...emailForm, currentPassword: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="New email address"
|
||||||
|
value={emailForm.newEmail}
|
||||||
|
onChange={(e) => setEmailForm({ ...emailForm, newEmail: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={emailLoading}
|
||||||
|
className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{emailLoading && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Update Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Change Password */}
|
||||||
|
<form onSubmit={handlePasswordChange} className="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 p-5 space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-900 dark:text-white font-medium">
|
||||||
|
<Lock size={18} className="text-sky-500" />
|
||||||
|
Change Password
|
||||||
|
</div>
|
||||||
|
{pwMsg && (
|
||||||
|
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-xl ${pwMsg.type === 'success' ? 'bg-emerald-50 dark:bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' : 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400'}`}>
|
||||||
|
{pwMsg.type === 'success' ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
||||||
|
{pwMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Current password"
|
||||||
|
value={pwForm.currentPassword}
|
||||||
|
onChange={(e) => setPwForm({ ...pwForm, currentPassword: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="New password (min 8 characters)"
|
||||||
|
value={pwForm.newPassword}
|
||||||
|
onChange={(e) => setPwForm({ ...pwForm, newPassword: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
value={pwForm.confirmPassword}
|
||||||
|
onChange={(e) => setPwForm({ ...pwForm, confirmPassword: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pwLoading}
|
||||||
|
className="px-4 py-2 rounded-xl bg-sky-500 text-white text-sm font-medium hover:bg-sky-600 disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{pwLoading && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user