From 696e2187f41290b6d0df25fdd47fae7adca01f20 Mon Sep 17 00:00:00 2001 From: Hammer Date: Wed, 28 Jan 2026 21:44:22 +0000 Subject: [PATCH] feat: password reset UI (forgot password, reset page, admin reset button) --- src/App.tsx | 4 + src/lib/api.ts | 39 +++++++ src/pages/AdminPage.tsx | 78 +++++++++++-- src/pages/ForgotPasswordPage.tsx | 105 +++++++++++++++++ src/pages/LoginPage.tsx | 12 +- src/pages/ResetPasswordPage.tsx | 192 +++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 src/pages/ForgotPasswordPage.tsx create mode 100644 src/pages/ResetPasswordPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 123ff0b..9793769 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ import EmailsPage from '@/pages/EmailsPage'; import SettingsPage from '@/pages/SettingsPage'; import AdminPage from '@/pages/AdminPage'; import InvitePage from '@/pages/InvitePage'; +import ForgotPasswordPage from '@/pages/ForgotPasswordPage'; +import ResetPasswordPage from '@/pages/ResetPasswordPage'; import { PageLoader } from '@/components/LoadingSpinner'; function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -34,6 +36,8 @@ export default function App() { isAuthenticated ? : } /> } /> + } /> + } /> diff --git a/src/lib/api.ts b/src/lib/api.ts index b99964e..d15b82a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -255,6 +255,10 @@ class ApiClient { 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) async validateInvite(token: string): Promise<{ id: string; email: string; name: string; role: string; expiresAt: string }> { const response = await fetch(`${AUTH_BASE}/auth/invite/${token}`); @@ -265,6 +269,41 @@ class ApiClient { 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 }> { const response = await fetch(`${AUTH_BASE}/auth/invite/${token}/accept`, { method: 'POST', diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index 0b4a797..4107f86 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -1,5 +1,5 @@ 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 { useAuthStore } from '@/stores/auth'; import type { User, Invite } from '@/types'; @@ -21,6 +21,12 @@ export default function AdminPage() { const [inviteUrl, setInviteUrl] = useState(''); const [copied, setCopied] = useState(false); + // Password reset + const [resetUserId, setResetUserId] = useState(null); + const [resetUrl, setResetUrl] = useState(''); + const [resetCopied, setResetCopied] = useState(false); + const [resetLoading, setResetLoading] = useState(false); + useEffect(() => { 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) => { if (!confirm('Are you sure you want to delete this user? All their data will be lost.')) return; try { @@ -180,13 +211,44 @@ export default function AdminPage() { {user.id !== currentUser?.id && ( - +
+ {resetUserId === user.id ? ( +
+ ✓ Link ready + + +
+ ) : ( + <> + + + + )} +
)} diff --git a/src/pages/ForgotPasswordPage.tsx b/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..d1b850c --- /dev/null +++ b/src/pages/ForgotPasswordPage.tsx @@ -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 ( +
+
+ {/* Logo */} +
+
+ +
+

Forgot Password

+

+ {submitted + ? 'Check your email or contact your admin' + : 'Enter your email to request a password reset'} +

+
+ + {/* Card */} +
+ {submitted ? ( +
+
+

+ If an account with {email} exists, a password reset has been initiated. Contact your administrator for the reset link. +

+
+ + + Back to login + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ + + +
+ + + Back to login + +
+
+ )} +
+
+
+ ); +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index b730fe4..01c411e 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, Link } from 'react-router-dom'; import { useAuthStore } from '@/stores/auth'; import { Network, Eye, EyeOff } from 'lucide-react'; import LoadingSpinner from '@/components/LoadingSpinner'; @@ -61,7 +61,15 @@ export default function LoginPage() {
- +
+ + + Forgot password? + +
(); + 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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+
+ +
+

Invalid Reset Link

+

{error}

+ + ← Back to login + +
+
+ ); + } + + if (success) { + return ( +
+
+
+ +
+

Password Reset

+

Your password has been successfully reset. You can now sign in with your new password.

+ +
+
+ ); + } + + return ( +
+
+ {/* Logo */} +
+
+ +
+

Reset Password

+

Choose a new password for your account

+
+ + {/* Card */} +
+ {email && ( +
+

+ Resetting password for {email} +

+
+ )} + +
+ {submitError && ( +
+ {submitError} +
+ )} + +
+ +
+ 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" + /> + +
+
+ +
+ + 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" + /> +
+ + +
+ +
+ + ← Back to login + +
+
+
+
+ ); +}