/* 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) => `
  • ${esc(c)}
  • `).join(''); $('#recovery-done').dataset.next = result.status; goto('step-recovery'); return; } if (result.status === 'passwordChangeRequired') { if (!flow.includes('step-newpassword')) setFlow([...flow, 'step-newpassword']); goto('step-newpassword'); return; } done(); } /** Leaves the login page entirely; the console loads as a fresh document. */ function done() { window.location.href = '/admin'; } $('#step-password').addEventListener('submit', async (event) => { event.preventDefault(); showError(''); const button = event.target.querySelector('button[type="submit"]'); button.disabled = true; try { const email = $('#login-email').value.trim(); $('#pw-username').value = email; handle(await api('/login', { method: 'POST', body: { email, password: $('#login-password').value } })); } catch (err) { showError(err.message); } finally { button.disabled = false; } }); $('#step-2fa').addEventListener('submit', async (event) => { event.preventDefault(); showError(''); const button = event.target.querySelector('button[type="submit"]'); button.disabled = true; try { const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } }); $('#twofa-code').value = ''; handle(result); } catch (err) { showError(err.message); $('#twofa-code').select(); } finally { button.disabled = false; } }); $('#recovery-copy').addEventListener('click', async () => { try { await navigator.clipboard.writeText(recoveryCodes.join('\n')); $('#recovery-copy').textContent = 'Copied'; setTimeout(() => { $('#recovery-copy').textContent = 'Copy codes'; }, 2500); } catch { showError('Copying was blocked by the browser. Write the codes down instead.'); } }); $('#recovery-done').addEventListener('click', () => { if ($('#recovery-done').dataset.next === 'passwordChangeRequired') goto('step-newpassword'); else done(); }); $('#step-newpassword').addEventListener('submit', async (event) => { event.preventDefault(); showError(''); if ($('#pw-new').value !== $('#pw-again').value) { return showError('The two new passwords do not match.'); } const button = event.target.querySelector('button[type="submit"]'); button.disabled = true; try { await api('/account/password', { method: 'POST', body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value }, }); done(); } catch (err) { showError(err.message); } finally { button.disabled = false; } }); /* ---------------------------------------------------------------- start */ (async function init() { try { const session = await api('/session'); $('#auth-site').textContent = session.siteName || 'Visitor admin'; document.title = `Sign in — ${session.siteName || 'visitor admin'}`; if (session.domainRule) { $('#domain-rule').textContent = `Use your ${session.domainRule} address.`; $('#domain-rule').hidden = false; } if (session.admin && session.mustChangePassword) { $('#pw-username').value = session.user?.email || ''; setFlow(['step-newpassword']); goto('step-newpassword'); return; } if (session.admin) return done(); if (session.setupNeeded) { $('#setup-message').textContent = 'Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first account.'; setFlow(['step-setup']); goto('step-setup'); return; } } catch { /* the server is unreachable; the sign in page still renders */ } goto('step-password'); })();