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:
root
2026-03-04 21:07:08 +00:00
co-authored by Claude Opus 4.6
parent a228872d37
commit 490082a0c9
7 changed files with 312 additions and 2 deletions
+30
View File
@@ -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;
+51
View File
@@ -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;
+17
View File
@@ -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,