diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx index 70b9b4eb..d0c531c5 100644 --- a/app/(auth)/reset-password/page.tsx +++ b/app/(auth)/reset-password/page.tsx @@ -1,21 +1,99 @@ 'use client' -import { useState } from 'react' -import { useRouter } from 'next/navigation' +import { useEffect, useState, Suspense } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' +import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { Loader2, KeyRound } from 'lucide-react' -export default function ResetPasswordPage() { +/** + * Three entry modes: + * + * - 'set-password': a recovery session already exists (legacy email links + * land via /auth/callback, or a token was just verified below). + * - 'confirm-link': the email link points here with ?token_hash=. The + * token is verified ONLY on an explicit button click: corporate mail + * scanners (Microsoft Defender SafeLinks) render pages and follow + * links but do not click buttons, so the one-time token survives + * scanning. Never verify in an effect; that re-opens the burn. + * - 'enter-code': no session, no token_hash. The email also carries a + * 6-digit code the user can type together with their email address. + */ +type Mode = 'loading' | 'set-password' | 'confirm-link' | 'enter-code' + +function ResetPasswordInner() { const t = useTranslations('reset_password') + const searchParams = useSearchParams() + const tokenHash = searchParams.get('token_hash') + const [mode, setMode] = useState('loading') + const [email, setEmail] = useState('') + const [code, setCode] = useState('') const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [isLoading, setIsLoading] = useState(false) const { toast } = useToast() const router = useRouter() + const supabase = createClient() + + useEffect(() => { + let cancelled = false + supabase.auth.getSession().then(({ data: { session } }) => { + if (cancelled) return + if (session) setMode('set-password') + else if (tokenHash) setMode('confirm-link') + else setMode('enter-code') + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const verifyFailed = (description: string) => { + toast({ + title: t('verify_failed_title'), + description, + variant: 'destructive', + }) + } + + const handleConfirmLink = async () => { + if (!tokenHash) return + setIsLoading(true) + const { error } = await supabase.auth.verifyOtp({ + token_hash: tokenHash, + type: 'recovery', + }) + setIsLoading(false) + if (error) { + // Burned or expired link: fall back to typing the code (a fresh + // request may be needed, the hint says so). + setMode('enter-code') + verifyFailed(t('link_invalid_description')) + return + } + setMode('set-password') + } + + const handleVerifyCode = async (e: React.FormEvent) => { + e.preventDefault() + setIsLoading(true) + const { error } = await supabase.auth.verifyOtp({ + email: email.trim(), + token: code.trim(), + type: 'recovery', + }) + setIsLoading(false) + if (error) { + verifyFailed(t('code_invalid_description')) + return + } + setMode('set-password') + } const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault() @@ -87,6 +165,13 @@ export default function ResetPasswordPage() { } } + const subtitle = + mode === 'confirm-link' + ? t('confirm_subtitle') + : mode === 'enter-code' + ? t('code_subtitle') + : t('subtitle') + return (
@@ -97,56 +182,141 @@ export default function ResetPasswordPage() {

{t('title')}

-

- {t('subtitle')} -

+

{subtitle}

-
-
- - setPassword(e.target.value)} - required - minLength={8} - disabled={isLoading} - className="h-11" - /> + {mode === 'loading' && ( +
+
-
- - setConfirmPassword(e.target.value)} - required - minLength={8} + )} + + {mode === 'confirm-link' && ( +
+ +

+ {t('confirm_hint')} +

- - + )} + + {mode === 'enter-code' && ( +
+
+ + setEmail(e.target.value)} + required + disabled={isLoading} + className="h-11" + /> +
+
+ + setCode(e.target.value)} + required + minLength={6} + maxLength={6} + disabled={isLoading} + className="h-11 tracking-widest" + /> +
+ +

+ {t('code_hint')} +

+
+ )} + + {mode === 'set-password' && ( +
+
+ + setPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + disabled={isLoading} + className="h-11" + /> +
+ +
+ )}
) } + +export default function ResetPasswordPage() { + return ( + + + + ) +} diff --git a/messages/en.json b/messages/en.json index 63f2a6a0..286e8fbb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -850,7 +850,21 @@ "mismatch_title": "Passwords don't match", "mismatch_description": "Make sure you entered the same password in both fields.", "save_failed_title": "Could not save password", - "save_failed_description": "Please try again." + "save_failed_description": "Please try again.", + "confirm_subtitle": "Confirm to continue to the password reset.", + "confirm_button": "Continue", + "confirm_verifying": "Verifying...", + "confirm_hint": "For security, the link is only used when you click the button.", + "code_subtitle": "Enter your email and the one-time code from the email.", + "email_label": "Email address", + "code_label": "One-time code", + "code_placeholder": "6-digit code", + "code_button": "Verify code", + "code_verifying": "Verifying...", + "code_hint": "The code is in the password reset email. If no email arrived, request a new reset from the login page.", + "verify_failed_title": "Verification failed", + "link_invalid_description": "The link is invalid or has already been used. Enter the one-time code from the email instead, or request a new reset.", + "code_invalid_description": "The code is invalid or has expired. Check the code or request a new reset from the login page." }, "customer_detail": { "back": "Back to customers", diff --git a/messages/sv.json b/messages/sv.json index 997a46fa..6f825274 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -850,7 +850,21 @@ "mismatch_title": "Lösenorden matchar inte", "mismatch_description": "Kontrollera att du skrev samma lösenord i båda fälten.", "save_failed_title": "Kunde inte spara lösenordet", - "save_failed_description": "Försök igen." + "save_failed_description": "Försök igen.", + "confirm_subtitle": "Bekräfta för att fortsätta till lösenordsåterställningen.", + "confirm_button": "Fortsätt", + "confirm_verifying": "Verifierar...", + "confirm_hint": "Av säkerhetsskäl används länken först när du klickar på knappen.", + "code_subtitle": "Ange din e-post och engångskoden från mailet.", + "email_label": "E-postadress", + "code_label": "Engångskod", + "code_placeholder": "6-siffrig kod", + "code_button": "Verifiera kod", + "code_verifying": "Verifierar...", + "code_hint": "Koden finns i mailet om lösenordsåterställning. Hittar du inget mail kan du begära en ny återställning från inloggningssidan.", + "verify_failed_title": "Verifieringen misslyckades", + "link_invalid_description": "Länken är ogiltig eller har redan använts. Ange engångskoden från mailet i stället, eller begär en ny återställning.", + "code_invalid_description": "Koden är ogiltig eller har gått ut. Kontrollera koden eller begär en ny återställning från inloggningssidan." }, "customer_detail": { "back": "Tillbaka till kunder",