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
+62 -16
View File
@@ -5,7 +5,9 @@ Runs in one Docker container, stores everything locally in SQLite, and mirrors e
and sign out to a Google Sheet so someone outside the building can see who is on site during
an evacuation.
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out.
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. No link to
the admin console: a kiosk is a public terminal and administration does not belong on it.
Reach the console from a staff machine at `/admin`.
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
visitor can ask for, sites, admin accounts, and system status.
@@ -19,7 +21,7 @@ two factor.
|---|---|---|
| First and last name | typed each visit | on file |
| Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | taken at the kiosk |
| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk |
| WWCC / VIT / none | typed each visit | on file |
| Mobile and/or email | at least one required | on file |
@@ -103,25 +105,47 @@ staff list and log, and cannot touch the others.
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
the site, name, who they are visiting, time in, WWCC/VIT status (or a boxed **No WWCC / VIT**),
the photo if you want it, and an optional line of your own text.
the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a
boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text.
Set the label size in millimetres to match your stock. Type scales with the smaller dimension,
so small labels stay legible. Common sizes:
| Stock | mm |
|---|---|
| Card size | 86 × 54 |
| Brother DK-11202 shipping | 100 × 62 |
| Brother DK-11209 small address | 62 × 29 |
| Dymo 99014 shipping | 101 × 54 |
Use **Preview badge** to check the layout in a browser before committing a roll to it. The kiosk
browser needs the label printer set as its default, with margins off and scaling at 100%.
Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label
noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is
what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type
scales with the constraining dimension, so small labels stay readable.
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
button on the confirmation screen if the first one jams.
### Brother QL-820NWB
The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a
continuous roll, 62 × 90 mm is a good visitor badge.
It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is
the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls:
| Roll | Size | Good for |
|---|---|---|
| DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge |
| DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose |
| DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below |
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
| DK-11209 | 29 × 62 mm die-cut | Name and host only |
**The red option.** Tick *Print the heading and the no-check warning in red* and the site name
and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check
obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly
15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing.
**Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running
the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to
100%. Then use **Preview badge** in the console and print one before committing a roll — the
preview renders at the exact millimetre size the printer will receive.
The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
it as a network printer on whichever device drives the kiosk browser.
## WWCC and VIT expiry warnings
Give a recurring visitor an expiry date and the console watches it. Inside
@@ -275,12 +299,34 @@ spreadsheet covers every site.
## Recurring visitors and PINs
Each saved person is one record: **mobile number, email address and PIN are all unique**, checked
when a record is added or edited and enforced by the database. If an existing database already
contains duplicates, the startup log names who collides and the checks stay at the application
level until you fix them.
### A photo on file
Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded
file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting,
and the sign in completes with their pass printing immediately.
The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a
true snapshot: replacing someone's photo later does not change what past visits show, and photo
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.
### PINs
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
15 minutes.
Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on
simultaneous recurring visitors. Deactivating someone frees theirs.
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
PINs from the admin console afterwards.
+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 */
+95 -2
View File
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
import { decryptPin, pinLookup } from './pins.js';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.photoDir, { recursive: true });
@@ -16,9 +17,10 @@ CREATE TABLE IF NOT EXISTS sites (
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
badge_enabled INTEGER NOT NULL DEFAULT 0,
badge_width_mm REAL NOT NULL DEFAULT 86,
badge_height_mm REAL NOT NULL DEFAULT 54,
badge_width_mm REAL NOT NULL DEFAULT 62,
badge_height_mm REAL NOT NULL DEFAULT 100,
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_accent INTEGER NOT NULL DEFAULT 0,
badge_note TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -46,6 +48,8 @@ CREATE TABLE IF NOT EXISTS frequent_visitors (
check_expiry TEXT,
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
pin_enc TEXT NOT NULL,
pin_lookup TEXT,
photo_path TEXT,
notes TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -138,8 +142,17 @@ addColumn('hosts', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE CASCADE');
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
addColumn('visits', 'site_name', 'TEXT');
addColumn('visits', 'check_expiry', 'TEXT');
addColumn('visits', 'photo_path', 'TEXT');
// NULL site_id on a recurring visitor means they are welcome at every site.
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
// A photo kept on file, so a regular visitor is not asked to pose every visit.
addColumn('frequent_visitors', 'photo_path', 'TEXT');
// PINs are stored encrypted with a random IV, so the same PIN encrypts differently
// every time and cannot be compared. This deterministic digest makes the uniqueness
// check and the index possible.
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
// Two-colour printing, for rolls like the Brother DK-22251.
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
@@ -156,6 +169,86 @@ db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.i
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
/* -------------------------------------------------- one record per person */
/**
* Existing records predate the PIN digest, so fill it in once. Without this,
* a legacy visitor's PIN would be invisible to the uniqueness check and could be
* handed out to somebody else.
*/
const needingLookup = db
.prepare('SELECT id, pin_enc FROM frequent_visitors WHERE pin_lookup IS NULL')
.all();
if (needingLookup.length) {
const setLookup = db.prepare('UPDATE frequent_visitors SET pin_lookup = ? WHERE id = ?');
let filled = 0;
for (const row of needingLookup) {
const pin = decryptPin(row.pin_enc);
if (pin) {
setLookup.run(pinLookup(pin), row.id);
filled += 1;
}
}
console.log(`[db] indexed ${filled} existing PIN(s) for the uniqueness check`);
}
/** Names any records that already collide, so an admin knows who to fix. */
function reportDuplicates(column, label) {
const rows = db
.prepare(
`SELECT ${column} AS value, GROUP_CONCAT(first_name || ' ' || last_name, ', ') AS people
FROM frequent_visitors
WHERE ${column} IS NOT NULL AND ${column} <> ''
GROUP BY ${column} HAVING COUNT(*) > 1`
)
.all();
for (const row of rows) {
console.warn(`[db] duplicate ${label} shared by: ${row.people}`);
}
return rows.length;
}
const duplicates =
reportDuplicates('lower(email)', 'email address') + reportDuplicates('pin_lookup', 'PIN');
if (duplicates) {
console.warn(
'[db] Fix the records above in Admin -> Recurring visitors. Until then the database ' +
'cannot enforce uniqueness, though new and edited records are still checked.'
);
}
/**
* Saved people must be unique on mobile number, email address and PIN. The phone
* column has carried a UNIQUE constraint from the start; these add the other two.
* Existing data may already contain duplicates, so a failure here is reported
* rather than thrown — the application-level checks still refuse new collisions.
*/
function addUniqueIndex(name, sql, what) {
try {
db.exec(sql);
} catch (err) {
console.warn(
`[db] could not enforce unique ${what}: ${err.message}\n` +
` Existing records collide. Fix them in Admin -> Recurring visitors; ` +
`new and edited records are still checked.`
);
}
}
addUniqueIndex(
'idx_freq_email',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_email
ON frequent_visitors(lower(email)) WHERE email IS NOT NULL AND email <> ''`,
'email addresses'
);
addUniqueIndex(
'idx_freq_pin',
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_pin
ON frequent_visitors(pin_lookup) WHERE pin_lookup IS NOT NULL`,
'PINs'
);
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
+23
View File
@@ -31,6 +31,29 @@ export function savePhoto(dataUrl) {
return path.join(folder, name);
}
/**
* Copies a recurring visitor's stored photo into a new file for one visit.
*
* A copy rather than a shared reference on purpose: the visit record is a snapshot
* of who was in the building that day, so replacing someone's profile photo later
* must not retroactively change what every past visit shows. It also keeps photo
* retention simple — purging old visits can never delete a live profile photo.
*/
export function copyStoredPhoto(relative) {
const source = photoAbsolutePath(relative);
if (!source) return null;
const now = new Date();
const folder = path.join(String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'));
const dir = path.join(config.photoDir, folder);
fs.mkdirSync(dir, { recursive: true });
const ext = path.extname(source) || '.jpeg';
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}${ext}`;
fs.copyFileSync(source, path.join(dir, name));
return path.join(folder, name);
}
export function photoAbsolutePath(relative) {
if (!relative) return null;
const resolved = path.resolve(config.photoDir, relative);
+24 -8
View File
@@ -43,12 +43,28 @@ export function verifyPin(stored, candidate) {
return crypto.timingSafeEqual(a, b);
}
export function generatePin() {
// Avoids the handful of PINs people will misread on a printed pass.
const banned = new Set(['0000', '1111', '1234', '4321', '9999']);
let pin;
do {
pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
} while (banned.has(pin));
return pin;
// PINs people will misread on a printed pass, or guess first.
const BANNED_PINS = new Set(['0000', '1111', '1234', '4321', '9999', '1122', '2580']);
/**
* A deterministic digest of a PIN, so two records can be compared without either
* being decrypted. Keyed with APP_SECRET, so the database alone does not let
* anyone build a lookup table of all 10,000 possibilities.
*/
export function pinLookup(pin) {
return crypto.createHmac('sha256', key).update(String(pin)).digest('hex');
}
/**
* A PIN nobody else holds. `isTaken` is passed in by the caller so this module
* stays free of database knowledge.
*/
export function generatePin(isTaken = () => false) {
for (let attempt = 0; attempt < 200; attempt += 1) {
const pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
if (!BANNED_PINS.has(pin) && !isTaken(pin)) return pin;
}
throw new Error(
'No unused 4 digit PIN could be found. Deactivate some old recurring visitors first.'
);
}
+83 -18
View File
@@ -4,8 +4,8 @@ import QRCode from 'qrcode';
import fs from 'node:fs';
import db from '../db.js';
import config from '../config.js';
import { decryptPin, encryptPin, generatePin } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
import * as sheets from '../sheets.js';
import * as tls from '../tls.js';
import * as users from '../users.js';
@@ -378,7 +378,7 @@ router.patch('/sites/:id', (req, res) => {
const badge = req.body?.badge || {};
db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
).run(
clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -387,6 +387,7 @@ router.patch('/sites/:id', (req, res) => {
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent,
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
, site.id);
@@ -549,6 +550,7 @@ function shapeFrequent(row, includePin = false) {
checkExpiry: row.check_expiry,
defaultHostId: row.default_host_id,
siteId: row.site_id,
hasPhoto: Boolean(row.photo_path),
notes: row.notes,
active: Boolean(row.active),
createdAt: row.created_at,
@@ -607,7 +609,25 @@ router.get('/alerts', (req, res) => {
});
});
function validateFrequent(body, { existingPhone = null } = {}) {
/** True when another recurring visitor already holds this PIN. */
function pinTaken(pin, excludeId = null) {
const row = db
.prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?')
.get(pinLookup(pin));
return Boolean(row) && row.id !== excludeId;
}
function allocatePin(requested, excludeId = null) {
if (requested && /^\d{4}$/.test(String(requested))) {
if (pinTaken(String(requested), excludeId)) {
throw new Error('Another recurring visitor already uses that PIN. Choose a different one.');
}
return String(requested);
}
return generatePin((candidate) => pinTaken(candidate, excludeId));
}
function validateFrequent(body, { existingPhone = null, existingId = null } = {}) {
const firstName = titleCase(body?.firstName, 60);
const lastName = titleCase(body?.lastName, 60);
const phone = normalisePhone(body?.phone);
@@ -621,9 +641,24 @@ function validateFrequent(body, { existingPhone = null } = {}) {
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
throw new Error(`A ${checkType} number is required.`);
}
if (phone !== existingPhone) {
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
// One record per person: mobile number, email and PIN must each be unique.
const phoneClash = db
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?')
.get(phone);
if (phoneClash && phoneClash.id !== existingId) {
throw new Error(
`${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.`
);
}
if (email) {
const emailClash = db
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?')
.get(email);
if (emailClash && emailClash.id !== existingId) {
throw new Error(
`${emailClash.first_name} ${emailClash.last_name} already uses that email address.`
);
}
}
return {
firstName,
@@ -644,17 +679,19 @@ router.post('/frequent', (req, res) => {
const v = validateFrequent(req.body);
const scope = scopedSiteId(req);
const siteId = scope || v.siteId;
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
const pin = allocatePin(req.body?.pin);
const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null;
const info = db
.prepare(
`INSERT INTO frequent_visitors
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
default_host_id, site_id, pin_enc, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), v.notes
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
);
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
} catch (err) {
@@ -666,16 +703,31 @@ router.patch('/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.' });
try {
const v = validateFrequent({ ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone });
const v = validateFrequent(
{ ...shapeFrequent(row), ...req.body },
{ existingPhone: row.phone, existingId: row.id }
);
const scope = scopedSiteId(req);
// A new photo replaces the old file; removePhoto clears it entirely.
let photoPath = row.photo_path;
if (req.body?.photo) {
photoPath = savePhoto(req.body.photo);
if (row.photo_path) deletePhoto(row.photo_path);
} else if (req.body?.removePhoto) {
if (row.photo_path) deletePhoto(row.photo_path);
photoPath = null;
}
db.prepare(
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
).run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId,
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
photoPath,
v.notes,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
row.id
@@ -686,14 +738,27 @@ router.patch('/frequent/:id', (req, res) => {
}
});
/** The photo kept on file for a recurring visitor. */
router.get('/frequent/:id/photo', (req, res) => {
const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id);
const abs = row && photoAbsolutePath(row.photo_path);
if (!abs) return res.status(404).send('No photo on file.');
res.setHeader('Cache-Control', 'private, max-age=60');
res.sendFile(abs);
});
router.post('/frequent/:id/pin', (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.' });
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
encryptPin(pin),
row.id
);
let pin;
try {
pin = allocatePin(req.body?.pin, row.id);
} catch (err) {
return res.status(400).json({ error: err.message });
}
db.prepare(
"UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?"
).run(encryptPin(pin), pinLookup(pin), row.id);
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
res.json({ ok: true, pin });
});
+9 -2
View File
@@ -2,7 +2,7 @@ import express from 'express';
import rateLimit from 'express-rate-limit';
import db from '../db.js';
import config from '../config.js';
import { savePhoto, photoAbsolutePath } from '../photos.js';
import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js';
@@ -150,10 +150,15 @@ router.post('/signin', signInLimiter, (req, res) => {
});
}
// A recurring visitor with a photo on file is not asked to pose again; the
// stored photo is copied onto this visit as its own snapshot.
let photoPath = null;
if (body.photo) {
photoPath = savePhoto(body.photo);
} else if (config.requirePhoto) {
} else if (isFrequent && frequent.photo_path) {
photoPath = copyStoredPhoto(frequent.photo_path);
}
if (!photoPath && config.requirePhoto) {
return res.status(400).json({ error: 'A photo is required to sign in.' });
}
@@ -336,6 +341,8 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
checkType: person.check_type,
checkNumber: person.check_number,
defaultHostId: person.default_host_id,
// Tells the kiosk it can skip the camera step entirely.
hasPhoto: Boolean(person.photo_path),
openVisit: open || null,
});
});
+73 -34
View File
@@ -53,6 +53,7 @@ export function shapeSite(site) {
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
accent: Boolean(site.badge_accent),
note: site.badge_note,
},
};
@@ -70,14 +71,43 @@ const esc = (value) =>
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
*/
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
const width = Number(site.badge_width_mm) || 86;
const height = Number(site.badge_height_mm) || 54;
const width = Number(site.badge_width_mm) || 62;
const height = Number(site.badge_height_mm) || 100;
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
// Scale the type with the smaller dimension so tiny labels stay legible.
const unit = Math.min(width, height);
const nameSize = Math.max(3.4, unit * 0.115);
const bodySize = Math.max(2.1, unit * 0.062);
// A label noticeably taller than it is wide gets a stacked layout. That is the
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
// fixes the width and the length runs down the badge.
const portrait = height >= width * 1.2;
// Type scales with the dimension that constrains it: the width on a portrait
// badge, the shorter side on a wide one. Keeps small stock legible.
const unit = portrait ? width : Math.min(width, height);
const pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
const photoWidth = portrait ? unit * 0.52 : unit * 0.42;
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
// else it prints as grey, so it is off unless the site opts in.
const accent = site.badge_accent ? '#d00019' : '#000';
const timeIn = new Date(visit.signed_in_at);
const noCheck = visit.check_type === 'NONE';
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
const details = `
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
noCheck
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>`;
return `<!doctype html>
<html lang="en-AU">
@@ -91,27 +121,40 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
padding: ${pad}mm;
display: flex;
gap: ${unit * 0.07}mm;
align-items: stretch;
flex-direction: ${portrait ? 'column' : 'row'};
align-items: ${portrait ? 'center' : 'stretch'};
text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${unit * 0.42}mm;
width: ${photoWidth}mm;
${portrait ? `height: ${photoWidth * 0.78}mm;` : ''}
flex: 0 0 auto;
object-fit: cover;
border: 0.3mm solid #000;
}
.body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.body {
flex: 1 1 auto;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
${portrait ? 'align-items: center;' : ''}
}
.site {
font-size: ${bodySize * 0.85}mm;
letter-spacing: 0.02em;
border-bottom: 0.35mm solid #000;
padding-bottom: ${unit * 0.025}mm;
margin-bottom: ${unit * 0.045}mm;
width: 100%;
font-size: ${bodySize * 0.8}mm;
letter-spacing: 0.03em;
text-transform: uppercase;
color: ${accent};
border-bottom: 0.35mm solid ${accent};
padding-bottom: ${unit * 0.02}mm;
margin-bottom: ${unit * 0.035}mm;
}
.name {
font-size: ${nameSize}mm;
@@ -120,14 +163,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; }
.rows {
margin-top: auto;
padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm;
line-height: 1.35;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.03}mm; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.3mm solid #000;
font-size: ${bodySize * 0.85}mm;
border: 0.35mm solid ${accent};
color: ${accent};
font-weight: 700;
font-size: ${bodySize * 0.9}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
@@ -137,22 +187,11 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
</head>
<body>
<div class="badge">
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
${photo}
<div class="body">
<div class="site">${esc(site.name)} &middot; VISITOR</div>
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
visit.check_type === 'NONE'
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>
${details}
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}