${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('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.
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.
${field('Line printed at the bottom', 'note', site.badge.note)}`,
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,
},
},
});
toast('Site saved. Preview the badge before committing a roll to it.');
loadSites();
} catch (err) {
toast(err.message, true);
}
},
{ onOpen: wireBadgePreset }
);
}
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 ? '' : '') +
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
? `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)}
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.