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
53 changes: 53 additions & 0 deletions packages/web/src/lib/__tests__/loginCode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 39 additions & 0 deletions packages/web/src/lib/loginCode.ts
Original file line number Diff line number Diff line change
@@ -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;
}
76 changes: 76 additions & 0 deletions packages/web/src/pages/Signin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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!) {
Expand Down Expand Up @@ -94,6 +95,9 @@
const [captchaPayload, setCaptchaPayload] = useState<string | null>(null);
const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState<string | null>(null);
const magicLinkEmailRef = useRef<HTMLInputElement>(null);
const [loginCodeInput, setLoginCodeInput] = useState('');
const [codeVerifying, setCodeVerifying] = useState(false);
const [codeError, setCodeError] = useState<string | null>(null);

const [showPassword, setShowPassword] = useState(false);
const [useMagicLink, setUseMagicLink] = useState(initialMagicLink);
Expand Down Expand Up @@ -238,7 +242,7 @@
hasEmail: hasEnteredEmail(formData.magicLinkEmail),
});
if (target === 'email') magicLinkEmailRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps

Check failure on line 245 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
Expand Down Expand Up @@ -441,6 +445,34 @@
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 (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 flex items-center justify-center p-4 lg:py-3 relative overflow-hidden">
Expand Down Expand Up @@ -613,6 +645,48 @@
</p>
</div>

{/* 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. */}
<div className="pt-4 border-t border-teal-500/20">
<label htmlFor="loginCode" className="block text-sm font-medium text-teal-200 mb-2 text-center">
Or enter the code from the email
</label>
<input
id="loginCode"
type="text"
inputMode="text"
autoComplete="one-time-code"
autoFocus
value={loginCodeInput}
onChange={(e) => { 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 && (
<p className="mt-2 text-xs text-red-400 text-center" role="alert">{codeError}</p>
)}
<button
type="button"
onClick={handleVerifyCode}
disabled={!isCompleteLoginCode(loginCodeInput) || codeVerifying}
className="mt-3 w-full bg-gradient-to-r from-teal-600 to-cyan-600 hover:from-teal-500 hover:to-cyan-500 border border-teal-400/50 text-white font-semibold py-3 px-6 rounded-xl transition-all duration-200 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 flex items-center justify-center space-x-2"
>
{codeVerifying ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
<span>Verifying…</span>
</>
) : (
<>
<ArrowRight className="h-5 w-5" />
<span>Enter code &amp; sign in</span>
</>
)}
</button>
</div>

<div className="pt-4 border-t border-teal-500/20 space-y-2">
<button
type="button"
Expand All @@ -628,6 +702,8 @@
setMagicLinkSent(false);
setFormData({ ...formData, magicLinkEmail: '' });
setResendCooldown(0);
setLoginCodeInput('');
setCodeError(null);
}}
className="w-full text-sm text-teal-400 hover:text-teal-300"
>
Expand Down
Loading