feat: admin password reset link UI + self-service reset page
This commit is contained in:
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Todo App - Task management made simple" />
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
<title>Todo App</title>
|
<title>Todo App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-CA4dNo0a.js"></script>
|
<script type="module" crossorigin src="/assets/index-D6mF4afG.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Bq_EO_v_.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-XdXarncg.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { LoginPage } from '@/pages/Login';
|
import { LoginPage } from '@/pages/Login';
|
||||||
import { SetupPage } from '@/pages/Setup';
|
import { SetupPage } from '@/pages/Setup';
|
||||||
|
import { ResetPasswordPage } from '@/pages/ResetPassword';
|
||||||
import { InboxPage } from '@/pages/Inbox';
|
import { InboxPage } from '@/pages/Inbox';
|
||||||
import { TodayPage } from '@/pages/Today';
|
import { TodayPage } from '@/pages/Today';
|
||||||
import { UpcomingPage } from '@/pages/Upcoming';
|
import { UpcomingPage } from '@/pages/Upcoming';
|
||||||
@@ -45,6 +46,7 @@ function AppRoutes() {
|
|||||||
isAuthenticated ? <Navigate to="/inbox" replace /> : <LoginPage />
|
isAuthenticated ? <Navigate to="/inbox" replace /> : <LoginPage />
|
||||||
} />
|
} />
|
||||||
<Route path="/setup" element={<SetupPage />} />
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected routes */}
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
|
|||||||
@@ -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
|
// Projects
|
||||||
async getProjects(): Promise<Project[]> {
|
async getProjects(): Promise<Project[]> {
|
||||||
return this.fetch('/projects');
|
return this.fetch('/projects');
|
||||||
@@ -246,10 +268,9 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetUserPassword(id: string, newPassword: string): Promise<void> {
|
async createPasswordReset(id: string): Promise<{ resetUrl: string }> {
|
||||||
await this.fetch(`/admin/users/${id}/reset-password`, {
|
return this.fetch(`/admin/users/${id}/reset-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ newPassword }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,21 +71,35 @@ export function AdminPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
const [resetUserId, setResetUserId] = useState<string | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [resetUrl, setResetUrl] = useState('');
|
||||||
const [resetSuccess, setResetSuccess] = useState('');
|
const [resetCopied, setResetCopied] = useState(false);
|
||||||
|
const [resetLoading, setResetLoading] = useState(false);
|
||||||
|
|
||||||
const handleResetPassword = async (userId: string) => {
|
const handleGenerateResetLink = async (userId: string) => {
|
||||||
if (!newPassword || newPassword.length < 8) return;
|
setResetLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.resetUserPassword(userId, newPassword);
|
const result = await api.createPasswordReset(userId);
|
||||||
setResetSuccess(userId);
|
setResetUserId(userId);
|
||||||
setNewPassword('');
|
setResetUrl(result.resetUrl);
|
||||||
setTimeout(() => { setResetSuccess(''); setResetUserId(null); }, 2000);
|
|
||||||
} catch (error) {
|
} 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) => {
|
const handleDeleteUser = async (userId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
@@ -209,43 +223,27 @@ export function AdminPage() {
|
|||||||
<>
|
<>
|
||||||
{resetUserId === user.id ? (
|
{resetUserId === user.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{resetSuccess === user.id ? (
|
<span className="text-xs text-green-600">✓ Link generated!</span>
|
||||||
<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
|
<button
|
||||||
onClick={() => handleResetPassword(user.id)}
|
onClick={handleCopyResetUrl}
|
||||||
disabled={newPassword.length < 8}
|
className="text-xs px-2 py-1 bg-blue-600 text-white rounded flex items-center gap-1"
|
||||||
className="text-xs px-2 py-1 bg-blue-600 text-white rounded disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Set
|
{resetCopied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{resetCopied ? 'Copied!' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setResetUserId(null); setNewPassword(''); }}
|
onClick={dismissReset}
|
||||||
className="text-xs px-1 py-1 text-gray-400 hover:text-gray-600"
|
className="text-xs px-1 py-1 text-gray-400 hover:text-gray-600"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setResetUserId(user.id)}
|
onClick={() => handleGenerateResetLink(user.id)}
|
||||||
|
disabled={resetLoading}
|
||||||
className="p-1 text-gray-400 hover:text-blue-500"
|
className="p-1 text-gray-400 hover:text-blue-500"
|
||||||
title="Reset password"
|
title="Generate password reset link"
|
||||||
>
|
>
|
||||||
<KeyRound className="w-4 h-4" />
|
<KeyRound className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
180
src/pages/ResetPassword.tsx
Normal file
180
src/pages/ResetPassword.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user