Files
visitor-signin/public/js/admin.js
T

1392 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) {
// The session lapsed or was signed out in another tab.
if (res.status === 401 || data.mustChangePassword) {
toLogin();
}
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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 `<p class="empty">${esc(emptyMessage)}</p>`;
return `<table>
<thead><tr>${headings.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead>
<tbody>${rows.join('')}</tbody>
</table>`;
}
function field(label, name, value = '', type = 'text') {
return `<label class="modal-field"><span>${esc(label)}</span>
<input name="${name}" type="${type}" value="${esc(value)}"></label>`;
}
/* ------------------------------------------------------------ session */
// Signing in happens on /admin/login, a page of its own. If the session is gone
// or expires mid-use, go back there rather than trying to render a form here.
function toLogin() {
window.location.href = '/admin/login';
}
$('#logout').addEventListener('click', async () => {
await api('/logout', { method: 'POST' }).catch(() => {});
toLogin();
});
/* ---------------------------------------------------------------- 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 '<span class="pill off">No date</span>';
if (expiry.status === 'expired') {
return `<span class="pill bad">Expired ${Math.abs(expiry.daysLeft)}d ago</span>`;
}
if (expiry.status === 'expiring') {
return `<span class="pill warn">${expiry.daysLeft}d left</span>`;
}
return `<span class="mono">${esc(checkExpiry)}</span>`;
}
/* -------------------------------------------------------------- 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) => `<tr>
<td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td>
<td><strong>${esc(v.firstName)} ${esc(v.lastName)}</strong>
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
<td>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
<td>${esc(v.phone || v.email || '—')}</td>
<td class="mono">${stamp(v.signedInAt)}</td>
<td class="actions">
<button class="ghost" data-badge="${v.id}">Badge</button>
<button class="ghost" data-signout="${v.id}">Sign out</button>
</td>
</tr>`
),
'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) => `<tr>
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
<td>${esc(v.firstName)} ${esc(v.lastName)}
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
<td>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
<td>${esc(v.phone || v.email || '—')}</td>
<td class="mono">${stamp(v.signedInAt)}</td>
<td class="mono">${
v.signedOutAt
? `${stamp(v.signedOutAt)}${v.signedOutBy && v.signedOutBy !== 'visitor' ? ` <span class="pill out">${esc(v.signedOutBy)}</span>` : ''}`
: '<span class="pill">On site</span>'
}</td>
<td>${v.hasPhoto ? `<a class="ghost" href="/admin/api/photo/${v.id}" target="_blank" rel="noopener">View</a>` : '—'}</td>
</tr>`
),
'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
? `<p class="notice">${alerts.expired.length} expired, ${alerts.expiring.length} expiring within
${alerts.warningDays} days. Ask them for an updated card before their next visit.</p>`
: '';
$('#recurring-table').innerHTML = table(
['', 'Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
rows.map(
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
${p.company ? `<small>${esc(p.company)}</small>` : ''}
${p.email ? `<small>${esc(p.email)}</small>` : ''}</td>
<td class="mono">${esc(p.phone)}</td>
<td>${p.siteId ? esc(siteName(p.siteId)) : '<span class="pill off">Any site</span>'}</td>
<td>${p.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(p.checkType)} ${esc(p.checkNumber || '')}`}</td>
<td>${p.checkType === 'NONE' ? '—' : expiryCell(p.expiry, p.checkExpiry)}</td>
<td>${p.active ? '<span class="pill">Active</span>' : '<span class="pill off">Inactive</span>'}</td>
<td class="actions">
<button class="ghost" data-pass="${p.id}">Print card</button>
<button class="ghost" data-pin="${p.id}">New PIN</button>
<button class="ghost" data-edit-freq="${p.id}">Edit</button>
<button class="ghost danger" data-remove-freq="${p.id}">Remove</button>
</td>
</tr>`
),
'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}`));
})
);
$$('[data-remove-freq]').forEach((btn) =>
btn.addEventListener('click', async () => {
try {
confirmRemoveFrequent(await api(`/frequent/${btn.dataset.removeFreq}`));
} catch (err) {
toast(err.message, true);
}
})
);
}
function hostOptions(selectedId) {
return (
'<option value="">No default</option>' +
hosts
.filter((h) => h.active)
.map(
(h) =>
`<option value="${h.id}" ${Number(selectedId) === h.id ? 'selected' : ''}>${esc(h.name)}</option>`
)
.join('')
);
}
function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
return (
`<option value="">${esc(anyLabel)}</option>` +
sites
.map(
(s) =>
`<option value="${s.id}" ${Number(selectedId) === s.id ? 'selected' : ''}>${esc(s.name)}</option>`
)
.join('')
);
}
function openModal(
title,
bodyHtml,
onSave,
{ saveLabel = 'Save', hideCancel = false, destructive = false, onOpen, onClose } = {}
) {
$('#modal-title').textContent = title;
$('#modal-body').innerHTML = bodyHtml;
$('#modal-save').textContent = saveLabel;
$('#modal-cancel').hidden = hideCancel;
const modal = $('#modal');
modal.classList.toggle('destructive', destructive);
modal.returnValue = '';
modal.showModal();
onOpen?.();
modal.onclose = () => {
$('#modal-cancel').hidden = false;
$('#modal-save').textContent = 'Save';
modal.classList.remove('destructive');
onClose?.();
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
};
}
/* --------------------------------------------------- visitor photo editor */
// A recurring visitor can have a photo kept on file, so the kiosk never asks them
// to pose again. It can come from this machine's camera or from a file.
const photoEditor = { dataUrl: null, remove: false, stream: null };
function photoEditorMarkup(person) {
const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null;
return `
<div class="photo-editor">
<div class="photo-frame">
<img id="photo-preview" alt="Photo on file"${existing ? ` src="${existing}"` : ' hidden'}>
<video id="photo-video" playsinline muted autoplay hidden></video>
<p class="photo-empty" id="photo-empty"${existing ? ' hidden' : ''}>No photo on file</p>
</div>
<div class="photo-actions">
<button type="button" class="ghost" id="photo-camera">Use the camera</button>
<button type="button" class="ghost" id="photo-shoot" hidden>Take it</button>
<label class="ghost photo-upload">Upload a file
<input type="file" id="photo-file" accept="image/*" hidden>
</label>
<button type="button" class="ghost danger" id="photo-clear"${existing ? '' : ' hidden'}>Remove</button>
</div>
<p class="hint">With a photo saved here, this visitor signs in with their PIN and their
pass prints straight away — the kiosk does not ask them to pose.</p>
</div>`;
}
function wirePhotoEditor() {
photoEditor.dataUrl = null;
photoEditor.remove = false;
const preview = $('#photo-preview');
const video = $('#photo-video');
const empty = $('#photo-empty');
const showImage = (src) => {
preview.src = src;
preview.hidden = false;
video.hidden = true;
empty.hidden = true;
$('#photo-shoot').hidden = true;
$('#photo-camera').textContent = 'Retake';
$('#photo-clear').hidden = false;
};
$('#photo-camera').addEventListener('click', async () => {
try {
photoEditor.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 } },
audio: false,
});
video.srcObject = photoEditor.stream;
video.hidden = false;
preview.hidden = true;
empty.hidden = true;
$('#photo-shoot').hidden = false;
} catch {
toast('No camera available on this machine. Upload a file instead.', true);
}
});
$('#photo-shoot').addEventListener('click', () => {
// Square, cropped from the centre, to match the kiosk camera and the badge.
const side = Math.min(video.videoWidth, video.videoHeight);
if (!side) return toast('The camera is not ready yet. Try again in a moment.', true);
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 640;
canvas
.getContext('2d')
.drawImage(video, (video.videoWidth - side) / 2, (video.videoHeight - side) / 2, side, side, 0, 0, 640, 640);
photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72);
photoEditor.remove = false;
stopPhotoCamera();
showImage(photoEditor.dataUrl);
});
$('#photo-file').addEventListener('change', async (event) => {
const file = event.target.files[0];
if (!file) return;
if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true);
const reader = new FileReader();
reader.onload = () => {
photoEditor.dataUrl = reader.result;
photoEditor.remove = false;
stopPhotoCamera();
showImage(reader.result);
};
reader.readAsDataURL(file);
});
$('#photo-clear').addEventListener('click', () => {
photoEditor.dataUrl = null;
photoEditor.remove = true;
stopPhotoCamera();
preview.hidden = true;
video.hidden = true;
empty.hidden = false;
$('#photo-shoot').hidden = true;
$('#photo-clear').hidden = true;
$('#photo-camera').textContent = 'Use the camera';
});
}
function stopPhotoCamera() {
if (!photoEditor.stream) return;
photoEditor.stream.getTracks().forEach((t) => t.stop());
photoEditor.stream = null;
const video = $('#photo-video');
if (video) video.srcObject = null;
}
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('Company or organisation (optional)', 'company', person?.company)}
${field('Mobile number (their username)', 'phone', person?.phone, 'tel')}
${field('Email address', 'email', person?.email, 'email')}
<label class="modal-field"><span>Check held</span>
<select name="checkType">
<option value="NONE" ${!person || person.checkType === 'NONE' ? 'selected' : ''}>None</option>
<option value="WWCC" ${person?.checkType === 'WWCC' ? 'selected' : ''}>Working with Children Check</option>
<option value="VIT" ${person?.checkType === 'VIT' ? 'selected' : ''}>Victorian Institute of Teaching</option>
</select></label>
${field('Check number', 'checkNumber', person?.checkNumber)}
${field('Check expires', 'checkExpiry', person?.checkExpiry, 'date')}
<label class="modal-field"><span>Site</span>
<select name="siteId">${siteOptions(person?.siteId)}</select></label>
<label class="modal-field"><span>Usually visiting</span>
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
${field('PIN (leave blank to generate one)', 'pin', '')}
${field('Notes', 'notes', person?.notes)}
<h4 class="modal-section">Photo on file</h4>
${photoEditorMarkup(person)}
${editing ? `<label class="inline"><input type="checkbox" name="active" ${person.active ? 'checked' : ''}> Active</label>` : ''}
`,
async (form) => {
const payload = Object.fromEntries(form.entries());
payload.active = editing ? form.has('active') : true;
if (!payload.pin) delete payload.pin;
if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl;
if (photoEditor.remove) payload.removePhoto = true;
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);
}
},
{ onOpen: wirePhotoEditor, onClose: stopPhotoCamera }
);
}
/**
* Removing a saved visitor is permanent, so the confirmation spells out what
* happens: the record and its PIN go, the visit history stays. Deactivating is
* offered alongside for the common case of someone who has simply stopped coming.
*/
function confirmRemoveFrequent(person) {
const name = `${person.firstName} ${person.lastName}`;
openModal(
`Remove ${name}?`,
`${
person.onSite
? `<p class="notice">${esc(person.firstName)} is signed in right now. Removing the
record will not sign them out — their visit stays open and they can still sign out
with their last name and mobile number.</p>`
: ''
}
<ul class="plain-list">
<li>Their saved record, PIN and photo are deleted for good.</li>
<li>Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.</li>
<li>${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.</li>
<li>Any card they are carrying stops working.</li>
</ul>
<p class="hint">If they might come back, untick <strong>Active</strong> in Edit instead —
that keeps the record and their history intact.</p>`,
async () => {
try {
const result = await api(`/frequent/${person.id}?force=1`, { method: 'DELETE' });
toast(`${result.name} removed. ${result.visitsKept} past visit(s) kept in the log.`);
loadRecurring();
} catch (err) {
toast(err.message, true);
}
},
{ saveLabel: 'Remove permanently', destructive: true }
);
}
function showPin(pin, id) {
openModal(
'PIN issued',
`<p class="pin-reveal">${esc(pin)}</p>
<p class="hint">Print the card now, or write this down. You can reprint it later from the
recurring visitors list.</p>`,
() => 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) => `<tr>
${showSite ? `<td>${esc(siteName(h.site_id))}</td>` : ''}
<td><strong>${esc(h.name)}</strong></td>
<td>${esc(h.area || '—')}</td>
<td>${esc(h.email || '—')}</td>
<td>${h.active ? '<span class="pill">Shown</span>' : '<span class="pill off">Hidden</span>'}</td>
<td class="actions">
<button class="ghost" data-edit-host="${h.id}">Edit</button>
<button class="ghost" data-toggle-host="${h.id}">${h.active ? 'Hide' : 'Show'}</button>
</td>
</tr>`
),
'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) => `<article class="card site-card">
<div class="site-head">
<div>
<h3>${esc(s.name)} ${s.active ? '' : '<span class="pill off">Inactive</span>'}</h3>
<p class="hint">Kiosk address: <code>/?site=${esc(s.slug)}</code></p>
</div>
<div class="actions">
<button class="ghost" data-edit-site="${s.id}">Edit</button>
<button class="ghost" data-preview-badge="${s.id}">Preview badge</button>
</div>
</div>
<dl class="site-meta">
<dt>Badge printing</dt>
<dd>${
s.badge.enabled
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}`
: 'Off'
}</dd>
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Kiosk branding</dt>
<dd>
${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} ·
<span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
</dd>
</dl>
</article>`
)
.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')
)
);
}
/**
* Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes
* 1262 mm wide media and prints up to 60.96 mm across, so anything wider than
* 62 mm is for a different printer.
*/
const LABEL_PRESETS = [
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
];
function openSiteModal(site) {
openModal(
`Edit ${site.name}`,
`${field('Site name', 'name', site.name)}
${field('Kiosk slug', 'slug', site.slug)}
<label class="inline"><input type="checkbox" name="active" ${site.active ? 'checked' : ''}> Active</label>
<h4 class="modal-section">Badge printing</h4>
<label class="inline"><input type="checkbox" name="badgeEnabled" ${site.badge.enabled ? 'checked' : ''}> Print a badge after each sign in</label>
<label class="modal-field"><span>Label stock</span>
<select name="preset" id="badge-preset">
<option value="">Custom size</option>
${LABEL_PRESETS.map(
(p) =>
`<option value="${p.id}" ${
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
}>${esc(p.label)}</option>`
).join('')}
</select></label>
<div class="modal-row">
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
</div>
<p class="hint" id="badge-warning" hidden></p>
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
<p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
${field('Line printed at the bottom', 'note', site.badge.note)}
<h4 class="modal-section">Kiosk branding</h4>
<div class="banner-editor">
<div class="banner-frame" id="banner-frame">
<img id="banner-preview" alt="Banner"${
site.branding.hasBanner ? ` src="/admin/api/sites/${site.id}/banner?t=${Date.now()}"` : ' hidden'
}>
<p class="banner-empty" id="banner-empty"${site.branding.hasBanner ? ' hidden' : ''}>No banner</p>
</div>
<div class="photo-actions">
<label class="ghost photo-upload">Upload an image
<input type="file" id="banner-file" accept="image/png,image/jpeg,image/webp" hidden>
</label>
<button type="button" class="ghost danger" id="banner-clear"${site.branding.hasBanner ? '' : ' hidden'}>Remove</button>
</div>
<p class="hint">A PNG with a transparent background works best — it sits straight on the
bar colour with nothing painted behind it. Up to 2 MB.</p>
<div class="modal-row">
${field('Height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
<label class="modal-field"><span>Position in the bar</span>
<select name="bannerAlign">
<option value="left" ${site.branding.bannerAlign !== 'center' ? 'selected' : ''}>Left</option>
<option value="center" ${site.branding.bannerAlign === 'center' ? 'selected' : ''}>Centred</option>
</select></label>
</div>
</div>
<div class="modal-row modal-row-3">
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
${colourField('Sign out', 'signout', site.branding.signout, '#2c4a6b')}
${colourField('Page background', 'page', site.branding.page, '#e7ecf0')}
</div>
${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)}
<p class="hint" id="contrast-note"></p>
<p class="hint">Text on the bar and on buttons is chosen automatically for contrast, so a
pale brand colour gets dark text rather than white. Labels and hints are a softened version
of the body text, kept readable against the background. Clear a box for the default.</p>`,
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'),
accent: form.has('accent'),
note: data.note,
},
branding: {
brand: data.brand || null,
signout: data.signout || null,
page: data.page || null,
text: data.text || null,
bannerHeight: Number(data.bannerHeight) || 64,
bannerAlign: data.bannerAlign,
},
},
});
if (bannerEditor.dataUrl) {
await api(`/sites/${site.id}/banner`, {
method: 'POST',
body: { image: bannerEditor.dataUrl },
});
} else if (bannerEditor.remove) {
await api(`/sites/${site.id}/banner`, { method: 'DELETE' });
}
toast('Site saved. Reload the kiosk to see the change.');
loadSites();
} catch (err) {
toast(err.message, true);
}
},
{
onOpen: () => {
wireBadgePreset();
wireBannerEditor();
},
}
);
}
/**
* A colour box paired with a text field, so a colour can be picked by eye or
* pasted from a brand guide, and cleared entirely to fall back to the default.
*/
function colourField(label, name, value, fallback) {
const current = value || '';
return `<label class="modal-field colour-field">
<span>${esc(label)}</span>
<span class="colour-row">
<input type="color" data-colour-for="${name}" value="${esc(current || fallback)}">
<input type="text" name="${name}" value="${esc(current)}" placeholder="${esc(fallback)}"
maxlength="7" spellcheck="false">
</span>
</label>`;
}
/** WCAG contrast ratio, mirroring the server so the console can warn as you type. */
function contrastRatio(a, b) {
const lum = (hex) => {
const [r, g, bl] = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * bl;
};
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const bannerEditor = { dataUrl: null, remove: false };
function wireBannerEditor() {
bannerEditor.dataUrl = null;
bannerEditor.remove = false;
// Keep the swatch and the hex box in step, in both directions.
$$('#modal-form [data-colour-for]').forEach((swatch) => {
const text = $(`#modal-form [name="${swatch.dataset.colourFor}"]`);
swatch.addEventListener('input', () => {
text.value = swatch.value;
});
text.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(text.value.trim())) swatch.value = text.value.trim();
});
});
// Live contrast readout, because a colour that looks fine in a swatch can be
// unreadable as body text.
const pageInput = $('#modal-form [name="page"]');
const textInput = $('#modal-form [name="text"]');
const note = $('#contrast-note');
const showContrast = () => {
const page = pageInput.value.trim() || pageInput.placeholder;
const text = textInput.value.trim() || textInput.placeholder;
if (!/^#[0-9a-fA-F]{6}$/.test(page) || !/^#[0-9a-fA-F]{6}$/.test(text)) {
note.hidden = true;
return;
}
const ratio = contrastRatio(text, page);
note.hidden = false;
if (ratio >= 7) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — comfortable at arm's length.`;
} else if (ratio >= 4.5) {
note.className = 'hint';
note.textContent = `Contrast ${ratio.toFixed(1)}:1 — readable, but aim for 7:1 on a kiosk people read standing up.`;
} else {
note.className = 'hint warn';
note.textContent = `Contrast only ${ratio.toFixed(1)}:1. This will be hard to read — pick a darker or lighter body text.`;
}
};
[pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast));
showContrast();
$('#banner-file').addEventListener('change', (event) => {
const file = event.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) return toast('That image is over 2 MB. Use a smaller one.', true);
const reader = new FileReader();
reader.onload = () => {
bannerEditor.dataUrl = reader.result;
bannerEditor.remove = false;
$('#banner-preview').src = reader.result;
$('#banner-preview').hidden = false;
$('#banner-empty').hidden = true;
$('#banner-clear').hidden = false;
};
reader.readAsDataURL(file);
});
$('#banner-clear').addEventListener('click', () => {
bannerEditor.dataUrl = null;
bannerEditor.remove = true;
$('#banner-preview').hidden = true;
$('#banner-preview').removeAttribute('src');
$('#banner-empty').hidden = false;
$('#banner-clear').hidden = true;
});
}
function wireBadgePreset() {
const width = $('#modal-form [name="widthMm"]');
const height = $('#modal-form [name="heightMm"]');
const warning = $('#badge-warning');
const check = () => {
const w = Number(width.value);
const h = Number(height.value);
if (w > 62) {
warning.hidden = false;
warning.className = 'hint warn';
warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`;
} else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) {
warning.hidden = false;
warning.className = 'hint warn';
warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.';
} else {
warning.hidden = true;
}
};
$('#badge-preset').addEventListener('change', (event) => {
const preset = LABEL_PRESETS.find((p) => p.id === event.target.value);
if (!preset) return;
width.value = preset.w;
height.value = preset.h;
$('#badge-photo').checked = preset.photo;
$('#badge-accent').checked = Boolean(preset.accent);
check();
});
[width, height].forEach((el) => el.addEventListener('input', () => {
$('#badge-preset').value = '';
check();
}));
$('#badge-photo').addEventListener('change', check);
check();
}
$('#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 ? '' : '<option value="all">All sites</option>') +
sites.map((s) => `<option value="${s.id}">${esc(s.name)}</option>`).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 = '<p class="empty">Only an owner account can manage admins.</p>';
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) => `<tr>
<td><strong>${esc(u.email)}</strong>${u.active ? '' : ' <span class="pill off">Disabled</span>'}</td>
<td>${esc(u.name || '—')}</td>
<td>${esc(u.role)}</td>
<td>${u.siteId ? esc(siteName(u.siteId)) : 'All sites'}</td>
<td>${u.twoFactorOn ? '<span class="pill">On</span>' : '<span class="pill warn">Not set up</span>'}</td>
<td class="mono">${stamp(u.lastLoginAt)}</td>
<td class="actions">
<button class="ghost" data-edit-user="${u.id}">Edit</button>
<button class="ghost" data-reset-pw="${u.id}">Reset password</button>
<button class="ghost" data-reset-2fa="${u.id}">Reset 2FA</button>
</td>
</tr>`
),
'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)}
<label class="modal-field"><span>Role</span>
<select name="role">
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin — day to day</option>
<option value="owner" ${user.role === 'owner' ? 'selected' : ''}>Owner — can manage admins and sites</option>
</select></label>
<label class="modal-field"><span>Limit to one site</span>
<select name="siteId">${siteOptions(user.siteId, { anyLabel: 'All sites' })}</select></label>
<label class="inline"><input type="checkbox" name="active" ${user.active ? 'checked' : ''}> Active</label>`,
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',
`<p class="pin-reveal small">${esc(temporaryPassword)}</p>
<p class="hint">Give this to them in person or over the phone. They will be asked to
change it as soon as they sign in.</p>`,
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', '')}
<label class="modal-field"><span>Role</span>
<select name="role">
<option value="admin">Admin — day to day</option>
<option value="owner">Owner — can manage admins and sites</option>
</select></label>
<label class="modal-field"><span>Limit to one site</span>
<select name="siteId">${siteOptions(null, { anyLabel: 'All sites' })}</select></label>`,
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',
`<p class="hint">Temporary password for ${esc(created.email)}:</p>
<p class="pin-reveal small">${esc(created.temporaryPassword)}</p>
<p class="hint">They will set their own password and enrol two factor at first sign in.</p>`,
null,
{ saveLabel: 'Done', hideCancel: true }
);
} catch (err) {
toast(err.message, true);
}
}
);
});
/* -------------------------------------------------------------- system */
async function loadSystem() {
const s = await api(scoped('/status'));
$('#system-body').innerHTML = `
<dl>
<dt>Time zone</dt><dd>${esc(s.timezone)}</dd>
<dt>Sites</dt><dd>${s.siteCount}</dd>
<dt>Photo required</dt><dd>${s.requirePhoto ? 'Yes' : 'No'}</dd>
<dt>Photos kept for</dt><dd>${s.photoRetentionDays} days</dd>
<dt>Expiry warning</dt><dd>${s.expiryWarningDays} days before a WWCC or VIT lapses</dd>
<dt>Nightly auto sign out</dt><dd>${s.autoSignOutTime ? esc(s.autoSignOutTime) : 'Off'}</dd>
<dt>Two factor</dt><dd>${s.require2fa ? 'Required for every admin' : 'Optional'}</dd>
<dt>Admin email domain</dt><dd>${s.domainRule ? esc(s.domainRule) : 'Any address'}</dd>
<dt>On site now</dt><dd>${s.onSite}</dd>
<dt>Google Sheet</dt><dd>${
s.sheets.enabled
? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${
s.sheets.stale ? ' <span class="pill warn">waiting to retry</span>' : ''
}${s.sheets.lastError ? `<br><span class="pill bad">${esc(s.sheets.lastError)}</span>` : ''}`
: 'Turned off in the environment file.'
}</dd>
${
s.sheets.enabled
? `<dt>On the sheet</dt><dd>${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
}, last written ${stamp(s.sheets.lastOk)}</dd>
<dt>Service account</dt>
<dd>${
s.sheets.serviceAccount
? `<code>${esc(s.sheets.serviceAccount)}</code>
<br><span class="hint">The spreadsheet must be shared with this address, with Editor access.</span>`
: '<span class="pill bad">No key file could be read</span>'
}</dd>
<dt>Spreadsheet</dt>
<dd>${
s.sheets.spreadsheetId
? `<a href="https://docs.google.com/spreadsheets/d/${esc(s.sheets.spreadsheetId)}/edit" target="_blank" rel="noopener">open the sheet</a>`
: '<span class="pill warn">SHEETS_SPREADSHEET_ID is not set</span>'
}</dd>`
: ''
}
</dl>
<div class="sys-actions">
<button class="ghost" id="sheet-test">Test the sheet connection</button>
<button class="ghost" id="sheet-resync">Rebuild the sheet now</button>
<button class="ghost danger" id="photo-purge">Purge photos past retention</button>
</div>
<h3 class="section-gap">Certificate</h3>
${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/resync', { method: 'POST' });
toast(
r.skipped
? 'Sheet mirroring is off.'
: `Sheet rewritten with ${r.rows} ${r.rows === 1 ? 'person' : 'people'} on site.`
);
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 `<p class="notice">HTTPS is off, so the kiosk camera will only work on localhost.
Set <code>HTTPS_ENABLED=true</code> in the environment file and restart.</p>`;
}
if (!tls.server) {
return '<p class="notice">HTTPS is on but no certificate could be read.</p>';
}
const soon = tls.server.daysLeft < 30;
return `
<dl>
<dt>Server certificate</dt>
<dd>Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')}
<span class="pill ${soon ? 'warn' : ''}">${tls.server.daysLeft} days</span></dd>
<dt>Valid for</dt><dd>${esc(tls.server.names.join(', '))}</dd>
<dt>Authority expires</dt>
<dd>${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'}
${tls.ca ? `<span class="pill">${tls.ca.daysLeft} days</span>` : ''}</dd>
<dt>CA fingerprint</dt><dd class="fingerprint">${esc(tls.ca?.fingerprint || '—')}</dd>
</dl>
<p class="hint">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.</p>
<div class="sys-actions">
<a class="ghost" href="/admin/api/tls/ca.crt" download>Download the CA certificate</a>
<button class="ghost owner-only" id="renew-cert">Renew the server certificate</button>
<button class="ghost danger owner-only" id="new-ca">Start a new authority</button>
</div>`;
}
function renderAccount(status) {
$('#account-body').innerHTML = `
<dl>
<dt>Signed in as</dt><dd>${esc(me.email)}</dd>
<dt>Role</dt><dd>${esc(me.role)}${me.siteId ? ` — ${esc(siteName(me.siteId))} only` : ''}</dd>
<dt>Two factor</dt><dd>${me.twoFactorOn ? 'On' : 'Not set up'}</dd>
</dl>
<div class="sys-actions">
<button class="ghost" id="change-password">Change my password</button>
${me.twoFactorOn
? status.require2fa
? ''
: '<button class="ghost danger" id="disable-2fa">Turn off two factor</button>'
: '<button class="ghost" id="enable-2fa">Set up two factor</button>'}
</div>`;
$('#change-password').addEventListener('click', () => {
openModal(
'Change your password',
`${field('Current password', 'currentPassword', '', 'password')}
${field('New password', 'newPassword', '', 'password')}
<p class="hint">At least 12 characters, with upper and lower case and a number.</p>`,
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',
`<p class="hint">Scan this with your authenticator app, then enter the code it shows.</p>
<img src="${qr}" alt="Two factor QR code" width="200" height="200">
<p class="hint">Or enter this key by hand: <code>${esc(secret)}</code></p>
${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',
`<p class="hint">Each of these works once if you lose your phone. Save them somewhere safe.</p>
<ul class="recovery">${r.recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`,
null,
{ saveLabel: 'Done', hideCancel: true }
);
} catch (err) {
toast(err.message, true);
}
},
{ saveLabel: 'Turn on' }
);
});
$('#disable-2fa')?.addEventListener('click', () => {
openModal(
'Turn off two factor',
`<p class="hint">Confirm with your password.</p>
${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');
// The server redirects an unauthenticated /admin to the login page, so reaching
// here without a session means it lapsed between the page load and this call.
if (!session.admin || session.mustChangePassword) return toLogin();
me = { ...session.user, domainRule: session.domainRule };
$('#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();