/* Visitor sign in — admin login page. * * A page of its own, not a panel hidden inside the console. When it finishes it * navigates to /admin, so the console loads fresh with no login markup in it. */ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ); async function api(path, { method = 'GET', body } = {}) { const res = await fetch(`/admin/api${path}`, { method, headers: body ? { 'Content-Type': 'application/json' } : undefined, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`); return data; } function showError(message) { const el = $('#login-error'); el.textContent = message || ''; el.hidden = !message; } /* ------------------------------------------------------------- screens */ const SCREENS = { 'step-password': { title: 'Sign in', subtitle: 'Use the email address your account was set up with.', focus: '#login-email', }, 'step-2fa-verify': { node: 'step-2fa', title: 'Two factor', subtitle: 'Enter the current code from your authenticator app.', focus: '#twofa-code', back: true, }, 'step-2fa-setup': { node: 'step-2fa', title: 'Set up two factor', subtitle: 'Scan this with Google Authenticator, Authy, 1Password or similar, then enter the code it shows.', focus: '#twofa-code', back: true, }, 'step-recovery': { title: 'Recovery codes', subtitle: 'Each works once, if you lose the phone with your authenticator on it. Save them now — they are not shown again.', }, 'step-newpassword': { title: 'Choose a password', subtitle: 'Set one only you know before you continue.', focus: '#pw-current', }, 'step-setup': { title: 'Not set up yet', subtitle: 'No admin account exists on this server.', }, }; let flow = ['step-password']; let currentKey = 'step-password'; let recoveryCodes = []; function setFlow(steps) { flow = steps; } function renderRail(key) { const rail = $('#auth-rail'); const index = flow.indexOf(key); if (flow.length < 2 || index < 0) { rail.hidden = true; return; } rail.hidden = false; rail.innerHTML = flow.map((_, i) => ``).join('') + `Step ${index + 1} of ${flow.length}`; } function goto(key) { const meta = SCREENS[key] || SCREENS['step-password']; const nodeId = meta.node || key; currentKey = key; $('#auth-title').textContent = meta.title; $('#auth-subtitle').textContent = meta.subtitle || ''; $('#auth-subtitle').hidden = !meta.subtitle; $('#auth-back').hidden = !meta.back; renderRail(key); showError(''); $$('.auth-screen').forEach((el) => { el.hidden = el.id !== nodeId; el.classList.remove('entering'); }); const entering = document.getElementById(nodeId); void entering.offsetWidth; // restart the animation if the same node is reused entering.classList.add('entering'); const focusTarget = meta.focus ? $(meta.focus) : entering.querySelector('input'); if (focusTarget) setTimeout(() => focusTarget.focus(), 50); } $('#auth-back').addEventListener('click', async () => { if (!SCREENS[currentKey]?.back) return; await api('/logout', { method: 'POST' }).catch(() => {}); $('#twofa-code').value = ''; $('#login-password').value = ''; setFlow(['step-password']); goto('step-password'); }); /* ---------------------------------------------------------- the journey */ function handle(result) { if (result.status === 'twoFactorSetup') { $('#twofa-setup').hidden = false; $('#twofa-qr').src = result.qr; $('#twofa-secret').textContent = result.secret; $('#twofa-label').textContent = '6 digit code from the app'; // Declared in full now, so the step counter never changes its total midway. setFlow([ 'step-password', 'step-2fa-setup', 'step-recovery', ...(result.passwordChangeToFollow ? ['step-newpassword'] : []), ]); goto('step-2fa-setup'); return; } if (result.status === 'twoFactorRequired') { $('#twofa-setup').hidden = true; $('#twofa-label').textContent = '6 digit code, or a recovery code'; setFlow([ 'step-password', 'step-2fa-verify', ...(result.passwordChangeToFollow ? ['step-newpassword'] : []), ]); goto('step-2fa-verify'); return; } if (result.recoveryCodes) { recoveryCodes = result.recoveryCodes; $('#recovery-list').innerHTML = recoveryCodes.map((c) => `