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
8 changes: 5 additions & 3 deletions packages/web/src/components/CodeCaptcha.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ interface CodeCaptchaProps {
className?: string;
difficulty?: DifficultyLevel;
style?: CaptchaStyle;
autoFocus?: boolean;
}

export function CodeCaptcha({
onVerified,
onError,
className = '',
difficulty = 'easy',
style: initialStyle = 'math'
style: initialStyle = 'math',
autoFocus = true
}: CodeCaptchaProps) {
const [currentStyle, setCurrentStyle] = useState<CaptchaStyle>(initialStyle);
const [code, setCode] = useState('');
Expand Down Expand Up @@ -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 });
Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/lib/__tests__/loginFocus.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 24 additions & 0 deletions packages/web/src/lib/loginFocus.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 21 additions & 3 deletions packages/web/src/pages/Signin.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,6 +9,7 @@
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!) {
Expand Down Expand Up @@ -92,6 +93,7 @@
});
const [captchaPayload, setCaptchaPayload] = useState<string | null>(null);
const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState<string | null>(null);
const magicLinkEmailRef = useRef<HTMLInputElement>(null);

const [showPassword, setShowPassword] = useState(false);
const [useMagicLink, setUseMagicLink] = useState(initialMagicLink);
Expand Down Expand Up @@ -225,6 +227,20 @@
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

Check failure on line 241 in packages/web/src/pages/Signin.tsx

View workflow job for this annotation

GitHub Actions / Lint & Type Check

Definition for rule 'react-hooks/exhaustive-deps' was not found
}, [useMagicLink, magicLinkSent]);

// Check if guest access is enabled
const { data: systemSettings } = useQuery(GET_SYSTEM_SETTINGS);
const isGuestEnabled = systemSettings?.systemSettings?.allowAnonymousGuest ?? true;
Expand Down Expand Up @@ -630,13 +646,13 @@
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
ref={magicLinkEmailRef}
type="email"
id="magicLinkEmail"
name="magicLinkEmail"
value={formData.magicLinkEmail}
onChange={handleChange}
autoComplete="email"
autoFocus
className={`w-full pl-10 py-3 bg-gray-700/50 backdrop-blur-sm border rounded-xl text-gray-100 focus:outline-none focus:ring-2 transition-all ${
magicLinkEmailValid === false
? 'pr-10 border-red-500/50 focus:ring-red-500/50'
Expand Down Expand Up @@ -698,11 +714,13 @@
)}
</div>

{/* CAPTCHA for Magic Link */}
{/* CAPTCHA for Magic Link — only auto-focuses when an email is
already entered; otherwise the email field gets focus first. */}
<div>
<CodeCaptcha
onVerified={(code) => setMagicLinkCaptchaPayload(code)}
className="w-full"
autoFocus={hasEnteredEmail(formData.magicLinkEmail)}
/>
</div>

Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/login-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading