mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
fix(frontend): improve UX for expired or duplicate password reset links (#12123)
## Summary Improves the user experience when a password reset link has expired or been used, replacing the confusing generic error with a clean, helpful message. ## Changes - Added `ExpiredLinkMessage` component that displays a user-friendly error state - Updated reset password page to detect expired/used links from: - Supabase error format (`error=access_denied&error_code=otp_expired&error_description=...`) - Internal clean format (`error=link_expired`) - Enhanced callback route to detect and map expired/invalid link errors - Clear, actionable UI with: - Friendly error message explaining what happened - "Send Me a New Link" button to request a new reset email - Login link for users who already have access ## Before Users saw a confusing URL with error parameters and an unclear form: ``` /reset-password?error=access_denied&error_code=otp_expired&error_description=Email+link+is+invalid+or+has+expired ``` ## After Users see a clean, helpful message explaining the issue and how to fix it. <img width="548" height="454" alt="image" src="https://github.com/user-attachments/assets/e867e522-146c-4d43-91b3-9e62d2957f95" /> Closes SECRT-1369 ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [ ] Navigate to `/reset-password?error=link_expired` and verify the ExpiredLinkMessage component appears - [ ] Click "Send Me a New Link" and verify the email form appears - [ ] Navigate to `/reset-password?error=access_denied&error_code=otp_expired` and verify same behavior <!-- greptile_comment --> <details><summary><h3>Greptile Summary</h3></summary> Improved password reset UX by adding an `ExpiredLinkMessage` component that displays when users follow expired or already-used reset links. The implementation detects expired link errors from Supabase (`error_code=otp_expired`) and internal format (`error=link_expired`), replacing confusing URL parameters with a clean message. **Key changes:** - Added error detection logic in both callback route and reset password page to identify expired/invalid links - Created new `ExpiredLinkMessage` component with friendly messaging - Enhanced error handling to differentiate between expired links and other errors **Issues found:** - The "Send Me a New Link" button misleadingly suggests it will send an email, but it only reveals the email form - user must still enter email and submit - `access_denied` error detection may be too broad and could incorrectly classify non-expired errors as expired links </details> <details><summary><h3>Confidence Score: 3/5</h3></summary> - This PR improves UX but has logic issues that could mislead users - The implementation correctly detects expired links and displays helpful UI, but the "Send Me a New Link" button doesn't actually send an email (just shows the form), which creates a misleading user experience. Additionally, the `access_denied` error check is overly broad and could incorrectly classify errors. These are functional issues that should be addressed before merge. - Pay close attention to `page.tsx` - the `handleSendNewLink` function and error detection logic need refinement </details> <details><summary><h3>Flowchart</h3></summary> ```mermaid flowchart TD Start[User clicks reset link with code] --> Callback[API: /auth/callback/reset-password] Callback --> CheckCode{Code valid?} CheckCode -->|No - expired/invalid/used| DetectError[Detect error type] DetectError --> CheckExpired{Contains expired/<br/>invalid/otp_expired/<br/>already/used?} CheckExpired -->|Yes| RedirectExpired[Redirect to /reset-password?error=link_expired] CheckExpired -->|No| RedirectOther[Redirect to /reset-password?error=message] CheckCode -->|Yes| RedirectSuccess[Redirect to /reset-password] RedirectExpired --> PageLoad[Page: /reset-password] RedirectOther --> PageLoad RedirectSuccess --> PageLoad PageLoad --> ParseParams[Parse URL params] ParseParams --> CheckErrorParams{Has error or<br/>error_code?} CheckErrorParams -->|Yes| CheckExpiredParams{error=link_expired OR<br/>errorCode=otp_expired OR<br/>error=access_denied OR<br/>description contains<br/>expired/invalid?} CheckExpiredParams -->|Yes| ShowExpired[Show ExpiredLinkMessage] CheckExpiredParams -->|No| ShowToast[Show error toast] CheckErrorParams -->|No| CheckUser{User<br/>authenticated?} ShowExpired --> ClickButton[User clicks 'Send Me a New Link'] ClickButton --> HideExpired[setShowExpiredMessage false] HideExpired --> ShowForm[Show email form] ShowToast --> ClearParams[Clear error params from URL] ClearParams --> CheckUser CheckUser -->|Yes| ShowPasswordForm[Show password change form] CheckUser -->|No| ShowForm[Show email form] ``` </details> <sub>Last reviewed commit: 80e9f40</sub> <!-- greptile_other_comments_section --> <!-- /greptile_comment -->
This commit is contained in:
@@ -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,42 @@ function ResetPasswordContent() {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
const [showExpiredMessage, setShowExpiredMessage] = 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
|
||||
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
|
||||
const descLower = errorDescription?.toLowerCase() || "";
|
||||
const isExpiredOrUsed =
|
||||
error === "link_expired" ||
|
||||
errorCode === "otp_expired" ||
|
||||
descLower.includes("expired") ||
|
||||
descLower.includes("already") ||
|
||||
descLower.includes("used");
|
||||
|
||||
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]);
|
||||
@@ -82,6 +107,10 @@ function ResetPasswordContent() {
|
||||
[sendEmailForm, toast],
|
||||
);
|
||||
|
||||
function handleShowEmailForm() {
|
||||
setShowExpiredMessage(false);
|
||||
}
|
||||
|
||||
const onChangePassword = useCallback(
|
||||
async (data: z.infer<typeof changePasswordFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
@@ -122,6 +151,17 @@ function ResetPasswordContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show expired link message if detected
|
||||
if (showExpiredMessage && !user) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
|
||||
<AuthCard title="Reset Password">
|
||||
<ExpiredLinkMessage onRequestNewLink={handleShowEmailForm} />
|
||||
</AuthCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
|
||||
<AuthCard title="Reset Password">
|
||||
|
||||
@@ -10,7 +10,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.redirect(
|
||||
`${origin}/reset-password?error=Missing verification code`,
|
||||
`${origin}/reset-password?error=${encodeURIComponent("Missing verification code")}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// Avoid broad checks like "invalid" which can match unrelated errors (e.g., PKCE errors)
|
||||
const errorMessage = result.error?.toLowerCase() || "";
|
||||
const isExpiredOrUsed =
|
||||
errorMessage.includes("expired") ||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +48,7 @@ export async function GET(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error("Password reset callback error:", error);
|
||||
return NextResponse.redirect(
|
||||
`${origin}/reset-password?error=Password reset failed`,
|
||||
`${origin}/reset-password?error=${encodeURIComponent("Password reset failed")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "../atoms/Button/Button";
|
||||
import { Link } from "../atoms/Link/Link";
|
||||
import { Text } from "../atoms/Text/Text";
|
||||
|
||||
interface Props {
|
||||
onRequestNewLink: () => void;
|
||||
}
|
||||
|
||||
export function ExpiredLinkMessage({ onRequestNewLink }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<Text variant="h3" className="text-center">
|
||||
Your reset password link has expired or has already been used
|
||||
</Text>
|
||||
<Text variant="body-medium" className="text-center text-muted-foreground">
|
||||
Click below to request a new password reset link.
|
||||
</Text>
|
||||
<Button variant="primary" onClick={onRequestNewLink} className="w-full">
|
||||
Request a New Link
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Text variant="small" className="text-muted-foreground">
|
||||
Already have access?
|
||||
</Text>
|
||||
<Link href="/login" variant="secondary">
|
||||
Log in here
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user