From d9bcbd9701523c03e20e0ecb5ad168485e58457d Mon Sep 17 00:00:00 2001 From: Hammer Date: Wed, 28 Jan 2026 19:13:36 +0000 Subject: [PATCH] feat: admin password reset link UI + self-service reset page --- dist/index.html | 4 +- src/App.tsx | 2 + src/lib/api.ts | 27 +++++- src/pages/Admin.tsx | 82 ++++++++-------- src/pages/ResetPassword.tsx | 180 ++++++++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+), 47 deletions(-) create mode 100644 src/pages/ResetPassword.tsx diff --git a/dist/index.html b/dist/index.html index 40e9d2e..c9f6db0 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 2094b47..352bca7 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 f11e1e6..bd5fe48 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 c9329ac..bdd87ec 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 0000000..135dc2a --- /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} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setPassword(e.target.value)} + required + minLength={8} + className="input" + placeholder="At least 8 characters" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + className="input" + placeholder="Confirm your password" + /> +
+ + +
+
+ +

+ + Back to login + +

+
+
+ ); +}