/* 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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) => `${esc(h)} `).join('')}
${rows.join('')}
`;
}
function field(label, name, value = '', type = 'text') {
return `${esc(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 '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)}
Badge
Sign out
`
),
'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 '}
Print card
New PIN
Edit
`
),
'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 (
'No default ' +
hosts
.filter((h) => h.active)
.map(
(h) =>
`${esc(h.name)} `
)
.join('')
);
}
function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
return (
`${esc(anyLabel)} ` +
sites
.map(
(s) =>
`${esc(s.name)} `
)
.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')}
Check held
None
Working with Children Check
Victorian Institute of Teaching
${field('Check number', 'checkNumber', person?.checkNumber)}
${field('Check expires', 'checkExpiry', person?.checkExpiry, 'date')}
Site
${siteOptions(person?.siteId)}
Usually visiting
${hostOptions(person?.defaultHostId)}
${field('PIN (leave blank to generate one)', 'pin', '')}
${field('Notes', 'notes', person?.notes)}
${editing ? ` Active ` : ''}
`,
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 '}
Edit
${h.active ? 'Hide' : 'Show'}
`
),
'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)}
Edit
Preview badge
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)}
Active
Badge printing
Print a badge after each sign in
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
Include the visitor's photo
${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 ? '' : 'All sites ') +
sites.map((s) => `${esc(s.name)} `).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)}
Edit
Reset password
Reset 2FA
`
),
'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)}
Role
Admin — day to day
Owner — can manage admins and sites
Limit to one site
${siteOptions(user.siteId, { anyLabel: 'All sites' })}
Active `,
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', '')}
Role
Admin — day to day
Owner — can manage admins and sites
Limit to one site
${siteOptions(null, { anyLabel: 'All sites' })} `,
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)} ` : ''
}
Test the sheet connection
Send queued rows now
Rebuild the live list
Purge photos past retention
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.
`;
}
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'}
Change my password
${me.twoFactorOn
? status.require2fa
? ''
: 'Turn off two factor '
: 'Set up two factor '}
`;
$('#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.
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.
${r.recoveryCodes.map((c) => `${esc(c)} `).join('')} `,
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');
// 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();