From ed07878646416664cdabe9ff46d16dc8d38700e7 Mon Sep 17 00:00:00 2001 From: SatoshiQaziMuhammed Date: Wed, 24 Jun 2026 16:58:12 -0700 Subject: [PATCH] fix(auth): make password reset work via Supabase native recovery flow (#19) PasswordReset called a non-existent 'password-reset' edge function, so users could never reset a forgotten password. Switch to Supabase Auth's built-in recovery: resetPasswordForEmail() to request the email, and updateUser({password}) within the PASSWORD_RECOVERY session to set the new password. Generic success message (no account enumeration); sign out after reset to force clean re-login. --- web/src/pages/PasswordReset.tsx | 96 ++++++++++++++------------------- 1 file changed, 39 insertions(+), 57 deletions(-) diff --git a/web/src/pages/PasswordReset.tsx b/web/src/pages/PasswordReset.tsx index 02294659..5be06db3 100644 --- a/web/src/pages/PasswordReset.tsx +++ b/web/src/pages/PasswordReset.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,7 +10,6 @@ import { Loader2, ArrowLeft } from 'lucide-react'; import { useTranslation } from 'react-i18next'; export default function PasswordReset() { - const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { toast } = useToast(); const { t } = useTranslation(); @@ -18,77 +17,72 @@ export default function PasswordReset() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [loading, setLoading] = useState(false); - const token = searchParams.get('token'); + // Recovery mode = user arrived via the password-reset email link (Supabase + // establishes a temporary recovery session and emits PASSWORD_RECOVERY). + const [recovery, setRecovery] = useState(false); + useEffect(() => { + if (window.location.hash.includes('type=recovery')) setRecovery(true); + const { data } = supabase.auth.onAuthStateChange((event) => { + if (event === 'PASSWORD_RECOVERY') setRecovery(true); + }); + return () => data.subscription.unsubscribe(); + }, []); + + // Step 1: request a reset link (Supabase sends the email + handles the token). const handleRequestReset = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); - try { - const { error } = await supabase.functions.invoke('password-reset', { - body: { action: 'request', email } - }); - + const redirectTo = `${window.location.origin}/reset-password`; + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { redirectTo }); if (error) throw error; + // Generic success message regardless of whether the email exists + // (no account enumeration). toast({ title: t('passwordReset.resetEmailSent'), description: t('passwordReset.resetEmailSentDesc'), }); - setEmail(''); } catch (error) { toast({ title: t('common.error'), description: error instanceof Error ? error.message : t('passwordReset.failedToSend'), - variant: "destructive" + variant: 'destructive', }); } finally { setLoading(false); } }; + // Step 2: set the new password using the active recovery session. const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); - + if (password !== confirmPassword) { - toast({ - title: t('common.error'), - description: t('passwordReset.passwordMismatch'), - variant: "destructive" - }); + toast({ title: t('common.error'), description: t('passwordReset.passwordMismatch'), variant: 'destructive' }); return; } - if (password.length < 8) { - toast({ - title: t('common.error'), - description: t('passwordReset.passwordTooShort'), - variant: "destructive" - }); + toast({ title: t('common.error'), description: t('passwordReset.passwordTooShort'), variant: 'destructive' }); return; } setLoading(true); - try { - const { error } = await supabase.functions.invoke('password-reset', { - body: { action: 'reset', token, newPassword: password } - }); - + const { error } = await supabase.auth.updateUser({ password }); if (error) throw error; - toast({ - title: t('passwordReset.success'), - description: t('passwordReset.successDesc'), - }); - + toast({ title: t('passwordReset.success'), description: t('passwordReset.successDesc') }); + // Force a clean re-login with the new password. + await supabase.auth.signOut(); navigate('/login'); } catch (error) { toast({ title: t('common.error'), description: error instanceof Error ? error.message : t('passwordReset.failedToReset'), - variant: "destructive" + variant: 'destructive', }); } finally { setLoading(false); @@ -105,15 +99,13 @@ export default function PasswordReset() { - {token ? t('passwordReset.resetPassword') : t('passwordReset.forgotPassword')} + {recovery ? t('passwordReset.resetPassword') : t('passwordReset.forgotPassword')} - {token - ? t('passwordReset.enterNewPassword') - : t('passwordReset.enterEmail')} + {recovery ? t('passwordReset.enterNewPassword') : t('passwordReset.enterEmail')} - {!token ? ( + {!recovery ? (
@@ -127,19 +119,14 @@ export default function PasswordReset() { disabled={loading} />
- + - +
-
@@ -159,7 +146,7 @@ export default function PasswordReset() { minLength={8} /> - +
- + - +
-
@@ -195,4 +177,4 @@ export default function PasswordReset() { ); -} \ No newline at end of file +}