From c21123885d65398ef4716d490c05d8152d6918c7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 1 Sep 2026 16:08:27 +1000 Subject: [PATCH] Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA --- README.md | 15 ++++++ public/css/admin.css | 7 +++ public/css/kiosk.css | 10 +++- public/index.html | 24 +++++----- public/js/admin.js | 56 +++++++++++++++++++++- public/js/kiosk.js | 107 ++++++++++++++++++++++++------------------- src/routes/admin.js | 57 +++++++++++++++++++++-- 7 files changed, 211 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 2b114cc..a6d2367 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,21 @@ retention cleaning up old visits can never delete a live profile photo. Leave the photo blank and they are asked at the kiosk as before. +### Removing someone + +**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops +working, the stored photo is deleted, and their mobile number, email and PIN become available +for someone else. + +Their **visit history is kept**. Visits store the name, contact details and host as their own +columns, so the log remains a complete record of who was in the building regardless of whether +the person is still on file. Removing someone who is currently signed in does not sign them out; +the confirmation says so, and their visit can still be closed with their last name and mobile +number at the kiosk. + +If they might come back, untick **Active** in Edit instead. That keeps the record, the PIN and +the history, but stops the PIN working at the kiosk. + ### PINs The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with diff --git a/public/css/admin.css b/public/css/admin.css index aa7365e..00d5424 100644 --- a/public/css/admin.css +++ b/public/css/admin.css @@ -344,3 +344,10 @@ tr.row-bad td { background: #fdf0f2; } background: #fdf3d8; color: var(--ink); } + +.plain-list { margin: 0 0 14px; padding-left: 20px; font-size: 14.5px; } +.plain-list li { margin-bottom: 5px; } + +/* The confirmation for a destructive action should not look like a Save. */ +#modal.destructive { border-top-color: var(--alert); } +#modal.destructive .primary { border-color: var(--alert); background: var(--alert); } diff --git a/public/css/kiosk.css b/public/css/kiosk.css index bbd569d..982a600 100644 --- a/public/css/kiosk.css +++ b/public/css/kiosk.css @@ -215,7 +215,15 @@ a:focus-visible { -webkit-overflow-scrolling: touch; } .host-option small { display: block; color: var(--muted); font-size: 14px; } -.host-empty { color: var(--muted); font-size: 15px; padding: 10px 2px; } +.host-empty { + color: var(--muted); + font-size: 15px; + margin: -6px 0 18px; + padding: 12px 14px; + background: var(--card); + border-left: 4px solid var(--rule); + border-radius: 3px; +} /* ---------------------------------------------------------- camera */ diff --git a/public/index.html b/public/index.html index d056748..39a1934 100644 --- a/public/index.html +++ b/public/index.html @@ -63,14 +63,14 @@

Who are you here to see?

- -
+ + @@ -166,14 +166,14 @@

Who are you here to see?

- -
+ + diff --git a/public/js/admin.js b/public/js/admin.js index 1d685e8..9772f95 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -268,6 +268,7 @@ async function loadRecurring() { + ` ), @@ -289,6 +290,15 @@ async function loadRecurring() { 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) { @@ -316,18 +326,25 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) { ); } -function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) { +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'))); }; @@ -494,6 +511,43 @@ function openRecurringModal(person = null) { ); } +/** + * 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.

` + : '' + } + +

If they might come back, untick Active in Edit instead — + that keeps the record and their history intact.

`, + 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', diff --git a/public/js/kiosk.js b/public/js/kiosk.js index 16b82ae..c50c2d3 100644 --- a/public/js/kiosk.js +++ b/public/js/kiosk.js @@ -186,12 +186,12 @@ tickClock(); /* --------------------------------------------------------------- hosts */ /** - * Two ways to say who you are visiting, because neither suits everyone: a dropdown - * for someone who wants to browse a short list, and a filter box for someone who - * knows the name and would rather type it. The filter narrows both, so the two - * never disagree about what is on offer. + * Who you are here to see: type to narrow, then choose from the dropdown. The + * dropdown is a native select, so a tablet gives it a proper full-screen picker + * with its own scrolling, which handles a long staff list better than a page of + * buttons ever did. */ -function renderHosts(listEl, searchValue, onPick, selectEl = null) { +function renderHosts(selectEl, searchValue, onPick, emptyEl = null) { const term = String(searchValue || '').trim().toLowerCase(); const matches = term ? hosts.filter( @@ -199,49 +199,48 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) { ) : hosts; - const choose = (id, name) => { - state.hostId = Number(id); - state.hostName = name; + const label = !hosts.length + ? 'Nobody has been added yet' + : matches.length === hosts.length + ? `Choose one of ${hosts.length}` + : `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`; + + selectEl.innerHTML = + `` + + matches + .map( + (h) => + `` + ) + .join(''); + selectEl.disabled = matches.length === 0; + + selectEl.onchange = () => { + const picked = hosts.find((h) => h.id === Number(selectEl.value)); + if (!picked) return; + state.hostId = picked.id; + state.hostName = picked.name; onPick(); }; - if (selectEl) { - const placeholder = - matches.length === hosts.length - ? `${hosts.length} ${hosts.length === 1 ? 'person' : 'people'} — choose a name` - : `${matches.length} ${matches.length === 1 ? 'match' : 'matches'}`; - selectEl.innerHTML = - `` + - matches - .map( - (h) => - `` - ) - .join(''); - selectEl.disabled = matches.length === 0; - selectEl.onchange = () => { - const picked = hosts.find((h) => h.id === Number(selectEl.value)); - if (picked) choose(picked.id, picked.name); - }; + if (emptyEl) { + emptyEl.hidden = matches.length > 0; + emptyEl.textContent = hosts.length + ? 'No one matches that. Check the spelling, or ask the front desk.' + : 'No one has been added for this site yet. Please see the front desk.'; } - if (!matches.length) { - listEl.innerHTML = `

