Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]);
Expand Down Expand Up @@ -113,6 +137,7 @@ function ResetPasswordContent() {
return;
}
setDisabled(true);
setLinkSent(true);
toast({
title: "Email Sent",
description:
Expand All @@ -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);
Expand Down Expand Up @@ -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">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading