diff --git a/packages/web/src/components/CodeCaptcha.tsx b/packages/web/src/components/CodeCaptcha.tsx index cde20a1f..d8d83e0c 100644 --- a/packages/web/src/components/CodeCaptcha.tsx +++ b/packages/web/src/components/CodeCaptcha.tsx @@ -10,6 +10,7 @@ interface CodeCaptchaProps { className?: string; difficulty?: DifficultyLevel; style?: CaptchaStyle; + autoFocus?: boolean; } export function CodeCaptcha({ @@ -17,7 +18,8 @@ export function CodeCaptcha({ onError, className = '', difficulty = 'easy', - style: initialStyle = 'math' + style: initialStyle = 'math', + autoFocus = true }: CodeCaptchaProps) { const [currentStyle, setCurrentStyle] = useState(initialStyle); const [code, setCode] = useState(''); @@ -309,8 +311,8 @@ export function CodeCaptcha({ }, [code, currentStyle]); useEffect(() => { - inputRef.current?.focus(); - }, []); + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); useEffect(() => { console.log('CAPTCHA state:', { currentStyle, codeLength, minLength, userInputLength: userInput.length, isDisabled: userInput.length < minLength }); diff --git a/packages/web/src/lib/__tests__/loginFocus.test.ts b/packages/web/src/lib/__tests__/loginFocus.test.ts new file mode 100644 index 00000000..d5b11212 --- /dev/null +++ b/packages/web/src/lib/__tests__/loginFocus.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { magicLinkFocusTarget, hasEnteredEmail } from '../loginFocus'; + +describe('magicLinkFocusTarget', () => { + it('focuses the email field when passwordless is active and no email entered', () => { + expect(magicLinkFocusTarget({ active: true, magicLinkSent: false, hasEmail: false })).toBe('email'); + }); + + it('focuses the captcha when passwordless is active and an email is already present', () => { + expect(magicLinkFocusTarget({ active: true, magicLinkSent: false, hasEmail: true })).toBe('captcha'); + }); + + it('focuses nothing while passwordless mode is inactive (password mode)', () => { + expect(magicLinkFocusTarget({ active: false, magicLinkSent: false, hasEmail: false })).toBe('none'); + expect(magicLinkFocusTarget({ active: false, magicLinkSent: false, hasEmail: true })).toBe('none'); + }); + + it('focuses nothing once the magic link has been sent (success view, no inputs)', () => { + expect(magicLinkFocusTarget({ active: true, magicLinkSent: true, hasEmail: true })).toBe('none'); + }); +}); + +describe('hasEnteredEmail', () => { + it('is false for empty, whitespace, null, or undefined', () => { + expect(hasEnteredEmail('')).toBe(false); + expect(hasEnteredEmail(' ')).toBe(false); + expect(hasEnteredEmail(null)).toBe(false); + expect(hasEnteredEmail(undefined)).toBe(false); + }); + + it('is true once a non-whitespace value is present', () => { + expect(hasEnteredEmail('a')).toBe(true); + expect(hasEnteredEmail(' john@example.com ')).toBe(true); + }); +}); diff --git a/packages/web/src/lib/loginFocus.ts b/packages/web/src/lib/loginFocus.ts new file mode 100644 index 00000000..69493a60 --- /dev/null +++ b/packages/web/src/lib/loginFocus.ts @@ -0,0 +1,24 @@ +/** + * Decide which field should receive focus when the passwordless (magic-link) + * sign-in form becomes active. Pure so it can be unit-tested without the DOM. + * + * Rule (per UX): when entering passwordless mode, focus the EMAIL field first if + * no email has been entered yet; if an email is already present, skip ahead to + * the CAPTCHA. While the form is inactive or the link has already been sent, + * nothing should grab focus. + */ +export type LoginFocusTarget = 'email' | 'captcha' | 'none'; + +export function magicLinkFocusTarget(opts: { + active: boolean; + magicLinkSent: boolean; + hasEmail: boolean; +}): LoginFocusTarget { + if (!opts.active || opts.magicLinkSent) return 'none'; + return opts.hasEmail ? 'captcha' : 'email'; +} + +/** Whether a raw email field value counts as "an email has been entered". */ +export function hasEnteredEmail(value: string | null | undefined): boolean { + return !!value && value.trim().length > 0; +} diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index cc02e6a0..8c1235d4 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useMutation, useQuery, gql } from '@apollo/client'; import { Eye, EyeOff, ArrowRight, Mail, Lock, Users, Github, Zap, Check, CheckCircle, XCircle, AlertTriangle, Shield } from 'lucide-react'; @@ -9,6 +9,7 @@ import { GuestModeDialog } from '../components/GuestModeDialog'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail } from '../utils/validation'; import { CodeCaptcha } from '../components/CodeCaptcha'; +import { magicLinkFocusTarget, hasEnteredEmail } from '../lib/loginFocus'; const LOGIN_MUTATION = gql` mutation Login($input: LoginInput!) { @@ -92,6 +93,7 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea }); const [captchaPayload, setCaptchaPayload] = useState(null); const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState(null); + const magicLinkEmailRef = useRef(null); const [showPassword, setShowPassword] = useState(false); const [useMagicLink, setUseMagicLink] = useState(initialMagicLink); @@ -225,6 +227,20 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea return undefined; }, [lockoutTime]); + // When passwordless mode becomes active, put the cursor where the user will + // type next: the email field if they haven't entered one yet, otherwise the + // captcha (whose own autoFocus is gated to that case). Runs on mode entry and + // when returning from the "link sent" view, not on every keystroke. + useEffect(() => { + const target = magicLinkFocusTarget({ + active: useMagicLink, + magicLinkSent, + hasEmail: hasEnteredEmail(formData.magicLinkEmail), + }); + if (target === 'email') magicLinkEmailRef.current?.focus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [useMagicLink, magicLinkSent]); + // Check if guest access is enabled const { data: systemSettings } = useQuery(GET_SYSTEM_SETTINGS); const isGuestEnabled = systemSettings?.systemSettings?.allowAnonymousGuest ?? true; @@ -630,13 +646,13 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea - {/* CAPTCHA for Magic Link */} + {/* CAPTCHA for Magic Link — only auto-focuses when an email is + already entered; otherwise the email field gets focus first. */}
setMagicLinkCaptchaPayload(code)} className="w-full" + autoFocus={hasEnteredEmail(formData.magicLinkEmail)} />
diff --git a/tests/e2e/login-focus.spec.ts b/tests/e2e/login-focus.spec.ts new file mode 100644 index 00000000..736ee7a9 --- /dev/null +++ b/tests/e2e/login-focus.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test'; +import { getBaseURL } from '../helpers/auth'; + +/** + * Passwordless sign-in focus order (@loginfocus). When the user switches to the + * Passwordless (magic-link) method, the cursor must land in the EMAIL field if + * no email has been entered yet — NOT in the captcha (the captcha used to steal + * focus on mount). If an email is already present, focus jumps ahead to the + * captcha. Reported by a user dogfooding the live login page. + */ + +test.describe('passwordless login focus order @loginfocus', () => { + test.use({ viewport: { width: 1440, height: 900 } }); + test.describe.configure({ timeout: 60_000 }); + + test('empty email → email field is focused first', async ({ page }) => { + await page.goto(`${getBaseURL()}/login`, { waitUntil: 'domcontentloaded' }); + await page.locator('button:has-text("Passwordless")').click(); + await expect(page.locator('#magicLinkEmail')).toBeFocused(); + await expect(page.locator('#captcha-input')).not.toBeFocused(); + }); + + test('email already present → captcha is focused on re-entry', async ({ page }) => { + await page.goto(`${getBaseURL()}/login`, { waitUntil: 'domcontentloaded' }); + await page.locator('button:has-text("Passwordless")').click(); + await page.locator('#magicLinkEmail').fill('dogfood@example.com'); + // Leave and re-enter passwordless mode; the email value persists. + await page.locator('button:has-text("Password")').first().click(); + await page.locator('button:has-text("Passwordless")').click(); + await expect(page.locator('#captcha-input')).toBeFocused(); + }); +});