fix(frontend): possible login issues related to turnstile (#11094)

## Changes 🏗️

We are seeing login and authentication issues in production and staging.
Locally though, the app behaves fine. We also had issues related to the
CAPTCHA in the past.

Our CAPTCHA code is less than ideal, with some heavy `useEffect` that
will load the Turnstile script into the DOM. I have the impression that
is loading the script multiple times ( due to dependencies on the
effects array not being well set ), or the like causing associated login
issues.

Created a new Turnstile component using
[`react-turnstile`](https://docs.page/marsidev/react-turnstile) that is
way simpler and should hopefully be more stable.

I also fixed an issue with the Credits popover layout rendering cropped
on the window.

## 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:
- [x] Login/logout on the app multiple times with Turnstile ON,
everything is stable
  - [x] Credits popover appears on the right place 

### For configuration changes:

None
This commit is contained in:
Ubbe
2025-10-06 16:59:27 +04:00
committed by GitHub
parent 2d8ab6b7c0
commit ff58ce174b
7 changed files with 120 additions and 58 deletions

View File

@@ -27,6 +27,7 @@
"dependencies": {
"@faker-js/faker": "10.0.0",
"@hookform/resolvers": "5.2.1",
"@marsidev/react-turnstile": "1.3.1",
"@next/third-parties": "15.4.6",
"@phosphor-icons/react": "2.1.10",
"@radix-ui/react-alert-dialog": "1.1.15",

View File

@@ -14,6 +14,9 @@ importers:
'@hookform/resolvers':
specifier: 5.2.1
version: 5.2.1(react-hook-form@7.62.0(react@18.3.1))
'@marsidev/react-turnstile':
specifier: 1.3.1
version: 1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@next/third-parties':
specifier: 15.4.6
version: 15.4.6(next@15.4.7(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
@@ -1428,6 +1431,12 @@ packages:
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@marsidev/react-turnstile@1.3.1':
resolution: {integrity: sha512-h2THG/75k4Y049hgjSGPIcajxXnh+IZAiXVbryQyVmagkboN7pJtBgR16g8akjwUBSfRrg6jw6KvPDjscQflog==}
peerDependencies:
react: ^17.0.2 || ^18.0.0 || ^19.0
react-dom: ^17.0.2 || ^18.0.0 || ^19.0
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -8668,6 +8677,11 @@ snapshots:
dependencies:
jsep: 1.4.0
'@marsidev/react-turnstile@1.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@mdx-js/react@3.1.1(@types/react@18.3.17)(react@18.3.1)':
dependencies:
'@types/mdx': 2.0.13

View File

@@ -1,4 +1,5 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Link } from "@/components/atoms/Link/Link";
@@ -6,18 +7,16 @@ import { AuthCard } from "@/components/auth/AuthCard";
import AuthFeedback from "@/components/auth/AuthFeedback";
import { EmailNotAllowedModal } from "@/components/auth/EmailNotAllowedModal";
import { GoogleOAuthButton } from "@/components/auth/GoogleOAuthButton";
import Turnstile from "@/components/auth/Turnstile";
import { Form, FormField } from "@/components/__legacy__/ui/form";
import { getBehaveAs } from "@/lib/utils";
import { LoadingLogin } from "./components/LoadingLogin";
import { useLoginPage } from "./useLoginPage";
import { Turnstile2 } from "@/components/auth/Turnstile2";
export default function LoginPage() {
const {
form,
feedback,
turnstile,
captchaKey,
isLoading,
isLoggedIn,
isCloudEnv,
@@ -28,6 +27,8 @@ export default function LoginPage() {
handleSubmit,
handleProviderLogin,
handleCloseNotAllowedModal,
handleCaptchaVerify,
handleCaptchaReady,
} = useLoginPage();
if (isUserLoading || isLoggedIn) {
@@ -84,19 +85,12 @@ export default function LoginPage() {
)}
/>
{/* Turnstile CAPTCHA Component */}
{turnstile.shouldRender ? (
<Turnstile
key={captchaKey}
siteKey={turnstile.siteKey}
onVerify={turnstile.handleVerify}
onExpire={turnstile.handleExpire}
onError={turnstile.handleError}
setWidgetId={turnstile.setWidgetId}
action="login"
shouldRender={turnstile.shouldRender}
<div className="flex items-center justify-center">
<Turnstile2
onVerified={handleCaptchaVerify}
onReady={handleCaptchaReady}
/>
) : null}
</div>
<Button
variant="primary"

View File

@@ -1,33 +1,28 @@
import { useTurnstile } from "@/hooks/useTurnstile";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { BehaveAs, getBehaveAs } from "@/lib/utils";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { login, providerLogin } from "./actions";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { TurnstileInstance } from "@marsidev/react-turnstile";
export function useLoginPage() {
const { supabase, user, isUserLoading } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaRef, setCaptchaRef] = useState<TurnstileInstance | null>(null);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = getBehaveAs() === BehaveAs.CLOUD;
const isVercelPreview = process.env.NEXT_PUBLIC_VERCEL_ENV === "preview";
const turnstile = useTurnstile({
action: "login",
autoVerify: false,
resetOnError: true,
});
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
@@ -36,11 +31,6 @@ export function useLoginPage() {
},
});
const resetCaptcha = useCallback(() => {
setCaptchaKey((k) => k + 1);
turnstile.reset();
}, [turnstile]);
useEffect(() => {
if (user) router.push("/");
}, [user]);
@@ -48,14 +38,14 @@ export function useLoginPage() {
async function handleProviderLogin(provider: LoginProvider) {
setIsGoogleLoading(true);
if (isCloudEnv && !turnstile.verified && !isVercelPreview) {
if (isCloudEnv && !captchaToken && !isVercelPreview) {
toast({
title: "Please complete the CAPTCHA challenge.",
variant: "info",
});
setIsGoogleLoading(false);
resetCaptcha();
captchaRef?.reset();
return;
}
@@ -64,7 +54,7 @@ export function useLoginPage() {
if (error) throw error;
setFeedback(null);
} catch (error) {
resetCaptcha();
captchaRef?.reset();
setIsGoogleLoading(false);
const errorString = JSON.stringify(error);
if (errorString.includes("not_allowed")) {
@@ -77,14 +67,14 @@ export function useLoginPage() {
async function handleLogin(data: z.infer<typeof loginFormSchema>) {
setIsLoading(true);
if (isCloudEnv && !turnstile.verified && !isVercelPreview) {
if (isCloudEnv && !captchaToken && !isVercelPreview) {
toast({
title: "Please complete the CAPTCHA challenge.",
variant: "info",
});
setIsLoading(false);
resetCaptcha();
captchaRef?.reset();
return;
}
@@ -95,11 +85,11 @@ export function useLoginPage() {
});
setIsLoading(false);
resetCaptcha();
captchaRef?.reset();
return;
}
const error = await login(data, turnstile.token as string);
const error = await login(data, captchaToken as string);
await supabase?.auth.refreshSession();
setIsLoading(false);
if (error) {
@@ -108,19 +98,24 @@ export function useLoginPage() {
variant: "destructive",
});
resetCaptcha();
// Always reset the turnstile on any error
turnstile.reset();
captchaRef?.reset();
return;
}
setFeedback(null);
}
function handleCaptchaVerify(token: string) {
setCaptchaToken(token);
}
function handleCaptchaReady(ref: TurnstileInstance) {
setCaptchaRef(ref);
}
return {
form,
feedback,
turnstile,
captchaKey,
captchaRef,
isLoggedIn: !!user,
isLoading,
isCloudEnv,
@@ -131,5 +126,7 @@ export function useLoginPage() {
handleSubmit: form.handleSubmit(handleLogin),
handleProviderLogin,
handleCloseNotAllowedModal: () => setShowNotAllowedModal(false),
handleCaptchaVerify,
handleCaptchaReady,
};
}

View File

@@ -39,6 +39,7 @@ export interface TaskGroup {
export default function Wallet() {
const { state, updateState } = useOnboarding();
const groups = useMemo<TaskGroup[]>(() => {
return [
{
@@ -348,10 +349,11 @@ export default function Wallet() {
</div>
</PopoverTrigger>
<PopoverContent
className={cn(
"absolute -right-[7.9rem] -top-[3.2rem] z-50 w-[28.5rem] px-[0.625rem] py-2",
"rounded-xl border-zinc-100 bg-white shadow-[0_3px_3px] shadow-zinc-200",
)}
side="bottom"
align="end"
sideOffset={12}
collisionPadding={16}
className={cn("z-50 w-[28.5rem] px-[0.625rem] py-2")}
>
{/* Header */}
<div className="mx-1 flex items-center justify-between border-b border-zinc-200 pb-3">

View File

@@ -80,19 +80,20 @@ export function Turnstile({
// Render a new widget
if (window.turnstile) {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (token: string) => {
onVerify(token);
},
"expired-callback": () => {
onExpire?.();
},
"error-callback": () => {
onError?.(new Error("Turnstile widget encountered an error"));
},
action,
});
widgetIdRef.current =
window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (token: string) => {
onVerify(token);
},
"expired-callback": () => {
onExpire?.();
},
"error-callback": () => {
onError?.(new Error("Turnstile widget encountered an error"));
},
action,
}) ?? "";
// Notify the hook about the widget ID
setWidgetId?.(widgetIdRef.current);
@@ -144,6 +145,7 @@ export function Turnstile({
// Add TypeScript interface to Window to include turnstile property
declare global {
interface Window {
// @ts-expect-error - turnstile is not defined in the window object
turnstile?: {
render: (
container: HTMLElement,

View File

@@ -0,0 +1,52 @@
"use client";
import { BehaveAs, getBehaveAs } from "@/lib/utils";
import { Turnstile, TurnstileInstance } from "@marsidev/react-turnstile";
import { useEffect, useRef, useState } from "react";
const TURNSTILE_SITE_KEY =
process.env.NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY || "";
type Props = {
onVerified: (token: string) => void;
onReady: (ref: TurnstileInstance) => void;
};
export function Turnstile2(props: Props) {
const captchaRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const behaveAs = getBehaveAs();
useEffect(() => {
if (captchaRef.current) {
props.onReady(captchaRef.current);
}
}, [captchaRef]);
function handleCaptchaVerify(token: string) {
setCaptchaToken(token);
props.onVerified(token);
}
// Only render in cloud environment
if (behaveAs !== BehaveAs.CLOUD) {
return null;
}
if (!TURNSTILE_SITE_KEY) {
return null;
}
// If it is already verified, no need to render
if (captchaToken) {
return null;
}
return (
<Turnstile
ref={captchaRef}
siteKey={TURNSTILE_SITE_KEY}
onSuccess={handleCaptchaVerify}
/>
);
}