Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-01 15:14:46 +10:00
parent a011587d66
commit 9da7e88eb3
11 changed files with 629 additions and 99 deletions
+45
View File
@@ -294,3 +294,48 @@ tr.row-bad td { background: #fdf0f2; }
word-break: break-all;
}
#system-body h3 { margin-bottom: 14px; }
/* ------------------------------------------------- visitor photo editor */
.photo-editor { margin-bottom: 6px; }
.photo-frame {
position: relative;
width: 148px;
aspect-ratio: 4 / 3;
margin-bottom: 12px;
background: #eef1f4;
border: 1px solid var(--rule);
border-radius: 3px;
overflow: hidden;
display: grid;
place-items: center;
}
.photo-frame img,
.photo-frame video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scaleX(-1);
}
.photo-empty { margin: 0; padding: 0 10px; color: var(--muted); font-size: 13px; text-align: center; }
.photo-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.photo-actions button, .photo-upload { padding: 8px 13px; font-size: 14px; }
.photo-upload { cursor: pointer; }
.thumb-empty {
width: 42px;
height: 42px;
border-radius: 3px;
background: repeating-linear-gradient(45deg, #eef1f4, #eef1f4 5px, #e3e8ed 5px, #e3e8ed 10px);
display: block;
}
.hint.warn {
padding: 9px 11px;
border-left: 4px solid #d9a441;
background: #fdf3d8;
color: var(--ink);
}
+1 -4
View File
@@ -224,10 +224,7 @@
<footer class="foot">
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
<span>
<button class="foot-link" id="change-site" hidden></button>
<a href="/admin">Admin</a>
</span>
<button class="foot-link" id="change-site" hidden></button>
</footer>
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
+195 -10
View File
@@ -254,7 +254,7 @@ async function loadRecurring() {
: '';
$('#recurring-table').innerHTML = table(
['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
['', '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>
@@ -316,7 +316,7 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
);
}
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) {
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) {
$('#modal-title').textContent = title;
$('#modal-body').innerHTML = bodyHtml;
$('#modal-save').textContent = saveLabel;
@@ -324,13 +324,124 @@ function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = f
const modal = $('#modal');
modal.returnValue = '';
modal.showModal();
onOpen?.();
modal.onclose = () => {
$('#modal-cancel').hidden = false;
$('#modal-save').textContent = 'Save';
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', () => {
const canvas = document.createElement('canvas');
const width = 720;
canvas.width = width;
canvas.height = Math.round((video.videoHeight / video.videoWidth) * width) || 540;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
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(
@@ -354,12 +465,16 @@ function openRecurringModal(person = null) {
<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 })
@@ -370,7 +485,8 @@ function openRecurringModal(person = null) {
} catch (err) {
toast(err.message, true);
}
}
},
{ onOpen: wirePhotoEditor, onClose: stopPhotoCamera }
);
}
@@ -501,7 +617,7 @@ async function loadSites() {
<dt>Badge printing</dt>
<dd>${
s.badge.enabled
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}`
? `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>` : ''}
@@ -522,6 +638,22 @@ async function loadSites() {
);
}
/**
* 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}`,
@@ -530,14 +662,26 @@ function openSiteModal(site) {
<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>
<label class="inline"><input type="checkbox" name="showPhoto" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
${field('Line printed at the bottom', 'note', site.badge.note)}
<p class="hint">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.</p>`,
<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)}`,
async (form) => {
const data = Object.fromEntries(form.entries());
try {
@@ -552,19 +696,60 @@ function openSiteModal(site) {
widthMm: Number(data.widthMm),
heightMm: Number(data.heightMm),
showPhoto: form.has('showPhoto'),
accent: form.has('accent'),
note: data.note,
},
},
});
toast('Site saved.');
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 {
+19 -5
View File
@@ -18,6 +18,7 @@ const state = {
checkNumber: '',
photo: null,
frequentVisitorId: null,
hasStoredPhoto: false,
};
let hosts = [];
@@ -143,6 +144,7 @@ function resetState() {
checkNumber: '',
photo: null,
frequentVisitorId: null,
hasStoredPhoto: false,
});
history = [];
$$('#app input').forEach((i) => {
@@ -312,8 +314,13 @@ function buildReview() {
/* ------------------------------------------------------------ submits */
async function submitSignIn() {
const button = state.mode === 'frequent' ? $('#cam-use') : $('#do-signin');
button.disabled = true;
// Called from the review screen, the camera screen, or straight from the host
// list when a recurring visitor already has a photo on file.
const button =
(current === 'photo' && $('#cam-use')) ||
(current === 'review' && $('#do-signin')) ||
null;
if (button) button.disabled = true;
try {
const result = await api('/api/signin', {
mode: state.mode,
@@ -342,7 +349,7 @@ async function submitSignIn() {
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
if (button) button.disabled = false;
}
}
@@ -501,9 +508,14 @@ $('#do-freq-auth').addEventListener('click', async () => {
state.frequentVisitorId = person.id;
state.firstName = person.firstName;
state.lastName = person.lastName;
// With a photo already on file there is nothing to pose for: picking a host
// completes the sign in and the pass prints straight away.
state.hasStoredPhoto = Boolean(person.hasPhoto);
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = '';
renderHosts($('#freq-host-list'), '', () => show('photo'));
renderHosts($('#freq-host-list'), '', () =>
state.hasStoredPhoto ? submitSignIn() : show('photo')
);
show('freq-host');
} catch (err) {
say(err.message);
@@ -514,7 +526,9 @@ $('#do-freq-auth').addEventListener('click', async () => {
});
$('#in-freq-host-search').addEventListener('input', (e) =>
renderHosts($('#freq-host-list'), e.target.value, () => show('photo'))
renderHosts($('#freq-host-list'), e.target.value, () =>
state.hasStoredPhoto ? submitSignIn() : show('photo')
)
);
/* sign out */