No one matches that. Check the spelling, or ask the front desk.

`; - return; - } + // Typing a name and pressing enter should just work when only one person is left. + selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : ''; +} - listEl.innerHTML = matches - .slice(0, 60) - .map( - (h) => - `` - ) - .join(''); - listEl.querySelectorAll('[data-host-id]').forEach((btn) => { - btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName)); - }); +/** Enter in the filter box picks the person when the filter leaves exactly one. */ +function pickOnlyMatch(selectEl) { + const only = selectEl.dataset.only; + if (!only) return false; + selectEl.value = only; + selectEl.dispatchEvent(new Event('change')); + return true; } function escapeHtml(value) { @@ -447,7 +446,7 @@ async function loadSiteContext() { change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site'; hosts = await api('/api/hosts'); - renderHosts($('#host-list'), '', () => show('guest-contact'), $('#host-select')); + renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty')); } $('#change-site').addEventListener('click', chooseSite); @@ -482,8 +481,14 @@ $('[data-next="guest-name"]').addEventListener('click', () => { }); $('#in-host-search').addEventListener('input', (e) => - renderHosts($('#host-list'), e.target.value, () => show('guest-contact'), $('#host-select')) + renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty')) ); +$('#in-host-search').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + pickOnlyMatch($('#host-select')); + } +}); $('[data-next="guest-contact"]').addEventListener('click', () => { const phone = $('#in-phone').value.trim(); @@ -557,10 +562,10 @@ $('#do-freq-auth').addEventListener('click', async () => { $('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`; $('#in-freq-host-search').value = ''; renderHosts( - $('#freq-host-list'), + $('#freq-host-select'), '', () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), - $('#freq-host-select') + $('#freq-host-empty') ); show('freq-host'); } catch (err) { @@ -573,12 +578,18 @@ $('#do-freq-auth').addEventListener('click', async () => { $('#in-freq-host-search').addEventListener('input', (e) => renderHosts( - $('#freq-host-list'), + $('#freq-host-select'), e.target.value, () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), - $('#freq-host-select') + $('#freq-host-empty') ) ); +$('#in-freq-host-search').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + pickOnlyMatch($('#freq-host-select')); + } +}); /* sign out */ diff --git a/src/routes/admin.js b/src/routes/admin.js index 00965b8..b88f229 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -585,7 +585,18 @@ router.get('/frequent', (req, res) => { router.get('/frequent/:id', (req, res) => { const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Not found.' }); - res.json(shapeFrequent(row, true)); + res.json({ + ...shapeFrequent(row, true), + // Context for the removal confirmation. + visitCount: db + .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') + .get(row.id).n, + onSite: Boolean( + db + .prepare('SELECT 1 FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') + .get(row.id) + ), + }); }); /** Everyone whose check lapses inside the warning window, or already has. */ @@ -763,9 +774,49 @@ router.post('/frequent/:id/pin', (req, res) => { res.json({ ok: true, pin }); }); +/** + * Removes a recurring visitor for good. + * + * Their visit history is deliberately kept: visits store the name, contact details + * and host as their own columns, so the log stays a complete record of who was in + * the building even after the person's saved record is gone. Deleting the record + * frees their mobile number, email and PIN for someone else. + * + * To keep someone on file but stop them signing in, untick Active instead. + */ router.delete('/frequent/:id', (req, res) => { - db.prepare('UPDATE frequent_visitors SET active = 0 WHERE id = ?').run(req.params.id); - res.json({ ok: true }); + const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); + if (!row) return res.status(404).json({ error: 'Not found.' }); + + const scope = scopedSiteId(req); + if (scope && row.site_id && row.site_id !== scope) { + return res.status(403).json({ error: 'That visitor belongs to another site.' }); + } + + const openVisit = db + .prepare('SELECT id FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') + .get(row.id); + if (openVisit && !req.query.force) { + return res.status(409).json({ + error: `${row.first_name} is signed in right now. Sign them out first, or confirm to remove anyway.`, + onSite: true, + }); + } + + const visitCount = db + .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') + .get(row.id).n; + + if (row.photo_path) deletePhoto(row.photo_path); + db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); + // visits.frequent_visitor_id is ON DELETE SET NULL, so the history survives. + db.prepare('DELETE FROM frequent_visitors WHERE id = ?').run(row.id); + + res.json({ + ok: true, + name: `${row.first_name} ${row.last_name}`, + visitsKept: visitCount, + }); }); /* ------------------------------------------------------------- visits */