From 80e9f4008f49aa1a7355d53e3bd88c18fcd19897 Mon Sep 17 00:00:00 2001 From: Bentlybro Date: Mon, 16 Feb 2026 12:03:07 +0000 Subject: [PATCH] fix(frontend): improve UX for expired or duplicate password reset links - Add ExpiredLinkMessage component with clear user messaging - Detect expired/used links from URL params (error, error_code, error_description) - Show friendly UI instead of confusing URL params - Map Supabase error messages to clean 'link_expired' param in callback route Closes SECRT-1369 --- .../app/(platform)/reset-password/page.tsx | 56 +++++++++++++++++-- .../api/auth/callback/reset-password/route.ts | 15 ++++- .../components/auth/ExpiredLinkMessage.tsx | 44 +++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 autogpt_platform/frontend/src/components/auth/ExpiredLinkMessage.tsx diff --git a/autogpt_platform/frontend/src/app/(platform)/reset-password/page.tsx b/autogpt_platform/frontend/src/app/(platform)/reset-password/page.tsx index dcbb2f97c5..c1a9fe5c59 100644 --- a/autogpt_platform/frontend/src/app/(platform)/reset-password/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/reset-password/page.tsx @@ -2,6 +2,7 @@ import { Button } from "@/components/atoms/Button/Button"; import { Input } from "@/components/atoms/Input/Input"; import { AuthCard } from "@/components/auth/AuthCard"; +import { ExpiredLinkMessage } from "@/components/auth/ExpiredLinkMessage"; import { Form, FormField } from "@/components/__legacy__/ui/form"; import LoadingBox from "@/components/__legacy__/ui/loading"; import { useToast } from "@/components/molecules/Toast/use-toast"; @@ -21,18 +22,41 @@ function ResetPasswordContent() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [disabled, setDisabled] = useState(false); + const [showExpiredMessage, setShowExpiredMessage] = useState(false); + const [linkSent, setLinkSent] = useState(false); useEffect(() => { const error = searchParams.get("error"); - if (error) { - toast({ - title: "Password Reset Failed", - description: error, - variant: "destructive", - }); + const errorCode = searchParams.get("error_code"); + const errorDescription = searchParams.get("error_description"); + if (error || errorCode) { + // Check if this is an expired/used link error + const isExpiredOrUsed = + error === "link_expired" || + errorCode === "otp_expired" || + error === "access_denied" || + errorDescription?.toLowerCase().includes("expired") || + errorDescription?.toLowerCase().includes("invalid"); + + if (isExpiredOrUsed) { + setShowExpiredMessage(true); + } else { + // Show toast for other errors + const errorMessage = + errorDescription || error || "Password reset failed"; + toast({ + title: "Password Reset Failed", + description: errorMessage, + variant: "destructive", + }); + } + + // Clear all error params from URL const newUrl = new URL(window.location.href); newUrl.searchParams.delete("error"); + newUrl.searchParams.delete("error_code"); + newUrl.searchParams.delete("error_description"); router.replace(newUrl.pathname + newUrl.search); } }, [searchParams, toast, router]); @@ -72,6 +96,7 @@ function ResetPasswordContent() { return; } setDisabled(true); + setLinkSent(true); toast({ title: "Email Sent", description: @@ -82,6 +107,10 @@ function ResetPasswordContent() { [sendEmailForm, toast], ); + function handleSendNewLink() { + setShowExpiredMessage(false); + } + const onChangePassword = useCallback( async (data: z.infer) => { setIsLoading(true); @@ -122,6 +151,21 @@ function ResetPasswordContent() { ); } + // Show expired link message if detected + if (showExpiredMessage && !user) { + return ( +
+ + + +
+ ); + } + return (
diff --git a/autogpt_platform/frontend/src/app/api/auth/callback/reset-password/route.ts b/autogpt_platform/frontend/src/app/api/auth/callback/reset-password/route.ts index c88b14e06d..0216f4bc99 100644 --- a/autogpt_platform/frontend/src/app/api/auth/callback/reset-password/route.ts +++ b/autogpt_platform/frontend/src/app/api/auth/callback/reset-password/route.ts @@ -26,8 +26,21 @@ export async function GET(request: NextRequest) { const result = await exchangePasswordResetCode(supabase, code); if (!result.success) { + // Check for expired or used link errors + const errorMessage = result.error?.toLowerCase() || ""; + const isExpiredOrUsed = + errorMessage.includes("expired") || + errorMessage.includes("invalid") || + errorMessage.includes("otp_expired") || + errorMessage.includes("already") || + errorMessage.includes("used"); + + const errorParam = isExpiredOrUsed + ? "link_expired" + : encodeURIComponent(result.error || "Password reset failed"); + return NextResponse.redirect( - `${origin}/reset-password?error=${encodeURIComponent(result.error || "Password reset failed")}`, + `${origin}/reset-password?error=${errorParam}`, ); } diff --git a/autogpt_platform/frontend/src/components/auth/ExpiredLinkMessage.tsx b/autogpt_platform/frontend/src/components/auth/ExpiredLinkMessage.tsx new file mode 100644 index 0000000000..69ac71223e --- /dev/null +++ b/autogpt_platform/frontend/src/components/auth/ExpiredLinkMessage.tsx @@ -0,0 +1,44 @@ +import { Button } from "../atoms/Button/Button"; +import { Link } from "../atoms/Link/Link"; +import { Text } from "../atoms/Text/Text"; + +interface Props { + onSendNewLink: () => void; + isLoading?: boolean; + linkSent?: boolean; +} + +export function ExpiredLinkMessage({ + onSendNewLink, + isLoading = false, + linkSent = false, +}: Props) { + return ( +
+ + Your reset password link has expired or has already been used + + + Click below to recover your password. A new link will be sent to your + email. + + +
+ + Already have access? + + + Log in here + +
+
+ ); +}