From f27c431df7d7902d399e8fd9c3cb18756a5b5f49 Mon Sep 17 00:00:00 2001 From: StringKE Date: Fri, 14 Aug 2026 11:33:52 +0400 Subject: [PATCH] fix(auth): gate protected actions on Turnstile Signed-off-by: StringKE --- .../src/routes/forgot-password/index.test.tsx | 33 +++++++- .../src/routes/forgot-password/index.tsx | 7 +- .../routes/sign-in/SignInGuestButton.test.tsx | 11 +++ .../src/routes/sign-in/SignInGuestButton.tsx | 8 +- .../routes/sign-in/SignInOtpPanel.test.tsx | 22 ++++- .../src/routes/sign-in/SignInOtpPanel.tsx | 6 +- .../src/routes/sign-in/SignInPage.test.tsx | 1 + apps/server/src/routes/sign-in/SignInPage.tsx | 24 ++++-- .../sign-in/SignInSocialButtons.test.tsx | 43 ++++++++++ .../routes/sign-in/SignInSocialButtons.tsx | 4 +- .../src/routes/sign-in/useSignIn.test.tsx | 82 ++++++++++++++++++- apps/server/src/routes/sign-in/useSignIn.ts | 32 ++++++-- docs/design/01-authentication.md | 2 + docs/zh-Hans/design/01-authentication.md | 4 +- 14 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/routes/sign-in/SignInSocialButtons.test.tsx diff --git a/apps/server/src/routes/forgot-password/index.test.tsx b/apps/server/src/routes/forgot-password/index.test.tsx index 41913f04..149ece7c 100644 --- a/apps/server/src/routes/forgot-password/index.test.tsx +++ b/apps/server/src/routes/forgot-password/index.test.tsx @@ -3,7 +3,7 @@ import { act } from 'react' import { createRoot } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { InputHTMLAttributes, ReactNode } from 'react' +import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from 'react' const routerState = vi.hoisted(() => ({ navigate: vi.fn(), @@ -17,6 +17,11 @@ const mutationState = vi.hoisted(() => ({ }[], })) +const authConfigState = vi.hoisted(() => ({ + data: undefined as { turnstileSiteKey: string | null } | undefined, + isPending: false, +})) + vi.mock('@lingui/react/macro', () => ({ Trans: ({ children }: { children: ReactNode }) => <>{children}, useLingui: () => ({ t: (strings: TemplateStringsArray) => strings[0] }), @@ -36,7 +41,11 @@ vi.mock('../../lib/router', () => ({ })) vi.mock('@tanstack/react-query', () => ({ - useQuery: () => ({ data: undefined, isPending: false, error: null }), + useQuery: () => ({ + data: authConfigState.data, + isPending: authConfigState.isPending, + error: null, + }), useMutation: (options: (typeof mutationState.captured)[number]) => { mutationState.captured.push(options) return { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false, isSuccess: false } @@ -54,7 +63,11 @@ vi.mock('../../components/layout', () => ({ vi.mock('../../components/ui', () => ({ Alert: ({ children }: { children: ReactNode }) =>
{children}
, - Button: ({ children }: { children: ReactNode }) => , + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), Field: ({ children }: { children: ReactNode }) =>
{children}
, Input: (props: InputHTMLAttributes) => , PageHeader: ({ title }: { title: ReactNode }) =>

{title}

, @@ -126,6 +139,8 @@ describe('ForgotPasswordPage navigation links', () => { mutationState.captured.length = 0 routerState.search = {} routerState.pathname = '/forgot-password' + authConfigState.data = undefined + authConfigState.isPending = false globalThis.sessionStorage.clear() globalThis.history.replaceState({}, '', '/forgot-password') }) @@ -139,6 +154,18 @@ describe('ForgotPasswordPage navigation links', () => { await unmount(container, root) }) + it('disables reset-link delivery until Turnstile is ready', async () => { + authConfigState.data = { turnstileSiteKey: 'site-key' } + + const { container, root } = await renderPage() + + const submit = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('Send reset link'), + ) + expect(submit?.disabled).toBe(true) + await unmount(container, root) + }) + it('keeps organization and locale context when returning to sign in', async () => { routerState.search = { organization_id: 'org-1', locale: 'en' } globalThis.history.replaceState({}, '', '/forgot-password?organization_id=org-1&locale=en') diff --git a/apps/server/src/routes/forgot-password/index.tsx b/apps/server/src/routes/forgot-password/index.tsx index 05c3a572..16c636ac 100644 --- a/apps/server/src/routes/forgot-password/index.tsx +++ b/apps/server/src/routes/forgot-password/index.tsx @@ -114,6 +114,8 @@ function RequestStep({ organizationId, onDone }: RequestStepProps): ReactNode { turnstileToken, setTurnstileToken, ) + const turnstileReady = + !authConfigQuery.isPending && (authConfig.turnstileSiteKey === null || Boolean(turnstileToken)) const requestMutation = useMutation({ mutationFn: (emailValue: string) => @@ -145,6 +147,7 @@ function RequestStep({ organizationId, onDone }: RequestStepProps): ReactNode { setEmailError(t`Enter a valid email address`) return } + if (!turnstileReady) return await requestMutation.mutateAsync(email) } @@ -171,10 +174,10 @@ function RequestStep({ organizationId, onDone }: RequestStepProps): ReactNode { /> - -
) diff --git a/apps/server/src/routes/sign-in/SignInGuestButton.test.tsx b/apps/server/src/routes/sign-in/SignInGuestButton.test.tsx index 9ccf1eed..08370c20 100644 --- a/apps/server/src/routes/sign-in/SignInGuestButton.test.tsx +++ b/apps/server/src/routes/sign-in/SignInGuestButton.test.tsx @@ -51,6 +51,17 @@ describe('SignInGuestButton', () => { }) expect(onContinue).toHaveBeenCalledTimes(1) + await act(async () => { + root.render() + }) + const turnstileBlockedButton = container.querySelector('button') + expect(turnstileBlockedButton?.disabled).toBe(true) + + await act(async () => { + turnstileBlockedButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(onContinue).toHaveBeenCalledTimes(1) + await act(async () => { root.unmount() }) diff --git a/apps/server/src/routes/sign-in/SignInGuestButton.tsx b/apps/server/src/routes/sign-in/SignInGuestButton.tsx index 9889714f..2e38ccfa 100644 --- a/apps/server/src/routes/sign-in/SignInGuestButton.tsx +++ b/apps/server/src/routes/sign-in/SignInGuestButton.tsx @@ -25,9 +25,14 @@ const guestStyles = stylex.create({ export type SignInGuestButtonProps = { onContinue: () => void isLoading: boolean + disabled?: boolean } -export function SignInGuestButton({ onContinue, isLoading }: SignInGuestButtonProps): ReactNode { +export function SignInGuestButton({ + onContinue, + isLoading, + disabled = false, +}: SignInGuestButtonProps): ReactNode { const { t } = useLingui() return (
@@ -40,6 +45,7 @@ export function SignInGuestButton({ onContinue, isLoading }: SignInGuestButtonPr variant="secondary" fullWidth isLoading={isLoading} + disabled={disabled} aria-label={t`Continue as guest`} onClick={onContinue} > diff --git a/apps/server/src/routes/sign-in/SignInOtpPanel.test.tsx b/apps/server/src/routes/sign-in/SignInOtpPanel.test.tsx index b0765843..26ee8038 100644 --- a/apps/server/src/routes/sign-in/SignInOtpPanel.test.tsx +++ b/apps/server/src/routes/sign-in/SignInOtpPanel.test.tsx @@ -20,18 +20,22 @@ function profileValues(): ProfileValues { } } -function renderPanel(enabledMethods: readonly OtpSignInMethod[]): string { +function renderPanel( + enabledMethods: readonly OtpSignInMethod[], + options: { identifier?: string; isTurnstileReady?: boolean } = {}, +): string { return renderToStaticMarkup( { expect(html.indexOf('WhatsApp OTP')).toBeLessThan(html.indexOf('SMS OTP')) expect(html).not.toContain('Email OTP') }) + + it('disables OTP delivery until Turnstile is ready', () => { + const blocked = renderPanel(['otp-email'], { + identifier: 'owner@example.com', + isTurnstileReady: false, + }) + const ready = renderPanel(['otp-email'], { + identifier: 'owner@example.com', + isTurnstileReady: true, + }) + + expect(blocked).toMatch(/]*disabled=""[^>]*>Send code via email<\/button>/) + expect(ready).not.toMatch(/]*disabled=""[^>]*>Send code via email<\/button>/) + }) }) diff --git a/apps/server/src/routes/sign-in/SignInOtpPanel.tsx b/apps/server/src/routes/sign-in/SignInOtpPanel.tsx index d05cff6d..faadff96 100644 --- a/apps/server/src/routes/sign-in/SignInOtpPanel.tsx +++ b/apps/server/src/routes/sign-in/SignInOtpPanel.tsx @@ -27,6 +27,7 @@ export type SignInOtpPanelProps = { profileFields: readonly ProfileFieldKey[] requiredProfileFields: readonly ProfileFieldKey[] isLoading: boolean + isTurnstileReady: boolean onChangeIdentifier: (value: string) => void onChangeProfileValue: (field: ProfileFieldKey, value: string) => void onChangeCode: (value: string) => void @@ -45,6 +46,7 @@ export function SignInOtpPanel({ profileFields, requiredProfileFields, isLoading, + isTurnstileReady, onChangeIdentifier, onChangeProfileValue, onChangeCode, @@ -63,7 +65,7 @@ export function SignInOtpPanel({ }, [step]) function handleIdentifierKey(event: KeyboardEvent): void { - if (event.key === 'Enter' && identifier.trim()) onRequestOtp() + if (event.key === 'Enter' && identifier.trim() && isTurnstileReady) onRequestOtp() } function handleCodeKey(event: KeyboardEvent): void { @@ -138,7 +140,7 @@ export function SignInOtpPanel({ @@ -461,6 +464,7 @@ function SignInPage(): ReactNode { @@ -592,6 +601,7 @@ function SignInPage(): ReactNode { profileFields={profileFields} requiredProfileFields={requiredFields} isLoading={state.isLoading} + isTurnstileReady={state.turnstileReady} onChangeIdentifier={actions.setIdentifier} onChangeProfileValue={actions.setProfileValue} onChangeCode={actions.setOtpCode} @@ -605,12 +615,14 @@ function SignInPage(): ReactNode { ) : null} {ambiguousResolution ? null : state.authConfig.guest ? ( - + ) : state.guestEntryPending ? (