diff --git a/packages/shared/src/components/auth/LoginForm.tsx b/packages/shared/src/components/auth/LoginForm.tsx index 2d3045933c..4a6d0e014e 100644 --- a/packages/shared/src/components/auth/LoginForm.tsx +++ b/packages/shared/src/components/auth/LoginForm.tsx @@ -1,7 +1,11 @@ import classNames from 'classnames'; import type { Dispatch, FormEvent, ReactElement, SetStateAction } from 'react'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import type { TurnstileInstance } from '@marsidev/react-turnstile'; +import { Turnstile } from '@marsidev/react-turnstile'; import type { LoginPasswordParameters } from '../../lib/auth'; +import { AuthEventNames } from '../../lib/auth'; +import { Origin } from '../../lib/log'; import { formToJson } from '../../lib/form'; import { Button, ButtonVariant } from '../buttons/Button'; import { ClickableText } from '../buttons/ClickableText'; @@ -12,6 +16,7 @@ import AuthForm from './AuthForm'; import { IconSize } from '../Icon'; import Alert, { AlertParagraph, AlertType } from '../widgets/Alert'; import { labels } from '../../lib'; +import { useLogContext } from '../../contexts/LogContext'; export interface LoginFormProps { onForgotPassword?: (email: string) => unknown; @@ -34,7 +39,9 @@ export type LoginHintState = [ export type LoginFormParams = Pick< LoginPasswordParameters, 'identifier' | 'password' ->; +> & { + turnstileToken?: string; +}; function LoginForm({ onForgotPassword, @@ -48,6 +55,18 @@ function LoginForm({ autoFocus = true, onSignup, }: LoginFormProps): ReactElement { + const { logEvent } = useLogContext(); + const turnstileRef = useRef(null); + const [turnstileLoaded, setTurnstileLoaded] = useState(false); + const [turnstileError, setTurnstileError] = useState(false); + const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_KEY ?? ''; + + useEffect(() => { + if (hint) { + turnstileRef.current?.reset(); + } + }, [hint]); + const onLogin = async (e: FormEvent) => { e.preventDefault(); @@ -55,7 +74,20 @@ function LoginForm({ return; } + if (turnstileSiteKey && !turnstileRef.current?.getResponse()) { + logEvent({ + event_name: AuthEventNames.LoginError, + extra: JSON.stringify({ + error: 'Turnstile not valid', + origin: Origin.LoginTurnstile, + }), + }); + setTurnstileError(true); + return; + } + const form = formToJson(e.currentTarget); + form.turnstileToken = turnstileRef.current?.getResponse() ?? undefined; onPasswordLogin(form); }; const [shouldFocus, setShouldFocus] = useState(autoFocus); @@ -109,6 +141,21 @@ function LoginForm({ saveHintSpace onChange={() => hint && setHint(null)} /> + {turnstileSiteKey && ( + setTurnstileLoaded(true)} + /> + )} + {turnstileError && ( + + )} {onForgotPassword && ( {loginButton} diff --git a/packages/shared/src/hooks/useLogin.ts b/packages/shared/src/hooks/useLogin.ts index f53a4f64ac..10d8ed2288 100644 --- a/packages/shared/src/hooks/useLogin.ts +++ b/packages/shared/src/hooks/useLogin.ts @@ -23,6 +23,7 @@ import type { SignBackProvider } from './auth/useSignBack'; import { useSignBack } from './auth/useSignBack'; import type { LoggedUser } from '../lib/user'; import { labels } from '../lib'; +import { Origin } from '../lib/log'; import { useEventListener } from './useEventListener'; import { broadcastChannel, webappUrl } from '../lib/constants'; import { isIOSNative } from '../lib/func'; @@ -69,6 +70,7 @@ const useLogin = ({ return betterAuthSignIn({ email: form.identifier, password: form.password, + turnstileToken: form.turnstileToken, }); }, onSuccess: async (res) => { @@ -78,7 +80,8 @@ const useLogin = ({ extra: JSON.stringify({ error: res.error, displayedError: labels.auth.error.invalidEmailOrPassword, - origin: 'betterauth email login', + origin: Origin.BetterAuthEmailLogin, + userAgent: navigator.userAgent, }), }); setHint(labels.auth.error.invalidEmailOrPassword); @@ -93,7 +96,7 @@ const useLogin = ({ event_name: AuthEventNames.LoginError, extra: JSON.stringify({ error: 'Missing user after Better Auth email login', - origin: 'betterauth email login boot', + origin: Origin.BetterAuthEmailLoginBoot, }), }); setHint(labels.auth.error.generic); @@ -113,7 +116,7 @@ const useLogin = ({ error, 'Failed to refresh Better Auth login state', ), - origin: 'betterauth email login boot', + origin: Origin.BetterAuthEmailLoginBoot, }), }); setHint(labels.auth.error.generic); @@ -138,7 +141,7 @@ const useLogin = ({ event_name: AuthEventNames.LoginError, extra: JSON.stringify({ error: result.error, - origin: 'betterauth native id token', + origin: Origin.BetterAuthNativeIdToken, }), }); return; @@ -150,7 +153,7 @@ const useLogin = ({ event_name: AuthEventNames.LoginError, extra: JSON.stringify({ error: 'Missing user after Better Auth social login', - origin: 'betterauth native id token boot', + origin: Origin.BetterAuthNativeIdTokenBoot, }), }); displayToast(labels.auth.error.generic); @@ -168,7 +171,7 @@ const useLogin = ({ error, 'Failed to refresh Better Auth social login state', ), - origin: 'betterauth native id token boot', + origin: Origin.BetterAuthNativeIdTokenBoot, }), }); displayToast(labels.auth.error.generic); @@ -187,7 +190,7 @@ const useLogin = ({ event_name: AuthEventNames.LoginError, extra: JSON.stringify({ error: error || 'Failed to get social login URL', - origin: 'betterauth social url', + origin: Origin.BetterAuthSocialUrl, }), }); socialPopup?.close(); @@ -206,7 +209,7 @@ const useLogin = ({ event_name: AuthEventNames.LoginError, extra: JSON.stringify({ error: 'Failed to open social login window', - origin: 'betterauth social popup', + origin: Origin.BetterAuthSocialPopup, }), }); displayToast(labels.auth.error.generic); diff --git a/packages/shared/src/lib/betterAuth.ts b/packages/shared/src/lib/betterAuth.ts index bee2eb80a0..f124a39d00 100644 --- a/packages/shared/src/lib/betterAuth.ts +++ b/packages/shared/src/lib/betterAuth.ts @@ -90,11 +90,22 @@ const betterAuthPost = async >( export const betterAuthSignIn = async ({ email, password, + turnstileToken, }: { email: string; password: string; + turnstileToken?: string; }): Promise => { - return betterAuthPost('sign-in/email', { email, password }, 'Sign in failed'); + const headers: Record = {}; + if (turnstileToken) { + headers['x-captcha-response'] = turnstileToken; + } + return betterAuthPost( + 'sign-in/email', + { email, password }, + 'Sign in failed', + Object.keys(headers).length > 0 ? headers : undefined, + ); }; export const betterAuthSignUp = async ({ diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 40e2609ffd..76927efc85 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -85,6 +85,14 @@ export enum Origin { // Onboarding v2 OnboardingModal = 'onboarding modal', OnboardingFeedEnd = 'onboarding feed end', + // Auth + BetterAuthEmailLogin = 'betterauth email login', + BetterAuthEmailLoginBoot = 'betterauth email login boot', + BetterAuthNativeIdToken = 'betterauth native id token', + BetterAuthNativeIdTokenBoot = 'betterauth native id token boot', + BetterAuthSocialUrl = 'betterauth social url', + BetterAuthSocialPopup = 'betterauth social popup', + LoginTurnstile = 'login turnstile', } export enum LogEvent {