Compare commits

...

1 Commits

Author SHA1 Message Date
Bently
47815a1afb fix(frontend): improve UX for expired or duplicate password reset links 2025-11-14 21:46:29 +00:00
3 changed files with 114 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 Turnstile from "@/components/auth/Turnstile";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import LoadingBox from "@/components/__legacy__/ui/loading";
@@ -25,18 +26,41 @@ function ResetPasswordContent() {
const [disabled, setDisabled] = useState(false);
const [sendEmailCaptchaKey, setSendEmailCaptchaKey] = useState(0);
const [changePasswordCaptchaKey, setChangePasswordCaptchaKey] = useState(0);
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]);
@@ -113,6 +137,7 @@ function ResetPasswordContent() {
return;
}
setDisabled(true);
setLinkSent(true);
toast({
title: "Email Sent",
description:
@@ -123,6 +148,11 @@ function ResetPasswordContent() {
[sendEmailForm, sendEmailTurnstile, resetSendEmailCaptcha, toast],
);
const handleSendNewLink = useCallback(() => {
// Show the normal form to collect email and CAPTCHA
setShowExpiredMessage(false);
}, []);
const onChangePassword = useCallback(
async (data: z.infer<typeof changePasswordFormSchema>) => {
setIsLoading(true);
@@ -183,6 +213,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,49 @@
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">
This password reset link has expired or already been used
</Text>
<div className="flex flex-col gap-4 text-center">
<Text variant="large" className="text-center text-muted-foreground">
Don&apos;t worry this can happen if the link is opened more than
once or has timed out.
</Text>
<Text variant="large" className="text-center text-muted-foreground">
Click below to request a new password reset link.
</Text>
</div>
<Button
variant="primary"
onClick={onSendNewLink}
loading={isLoading}
disabled={linkSent}
className="w-full max-w-sm"
>
{linkSent ? "Link Sent!" : "Send Me a New Link"}
</Button>
<div className="mt-2 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>
);
}