or
@@ -93,6 +115,7 @@
diff --git a/public/js/auth.js b/public/js/auth.js
index 2467cb1c..0ae1d303 100644
--- a/public/js/auth.js
+++ b/public/js/auth.js
@@ -258,7 +258,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (ssoLabel && data.buttonLabel) ssoLabel.textContent = data.buttonLabel;
if (data.disableLocalAuth) {
// Hide local login form fields, only show SSO
- var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, #show-register, #show-forgot');
+ var localFields = document.querySelectorAll('#login-form .form-group, #btn-local-login, ' +
+ '#btn-login-continue, #login-choice, #login-change-email, #show-register, #show-forgot');
localFields.forEach(function(el) { el.style.display = 'none'; });
if (ssoDivider) ssoDivider.style.display = 'none';
}
@@ -716,7 +717,137 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
+ // ---- EMAIL-FIRST SIGN IN ----
+ // Email, then both ways in offered together. A code needs mail to arrive and
+ // a password does not, so neither is allowed to be the only route: the
+ // password option sits next to the code option rather than behind it.
+ function reveal(id, on) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ // classList, not a string replace: hiding twice used to append 'hidden'
+ // twice, and a non-global replace then stripped only one of them, so the
+ // element stayed hidden forever. Measured on the "use a different email"
+ // link, which never reappeared.
+ if (on) el.classList.remove('hidden'); else el.classList.add('hidden');
+ el.style.display = on ? (id === 'login-choice' ? 'flex' : '') : 'none';
+ }
+
+ function loginStep(step) {
+ reveal('btn-login-continue', step === 'email');
+ reveal('login-choice', step === 'choice');
+ reveal('login-code-group', step === 'code');
+ reveal('login-password-group', step === 'password');
+ reveal('btn-local-login', step === 'code' || step === 'password');
+ reveal('login-change-email', step !== 'email');
+ var email = document.getElementById('login-email');
+ if (email) email.readOnly = step !== 'email';
+ var submit = document.getElementById('btn-local-login');
+ if (submit) submit.textContent = step === 'code' ? 'Sign in with code' : 'Sign In';
+ loginMode = step;
+ }
+ var loginMode = 'email';
+
+ var continueBtn = document.getElementById('btn-login-continue');
+ if (continueBtn) continueBtn.addEventListener('click', function () {
+ var email = (document.getElementById('login-email') || {}).value || '';
+ if (!email.trim() || email.indexOf('@') === -1) { showToast('Enter your email', 'error'); return; }
+ loginStep('choice');
+ });
+
+ var changeEmail = document.getElementById('login-change-email');
+ if (changeEmail) changeEmail.addEventListener('click', function (e) {
+ e.preventDefault();
+ ['login-code', 'login-password', 'login-totp'].forEach(function (id) {
+ var el = document.getElementById(id); if (el) el.value = '';
+ });
+ reveal('totp-group', false);
+ loginStep('email');
+ var email = document.getElementById('login-email');
+ if (email) email.focus();
+ });
+
+ var usePassword = document.getElementById('btn-login-use-password');
+ if (usePassword) usePassword.addEventListener('click', function () {
+ loginStep('password');
+ var pw = document.getElementById('login-password');
+ if (pw) pw.focus();
+ });
+
+ var sendCode = document.getElementById('btn-login-send-code');
+ if (sendCode) sendCode.addEventListener('click', function () {
+ var email = ((document.getElementById('login-email') || {}).value || '').trim();
+ if (!email) { showToast('Enter your email', 'error'); return; }
+ sendCode.disabled = true;
+ fetch('/api/auth/login-code/request', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: email })
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) {
+ // The server answers the same way whether or not the account exists, so
+ // this says what was done, not what was found.
+ loginStep('code');
+ var hint = document.getElementById('login-code-hint');
+ if (hint) {
+ hint.textContent = data && data.error
+ ? data.error
+ : 'If ' + email + ' has an account, a code is on its way. It expires in 10 minutes. ' +
+ 'No code? Use your password instead.';
+ }
+ var box = document.getElementById('login-code');
+ if (box) box.focus();
+ })
+ .catch(function () { showToast('Connection error', 'error'); })
+ .finally(function () { sendCode.disabled = false; });
+ });
+
// ---- LOGIN FORM SUBMIT ----
+ function submitCode() {
+ var email = document.getElementById('login-email').value.trim();
+ var code = (document.getElementById('login-code') || {}).value || '';
+ var totpEl = document.getElementById('login-totp');
+ var totpCode = totpEl ? totpEl.value.trim() : '';
+ if (!code.trim()) { showToast('Enter the code from your email', 'error'); return false; }
+
+ var body = { email: email, code: code };
+ if (totpCode) body.totpCode = totpCode;
+ var attempt = authState();
+ return fetch('/api/auth/login-code/verify', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+ .then(function (r) { return r.json(); })
+ .then(function (data) { return handleSignIn(data, attempt); })
+ .catch(function (err) {
+ if (boundary.blocked() || err.name === 'AbortError') return;
+ showToast('Connection error', 'error');
+ });
+ }
+
+ // Shared by both routes in, so a code sign-in gets the same 2FA prompt, the
+ // same verification notice and the same welcome as a password sign-in.
+ function handleSignIn(data, attempt) {
+ if (boundary.blocked() && !(data.success && data.token && data.user)) return;
+ if (data.requires2FA) {
+ var grp = document.getElementById('totp-group');
+ if (grp) { grp.style.display = 'block'; grp.className = grp.className.replace('hidden', '').trim(); }
+ showToast('Enter your 2FA code', 'info');
+ return;
+ }
+ if (data.needsVerification) {
+ var resendBox = document.getElementById('resend-verify-box');
+ if (resendBox) resendBox.className = resendBox.className.replace('hidden', '').trim();
+ showToast('Verify your email first. Check inbox.', 'error');
+ return;
+ }
+ if (data.success && data.token && data.user) {
+ return enterApp(data.user, data.token, true, data.sessionId, attempt).then(function (entered) {
+ if (entered) showToast('Welcome, ' + data.user.name + '!', 'success');
+ });
+ }
+ showToast(data.error || 'Sign-in failed', 'error');
+ }
+
function submitLogin() {
var email = document.getElementById('login-email').value.trim();
@@ -725,7 +856,7 @@ document.addEventListener('DOMContentLoaded', function() {
var totpCode = totpEl ? totpEl.value.trim() : '';
if (!email || !password) {
- showToast('Enter email and password', 'error');
+ showToast('Enter your password', 'error');
return false;
}
@@ -792,8 +923,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (loginForm) loginForm.addEventListener('submit', function(e) {
e.preventDefault();
e.stopPropagation();
- authenticate(submitLogin, 'Signing in...');
+ authenticate(loginMode === 'code' ? submitCode : submitLogin, 'Signing in...');
});
+ loginStep('email');
// ---- RESEND VERIFICATION LINK ----
var resendLink = document.getElementById('resend-verify-link');
diff --git a/public/js/authFetch.js b/public/js/authFetch.js
index d944e00e..17792ff5 100644
--- a/public/js/authFetch.js
+++ b/public/js/authFetch.js
@@ -3,8 +3,13 @@ if (!window.__fetchAuthIntercepted) {
window.__fetchAuthIntercepted = true;
var rawFetch = window.fetch.bind(window);
var boundary = window.AccountBoundary;
+ // Callable with no verified owner, because they are how one is obtained.
+ // Anything not on this list is rejected before it is sent, which is right —
+ // but it means a new sign-in endpoint has to be added here or it fails as a
+ // "Connection error" with no request ever leaving the browser.
var authPaths = new Set([
'/api/auth/login', '/api/auth/register', '/api/auth/logout',
+ '/api/auth/login-code/request', '/api/auth/login-code/verify',
'/api/auth/forgot-password', '/api/auth/reset-password',
'/api/auth/verify-email', '/api/auth/resend-verification',
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status'
@@ -39,7 +44,10 @@ if (!window.__fetchAuthIntercepted) {
if (!ticket && !authPaths.has(url.pathname) && url.pathname !== '/api/auth/me' && url.pathname !== '/api/models') {
return Promise.reject(boundary.error());
}
- var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register')
+ // Signing in with a code establishes a session exactly as a password does,
+ // so the boundary has to be prepared for a new owner the same way.
+ var login = (url.pathname === '/api/auth/login' || url.pathname === '/api/auth/register'
+ || url.pathname === '/api/auth/login-code/verify')
&& String((init && init.method) || (input && input.method) || 'GET').toUpperCase() === 'POST';
try { if (login) boundary.startLogin(); } catch (e) { return Promise.reject(e); }
var revision = boundary.revision();
diff --git a/server.js b/server.js
index 509a8e69..2ebf4d30 100644
--- a/server.js
+++ b/server.js
@@ -103,6 +103,23 @@ app.use('/api/auth/login', rateLimit({
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
standardHeaders: true, legacyHeaders: false
}));
+// Asking for a code sends mail to someone else's address, so it is limited more
+// tightly than a login attempt: the cost of abuse lands on the mailbox owner.
+// Note these are separate limiters rather than covered by the one above —
+// Express matches app.use paths on segment boundaries, so '/api/auth/login'
+// does not match '/api/auth/login-code'.
+app.use('/api/auth/login-code/request', rateLimit({
+ windowMs: 60 * 60 * 1000,
+ max: parseInt(process.env.LOGIN_CODE_RATE_LIMIT_MAX || '5', 10),
+ message: { error: 'Too many code requests. Try again later, or sign in with your password.' },
+ standardHeaders: true, legacyHeaders: false
+}));
+app.use('/api/auth/login-code/verify', rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: parseInt(process.env.LOGIN_RATE_LIMIT_MAX || '10', 10),
+ message: { error: 'Too many attempts. Try again in 15 minutes.' },
+ standardHeaders: true, legacyHeaders: false
+}));
app.use('/api/auth/register', rateLimit({
windowMs: 60 * 60 * 1000, max: 5,
message: { error: 'Too many registration attempts.' },
diff --git a/src/routes/auth.js b/src/routes/auth.js
index 0f19c3ae..2e49948b 100644
--- a/src/routes/auth.js
+++ b/src/routes/auth.js
@@ -10,6 +10,7 @@ const crypto = require('crypto');
const db = require('../db/database');
const { JWT_SECRET, authMiddleware } = require('../middleware/auth');
const invites = require('../utils/registrationInvites');
+const loginCodes = require('../utils/loginCodes');
const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions');
const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify');
var logger = require('../utils/logger');
@@ -314,6 +315,101 @@ router.post('/resend-verification', async (req, res) => {
} catch (err) { console.error('[Auth] Resend error:', err.message); res.status(500).json({ error: 'Failed to send email' }); }
});
+// ============================================================
+// SIGN-IN CODE
+// ============================================================
+// Signing in with a code emailed to you, offered alongside the password rather
+// than instead of it: the code depends on mail being delivered and the password
+// does not, so neither can be the only way in.
+//
+// Requesting one answers identically whether or not the address exists. A
+// sign-in screen that says "no such account" is a way of finding out who has
+// one, and that is worth more to an attacker than the code is.
+router.post('/login-code/request', requireLocalAuth, async (req, res) => {
+ var email = String((req.body && req.body.email) || '').toLowerCase().trim();
+ // Said the same way on every path below, including the ones that do nothing.
+ var GENERIC = { success: true, message: 'If that account exists, a code is on its way.' };
+ try {
+ if (!email || email.length > 320) return res.json(GENERIC);
+
+ var user = await db.get('SELECT id, email, name, disabled FROM users WHERE email = ?', [email]);
+ if (!user || user.disabled) {
+ console.warn('[Auth] login-code: no deliverable account (ip=' + req.ip + ')');
+ return res.json(GENERIC);
+ }
+
+ var code = await loginCodes.issue(db, user.id);
+ loginCodes.sweep(db);
+ // Fire and forget: whether the mail went is not something the response may
+ // reveal, and the password is still there if it did not.
+ sendEmail(user.email, 'Your sign-in code', loginCodes.emailBody(code, 'PedAI'))
+ .catch(function (err) { console.warn('[Auth] login-code send failed:', err.message); });
+
+ await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
+ [user.id, 'login_code_requested', req.ip]).catch(function () {});
+ res.json(GENERIC);
+ } catch (err) {
+ console.error('[Auth] login-code request error:', err.message);
+ res.json(GENERIC);
+ }
+});
+
+router.post('/login-code/verify', requireLocalAuth, async (req, res) => {
+ try {
+ var email = String((req.body && req.body.email) || '').toLowerCase().trim();
+ var code = String((req.body && req.body.code) || '');
+ var totpCode = (req.body && req.body.totpCode) || '';
+ // One message for every failure. Which of them it was is not the caller's
+ // business, and saying would turn this into an account oracle.
+ var REFUSED = { error: 'That code is not valid. Request a new one, or sign in with your password.' };
+ if (!email || !code) return res.status(400).json(REFUSED);
+
+ var user = await db.get('SELECT * FROM users WHERE email = ?', [email]);
+ if (!user || user.disabled) {
+ console.warn('[Auth] login-code verify: no deliverable account (ip=' + req.ip + ')');
+ return res.status(401).json(REFUSED);
+ }
+
+ var ok = await loginCodes.consume(db, user.id, code);
+ if (!ok) {
+ logger.access(user.id, 'login_code', req, false);
+ return res.status(401).json(REFUSED);
+ }
+
+ // A code proves you can read the mailbox, which is one factor. An account
+ // that asked for a second still wants it.
+ if (user.totp_enabled) {
+ if (!totpCode) return res.json({ requires2FA: true });
+ var totpInput = String(totpCode).trim();
+ var verified = speakeasy.totp.verify({ secret: user.totp_secret, encoding: 'base32', token: totpInput, window: 1 });
+ if (!verified) {
+ var consumed = await tryConsumeBackupCode(user.id, totpInput);
+ if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' });
+ }
+ }
+
+ var token = signAuthToken(user.id, req);
+ await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)',
+ [user.id, 'login_code', req.ip]);
+ logger.access(user.id, 'login_code', req, true);
+
+ var sessionId = generateSessionId();
+ await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
+ [sessionId, user.id, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
+ notifyNewLogin(user.id, parseUserAgent(req.headers['user-agent']), req.ip);
+
+ setAuthCookie(res, token);
+ res.json({
+ success: true, token: token, sessionId: sessionId,
+ user: { id: user.id, email: user.email, name: user.name, role: user.role,
+ totp_enabled: user.totp_enabled, email_verified: user.email_verified }
+ });
+ } catch (err) {
+ console.error('[Auth] login-code verify error:', err.message);
+ res.status(500).json({ error: 'Sign-in failed' });
+ }
+});
+
// ============================================================
// LOGIN (checks disabled status)
// ============================================================
diff --git a/src/utils/loginCodes.js b/src/utils/loginCodes.js
new file mode 100644
index 00000000..02b11572
--- /dev/null
+++ b/src/utils/loginCodes.js
@@ -0,0 +1,110 @@
+// ============================================================
+// SIGN-IN CODES
+// ============================================================
+// A six-digit code emailed to you, as an alternative to typing a password —
+// not a replacement for one. Both are always offered, because the code depends
+// on mail being delivered and the password does not.
+//
+// The rules that make this safe rather than a second, weaker front door:
+//
+// - Only a hash is stored, so a code read out of the database is not a
+// working credential.
+// - Ten minutes, single use, and requesting a new one invalidates the old.
+// - Five wrong guesses burns the code. Six digits is a million possibilities,
+// which is plenty against a person and nothing against a script with
+// unlimited tries at one code.
+// - Requesting a code says the same thing whether or not the address exists.
+// A sign-in screen that answers "no such account" is an oracle for finding
+// out who has one.
+// - Two-factor still applies. A code proves you can read the mailbox, which
+// is one factor, and an account that asked for a second still wants it.
+
+var crypto = require('crypto');
+var bcrypt = require('bcryptjs');
+
+var TTL_MINUTES = 10;
+var MAX_ATTEMPTS = 5;
+var LENGTH = 6;
+
+// Uniform across the full range, unlike % on a random byte, which would make
+// low digits slightly likelier.
+function generate() {
+ var max = Math.pow(10, LENGTH);
+ var value;
+ do {
+ value = crypto.randomBytes(4).readUInt32BE(0);
+ } while (value >= Math.floor(0xFFFFFFFF / max) * max);
+ return String(value % max).padStart(LENGTH, '0');
+}
+
+function normalise(input) {
+ return String(input || '').replace(/\D/g, '').slice(0, LENGTH);
+}
+
+async function issue(db, userId) {
+ var code = generate();
+ // One live code per account: asking for a new one must not leave the old one
+ // working, or a code forwarded to the wrong place stays usable.
+ await db.run('DELETE FROM login_codes WHERE user_id = ?', [userId]);
+ await db.run(
+ 'INSERT INTO login_codes (user_id, code_hash, expires_at) VALUES (?, ?, NOW() + INTERVAL \'' +
+ TTL_MINUTES + ' minutes\')',
+ [userId, await bcrypt.hash(code, 10)]
+ );
+ return code;
+}
+
+/**
+ * Returns true only for a live, unused, correctly-guessed code.
+ *
+ * Every failure path looks the same to the caller. Telling someone that a code
+ * was right but expired, or that there was no code at all, is information they
+ * did not have.
+ */
+async function consume(db, userId, input) {
+ var code = normalise(input);
+ if (code.length !== LENGTH) return false;
+
+ var row = await db.get(
+ 'SELECT id, code_hash, attempts FROM login_codes ' +
+ 'WHERE user_id = ? AND used_at IS NULL AND expires_at > NOW() ' +
+ 'ORDER BY created_at DESC LIMIT 1',
+ [userId]
+ );
+ if (!row) return false;
+ if (row.attempts >= MAX_ATTEMPTS) {
+ await db.run('DELETE FROM login_codes WHERE id = ?', [row.id]);
+ return false;
+ }
+
+ var ok = await bcrypt.compare(code, row.code_hash);
+ if (!ok) {
+ await db.run('UPDATE login_codes SET attempts = attempts + 1 WHERE id = ?', [row.id]);
+ return false;
+ }
+ // Marked used before the caller issues a session, so a replay cannot race it.
+ var claimed = await db.get(
+ 'UPDATE login_codes SET used_at = NOW() WHERE id = ? AND used_at IS NULL RETURNING id',
+ [row.id]
+ );
+ return Boolean(claimed);
+}
+
+// Expired and spent codes are of no use to anyone; this keeps the table from
+// growing without bound. Safe to call and ignore.
+async function sweep(db) {
+ try {
+ await db.run("DELETE FROM login_codes WHERE expires_at < NOW() - INTERVAL '1 day'");
+ } catch (e) { /* housekeeping never fails a request */ }
+}
+
+function emailBody(code, appName) {
+ return '
Your sign-in code for ' + (appName || 'PedAI') + ' is:
' +
+ '
' + code + '
' +
+ '
It expires in ' + TTL_MINUTES + ' minutes and can be used once.
' +
+ '
If you did not ask to sign in, ignore this message ' +
+ 'and nothing will happen. Your password still works as usual.
';
+}
+
+module.exports = { issue, consume, sweep, generate, normalise, emailBody,
+ TTL_MINUTES, MAX_ATTEMPTS, LENGTH };
diff --git a/test/login-codes.test.js b/test/login-codes.test.js
new file mode 100644
index 00000000..88da1371
--- /dev/null
+++ b/test/login-codes.test.js
@@ -0,0 +1,96 @@
+// ============================================================
+// SIGN-IN CODES
+// ============================================================
+// A code emailed to you, offered beside the password rather than instead of it.
+// The rules here are what keep it from being a second, weaker front door.
+
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+const lc = require('../src/utils/loginCodes');
+const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
+
+test('codes are six digits and uniformly drawn', () => {
+ const seen = new Set();
+ for (let i = 0; i < 3000; i++) {
+ const code = lc.generate();
+ assert.match(code, /^\d{6}$/);
+ seen.add(code);
+ }
+ // Rejection sampling, not modulo on a random byte, which would make low
+ // digits likelier.
+ assert.match(read('src/utils/loginCodes.js'), /while \(value >= Math\.floor\(0xFFFFFFFF \/ max\) \* max\)/);
+ assert.ok(seen.size > 2800, 'not repeating itself');
+ assert.equal(lc.normalise(' 12 34-56 '), '123456');
+});
+
+test('a code is stored as a hash, used once, and superseded by the next', () => {
+ const src = read('src/utils/loginCodes.js');
+ // A code read out of the database must not be a working credential.
+ assert.match(src, /bcrypt\.hash\(code, 10\)/);
+ // The value bound to code_hash is the hash, never the code itself.
+ assert.match(src, /\[userId, await bcrypt\.hash\(code, 10\)\]/);
+ assert.doesNotMatch(src, /\[userId, code\]/, 'the plaintext is never inserted');
+ // Asking for a new one must not leave the old one working.
+ assert.match(src, /DELETE FROM login_codes WHERE user_id = \?/);
+ // Marked used before a session is issued, so a replay cannot race it.
+ assert.match(src, /UPDATE login_codes SET used_at = NOW\(\) WHERE id = \? AND used_at IS NULL RETURNING id/);
+ assert.match(src, /expires_at > NOW\(\)/);
+ assert.match(src, /row\.attempts >= MAX_ATTEMPTS/);
+ assert.equal(lc.MAX_ATTEMPTS, 5);
+ assert.equal(lc.TTL_MINUTES, 10);
+});
+
+test('neither endpoint says whether an account exists', () => {
+ const route = read('src/routes/auth.js');
+ // A sign-in screen that answers "no such account" is a way of finding out who
+ // has one, which is worth more to an attacker than the code is.
+ assert.match(route, /var GENERIC = \{ success: true, message: 'If that account exists, a code is on its way\.' \};/);
+ assert.match(route, /if \(!user \|\| user\.disabled\) \{\s*\n\s*console\.warn\('\[Auth\] login-code: no deliverable account/);
+ // One refusal for every failure on verify, too.
+ assert.match(route, /var REFUSED = \{ error: 'That code is not valid\./);
+ // A code proves you can read the mailbox. An account that asked for a second
+ // factor still wants it.
+ assert.match(route, /if \(user\.totp_enabled\) \{\s*\n\s*if \(!totpCode\) return res\.json\(\{ requires2FA: true \}\);/);
+});
+
+test('the new endpoints are rate limited, and reachable while signed out', () => {
+ const server = read('server.js');
+ // Express matches app.use paths on segment boundaries, so the /api/auth/login
+ // limiter does not cover /api/auth/login-code — verified against a real
+ // router, not assumed.
+ assert.match(server, /app\.use\('\/api\/auth\/login-code\/request', rateLimit\(\{/);
+ assert.match(server, /app\.use\('\/api\/auth\/login-code\/verify', rateLimit\(\{/);
+ // Asking for a code sends mail to someone else's address, so it is limited
+ // more tightly than an attempt to sign in.
+ assert.match(server, /LOGIN_CODE_RATE_LIMIT_MAX \|\| '5'/);
+
+ // authFetch rejects any /api path not on its allowlist before it is sent,
+ // which surfaced as "Connection error" with no request leaving the browser.
+ const wrapper = read('public/js/authFetch.js');
+ assert.match(wrapper, /'\/api\/auth\/login-code\/request', '\/api\/auth\/login-code\/verify'/);
+ assert.match(wrapper, /url\.pathname === '\/api\/auth\/login-code\/verify'/, 'and it counts as a login');
+});
+
+test('the screen asks for the email first and always offers both ways in', () => {
+ const html = read('public/index.html');
+ assert.match(html, /id="btn-login-continue"/);
+ assert.match(html, /id="btn-login-send-code"/);
+ assert.match(html, /id="btn-login-use-password"/);
+ assert.match(html, /id="login-change-email"/);
+ // Creating an account stays on the first step.
+ assert.match(html, /id="show-register"/);
+
+ const js = read('public/js/auth.js');
+ // A code needs mail to arrive and a password does not, so the password option
+ // sits beside the code option rather than behind it.
+ assert.match(js, /reveal\('login-choice', step === 'choice'\)/);
+ assert.match(js, /loginMode === 'code' \? submitCode : submitLogin/);
+ // classList, not a string replace: hiding twice appended 'hidden' twice and a
+ // non-global replace stripped only one, so the element stayed hidden forever.
+ assert.match(js, /if \(on\) el\.classList\.remove\('hidden'\); else el\.classList\.add\('hidden'\);/);
+ // One response handler, so a code sign-in gets the same 2FA prompt and the
+ // same welcome as a password one.
+ assert.match(js, /function handleSignIn\(data, attempt\)/);
+});