/* 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.' }
    History tab
    ${esc(s.sheets.logTab)} — last written ${stamp(s.sheets.lastOk)}
    Live tab
    ${esc(s.sheets.onSiteTab)} — ${ s.sheets.onSiteCount === null ? 'not synced yet' : `${s.sheets.onSiteCount} on site` }, last synced ${stamp(s.sheets.lastOnSiteSync)}${ s.sheets.onSiteError ? ` ${esc(s.sheets.onSiteError)}` : '' }

    Certificate

    ${renderTls(s.tls)}`; $('#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-resync').addEventListener('click', async () => { try { const r = await api('/sheets/sync', { method: 'POST' }); toast(r.skipped ? 'Sheet mirroring is off.' : `Live tab rewritten with ${r.rows} on site.`); 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); } }); $('#sheet-resync').addEventListener('click', async () => { try { const r = await api('/sheets/resync', { method: 'POST' }); toast(`Live list rebuilt with ${r.rows} ${r.rows === 1 ? 'person' : 'people'}.`); loadSystem(); } catch (err) { toast(err.message, true); } }); $('#renew-cert')?.addEventListener('click', async () => { try { const r = await api('/tls/renew', { method: 'POST', body: { newCa: false } }); toast( r.info.server ? `Certificate good until ${new Date(r.info.server.validTo).toLocaleDateString('en-AU')}.` : 'Certificate checked.' ); loadSystem(); } catch (err) { toast(err.message, true); } }); $('#new-ca')?.addEventListener('click', async () => { const warning = 'Create a brand new certificate authority?' + '\n\n' + 'Every kiosk device will show a warning until you install the new CA file on it. ' + 'Only do this if the old key may have leaked.'; if (!confirm(warning)) return; try { await api('/tls/renew', { method: 'POST', body: { newCa: true } }); toast('New authority created. Install it on every kiosk device.'); 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); $$('.owner-only').forEach((el) => { el.hidden = me.role !== 'owner'; }); } function renderTls(tls) { if (!tls?.enabled) { return `

    HTTPS is off, so the kiosk camera will only work on localhost. Set HTTPS_ENABLED=true in the environment file and restart.

    `; } if (!tls.server) { return '

    HTTPS is on but no certificate could be read.

    '; } const soon = tls.server.daysLeft < 30; return `
    Server certificate
    Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')} ${tls.server.daysLeft} days
    Valid for
    ${esc(tls.server.names.join(', '))}
    Authority expires
    ${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'} ${tls.ca ? `${tls.ca.daysLeft} days` : ''}
    CA fingerprint
    ${esc(tls.ca?.fingerprint || '—')}

    Install the CA file on each kiosk device once. The server certificate renews itself before it lapses, and devices that trust the authority keep working without being touched again.

    Download the CA certificate
    `; } 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();