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
This commit is contained in:
Bentlybro
2026-02-16 12:03:07 +00:00
parent 223df9d3da
commit 80e9f4008f
3 changed files with 108 additions and 7 deletions

View File

@@ -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<typeof changePasswordFormSchema>) => {
setIsLoading(true);
@@ -122,6 +151,21 @@ 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
onSendNewLink={handleSendNewLink}
isLoading={isLoading}
linkSent={linkSent}
/>
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] w-full flex-col items-center justify-center">
<AuthCard title="Reset Password">

View File

@@ -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}`,
);
}

View File

@@ -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 (
<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 recover your password. A new link will be sent to your
email.
</Text>
<Button
variant="primary"
onClick={onSendNewLink}
loading={isLoading}
disabled={linkSent}
className="w-full"
>
{linkSent ? "Check Your Email" : "Send Me a New Link"}
</Button>
<div className="flex items-center gap-1">
<Text variant="body-small" className="text-muted-foreground">
Already have access?
</Text>
<Link href="/login" variant="secondary">
Log in here
</Link>
</div>
</div>
);
}