feat: admin password reset link UI + self-service reset page

This commit is contained in:
2026-01-28 19:13:36 +00:00
parent a20a5c5054
commit d9bcbd9701
5 changed files with 248 additions and 47 deletions

4
dist/index.html vendored
View File

@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Todo App - Task management made simple" />
<title>Todo App</title>
<script type="module" crossorigin src="/assets/index-CA4dNo0a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bq_EO_v_.css">
<script type="module" crossorigin src="/assets/index-D6mF4afG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-XdXarncg.css">
</head>
<body>
<div id="root"></div>

View File

@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth';
import { Layout } from '@/components/Layout';
import { LoginPage } from '@/pages/Login';
import { SetupPage } from '@/pages/Setup';
import { ResetPasswordPage } from '@/pages/ResetPassword';
import { InboxPage } from '@/pages/Inbox';
import { TodayPage } from '@/pages/Today';
import { UpcomingPage } from '@/pages/Upcoming';
@@ -45,6 +46,7 @@ function AppRoutes() {
isAuthenticated ? <Navigate to="/inbox" replace /> : <LoginPage />
} />
<Route path="/setup" element={<SetupPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* Protected routes */}
<Route element={<Layout />}>

View File

@@ -103,6 +103,28 @@ class ApiClient {
}
}
// Password reset (public)
async validateResetToken(token: string): Promise<{ valid: boolean; userName: string }> {
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Invalid reset link' }));
throw new Error(error.error);
}
return response.json();
}
async submitPasswordReset(token: string, newPassword: string): Promise<void> {
const response = await fetch(`${AUTH_BASE}/auth/reset-password/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newPassword }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to reset password' }));
throw new Error(error.error);
}
}
// Projects
async getProjects(): Promise<Project[]> {
return this.fetch('/projects');
@@ -246,10 +268,9 @@ class ApiClient {
});
}
async resetUserPassword(id: string, newPassword: string): Promise<void> {
await this.fetch(`/admin/users/${id}/reset-password`, {
async createPasswordReset(id: string): Promise<{ resetUrl: string }> {
return this.fetch(`/admin/users/${id}/reset-password`, {
method: 'POST',
body: JSON.stringify({ newPassword }),
});
}

View File

@@ -71,21 +71,35 @@ export function AdminPage() {
};
const [resetUserId, setResetUserId] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [resetSuccess, setResetSuccess] = useState('');
const [resetUrl, setResetUrl] = useState('');
const [resetCopied, setResetCopied] = useState(false);
const [resetLoading, setResetLoading] = useState(false);
const handleResetPassword = async (userId: string) => {
if (!newPassword || newPassword.length < 8) return;
const handleGenerateResetLink = async (userId: string) => {
setResetLoading(true);
try {
await api.resetUserPassword(userId, newPassword);
setResetSuccess(userId);
setNewPassword('');
setTimeout(() => { setResetSuccess(''); setResetUserId(null); }, 2000);
const result = await api.createPasswordReset(userId);
setResetUserId(userId);
setResetUrl(result.resetUrl);
} catch (error) {
console.error('Failed to reset password:', 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) => {
if (!confirm('Are you sure you want to delete this user?')) return;
@@ -209,43 +223,27 @@ export function AdminPage() {
<>
{resetUserId === user.id ? (
<div className="flex items-center gap-1">
{resetSuccess === user.id ? (
<span className="text-xs text-green-600"> Reset!</span>
) : (
<>
<input
type="text"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New password (8+ chars)"
className="text-xs border border-gray-200 rounded px-2 py-1 w-40"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleResetPassword(user.id);
if (e.key === 'Escape') { setResetUserId(null); setNewPassword(''); }
}}
/>
<button
onClick={() => handleResetPassword(user.id)}
disabled={newPassword.length < 8}
className="text-xs px-2 py-1 bg-blue-600 text-white rounded disabled:opacity-50"
>
Set
</button>
<button
onClick={() => { setResetUserId(null); setNewPassword(''); }}
className="text-xs px-1 py-1 text-gray-400 hover:text-gray-600"
>
</button>
</>
)}
<span className="text-xs text-green-600"> Link generated!</span>
<button
onClick={handleCopyResetUrl}
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-gray-400 hover:text-gray-600"
>
</button>
</div>
) : (
<button
onClick={() => setResetUserId(user.id)}
onClick={() => handleGenerateResetLink(user.id)}
disabled={resetLoading}
className="p-1 text-gray-400 hover:text-blue-500"
title="Reset password"
title="Generate password reset link"
>
<KeyRound className="w-4 h-4" />
</button>

180
src/pages/ResetPassword.tsx Normal file
View File

@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { useSearchParams, useNavigate, Link } from 'react-router-dom';
import { api } from '@/lib/api';
export function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isValid, setIsValid] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) {
setError('Invalid reset link');
setIsLoading(false);
return;
}
api.validateResetToken(token)
.then((data) => {
setIsValid(true);
setUserName(data.userName);
})
.catch((err) => setError(err.message))
.finally(() => setIsLoading(false));
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setIsSubmitting(true);
try {
await api.submitPasswordReset(token!, password);
setSuccess(true);
setTimeout(() => navigate('/login'), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset password');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full mx-auto"></div>
<p className="mt-4 text-gray-600">Validating reset link...</p>
</div>
</div>
);
}
if (!isValid && !success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full text-center">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="text-red-500 text-5xl mb-4">😕</div>
<h1 className="text-xl font-semibold text-gray-900">Invalid Reset Link</h1>
<p className="mt-2 text-gray-600">{error || 'This password reset link is invalid or has expired.'}</p>
<Link to="/login" className="mt-6 inline-block btn btn-primary">
Go to Login
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full text-center">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="text-green-500 text-5xl mb-4"></div>
<h1 className="text-xl font-semibold text-gray-900">Password Reset!</h1>
<p className="mt-2 text-gray-600">Your password has been updated successfully. Redirecting to login...</p>
<Link to="/login" className="mt-6 inline-block btn btn-primary">
Go to Login
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Reset Password</h1>
<p className="mt-2 text-gray-600">Choose a new password for your account</p>
</div>
{/* Form */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-800">
Resetting password for <strong>{userName}</strong>
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
New password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="input"
placeholder="At least 8 characters"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm new password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="input"
placeholder="Confirm your password"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full btn btn-primary py-2.5"
>
{isSubmitting ? 'Resetting password...' : 'Reset password'}
</button>
</form>
</div>
<p className="mt-6 text-center text-sm text-gray-500">
<Link to="/login" className="text-blue-600 hover:text-blue-700">
Back to login
</Link>
</p>
</div>
</div>
);
}