feat: password reset UI (forgot password, reset page, admin reset button)

This commit is contained in:
2026-01-28 21:44:22 +00:00
parent 6e451e0795
commit 696e2187f4
6 changed files with 420 additions and 10 deletions

View File

@@ -11,6 +11,8 @@ import EmailsPage from '@/pages/EmailsPage';
import SettingsPage from '@/pages/SettingsPage'; import SettingsPage from '@/pages/SettingsPage';
import AdminPage from '@/pages/AdminPage'; import AdminPage from '@/pages/AdminPage';
import InvitePage from '@/pages/InvitePage'; import InvitePage from '@/pages/InvitePage';
import ForgotPasswordPage from '@/pages/ForgotPasswordPage';
import ResetPasswordPage from '@/pages/ResetPasswordPage';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -34,6 +36,8 @@ export default function App() {
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage /> isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
} /> } />
<Route path="/invite/:token" element={<InvitePage />} /> <Route path="/invite/:token" element={<InvitePage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
<Route path="/" element={ <Route path="/" element={
<ProtectedRoute> <ProtectedRoute>
<Layout /> <Layout />

View File

@@ -255,6 +255,10 @@ class ApiClient {
await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' }); await this.fetch(`/admin/invites/${id}`, { method: 'DELETE' });
} }
async createPasswordReset(userId: string): Promise<{ resetUrl: string; email: string }> {
return this.fetch(`/admin/users/${userId}/reset-password`, { method: 'POST' });
}
// Invite acceptance (public - no auth) // Invite acceptance (public - no auth)
async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> { async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> {
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`); const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`);
@@ -265,6 +269,41 @@ class ApiClient {
return response.json(); return response.json();
} }
async requestPasswordReset(email: string): Promise<{ success: boolean; message: string; resetUrl?: string }> {
const response = await fetch(`${AUTH_BASE}/auth/reset-password/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || 'Request failed');
}
return response.json();
}
async validateResetToken(token: string): Promise<{ valid: boolean; email?: string }> {
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Invalid token' }));
throw new Error(error.error || 'Invalid or expired reset link');
}
return response.json();
}
async resetPassword(token: string, password: string): Promise<{ success: boolean }> {
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Reset failed' }));
throw new Error(error.error || 'Failed to reset password');
}
return response.json();
}
async acceptInvite(token: string, password: string, name?: string): Promise<{ success: boolean; token?: string }> { async acceptInvite(token: string, password: string, name?: string): Promise<{ success: boolean; token?: string }> {
const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, { const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, {
method: 'POST', method: 'POST',

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Users, Mail, Plus, Trash2, Copy, Check } from 'lucide-react'; import { Users, Mail, Plus, Trash2, Copy, Check, KeyRound } from 'lucide-react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { User, Invite } from '@/types'; import type { User, Invite } from '@/types';
@@ -21,6 +21,12 @@ export default function AdminPage() {
const [inviteUrl, setInviteUrl] = useState(''); const [inviteUrl, setInviteUrl] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Password reset
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [resetUrl, setResetUrl] = useState('');
const [resetCopied, setResetCopied] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
@@ -69,6 +75,31 @@ export default function AdminPage() {
} }
}; };
const handleGenerateResetLink = async (userId: string) => {
setResetLoading(true);
try {
const result = await api.createPasswordReset(userId);
setResetUserId(userId);
setResetUrl(result.resetUrl);
} catch (error) {
console.error('Failed to generate reset link:', error);
} finally {
setResetLoading(false);
}
};
const handleCopyResetUrl = () => {
navigator.clipboard.writeText(resetUrl);
setResetCopied(true);
setTimeout(() => setResetCopied(false), 2000);
};
const dismissReset = () => {
setResetUserId(null);
setResetUrl('');
setResetCopied(false);
};
const handleDeleteUser = async (userId: string) => { const handleDeleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return; if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return;
try { try {
@@ -180,13 +211,44 @@ export default function AdminPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{user.id !== currentUser?.id && ( {user.id !== currentUser?.id && (
<button <div className="flex items-center gap-1">
onClick={() => handleDeleteUser(user.id)} {resetUserId === user.id ? (
className="p-1 text-slate-400 hover:text-red-500 transition-colors" <div className="flex items-center gap-1">
title="Delete user" <span className="text-xs text-green-600 whitespace-nowrap"> Link ready</span>
> <button
<Trash2 className="w-4 h-4" /> onClick={handleCopyResetUrl}
</button> className="text-xs px-2 py-1 bg-blue-600 text-white rounded flex items-center gap-1"
>
{resetCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
{resetCopied ? 'Copied!' : 'Copy'}
</button>
<button
onClick={dismissReset}
className="text-xs px-1 py-1 text-slate-400 hover:text-slate-600"
>
</button>
</div>
) : (
<>
<button
onClick={() => handleGenerateResetLink(user.id)}
disabled={resetLoading}
className="p-1 text-slate-400 hover:text-blue-500 transition-colors"
title="Generate password reset link"
>
<KeyRound className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1 text-slate-400 hover:text-red-500 transition-colors"
title="Delete user"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Network, ArrowLeft } from 'lucide-react';
import { api } from '@/lib/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [submitted, setSubmitted] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await api.requestPasswordReset(email);
setSubmitted(true);
} catch (err: any) {
setError(err.message || 'Failed to request password reset');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
<Network className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900">Forgot Password</h1>
<p className="text-slate-500 mt-1">
{submitted
? 'Check your email or contact your admin'
: 'Enter your email to request a password reset'}
</p>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
{submitted ? (
<div className="text-center">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg mb-6">
<p className="text-sm text-green-700">
If an account with <strong>{email}</strong> exists, a password reset has been initiated. Contact your administrator for the reset link.
</p>
</div>
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{loading ? <LoadingSpinner size="sm" className="text-white" /> : null}
{loading ? 'Sending...' : 'Request Reset'}
</button>
<div className="text-center">
<Link
to="/login"
className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
>
<ArrowLeft className="w-4 h-4" />
Back to login
</Link>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { Network, Eye, EyeOff } from 'lucide-react'; import { Network, Eye, EyeOff } from 'lucide-react';
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
@@ -61,7 +61,15 @@ export default function LoginPage() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label> <div className="flex items-center justify-between mb-1.5">
<label className="block text-sm font-medium text-slate-700">Password</label>
<Link
to="/forgot-password"
className="text-xs text-blue-600 hover:text-blue-700"
>
Forgot password?
</Link>
</div>
<div className="relative"> <div className="relative">
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}

View File

@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { Network, Eye, EyeOff, CheckCircle } from 'lucide-react';
import { api } from '@/lib/api';
import LoadingSpinner from '@/components/LoadingSpinner';
export default function ResetPasswordPage() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) return;
(async () => {
try {
const data = await api.validateResetToken(token);
setEmail(data.email || '');
} catch (err: any) {
setError(err.message || 'Invalid or expired reset link');
} finally {
setLoading(false);
}
})();
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitError('');
if (password.length < 8) {
setSubmitError('Password must be at least 8 characters');
return;
}
if (password !== confirmPassword) {
setSubmitError('Passwords do not match');
return;
}
setSubmitting(true);
try {
await api.resetPassword(token!, password);
setSuccess(true);
} catch (err: any) {
setSubmitError(err.message || 'Failed to reset password');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50">
<LoadingSpinner size="lg" />
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 rounded-2xl mb-4">
<Network className="w-8 h-8 text-red-500" />
</div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Invalid Reset Link</h1>
<p className="text-slate-500 mb-6">{error}</p>
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
Back to login
</Link>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-14 h-14 bg-green-100 rounded-2xl mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-slate-900 mb-2">Password Reset</h1>
<p className="text-slate-500 mb-6">Your password has been successfully reset. You can now sign in with your new password.</p>
<button
onClick={() => navigate('/login')}
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Go to Login
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
<Network className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900">Reset Password</h1>
<p className="text-slate-500 mt-1">Choose a new password for your account</p>
</div>
{/* Card */}
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
{email && (
<div className="mb-6 p-3 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-700">
Resetting password for <strong>{email}</strong>
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{submitError && (
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
{submitError}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">New Password</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
placeholder="Min 8 characters"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
placeholder="Re-enter password"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{submitting ? <LoadingSpinner size="sm" className="text-white" /> : null}
{submitting ? 'Resetting...' : 'Reset Password'}
</button>
</form>
<div className="mt-4 text-center">
<Link
to="/login"
className="text-sm text-slate-500 hover:text-slate-700"
>
Back to login
</Link>
</div>
</div>
</div>
</div>
);
}