/* Visitor sign in — admin console. */ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); let me = null; // the signed in admin let sites = []; let hosts = []; let activeSiteId = 'all'; // which site the console is showing /* ------------------------------------------------------------ plumbing */ 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) { const error = new Error(data.error || `Request failed (${res.status}).`); error.payload = data; error.status = res.status; throw error; } return data; } /** Adds the site the console is currently scoped to. */ function scoped(path) { if (activeSiteId === 'all') return path; return path + (path.includes('?') ? '&' : '?') + `siteId=${activeSiteId}`; } let toastTimer = null; function toast(message, bad = false) { const el = $('#toast'); el.textContent = message; el.className = bad ? 'toast bad' : 'toast'; el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 5500); } const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ); const stamp = (iso) => iso ? new Date(iso).toLocaleString('en-AU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false, }) : '—'; function table(headings, rows, emptyMessage) { if (!rows.length) return `

${esc(emptyMessage)}

`; return `${headings.map((h) => ``).join('')}${rows.join('')}
${esc(h)}
`; } function field(label, name, value = '', type = 'text') { return ``; } function loginError(message) { const el = $('#login-error'); el.textContent = message; el.hidden = !message; } function loginStep(id) { $$('.login-step').forEach((s) => { s.hidden = s.id !== id; }); loginError(''); const input = document.getElementById(id)?.querySelector('input'); if (input) input.focus(); } /* ---------------------------------------------------------- login flow */ $('#step-password').addEventListener('submit', async (event) => { event.preventDefault(); loginError(''); try { const result = await api('/login', { method: 'POST', body: { email: $('#login-email').value, password: $('#login-password').value }, }); handleLoginResult(result); } catch (err) { loginError(err.message); } }); function handleLoginResult(result) { if (result.status === 'twoFactorSetup') { $('#twofa-intro').textContent = 'Two factor is required here. Scan this with an authenticator app (Google Authenticator, Authy, 1Password), then enter the code it shows.'; $('#twofa-setup').hidden = false; $('#twofa-qr').src = result.qr; $('#twofa-secret').textContent = result.secret; $('#twofa-label').textContent = '6 digit code from the app'; loginStep('step-2fa'); return; } if (result.status === 'twoFactorRequired') { $('#twofa-intro').textContent = 'Enter the code from your authenticator app.'; $('#twofa-setup').hidden = true; $('#twofa-label').textContent = '6 digit code, or a recovery code'; loginStep('step-2fa'); return; } if (result.recoveryCodes) { $('#recovery-list').innerHTML = result.recoveryCodes.map((c) => `
  • ${esc(c)}
  • `).join(''); $('#recovery-done').dataset.next = result.status; loginStep('step-recovery'); return; } if (result.status === 'passwordChangeRequired') { loginStep('step-newpassword'); return; } if (result.usedRecoveryCode) { toast(`Recovery code used. ${result.recoveryCodesLeft} left — reset two factor soon.`); } boot(); } $('#step-2fa').addEventListener('submit', async (event) => { event.preventDefault(); loginError(''); try { const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } }); $('#twofa-code').value = ''; handleLoginResult(result); } catch (err) { loginError(err.message); } }); $('#twofa-cancel').addEventListener('click', async () => { await api('/logout', { method: 'POST' }).catch(() => {}); loginStep('step-password'); }); $('#recovery-done').addEventListener('click', () => { if ($('#recovery-done').dataset.next === 'passwordChangeRequired') loginStep('step-newpassword'); else boot(); }); $('#step-newpassword').addEventListener('submit', async (event) => { event.preventDefault(); loginError(''); if ($('#pw-new').value !== $('#pw-again').value) return loginError('The two new passwords differ.'); try { await api('/account/password', { method: 'POST', body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value }, }); toast('Password updated.'); boot(); } catch (err) { loginError(err.message); } }); $('#logout').addEventListener('click', async () => { await api('/logout', { method: 'POST' }); location.reload(); }); /* ---------------------------------------------------------------- tabs */ $$('.tab').forEach((tab) => { tab.addEventListener('click', () => { $$('.tab').forEach((t) => t.classList.toggle('on', t === tab)); $$('.panel').forEach((p) => p.classList.toggle('on', p.id === `panel-${tab.dataset.tab}`)); loadTab(tab.dataset.tab); }); }); function currentTab() { return $('.tab.on')?.dataset.tab || 'onsite'; } function loadTab(name) { const loaders = { onsite: loadOnsite, log: loadLog, recurring: loadRecurring, hosts: loadHosts, sites: loadSites, admins: loadAdmins, system: loadSystem, }; loaders[name]?.().catch((err) => toast(err.message, true)); } $('#site-filter').addEventListener('change', (event) => { activeSiteId = event.target.value; $('#csv-link').href = scoped('/admin/api/visits.csv'); loadAlerts(); loadTab(currentTab()); }); function siteName(id) { return sites.find((s) => s.id === id)?.name || '—'; } /* ------------------------------------------------------------- alerts */ async function loadAlerts() { const data = await api(scoped('/alerts')); const total = data.expired.length + data.expiring.length; const count = $('#alert-count'); count.hidden = total === 0; count.textContent = total; const banner = $('#expiry-banner'); if (!total) { banner.hidden = true; } else { const parts = []; if (data.expired.length) parts.push(`${data.expired.length} expired`); if (data.expiring.length) parts.push(`${data.expiring.length} expiring within ${data.warningDays} days`); banner.hidden = false; banner.className = data.expired.length ? 'banner bad' : 'banner'; banner.textContent = `WWCC / VIT checks need attention: ${parts.join(', ')}.`; } return data; } function expiryCell(expiry, checkExpiry) { if (!checkExpiry) return 'No date'; if (expiry.status === 'expired') { return `Expired ${Math.abs(expiry.daysLeft)}d ago`; } if (expiry.status === 'expiring') { return `${expiry.daysLeft}d left`; } return `${esc(checkExpiry)}`; } /* -------------------------------------------------------------- on site */ async function loadOnsite() { const rows = await api(scoped('/onsite')); $('#onsite-count').textContent = rows.length; const showSite = activeSiteId === 'all' && sites.length > 1; $('#onsite-table').innerHTML = table( ['', 'Visitor', showSite ? 'Site' : 'Visiting', ...(showSite ? ['Visiting'] : []), 'Check', 'Contact', 'Signed in', ''], rows.map( (v) => ` ${v.hasPhoto ? `` : ''} ${esc(v.firstName)} ${esc(v.lastName)} ${v.visitorType === 'frequent' ? 'Recurring' : ''} ${showSite ? `${esc(v.siteName || '—')}` : ''} ${esc(v.hostName)} ${v.checkType === 'NONE' ? 'None' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`} ${esc(v.phone || v.email || '—')} ${stamp(v.signedInAt)} ` ), 'Nobody is signed in right now.' ); $$('[data-signout]').forEach((btn) => btn.addEventListener('click', async () => { await api(`/visits/${btn.dataset.signout}/signout`, { method: 'POST' }); toast('Signed out.'); loadOnsite(); }) ); $$('[data-badge]').forEach((btn) => btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank')) ); } $('#refresh-onsite').addEventListener('click', () => loadOnsite()); /* ------------------------------------------------------------ visit log */ async function loadLog() { const params = new URLSearchParams(); if ($('#log-from').value) params.set('from', $('#log-from').value); if ($('#log-to').value) params.set('to', $('#log-to').value); if ($('#log-q').value.trim()) params.set('q', $('#log-q').value.trim()); const rows = await api(scoped(`/visits?${params}`)); const showSite = activeSiteId === 'all' && sites.length > 1; $('#log-table').innerHTML = table( [...(showSite ? ['Site'] : []), 'Visitor', 'Visiting', 'Check', 'Contact', 'In', 'Out', 'Photo'], rows.map( (v) => ` ${showSite ? `${esc(v.siteName || '—')}` : ''} ${esc(v.firstName)} ${esc(v.lastName)} ${esc(v.hostName)} ${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`} ${esc(v.phone || v.email || '—')} ${stamp(v.signedInAt)} ${ v.signedOutAt ? `${stamp(v.signedOutAt)}${v.signedOutBy && v.signedOutBy !== 'visitor' ? ` ${esc(v.signedOutBy)}` : ''}` : 'On site' } ${v.hasPhoto ? `View` : '—'} ` ), 'No visits match those filters.' ); } $('#log-search').addEventListener('click', () => loadLog()); $('#log-q').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadLog(); }); /* ------------------------------------------------- recurring visitors */ async function loadRecurring() { const [rows, alerts] = await Promise.all([api(scoped('/frequent')), loadAlerts()]); $('#expiry-summary').innerHTML = alerts.expired.length + alerts.expiring.length ? `

    ${alerts.expired.length} expired, ${alerts.expiring.length} expiring within ${alerts.warningDays} days. Ask them for an updated card before their next visit.

    ` : ''; $('#recurring-table').innerHTML = table( ['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''], rows.map( (p) => ` ${esc(p.firstName)} ${esc(p.lastName)} ${p.email ? `${esc(p.email)}` : ''} ${esc(p.phone)} ${p.siteId ? esc(siteName(p.siteId)) : 'Any site'} ${p.checkType === 'NONE' ? 'None' : `${esc(p.checkType)} ${esc(p.checkNumber || '')}`} ${p.checkType === 'NONE' ? '—' : expiryCell(p.expiry, p.checkExpiry)} ${p.active ? 'Active' : 'Inactive'} ` ), 'No recurring visitors yet. Add one so they can sign in with a PIN.' ); $$('[data-pass]').forEach((btn) => btn.addEventListener('click', () => window.open(`/admin/api/pass/${btn.dataset.pass}`, '_blank')) ); $$('[data-pin]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Issue a new PIN? The old one stops working straight away.')) return; const { pin } = await api(`/frequent/${btn.dataset.pin}/pin`, { method: 'POST' }); showPin(pin, btn.dataset.pin); }) ); $$('[data-edit-freq]').forEach((btn) => btn.addEventListener('click', async () => { openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`)); }) ); } function hostOptions(selectedId) { return ( '' + hosts .filter((h) => h.active) .map( (h) => `` ) .join('') ); } function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) { return ( `` + sites .map( (s) => `` ) .join('') ); } function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) { $('#modal-title').textContent = title; $('#modal-body').innerHTML = bodyHtml; $('#modal-save').textContent = saveLabel; $('#modal-cancel').hidden = hideCancel; const modal = $('#modal'); modal.returnValue = ''; modal.showModal(); modal.onclose = () => { $('#modal-cancel').hidden = false; $('#modal-save').textContent = 'Save'; if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form'))); }; } function openRecurringModal(person = null) { const editing = Boolean(person); openModal( editing ? `Edit ${person.firstName} ${person.lastName}` : 'New recurring visitor', ` ${field('First name', 'firstName', person?.firstName)} ${field('Last name', 'lastName', person?.lastName)} ${field('Mobile number (their username)', 'phone', person?.phone, 'tel')} ${field('Email address', 'email', person?.email, 'email')} ${field('Check number', 'checkNumber', person?.checkNumber)} ${field('Check expires', 'checkExpiry', person?.checkExpiry, 'date')} ${field('PIN (leave blank to generate one)', 'pin', '')} ${field('Notes', 'notes', person?.notes)} ${editing ? `` : ''} `, async (form) => { const payload = Object.fromEntries(form.entries()); payload.active = editing ? form.has('active') : true; if (!payload.pin) delete payload.pin; try { const saved = editing ? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload }) : await api('/frequent', { method: 'POST', body: payload }); loadRecurring(); if (editing) toast('Saved.'); else showPin(saved.pin, saved.id); } catch (err) { toast(err.message, true); } } ); } function showPin(pin, id) { openModal( 'PIN issued', `

    ${esc(pin)}

    Print the card now, or write this down. You can reprint it later from the recurring visitors list.

    `, () => window.open(`/admin/api/pass/${id}`, '_blank'), { saveLabel: 'Print card' } ); } $('#new-recurring').addEventListener('click', () => openRecurringModal()); /* --------------------------------------------------------------- hosts */ async function loadHosts() { hosts = await api(scoped('/hosts')); $('#hosts-scope').textContent = activeSiteId === 'all' ? 'Showing every site. Pick a single site above before adding or importing people.' : `Showing ${siteName(Number(activeSiteId))}.`; const showSite = activeSiteId === 'all' && sites.length > 1; $('#hosts-table').innerHTML = table( [...(showSite ? ['Site'] : []), 'Name', 'Area', 'Email', 'Status', ''], hosts.map( (h) => ` ${showSite ? `${esc(siteName(h.site_id))}` : ''} ${esc(h.name)} ${esc(h.area || '—')} ${esc(h.email || '—')} ${h.active ? 'Shown' : 'Hidden'} ` ), 'No one is listed yet. Add people, or import a CSV, so visitors can say who they are seeing.' ); $$('[data-edit-host]').forEach((btn) => btn.addEventListener('click', () => openHostModal(hosts.find((h) => h.id === Number(btn.dataset.editHost))) ) ); $$('[data-toggle-host]').forEach((btn) => btn.addEventListener('click', async () => { const host = hosts.find((h) => h.id === Number(btn.dataset.toggleHost)); await api(`/hosts/${host.id}`, { method: 'PATCH', body: { active: !host.active } }); loadHosts(); }) ); } function openHostModal(host = null) { if (!host && activeSiteId === 'all' && sites.length > 1) { return toast('Choose a single site above first, so the person lands in the right list.', true); } openModal( host ? `Edit ${host.name}` : `Add a person to ${siteName(Number(activeSiteId))}`, `${field('Name', 'name', host?.name)} ${field('Area, team or role', 'area', host?.area)} ${field('Email address', 'email', host?.email, 'email')}`, async (form) => { const payload = Object.fromEntries(form.entries()); try { if (host) await api(`/hosts/${host.id}`, { method: 'PATCH', body: payload }); else await api('/hosts', { method: 'POST', body: { ...payload, siteId: activeSiteId } }); loadHosts(); toast('Saved.'); } catch (err) { toast(err.message, true); } } ); } $('#new-host').addEventListener('click', () => openHostModal()); $('#host-file').addEventListener('change', async (event) => { const file = event.target.files[0]; if (file) $('#host-csv').value = await file.text(); }); $('#do-host-import').addEventListener('click', async () => { const csv = $('#host-csv').value.trim(); if (!csv) return toast('Paste a CSV or choose a file first.', true); if (activeSiteId === 'all' && sites.length > 1) { return toast('Choose a single site above before importing.', true); } try { const result = await api('/hosts/import', { method: 'POST', body: { csv, replace: $('#host-replace').checked, siteId: activeSiteId }, }); toast(`${result.added} added, ${result.updated} updated. ${result.total} people listed.`); $('#host-csv').value = ''; loadHosts(); } catch (err) { toast(err.message, true); } }); /* --------------------------------------------------------------- sites */ async function loadSites() { sites = await api('/sites'); renderSiteFilter(); $('#sites-list').innerHTML = sites .map( (s) => `

    ${esc(s.name)} ${s.active ? '' : 'Inactive'}

    Kiosk address: /?site=${esc(s.slug)}

    Badge printing
    ${ s.badge.enabled ? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}` : 'Off' }
    ${s.badge.note ? `
    Badge note
    ${esc(s.badge.note)}
    ` : ''}
    ` ) .join(''); $$('[data-edit-site]').forEach((btn) => btn.addEventListener('click', () => openSiteModal(sites.find((s) => s.id === Number(btn.dataset.editSite))) ) ); $$('[data-preview-badge]').forEach((btn) => btn.addEventListener('click', () => window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank') ) ); } function openSiteModal(site) { openModal( `Edit ${site.name}`, `${field('Site name', 'name', site.name)} ${field('Kiosk slug', 'slug', site.slug)} ${field('Line printed at the bottom', 'note', site.badge.note)}

    Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother), 101 × 54 mm (Dymo). Preview before you commit a roll to it.

    `, async (form) => { const data = Object.fromEntries(form.entries()); try { await api(`/sites/${site.id}`, { method: 'PATCH', body: { name: data.name, slug: data.slug, active: form.has('active'), badge: { enabled: form.has('badgeEnabled'), widthMm: Number(data.widthMm), heightMm: Number(data.heightMm), showPhoto: form.has('showPhoto'), note: data.note, }, }, }); toast('Site saved.'); loadSites(); } catch (err) { toast(err.message, true); } } ); } $('#new-site').addEventListener('click', () => { openModal('Add a site', field('Site name', 'name', ''), async (form) => { try { await api('/sites', { method: 'POST', body: { name: form.get('name') } }); toast('Site added. Set its badge options next.'); loadSites(); } catch (err) { toast(err.message, true); } }); }); function renderSiteFilter() { const select = $('#site-filter'); const scopedToOne = Boolean(me?.siteId); $('#site-switch').hidden = sites.length < 2 && !scopedToOne; select.innerHTML = (scopedToOne ? '' : '') + sites.map((s) => ``).join(''); if (scopedToOne) activeSiteId = String(me.siteId); select.value = activeSiteId; $('#csv-link').href = scoped('/admin/api/visits.csv'); } /* -------------------------------------------------------------- admins */ async function loadAdmins() { if (me.role !== 'owner') { $('#admins-table').innerHTML = '

    Only an owner account can manage admins.

    '; return; } const rows = await api('/users'); $('#admins-note').textContent = me.domainRule ? `New accounts must use an ${me.domainRule} address.` : 'Any email address can be used for an admin account.'; $('#admins-table').innerHTML = table( ['Email', 'Name', 'Role', 'Site', 'Two factor', 'Last sign in', ''], rows.map( (u) => ` ${esc(u.email)}${u.active ? '' : ' Disabled'} ${esc(u.name || '—')} ${esc(u.role)} ${u.siteId ? esc(siteName(u.siteId)) : 'All sites'} ${u.twoFactorOn ? 'On' : 'Not set up'} ${stamp(u.lastLoginAt)} ` ), 'No admin accounts.' ); $$('[data-edit-user]').forEach((btn) => btn.addEventListener('click', () => { const user = rows.find((u) => u.id === Number(btn.dataset.editUser)); openModal( `Edit ${user.email}`, `${field('Name', 'name', user.name)} `, async (form) => { try { await api(`/users/${user.id}`, { method: 'PATCH', body: { name: form.get('name'), role: form.get('role'), siteId: form.get('siteId') || null, active: form.has('active'), }, }); toast('Saved.'); loadAdmins(); } catch (err) { toast(err.message, true); } } ); }) ); $$('[data-reset-pw]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Reset this password? They will have to set a new one at next sign in.')) return; const { temporaryPassword } = await api(`/users/${btn.dataset.resetPw}/reset-password`, { method: 'POST', }); openModal( 'Temporary password', `

    ${esc(temporaryPassword)}

    Give this to them in person or over the phone. They will be asked to change it as soon as they sign in.

    `, null, { saveLabel: 'Done', hideCancel: true } ); }) ); $$('[data-reset-2fa]').forEach((btn) => btn.addEventListener('click', async () => { if (!confirm('Clear their two factor setup? They will enrol again at next sign in.')) return; await api(`/users/${btn.dataset.reset2fa}/reset-2fa`, { method: 'POST' }); toast('Two factor cleared.'); loadAdmins(); }) ); } $('#new-admin').addEventListener('click', () => { openModal( 'Invite an admin', `${field('Email address', 'email', '', 'email')} ${field('Name', 'name', '')} `, async (form) => { try { const created = await api('/users', { method: 'POST', body: { email: form.get('email'), name: form.get('name'), role: form.get('role'), siteId: form.get('siteId') || null, }, }); loadAdmins(); openModal( 'Account created', `

    Temporary password for ${esc(created.email)}:

    ${esc(created.temporaryPassword)}

    They will set their own password and enrol two factor at first sign in.

    `, null, { saveLabel: 'Done', hideCancel: true } ); } catch (err) { toast(err.message, true); } } ); }); /* -------------------------------------------------------------- system */ async function loadSystem() { const s = await api(scoped('/status')); $('#system-body').innerHTML = `
    Time zone
    ${esc(s.timezone)}
    Sites
    ${s.siteCount}
    Photo required
    ${s.requirePhoto ? 'Yes' : 'No'}
    Photos kept for
    ${s.photoRetentionDays} days
    Expiry warning
    ${s.expiryWarningDays} days before a WWCC or VIT lapses
    Nightly auto sign out
    ${s.autoSignOutTime ? esc(s.autoSignOutTime) : 'Off'}
    Two factor
    ${s.require2fa ? 'Required for every admin' : 'Optional'}
    Admin email domain
    ${s.domainRule ? esc(s.domainRule) : 'Any address'}
    On site now
    ${s.onSite}
    Google Sheet
    ${ s.sheets.enabled ? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}` : 'Turned off in the environment file.' }
    Last sheet write
    ${stamp(s.sheets.lastOk)}
    `; $('#sheet-test').addEventListener('click', async () => { try { const r = await api('/sheets/test', { method: 'POST' }); toast(`Connected to "${r.title}".`); loadSystem(); } catch (err) { toast(err.message, true); } }); $('#sheet-flush').addEventListener('click', async () => { try { const r = await api('/sheets/flush', { method: 'POST' }); toast(`${r.sent} sent, ${r.remaining} still queued.`); loadSystem(); } catch (err) { toast(err.message, true); } }); $('#photo-purge').addEventListener('click', async () => { if (!confirm('Delete photos older than the retention window? This cannot be undone.')) return; const r = await api('/photos/purge', { method: 'POST' }); toast(`${r.purged} photo(s) deleted.`); }); renderAccount(s); } function renderAccount(status) { $('#account-body').innerHTML = `
    Signed in as
    ${esc(me.email)}
    Role
    ${esc(me.role)}${me.siteId ? ` — ${esc(siteName(me.siteId))} only` : ''}
    Two factor
    ${me.twoFactorOn ? 'On' : 'Not set up'}
    ${me.twoFactorOn ? status.require2fa ? '' : '' : ''}
    `; $('#change-password').addEventListener('click', () => { openModal( 'Change your password', `${field('Current password', 'currentPassword', '', 'password')} ${field('New password', 'newPassword', '', 'password')}

    At least 12 characters, with upper and lower case and a number.

    `, async (form) => { try { await api('/account/password', { method: 'POST', body: { currentPassword: form.get('currentPassword'), newPassword: form.get('newPassword'), }, }); toast('Password changed.'); } catch (err) { toast(err.message, true); } } ); }); $('#enable-2fa')?.addEventListener('click', async () => { const { qr, secret } = await api('/account/2fa/start', { method: 'POST' }); openModal( 'Set up two factor', `

    Scan this with your authenticator app, then enter the code it shows.

    Two factor QR code

    Or enter this key by hand: ${esc(secret)}

    ${field('6 digit code', 'code', '')}`, async (form) => { try { const r = await api('/account/2fa/enable', { method: 'POST', body: { code: form.get('code') } }); me.twoFactorOn = true; openModal( 'Recovery codes', `

    Each of these works once if you lose your phone. Save them somewhere safe.

    `, null, { saveLabel: 'Done', hideCancel: true } ); } catch (err) { toast(err.message, true); } }, { saveLabel: 'Turn on' } ); }); $('#disable-2fa')?.addEventListener('click', () => { openModal( 'Turn off two factor', `

    Confirm with your password.

    ${field('Password', 'password', '', 'password')}`, async (form) => { try { await api('/account/2fa/disable', { method: 'POST', body: { password: form.get('password') } }); me.twoFactorOn = false; toast('Two factor turned off.'); loadSystem(); } catch (err) { toast(err.message, true); } }, { saveLabel: 'Turn off' } ); }); } /* ---------------------------------------------------------------- boot */ async function boot() { const session = await api('/session'); if (!session.admin) { $('#login').hidden = false; $('#console').hidden = true; if (session.domainRule) { $('#domain-rule').textContent = `Use your ${session.domainRule} address.`; $('#domain-rule').hidden = false; } if (session.setupNeeded) { $('#setup-message').textContent = 'No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first one.'; loginStep('step-setup'); } else { loginStep('step-password'); } return; } if (session.mustChangePassword) { $('#login').hidden = false; $('#console').hidden = true; loginStep('step-newpassword'); return; } me = { ...session.user, domainRule: session.domainRule }; $('#login').hidden = true; $('#console').hidden = false; $('#site-name').textContent = session.siteName; document.title = `Admin — ${session.siteName}`; $$('.owner-only').forEach((el) => { el.hidden = me.role !== 'owner'; }); sites = await api('/sites'); renderSiteFilter(); hosts = await api(scoped('/hosts')); await loadAlerts().catch(() => {}); loadOnsite(); } boot();