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

This commit is contained in:
2026-09-03 14:43:03 +10:00
parent 8c97c314e0
commit e6731dbdfa
11 changed files with 169 additions and 31 deletions
+24
View File
@@ -20,6 +20,7 @@ two factor.
| | Guest sign in | Recurring visitor |
|---|---|---|
| First and last name | typed each visit | on file |
| Company or organisation | optional, typed each visit | on file |
| Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk |
| WWCC / VIT / none | typed each visit | on file |
@@ -27,6 +28,11 @@ two factor.
Sign out only needs a **last name** plus a **mobile number or email**, which works for both.
The company field is optional and clearly marked as such — plenty of visitors are not from
anywhere in particular. When it is filled in it appears on the badge, in the on-site list, on the
evacuation sheet, and in the visit log, and the log search matches on it, so you can pull up every
visit from one contractor.
## Quick start
```bash
@@ -439,6 +445,24 @@ docker compose up -d --build
**Changes to the code do nothing** — Compose reuses the existing image. Always
`docker compose up -d --build` after a `git pull`.
**Google Sheet says "The caller does not have permission"** — the app authenticated fine and
Google refused the spreadsheet. Work through these in order:
1. **Admin → System** shows the service account address. Open the sheet, press Share, paste that
address, set it to **Editor**, and untick "Notify people". This is the cause about nine times
in ten.
2. If your Google Workspace blocks sharing outside the organisation, the share will silently fail
or be refused — a service account address is external. Ask your Workspace admin to allow it,
or create the sheet in an account that permits external sharing.
3. If the sheet lives in a **Shared drive**, share the drive with the service account, not just
the file.
4. Check the spreadsheet ID matches the one in the sheet's URL. A wrong ID usually gives a 404,
but a valid ID for someone else's sheet gives this same 403.
5. Confirm the **Google Sheets API** is enabled on the project the key belongs to. A key from
project A cannot use an API enabled only on project B.
Press **Test the sheet connection** after each step.
**Browser still warns about the certificate** — the authority is installed but not trusted. On
iOS that is a second, separate step under Settings → General → About → Certificate Trust
Settings. On Android, use a hostname rather than a bare IP.
+1 -1
View File
@@ -52,7 +52,7 @@
<div class="filters">
<label><span>From</span><input type="date" id="log-from"></label>
<label><span>To</span><input type="date" id="log-to"></label>
<label class="grow"><span>Search name, host or contact</span><input id="log-q"></label>
<label class="grow"><span>Search name, company, host or contact</span><input id="log-q"></label>
<button class="ghost" id="log-search">Apply</button>
</div>
<div id="log-table"></div>
+3
View File
@@ -397,3 +397,6 @@ a:focus-visible {
min-height: 58px;
}
.picker:disabled { color: var(--muted); }
/* An optional field says so quietly, without shouting for attention. */
.field > span em { font-style: normal; opacity: 0.75; }
+5
View File
@@ -53,6 +53,11 @@
<span>Last name</span>
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Company or organisation <em>(optional)</em></span>
<input id="in-company" autocomplete="organization" autocapitalize="words" enterkeyhint="next"
placeholder="Leave blank if you're not here for work">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" data-next="guest-name">Continue</button>
+20 -3
View File
@@ -175,7 +175,8 @@ async function loadOnsite() {
(v) => `<tr>
<td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td>
<td><strong>${esc(v.firstName)} ${esc(v.lastName)}</strong>
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}</td>
${v.visitorType === 'frequent' ? '<span class="pill">Recurring</span>' : ''}
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
<td>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
@@ -220,7 +221,8 @@ async function loadLog() {
rows.map(
(v) => `<tr>
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
<td>${esc(v.firstName)} ${esc(v.lastName)}</td>
<td>${esc(v.firstName)} ${esc(v.lastName)}
${v.company ? `<small>${esc(v.company)}</small>` : ''}</td>
<td>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
<td>${esc(v.phone || v.email || '—')}</td>
@@ -258,6 +260,7 @@ async function loadRecurring() {
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>
${p.company ? `<small>${esc(p.company)}</small>` : ''}
${p.email ? `<small>${esc(p.email)}</small>` : ''}</td>
<td class="mono">${esc(p.phone)}</td>
<td>${p.siteId ? esc(siteName(p.siteId)) : '<span class="pill off">Any site</span>'}</td>
@@ -470,6 +473,7 @@ function openRecurringModal(person = null) {
`
${field('First name', 'firstName', person?.firstName)}
${field('Last name', 'lastName', person?.lastName)}
${field('Company or organisation (optional)', 'company', person?.company)}
${field('Mobile number (their username)', 'phone', person?.phone, 'tel')}
${field('Email address', 'email', person?.email, 'email')}
<label class="modal-field"><span>Check held</span>
@@ -1155,7 +1159,20 @@ async function loadSystem() {
s.sheets.enabled
? `<dt>On the sheet</dt><dd>${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
}, last written ${stamp(s.sheets.lastOk)}</dd>`
}, last written ${stamp(s.sheets.lastOk)}</dd>
<dt>Service account</dt>
<dd>${
s.sheets.serviceAccount
? `<code>${esc(s.sheets.serviceAccount)}</code>
<br><span class="hint">The spreadsheet must be shared with this address, with Editor access.</span>`
: '<span class="pill bad">No key file could be read</span>'
}</dd>
<dt>Spreadsheet</dt>
<dd>${
s.sheets.spreadsheetId
? `<a href="https://docs.google.com/spreadsheets/d/${esc(s.sheets.spreadsheetId)}/edit" target="_blank" rel="noopener">open the sheet</a>`
: '<span class="pill warn">SHEETS_SPREADSHEET_ID is not set</span>'
}</dd>`
: ''
}
</dl>
+5
View File
@@ -10,6 +10,7 @@ const state = {
mode: 'guest',
firstName: '',
lastName: '',
company: '',
hostId: null,
hostName: '',
phone: '',
@@ -136,6 +137,7 @@ function resetState() {
mode: 'guest',
firstName: '',
lastName: '',
company: '',
hostId: null,
hostName: '',
phone: '',
@@ -338,6 +340,7 @@ function afterPhoto() {
function buildReview() {
const rows = [
['Name', `${state.firstName} ${state.lastName}`],
...(state.company ? [['From', state.company]] : []),
['Visiting', state.hostName],
['Mobile', state.phone || '—'],
['Email', state.email || '—'],
@@ -369,6 +372,7 @@ async function submitSignIn() {
frequentVisitorId: state.frequentVisitorId,
firstName: state.firstName,
lastName: state.lastName,
company: state.company,
hostId: state.hostId,
phone: state.phone,
email: state.email,
@@ -520,6 +524,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
if (!last) return say('Enter your last name.');
state.firstName = first;
state.lastName = last;
state.company = $('#in-company').value.trim();
show('guest-host');
});
+5
View File
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS frequent_visitors (
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
company TEXT,
phone TEXT NOT NULL UNIQUE,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
@@ -71,6 +72,7 @@ CREATE TABLE IF NOT EXISTS visits (
frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
company TEXT,
phone TEXT,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
@@ -168,6 +170,9 @@ addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', 'TEXT');
// Optional "who are you from", handy for contractors and visiting staff.
addColumn('visits', 'company', 'TEXT');
addColumn('frequent_visitors', 'company', 'TEXT');
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)');
+19 -10
View File
@@ -606,6 +606,7 @@ function shapeFrequent(row, includePin = false) {
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
company: row.company,
phone: row.phone,
email: row.email,
checkType: row.check_type,
@@ -737,6 +738,7 @@ function validateFrequent(body, { existingPhone = null, existingId = null } = {}
return {
firstName,
lastName,
company: clean(body?.company, 80) || null,
phone,
email: email || null,
checkType,
@@ -759,13 +761,13 @@ router.post('/frequent', (req, res) => {
const info = db
.prepare(
`INSERT INTO frequent_visitors
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
(first_name, last_name, company, phone, email, check_type, check_number, check_expiry,
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber,
v.checkExpiry, 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) {
@@ -794,11 +796,11 @@ router.patch('/frequent/:id', (req, res) => {
}
db.prepare(
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?,
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_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.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId,
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
photoPath,
@@ -892,6 +894,7 @@ function shapeVisit(v) {
visitorType: v.visitor_type,
firstName: v.first_name,
lastName: v.last_name,
company: v.company,
phone: v.phone,
email: v.email,
checkType: v.check_type,
@@ -932,8 +935,10 @@ router.get('/visits', (req, res) => {
params.push(`${to}T23:59:59.999Z`);
}
if (q) {
where.push('(last_name LIKE ? OR first_name LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
where.push(
'(last_name LIKE ? OR first_name LIKE ? OR company LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)'
);
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`);
}
const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`;
res.json(db.prepare(sql).all(...params).map(shapeVisit));
@@ -969,9 +974,9 @@ router.get('/visits.csv', (req, res) => {
.all(...params);
const csv = toCsv([
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
['Visit ID', 'Site', 'Type', 'First name', 'Last name', 'Company', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'],
...rows.map((v) => [
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.phone, v.email,
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.company, v.phone, v.email,
v.check_type, v.check_number, v.host_name, v.visit_reason,
localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by,
v.photo_path ? 'yes' : 'no',
@@ -1041,6 +1046,7 @@ router.get('/pass/:id', (req, res) => {
border:1px solid var(--rule); }
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
.site { margin:0 0 14px; font-size:12px; color:var(--muted); }
.org { margin: 3px 0 0; font-size: 13px; color: var(--muted); }
.pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
font-variant-numeric: tabular-nums; color: var(--deep); }
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); }
@@ -1064,6 +1070,7 @@ router.get('/pass/:id', (req, res) => {
<div class="card">
<p class="site">${esc(site ? site.name : config.siteName)}</p>
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
${row.company ? `<p class="org">${esc(row.company)}</p>` : ''}
<p class="pin">${esc(pin)}</p>
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
<dl>
@@ -1105,6 +1112,8 @@ router.get('/status', (req, res) => {
lastError: sheets.status.lastError,
tab: sheets.tabName(),
stale: sheets.isStale(),
serviceAccount: sheets.serviceAccountEmail(),
spreadsheetId: config.sheets.spreadsheetId || null,
lastOnSiteSync: sheets.status.lastOnSiteSync,
onSiteCount: sheets.status.onSiteCount,
onSiteError: sheets.status.onSiteError,
+8 -3
View File
@@ -141,6 +141,8 @@ router.post('/signin', signInLimiter, (req, res) => {
const checkType = isFrequent ? frequent.check_type : clean(body.checkType, 10).toUpperCase();
const checkNumber = clean(isFrequent ? frequent.check_number : body.checkNumber, 40);
const checkExpiry = isFrequent ? frequent.check_expiry : clean(body.checkExpiry, 20);
// Optional: plenty of visitors are not from anywhere in particular.
const company = clean(isFrequent ? frequent.company : body.company, 80);
const visitReason = clean(body.visitReason, 120);
if (!firstName) return res.status(400).json({ error: 'First name is required.' });
@@ -184,9 +186,10 @@ router.post('/signin', signInLimiter, (req, res) => {
const info = db
.prepare(
`INSERT INTO visits
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, phone, email,
check_type, check_number, check_expiry, host_id, host_name, visit_reason, photo_path, signed_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, company,
phone, email, check_type, check_number, check_expiry, host_id, host_name, visit_reason,
photo_path, signed_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
site.id,
@@ -195,6 +198,7 @@ router.post('/signin', signInLimiter, (req, res) => {
isFrequent ? frequent.id : null,
firstName,
lastName,
company || null,
phone || null,
email || null,
checkType,
@@ -358,6 +362,7 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
lastName: person.last_name,
checkType: person.check_type,
checkNumber: person.check_number,
company: person.company,
defaultHostId: person.default_host_id,
// Tells the kiosk it can skip the camera step entirely.
hasPhoto: Boolean(person.photo_path),
+61 -8
View File
@@ -19,6 +19,7 @@ const HEADER = [
'Site',
'First name',
'Last name',
'Company',
'Visiting',
'Phone',
'Email',
@@ -65,6 +66,49 @@ function getClient() {
return client;
}
/**
* The service account's own address. Nothing works until the spreadsheet is
* shared with it, and it is buried in a JSON key file nobody wants to open on a
* server, so the admin console shows it.
*/
export function serviceAccountEmail() {
try {
return loadCredentials().client_email || null;
} catch {
return null;
}
}
/**
* Google's own wording for these failures says what went wrong but never what to
* do about it, so the common ones are rewritten with the actual next step.
*/
function explain(err) {
const code = err?.code || err?.response?.status;
const raw = String(err?.message || '');
const email = serviceAccountEmail();
if (code === 403 && /caller does not have permission|permission/i.test(raw)) {
return (
`The service account cannot open this spreadsheet. Share the sheet with ` +
`${email || 'the service account address'} and give it Editor access.`
);
}
if (code === 403 && /has not been used|accessNotConfigured|disabled/i.test(raw)) {
return 'The Google Sheets API is not enabled on that Google Cloud project. Enable it, then wait a minute and retry.';
}
if (code === 404) {
return 'No spreadsheet was found with that ID. Check SHEETS_SPREADSHEET_ID against the sheet URL.';
}
if (code === 400 && /Unable to parse range/i.test(raw)) {
return `The tab "${config.sheets.onSiteTab}" could not be addressed. Check SHEETS_ONSITE_TAB matches the tab name exactly.`;
}
if (/invalid_grant|Invalid JWT|clock/i.test(raw)) {
return "Google rejected the credentials. Check the server's clock is correct and the service account key has not been deleted.";
}
return raw || 'Unknown error talking to Google Sheets.';
}
export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
@@ -116,6 +160,7 @@ function onSiteRows() {
v.site_name || '',
v.first_name,
v.last_name,
v.company || '',
v.host_name,
v.phone || '',
v.email || '',
@@ -146,7 +191,7 @@ export async function syncOnSite() {
await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.onSiteTab}!A1:J${MAX_ROWS + 10}`,
range: `${config.sheets.onSiteTab}!A1:K${MAX_ROWS + 10}`,
});
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
@@ -162,8 +207,10 @@ export async function syncOnSite() {
return { rows: rows.length };
} catch (err) {
dirty = true;
status.lastError = err.message;
throw err;
status.lastError = explain(err);
const wrapped = new Error(status.lastError);
wrapped.cause = err;
throw wrapped;
} finally {
syncing = false;
}
@@ -182,11 +229,17 @@ export function mirror() {
export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureTab(sheets, { force: true });
await syncOnSite();
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
try {
const sheets = getClient();
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await ensureTab(sheets, { force: true });
await syncOnSite();
status.lastError = null;
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab };
} catch (err) {
status.lastError = explain(err);
throw new Error(status.lastError);
}
}
export function tabName() {
+18 -6
View File
@@ -97,9 +97,12 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
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));
// Square, matching the crop taken at the kiosk. Sized so that even a long name
// wrapping onto two lines still fits above the detail rows on a 62 x 90 mm label.
const photoWidth = portrait ? unit * 0.52 : unit * 0.5;
// Square, matching the crop taken at the kiosk. A company adds a line under the
// name, so on a portrait badge the photo gives that line back rather than
// letting a long name get clipped at the bottom edge.
const roomy = height >= width * 1.5;
const portraitPhoto = visit.company && !roomy ? 0.44 : 0.52;
const photoWidth = portrait ? unit * portraitPhoto : unit * 0.5;
// 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.
@@ -178,13 +181,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.org {
font-size: ${bodySize * 0.95}mm;
line-height: 1.2;
margin-top: ${unit * 0.02}mm;
overflow-wrap: anywhere;
}
.rows {
/* Portrait badges centre the whole block; wider ones push the detail rows to
the bottom edge, which is where the eye expects them beside a photo. */
margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'};
padding-top: ${unit * 0.04}mm;
margin-top: ${portrait ? `${unit * 0.045}mm` : 'auto'};
padding-top: ${unit * 0.035}mm;
font-size: ${bodySize}mm;
line-height: 1.35;
/* Tightened from 1.35 to make room for the company line without shrinking
the photo, which is the thing people actually look at. */
line-height: 1.28;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
@@ -208,6 +219,7 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
<div class="body">
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
${visit.company ? `<div class="org">${esc(visit.company)}</div>` : ''}
${details}
</div>
</div>