From 4fbc5649443e38d2174aaec21cdd8ed4ff6e90f4 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 23:51:52 -0300 Subject: [PATCH] feat: [US-042] - Password reset modal on token arrival Co-Authored-By: Claude Opus 4.5 --- prd.json | 2 +- progress.txt | 14 +++ src/app/login/page.tsx | 2 + src/components/PasswordResetModal.tsx | 170 ++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/components/PasswordResetModal.tsx diff --git a/prd.json b/prd.json index 4aa7581..196ae27 100644 --- a/prd.json +++ b/prd.json @@ -745,7 +745,7 @@ "Verify in browser using dev-browser skill" ], "priority": 42, - "passes": false, + "passes": true, "notes": "Dependencies: US-006. Complexity: S" } ] diff --git a/progress.txt b/progress.txt index b4d0d8b..c5d0f32 100644 --- a/progress.txt +++ b/progress.txt @@ -618,3 +618,17 @@ - Clear form fields after successful password update for security - Settings link in navbar uses neutral zinc colors to distinguish from admin/action links --- + +## 2026-01-22 - US-042 +- What was implemented: Password reset modal that automatically appears when a recovery token is detected in the URL +- Files changed: + - src/components/PasswordResetModal.tsx - new client component with modal that detects recovery tokens from URL hash, sets session, and provides password reset form + - src/app/login/page.tsx - integrated PasswordResetModal component on the login page +- **Learnings for future iterations:** + - PasswordResetModal is a standalone component that can be placed on any page to detect recovery tokens + - Use window.history.replaceState to clean the URL hash after extracting the token (prevents re-triggering on refresh) + - Separate tokenError state from form error state to show different UI (expired link vs. form validation) + - Modal uses fixed positioning with z-50 to overlay above page content + - After successful password update, sign out the user and redirect to login with success message (same as reset-password page) + - The modal coexists with the existing /reset-password page - both handle recovery tokens but in different UX patterns +--- diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index f9033de..231b62a 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import LoginForm from './LoginForm' +import PasswordResetModal from '@/components/PasswordResetModal' export default function LoginPage() { return ( @@ -18,6 +19,7 @@ export default function LoginPage() { > + ) } diff --git a/src/components/PasswordResetModal.tsx b/src/components/PasswordResetModal.tsx new file mode 100644 index 0000000..9a190f7 --- /dev/null +++ b/src/components/PasswordResetModal.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { createClient } from '@/lib/supabase/client' + +export default function PasswordResetModal() { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [tokenError, setTokenError] = useState(null) + + useEffect(() => { + const handleTokenFromUrl = async () => { + const hash = window.location.hash + if (!hash) return + + const params = new URLSearchParams(hash.substring(1)) + const accessToken = params.get('access_token') + const refreshToken = params.get('refresh_token') + const type = params.get('type') + + if (accessToken && refreshToken && type === 'recovery') { + const supabase = createClient() + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken, + }) + + if (error) { + setTokenError('Invalid or expired reset link. Please request a new password reset.') + return + } + + // Clear hash from URL without reloading + window.history.replaceState(null, '', window.location.pathname + window.location.search) + setIsOpen(true) + } + } + + handleTokenFromUrl() + }, []) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + + setLoading(true) + + const supabase = createClient() + + const { error: updateError } = await supabase.auth.updateUser({ + password, + }) + + if (updateError) { + setError(updateError.message) + setLoading(false) + return + } + + await supabase.auth.signOut() + + setIsOpen(false) + router.push('/login?message=password_reset_success') + } + + if (tokenError) { + return ( +
+
+

+ Reset link expired +

+

+ {tokenError} +

+ +
+
+ ) + } + + if (!isOpen) return null + + return ( +
+
+

+ Set new password +

+

+ Enter your new password below. +

+ +
+ {error && ( +
+

{error}

+
+ )} + +
+ + setPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500" + placeholder="••••••••" + /> +
+ + +
+
+
+ ) +}