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

This commit is contained in:
2026-09-01 16:08:27 +10:00
parent cccba98d97
commit c21123885d
7 changed files with 211 additions and 65 deletions
+15
View File
@@ -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. 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 ### PINs
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
+7
View File
@@ -344,3 +344,10 @@ tr.row-bad td { background: #fdf0f2; }
background: #fdf3d8; background: #fdf3d8;
color: var(--ink); 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); }
+9 -1
View File
@@ -215,7 +215,15 @@ a:focus-visible {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.host-option small { display: block; color: var(--muted); font-size: 14px; } .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 */ /* ---------------------------------------------------------- camera */
+12 -12
View File
@@ -63,14 +63,14 @@
<div class="rail" data-rail></div> <div class="rail" data-rail></div>
<h2>Who are you here to see?</h2> <h2>Who are you here to see?</h2>
<label class="field"> <label class="field">
<span>Pick from the list</span> <span>Start typing a name to narrow the list</span>
<select id="host-select" class="picker"></select>
</label>
<label class="field">
<span>Or start typing a name</span>
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson"> <input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
</label> </label>
<div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div> <label class="field">
<span>Then choose a name</span>
<select id="host-select" class="picker"></select>
</label>
<p class="host-empty" id="host-empty" hidden></p>
<div class="nav"> <div class="nav">
<button class="ghost" data-back>Back</button> <button class="ghost" data-back>Back</button>
</div> </div>
@@ -166,14 +166,14 @@
<section class="screen" id="screen-freq-host"> <section class="screen" id="screen-freq-host">
<h2 id="freq-greeting">Who are you here to see?</h2> <h2 id="freq-greeting">Who are you here to see?</h2>
<label class="field"> <label class="field">
<span>Pick from the list</span> <span>Start typing a name to narrow the list</span>
<select id="freq-host-select" class="picker"></select>
</label>
<label class="field">
<span>Or start typing a name</span>
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search"> <input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
</label> </label>
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div> <label class="field">
<span>Then choose a name</span>
<select id="freq-host-select" class="picker"></select>
</label>
<p class="host-empty" id="freq-host-empty" hidden></p>
<div class="nav"> <div class="nav">
<button class="ghost" data-go="home">Cancel</button> <button class="ghost" data-go="home">Cancel</button>
</div> </div>
+55 -1
View File
@@ -268,6 +268,7 @@ async function loadRecurring() {
<button class="ghost" data-pass="${p.id}">Print card</button> <button class="ghost" data-pass="${p.id}">Print card</button>
<button class="ghost" data-pin="${p.id}">New PIN</button> <button class="ghost" data-pin="${p.id}">New PIN</button>
<button class="ghost" data-edit-freq="${p.id}">Edit</button> <button class="ghost" data-edit-freq="${p.id}">Edit</button>
<button class="ghost danger" data-remove-freq="${p.id}">Remove</button>
</td> </td>
</tr>` </tr>`
), ),
@@ -289,6 +290,15 @@ async function loadRecurring() {
openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`)); 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) { 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-title').textContent = title;
$('#modal-body').innerHTML = bodyHtml; $('#modal-body').innerHTML = bodyHtml;
$('#modal-save').textContent = saveLabel; $('#modal-save').textContent = saveLabel;
$('#modal-cancel').hidden = hideCancel; $('#modal-cancel').hidden = hideCancel;
const modal = $('#modal'); const modal = $('#modal');
modal.classList.toggle('destructive', destructive);
modal.returnValue = ''; modal.returnValue = '';
modal.showModal(); modal.showModal();
onOpen?.(); onOpen?.();
modal.onclose = () => { modal.onclose = () => {
$('#modal-cancel').hidden = false; $('#modal-cancel').hidden = false;
$('#modal-save').textContent = 'Save'; $('#modal-save').textContent = 'Save';
modal.classList.remove('destructive');
onClose?.(); onClose?.();
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form'))); 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
? `<p class="notice">${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.</p>`
: ''
}
<ul class="plain-list">
<li>Their saved record, PIN and photo are deleted for good.</li>
<li>Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.</li>
<li>${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.</li>
<li>Any card they are carrying stops working.</li>
</ul>
<p class="hint">If they might come back, untick <strong>Active</strong> in Edit instead —
that keeps the record and their history intact.</p>`,
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) { function showPin(pin, id) {
openModal( openModal(
'PIN issued', 'PIN issued',
+49 -38
View File
@@ -186,12 +186,12 @@ tickClock();
/* --------------------------------------------------------------- hosts */ /* --------------------------------------------------------------- hosts */
/** /**
* Two ways to say who you are visiting, because neither suits everyone: a dropdown * Who you are here to see: type to narrow, then choose from the dropdown. The
* for someone who wants to browse a short list, and a filter box for someone who * dropdown is a native select, so a tablet gives it a proper full-screen picker
* knows the name and would rather type it. The filter narrows both, so the two * with its own scrolling, which handles a long staff list better than a page of
* never disagree about what is on offer. * buttons ever did.
*/ */
function renderHosts(listEl, searchValue, onPick, selectEl = null) { function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
const term = String(searchValue || '').trim().toLowerCase(); const term = String(searchValue || '').trim().toLowerCase();
const matches = term const matches = term
? hosts.filter( ? hosts.filter(
@@ -199,19 +199,14 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) {
) )
: hosts; : hosts;
const choose = (id, name) => { const label = !hosts.length
state.hostId = Number(id); ? 'Nobody has been added yet'
state.hostName = name; : matches.length === hosts.length
onPick(); ? `Choose one of ${hosts.length}`
}; : `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
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 = selectEl.innerHTML =
`<option value="">${escapeHtml(placeholder)}</option>` + `<option value="">${escapeHtml(label)}</option>` +
matches matches
.map( .map(
(h) => (h) =>
@@ -219,29 +214,33 @@ function renderHosts(listEl, searchValue, onPick, selectEl = null) {
) )
.join(''); .join('');
selectEl.disabled = matches.length === 0; selectEl.disabled = matches.length === 0;
selectEl.onchange = () => { selectEl.onchange = () => {
const picked = hosts.find((h) => h.id === Number(selectEl.value)); const picked = hosts.find((h) => h.id === Number(selectEl.value));
if (picked) choose(picked.id, picked.name); if (!picked) return;
state.hostId = picked.id;
state.hostName = picked.name;
onPick();
}; };
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) { // Typing a name and pressing enter should just work when only one person is left.
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`; selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
return;
} }
listEl.innerHTML = matches /** Enter in the filter box picks the person when the filter leaves exactly one. */
.slice(0, 60) function pickOnlyMatch(selectEl) {
.map( const only = selectEl.dataset.only;
(h) => if (!only) return false;
`<button class="host-option" data-host-id="${h.id}" data-host-name="${escapeHtml(h.name)}"> selectEl.value = only;
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''} selectEl.dispatchEvent(new Event('change'));
</button>` return true;
)
.join('');
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
btn.addEventListener('click', () => choose(btn.dataset.hostId, btn.dataset.hostName));
});
} }
function escapeHtml(value) { function escapeHtml(value) {
@@ -447,7 +446,7 @@ async function loadSiteContext() {
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site'; change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
hosts = await api('/api/hosts'); 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); $('#change-site').addEventListener('click', chooseSite);
@@ -482,8 +481,14 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
}); });
$('#in-host-search').addEventListener('input', (e) => $('#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', () => { $('[data-next="guest-contact"]').addEventListener('click', () => {
const phone = $('#in-phone').value.trim(); 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?`; $('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = ''; $('#in-freq-host-search').value = '';
renderHosts( renderHosts(
$('#freq-host-list'), $('#freq-host-select'),
'', '',
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')), () => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
$('#freq-host-select') $('#freq-host-empty')
); );
show('freq-host'); show('freq-host');
} catch (err) { } catch (err) {
@@ -573,12 +578,18 @@ $('#do-freq-auth').addEventListener('click', async () => {
$('#in-freq-host-search').addEventListener('input', (e) => $('#in-freq-host-search').addEventListener('input', (e) =>
renderHosts( renderHosts(
$('#freq-host-list'), $('#freq-host-select'),
e.target.value, e.target.value,
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')), () => (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 */ /* sign out */
+54 -3
View File
@@ -585,7 +585,18 @@ router.get('/frequent', (req, res) => {
router.get('/frequent/:id', (req, res) => { router.get('/frequent/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id); const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' }); 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. */ /** 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 }); 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) => { router.delete('/frequent/:id', (req, res) => {
db.prepare('UPDATE frequent_visitors SET active = 0 WHERE id = ?').run(req.params.id); const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
res.json({ ok: true }); 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 */ /* ------------------------------------------------------------- visits */