Skip to content
Merged
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
33 changes: 30 additions & 3 deletions apps/server/src/routes/forgot-password/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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] }),
Expand All @@ -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 }
Expand All @@ -54,7 +63,11 @@ vi.mock('../../components/layout', () => ({

vi.mock('../../components/ui', () => ({
Alert: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Button: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
<button type="button" {...props}>
{children}
</button>
),
Field: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Input: (props: InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
PageHeader: ({ title }: { title: ReactNode }) => <h1>{title}</h1>,
Expand Down Expand Up @@ -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')
})
Expand All @@ -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')
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/routes/forgot-password/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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)
}

Expand All @@ -171,10 +174,10 @@ function RequestStep({ organizationId, onDone }: RequestStepProps): ReactNode {
/>
</Field>

<Button type="submit" fullWidth isLoading={isSubmitting}>
<div ref={containerRef} {...stylex.props(styles.turnstile)} />
<Button type="submit" fullWidth isLoading={isSubmitting} disabled={!turnstileReady}>
<Trans>Send reset link</Trans>
</Button>
<div ref={containerRef} {...stylex.props(styles.turnstile)} />
</div>
</form>
)
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/routes/sign-in/SignInGuestButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ describe('SignInGuestButton', () => {
})
expect(onContinue).toHaveBeenCalledTimes(1)

await act(async () => {
root.render(<SignInGuestButton onContinue={onContinue} isLoading={false} disabled={true} />)
})
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()
})
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/routes/sign-in/SignInGuestButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div {...stylex.props(guestStyles.stack)}>
Expand All @@ -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}
>
Expand Down
22 changes: 20 additions & 2 deletions apps/server/src/routes/sign-in/SignInOtpPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SignInOtpPanel
method={enabledMethods[0] ?? 'otp-email'}
enabledMethods={enabledMethods}
step="input"
identifier=""
identifier={options.identifier ?? ''}
otpCode=""
profileValues={profileValues()}
profileFields={[]}
requiredProfileFields={[]}
isLoading={false}
isTurnstileReady={options.isTurnstileReady ?? true}
onChangeIdentifier={vi.fn()}
onChangeProfileValue={vi.fn()}
onChangeCode={vi.fn()}
Expand Down Expand Up @@ -60,4 +64,18 @@ describe('SignInOtpPanel', () => {
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(/<button[^>]*disabled=""[^>]*>Send code via email<\/button>/)
expect(ready).not.toMatch(/<button[^>]*disabled=""[^>]*>Send code via email<\/button>/)
})
})
6 changes: 4 additions & 2 deletions apps/server/src/routes/sign-in/SignInOtpPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -45,6 +46,7 @@ export function SignInOtpPanel({
profileFields,
requiredProfileFields,
isLoading,
isTurnstileReady,
onChangeIdentifier,
onChangeProfileValue,
onChangeCode,
Expand All @@ -63,7 +65,7 @@ export function SignInOtpPanel({
}, [step])

function handleIdentifierKey(event: KeyboardEvent<HTMLInputElement>): void {
if (event.key === 'Enter' && identifier.trim()) onRequestOtp()
if (event.key === 'Enter' && identifier.trim() && isTurnstileReady) onRequestOtp()
}

function handleCodeKey(event: KeyboardEvent<HTMLInputElement>): void {
Expand Down Expand Up @@ -138,7 +140,7 @@ export function SignInOtpPanel({
<Button
fullWidth
isLoading={isLoading}
disabled={!identifier.trim() || !profileComplete}
disabled={!identifier.trim() || !profileComplete || !isTurnstileReady}
onClick={onRequestOtp}
>
{isEmail ? (
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/routes/sign-in/SignInPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ vi.mock('./useSignIn', () => ({
error: null,
otpStep: 'input',
turnstileToken: null,
turnstileReady: true,
tenantSelection: signInState.tenantSelection,
},
{
Expand Down
24 changes: 18 additions & 6 deletions apps/server/src/routes/sign-in/SignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,13 @@ function SignInPage(): ReactNode {
</Alert>
) : null}

<div ref={containerRef} {...stylex.props(styles.turnstile)} />

<SignInSocialButtons
providers={state.authConfig.socialProviders}
onSelect={actions.handleSocial}
isLoading={state.isLoading}
disabled={!state.turnstileReady}
/>

{showSeparator ? (
Expand Down Expand Up @@ -426,7 +429,7 @@ function SignInPage(): ReactNode {
type="submit"
fullWidth
isLoading={state.isLoading}
disabled={!state.identifier.trim()}
disabled={!state.identifier.trim() || !state.turnstileReady}
>
<Trans>Continue with SSO</Trans>
</Button>
Expand Down Expand Up @@ -461,6 +464,7 @@ function SignInPage(): ReactNode {
<Button
fullWidth
isLoading={state.isLoading}
disabled={!state.turnstileReady}
onClick={actions.triggerPasskeyButton}
aria-label={t`Sign in with passkey`}
>
Expand Down Expand Up @@ -534,7 +538,10 @@ function SignInPage(): ReactNode {
fullWidth
isLoading={state.isLoading}
disabled={
!state.identifier.trim() || !state.password.trim() || !requiredProfileComplete
!state.identifier.trim() ||
!state.password.trim() ||
!requiredProfileComplete ||
!state.turnstileReady
}
>
{isSignUpFlow ? <Trans>Sign up</Trans> : <Trans>Sign in</Trans>}
Expand Down Expand Up @@ -572,7 +579,9 @@ function SignInPage(): ReactNode {
type="submit"
fullWidth
isLoading={state.isLoading}
disabled={!state.identifier.trim() || !requiredProfileComplete}
disabled={
!state.identifier.trim() || !requiredProfileComplete || !state.turnstileReady
}
>
<Trans>Send magic link</Trans>
</Button>
Expand All @@ -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}
Expand All @@ -605,12 +615,14 @@ function SignInPage(): ReactNode {
) : null}

{ambiguousResolution ? null : state.authConfig.guest ? (
<SignInGuestButton onContinue={actions.submitGuest} isLoading={state.isLoading} />
<SignInGuestButton
onContinue={actions.submitGuest}
isLoading={state.isLoading}
disabled={!state.turnstileReady}
/>
) : state.guestEntryPending ? (
<div aria-hidden="true" {...stylex.props(styles.guestEntryPlaceholder)} />
) : null}

<div ref={containerRef} {...stylex.props(styles.turnstile)} />
</div>
</AuthLayout>
)
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/routes/sign-in/SignInSocialButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import { SignInSocialButtons } from './SignInSocialButtons'

vi.mock('@lingui/react/macro', () => ({
Trans: ({ children }: { children: ReactNode }) => <>{children}</>,
}))

const providers = [
{
provider: 'github',
allowLogin: true,
allowUserCreation: true,
requireVerifiedEmail: true,
allowedEmailDomains: [],
blockedEmailDomains: [],
},
]

describe('SignInSocialButtons', () => {
it('disables provider authorization until Turnstile is ready', () => {
const blocked = renderToStaticMarkup(
<SignInSocialButtons
providers={providers}
onSelect={vi.fn()}
isLoading={false}
disabled={true}
/>,
)
const ready = renderToStaticMarkup(
<SignInSocialButtons
providers={providers}
onSelect={vi.fn()}
isLoading={false}
disabled={false}
/>,
)

expect(blocked).toMatch(/<button[^>]*disabled=""[^>]*>.*Continue with GitHub.*<\/button>/)
expect(ready).not.toMatch(/<button[^>]*disabled=""[^>]*>.*Continue with GitHub.*<\/button>/)
})
})
4 changes: 3 additions & 1 deletion apps/server/src/routes/sign-in/SignInSocialButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type SignInSocialButtonsProps = {
providers: readonly PublicSocialProvider[]
onSelect: (provider: string) => void
isLoading: boolean
disabled?: boolean
}

function GoogleIcon(): ReactNode {
Expand Down Expand Up @@ -111,6 +112,7 @@ export function SignInSocialButtons({
providers,
onSelect,
isLoading,
disabled = false,
}: SignInSocialButtonsProps): ReactNode {
const visibleProviders = useProviders(providers)

Expand All @@ -123,7 +125,7 @@ export function SignInSocialButtons({
key={p.id}
variant="secondary"
fullWidth
disabled={isLoading}
disabled={isLoading || disabled}
onClick={() => onSelect(p.id)}
{...stylex.props(styles.socialButton)}
>
Expand Down
Loading