diff --git a/dist/index.html b/dist/index.html
index 40e9d2e6..c9f6db00 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -6,8 +6,8 @@
Todo App
-
-
+
+
diff --git a/src/App.tsx b/src/App.tsx
index 2094b473..352bca71 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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 ? :
} />
} />
+ } />
{/* Protected routes */}
}>
diff --git a/src/lib/api.ts b/src/lib/api.ts
index f11e1e60..bd5fe482 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -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 {
+ 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 {
return this.fetch('/projects');
@@ -246,10 +268,9 @@ class ApiClient {
});
}
- async resetUserPassword(id: string, newPassword: string): Promise {
- 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 }),
});
}
diff --git a/src/pages/Admin.tsx b/src/pages/Admin.tsx
index c9329ac8..bdd87ec2 100644
--- a/src/pages/Admin.tsx
+++ b/src/pages/Admin.tsx
@@ -71,21 +71,35 @@ export function AdminPage() {
};
const [resetUserId, setResetUserId] = useState(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 ? (
- {resetSuccess === user.id ? (
- ✓ Reset!
- ) : (
- <>
- 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(''); }
- }}
- />
-
-
- >
- )}
+ ✓ Link generated!
+
+
) : (
diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx
new file mode 100644
index 00000000..135dc2a9
--- /dev/null
+++ b/src/pages/ResetPassword.tsx
@@ -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 (
+
+
+
+
Validating reset link...
+
+
+ );
+ }
+
+ if (!isValid && !success) {
+ return (
+
+
+
+
😕
+
Invalid Reset Link
+
{error || 'This password reset link is invalid or has expired.'}
+
+ Go to Login
+
+
+
+
+ );
+ }
+
+ if (success) {
+ return (
+
+
+
+
✓
+
Password Reset!
+
Your password has been updated successfully. Redirecting to login...
+
+ Go to Login
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
Reset Password
+
Choose a new password for your account
+
+
+ {/* Form */}
+
+
+
+ Resetting password for {userName}
+
+
+
+
+
+
+
+
+ Back to login
+
+
+
+
+ );
+}