${alerts.expired.length} expired, ${alerts.expiring.length} expiring within
${alerts.warningDays} days. Ask them for an updated card before their next visit.
`
),
'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 (
'' +
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, 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 `
No photo on file
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.
`;
}
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')}
${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)}
Photo on file
${photoEditorMarkup(person)}
${editing ? `` : ''}
`,
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
? `
${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.
`
: ''
}
Their saved record, PIN and photo are deleted for good.
Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.
${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.
Any card they are carrying stops working.
If they might come back, untick Active in Edit instead —
that keeps the record and their history intact.
`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
})
);
$$('[data-test-print]').forEach((btn) =>
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = 'Printing…';
try {
await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' });
toast('Sent to the printer.');
} catch (err) {
// Show the printer's own words as well as the summary, because a refusal
// usually names the reason and the summary cannot cover every case.
const raw = err.payload?.raw;
openModal(
'The printer refused the job',
`
${esc(err.message)}
${err.payload?.command ? `
Command:
${esc(err.payload.command)}
` : ''}
${raw ? `
What the printer and brother_ql said:
${esc(raw)}
` : ''}`,
null,
{ saveLabel: 'Close', hideCancel: true }
);
} finally {
btn.disabled = false;
btn.textContent = 'Test print';
loadSites();
}
})
);
}
/**
* Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes
* 12–62 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, roll: '62' },
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true, roll: '62' },
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, roll: '62red', accent: true },
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false, roll: '62' },
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false, roll: '62' },
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false, roll: '62' },
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true, roll: '62' },
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true, roll: '62' },
];
/**
* Which preset the saved settings correspond to. Dimensions alone are ambiguous —
* DK-22205 and DK-22251 are both 62 x 90 — so the roll type decides between them.
*/
function matchingPreset(site) {
return LABEL_PRESETS.find(
(p) =>
Number(site.badge.widthMm) === p.w &&
Number(site.badge.heightMm) === p.h &&
p.roll === (site.printer.label === '62red' ? '62red' : '62')
);
}
function openSiteModal(site) {
openModal(
`Edit ${site.name}`,
`${field('Site name', 'name', site.name)}
${field('Kiosk slug', 'slug', site.slug)}
Badge printing
A shortcut that fills in the boxes below. The size, the roll type and the
colour option are what actually get saved.
Two-colour printing is much slower than black alone.
Printer
${field('Printer IP address', 'printerHost', site.printer.host, 'text')}
${field('Port', 'printerPort', site.printer.port, 'number')}
This must match the roll actually in the machine, and it matters in both
directions. A two-colour job on a plain roll is refused as Wrong Roll Type; a
monochrome job on a DK-22251 is refused with Black/Red on White paper is installed
now. The DK-22251 needs the two-colour option even when the badge is entirely black.
With this on, the kiosk does not print at all — the server sends the badge
to the printer over the network, so a tablet needs no driver and no default printer. At 90°
the badge is laid out along the length of the label and turned, which reads correctly when
the label hangs from its short edge. Check it with Bitmap preview before
using a roll.
${field('Line printed at the bottom', 'note', site.badge.note)}
Kiosk branding
No banner
A PNG with a transparent background works best — it sits straight on the
bar colour with nothing painted behind it. Up to 2 MB.
${field('Height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
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.
`,
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,
},
printer: {
enabled: form.has('printerEnabled'),
host: data.printerHost,
port: Number(data.printerPort) || 9100,
model: data.printerModel,
rotate: Number(data.printerRotate) || 0,
label: data.printerLabel,
},
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 ``;
}
/** 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();
// Red is only possible on the two-colour roll, so say so as the two settings change.
const accentBox = $('#badge-accent');
const rollSelect = $('#modal-form [name="printerLabel"]');
const accentNote = $('#accent-note');
const showAccentNote = () => {
if (!accentNote) return;
if (!accentBox?.checked) {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'Everything prints black.';
return;
}
if (rollSelect?.value === '62red') {
accentNote.hidden = false;
accentNote.className = 'hint';
accentNote.textContent = 'The heading and the no-check warning will print red.';
} else {
accentNote.hidden = false;
accentNote.className = 'hint warn';
accentNote.textContent =
'The roll selected below cannot print red, so these will come out black. Load a DK-22251 and change the roll setting to use colour.';
}
};
accentBox?.addEventListener('change', showAccentNote);
rollSelect?.addEventListener('change', showAccentNote);
showAccentNote();
$('#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);
// The preset knows which roll it describes, so keep the two in step. Picking
// the DK-22251 preset while the roll stayed on "black only" is what makes the
// printer refuse the job.
const roll = $('#modal-form [name="printerLabel"]');
if (roll && preset.roll) {
roll.value = preset.roll;
roll.dispatchEvent(new Event('change'));
}
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 ? '' : '') +
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) => `
${
s.sheets.enabled
? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${
s.sheets.stale ? ' waiting to retry' : ''
}${s.sheets.lastError ? ` ${esc(s.sheets.lastError)}` : ''}`
: 'Turned off in the environment file.'
}
${
s.sheets.enabled
? `
On the sheet
${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
}, last written ${stamp(s.sheets.lastOk)}
Service account
${
s.sheets.serviceAccount
? `${esc(s.sheets.serviceAccount)} The spreadsheet must be shared with this address, with Editor access.`
: 'No key file could be read'
}
Spreadsheet
${
s.sheets.spreadsheetId
? `open the sheet`
: 'SHEETS_SPREADSHEET_ID is not set'
}
`
: ''
}
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/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 `
HTTPS is off, so the kiosk camera will only work on localhost.
Set HTTPS_ENABLED=true in the environment file and restart.
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.