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:00:58 +00:00
co-authored by Claude Opus 4.6
parent efafbea297
commit 2f381ff5f9
7 changed files with 313 additions and 2 deletions
+2 -1
View File
@@ -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' }] : []),
];
+2
View File
@@ -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() {
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Timesheet />} />
<Route path="history" element={<History />} />
<Route path="profile" element={<Profile />} />
<Route path="admin" element={<AdminRoute><Admin /></AdminRoute>} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+51 -1
View File
@@ -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() {
<option value="employee">Employee</option>
<option value="admin">Admin</option>
</select>
<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 ? (
<button
onClick={async () => {
@@ -302,6 +327,31 @@ function ManageUsers() {
)}
</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>
+159
View File
@@ -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> &middot; {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>
);
}