diff --git a/packages/web/src/lib/__tests__/loginCode.test.ts b/packages/web/src/lib/__tests__/loginCode.test.ts new file mode 100644 index 00000000..8d699a7c --- /dev/null +++ b/packages/web/src/lib/__tests__/loginCode.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { + LOGIN_CODE_ALPHABET, LOGIN_CODE_LENGTH, + normalizeLoginCode, formatLoginCode, formatLoginCodeInput, isCompleteLoginCode, +} from '../loginCode'; + +describe('login code alphabet', () => { + it('excludes the confusable glyphs O and 0', () => { + expect(LOGIN_CODE_ALPHABET).not.toContain('O'); + expect(LOGIN_CODE_ALPHABET).not.toContain('0'); + }); + it('is uppercase letters and digits only', () => { + expect(LOGIN_CODE_ALPHABET).toMatch(/^[A-Z1-9]+$/); + }); +}); + +describe('normalizeLoginCode', () => { + it('uppercases, strips dashes/spaces, and caps at 12', () => { + expect(normalizeLoginCode('abcd-efgh-jkmn')).toBe('ABCDEFGHJKMN'); + expect(normalizeLoginCode('AB CD ef')).toBe('ABCDEF'); + expect(normalizeLoginCode('ABCDEFGHJKMNPQRS')).toBe('ABCDEFGHJKMN'); // capped at 12 + }); + it('is empty for null/undefined/garbage', () => { + expect(normalizeLoginCode(null)).toBe(''); + expect(normalizeLoginCode(undefined)).toBe(''); + expect(normalizeLoginCode('-- --')).toBe(''); + }); + it('accepts a user typing lowercase without dashes', () => { + expect(normalizeLoginCode('a1b2c3d4e5f6')).toBe('A1B2C3D4E5F6'); + }); +}); + +describe('formatLoginCode / formatLoginCodeInput', () => { + it('groups a full canonical code as XXXX-XXXX-XXXX', () => { + expect(formatLoginCode('ABCDEFGHJKMN')).toBe('ABCD-EFGH-JKMN'); + }); + it('groups partial input progressively while typing', () => { + expect(formatLoginCodeInput('abcde')).toBe('ABCD-E'); + expect(formatLoginCodeInput('abcdefgh')).toBe('ABCD-EFGH'); + expect(formatLoginCodeInput('abcd-efgh-jkmn')).toBe('ABCD-EFGH-JKMN'); + }); +}); + +describe('isCompleteLoginCode', () => { + it('is true only at exactly 12 normalized characters', () => { + expect(isCompleteLoginCode('abcd-efgh-jkmn')).toBe(true); + expect(isCompleteLoginCode('abcd-efgh-jkm')).toBe(false); + expect(isCompleteLoginCode('')).toBe(false); + }); + it('agrees with the declared code length', () => { + expect('ABCDEFGHJKMN'.length).toBe(LOGIN_CODE_LENGTH); + }); +}); diff --git a/packages/web/src/lib/loginCode.ts b/packages/web/src/lib/loginCode.ts new file mode 100644 index 00000000..af1ce5be --- /dev/null +++ b/packages/web/src/lib/loginCode.ts @@ -0,0 +1,39 @@ +/** + * Emailed one-time sign-in CODE (type-in alternative to the magic link). Lets a + * user sign in on a device where they don't want to open their email: read the + * code on their phone, type it here. + * + * Format: 12 characters, shown grouped as XXXX-XXXX-XXXX. Alphabet is uppercase + * letters + digits with the confusable glyphs removed — no letter O and no digit + * 0 (so there's no O/0 ambiguity). Users may type lowercase and may omit the + * dashes; we normalise both before matching. Keep this normalisation byte-for- + * byte identical to the Worker's apps/api/src/loginCode.ts — the server hashes + * the normalised code, so a divergence would reject valid codes. + */ +export const LOGIN_CODE_ALPHABET = 'ABCDEFGHIJKLMNPQRSTUVWXYZ123456789'; // A–Z minus O, 1–9 (no 0) +export const LOGIN_CODE_LENGTH = 12; +export const LOGIN_CODE_GROUP = 4; + +/** Canonical form: uppercase, separators stripped, only A–Z/0–9 kept, max 12. */ +export function normalizeLoginCode(input: string | null | undefined): string { + return (input ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, LOGIN_CODE_LENGTH); +} + +/** Group a canonical code into dashed display form, e.g. ABCD-EFGH-JKMN. */ +export function formatLoginCode(canonical: string): string { + const groups: string[] = []; + for (let i = 0; i < canonical.length; i += LOGIN_CODE_GROUP) { + groups.push(canonical.slice(i, i + LOGIN_CODE_GROUP)); + } + return groups.join('-'); +} + +/** Format raw user input live as they type (normalise then re-insert dashes). */ +export function formatLoginCodeInput(raw: string): string { + return formatLoginCode(normalizeLoginCode(raw)); +} + +/** True once a full 12-character code has been entered. */ +export function isCompleteLoginCode(raw: string): boolean { + return normalizeLoginCode(raw).length === LOGIN_CODE_LENGTH; +} diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index 8c1235d4..0259e9dd 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -10,6 +10,7 @@ import { PasswordRequirements } from '../components/PasswordRequirements'; import { isValidEmail } from '../utils/validation'; import { CodeCaptcha } from '../components/CodeCaptcha'; import { magicLinkFocusTarget, hasEnteredEmail } from '../lib/loginFocus'; +import { formatLoginCodeInput, isCompleteLoginCode } from '../lib/loginCode'; const LOGIN_MUTATION = gql` mutation Login($input: LoginInput!) { @@ -94,6 +95,9 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea const [captchaPayload, setCaptchaPayload] = useState(null); const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState(null); const magicLinkEmailRef = useRef(null); + const [loginCodeInput, setLoginCodeInput] = useState(''); + const [codeVerifying, setCodeVerifying] = useState(false); + const [codeError, setCodeError] = useState(null); const [showPassword, setShowPassword] = useState(false); const [useMagicLink, setUseMagicLink] = useState(initialMagicLink); @@ -441,6 +445,34 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea await handleMagicLinkRequest({ preventDefault: () => {} } as React.FormEvent); }; + // Verify the type-in code from the email (alternative to clicking the link, so + // a user on a shared device never has to open their inbox there). On success the + // Worker returns a session token; persist it and reload into the app. + const handleVerifyCode = async () => { + if (!isCompleteLoginCode(loginCodeInput)) return; + setCodeVerifying(true); + setCodeError(null); + try { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127'; + const response = await fetch(`${apiUrl}/auth/code/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: formData.magicLinkEmail, code: loginCodeInput }), + }); + const data = await response.json().catch(() => ({})); + if (response.ok && data.token) { + setToken(data.token, true); + window.location.assign('/'); + } else { + setCodeError(data.message || 'Incorrect code. Please check and try again.'); + } + } catch { + setCodeError('Could not verify the code. Please try again.'); + } finally { + setCodeVerifying(false); + } + }; + return (
@@ -613,6 +645,48 @@ export function Signin({ initialMagicLink = false }: { initialMagicLink?: boolea

+ {/* Type-in code — alternative to clicking the link, so you can + sign in on a device where you don't want to open your email. */} +
+ + { setLoginCodeInput(formatLoginCodeInput(e.target.value)); setCodeError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') handleVerifyCode(); }} + placeholder="XXXX-XXXX-XXXX" + aria-label="Sign-in code from email" + className="w-full px-4 py-3 bg-gray-900/60 border border-teal-500/40 rounded-xl text-gray-100 font-mono text-center text-lg tracking-[0.25em] uppercase focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50" + /> + {codeError && ( +

{codeError}

+ )} + +
+