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 | | | Guest sign in | Recurring visitor |
|---|---|---| |---|---|---|
| First and last name | typed each visit | on file | | 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 | | Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | on file if saved, otherwise 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 | | 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. 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 ## Quick start
```bash ```bash
@@ -439,6 +445,24 @@ docker compose up -d --build
**Changes to the code do nothing** — Compose reuses the existing image. Always **Changes to the code do nothing** — Compose reuses the existing image. Always
`docker compose up -d --build` after a `git pull`. `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 **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 iOS that is a second, separate step under Settings → General → About → Certificate Trust
Settings. On Android, use a hostname rather than a bare IP. Settings. On Android, use a hostname rather than a bare IP.
+1 -1
View File
@@ -52,7 +52,7 @@
<div class="filters"> <div class="filters">
<label><span>From</span><input type="date" id="log-from"></label> <label><span>From</span><input type="date" id="log-from"></label>
<label><span>To</span><input type="date" id="log-to"></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> <button class="ghost" id="log-search">Apply</button>
</div> </div>
<div id="log-table"></div> <div id="log-table"></div>
+3
View File
@@ -397,3 +397,6 @@ a:focus-visible {
min-height: 58px; min-height: 58px;
} }
.picker:disabled { color: var(--muted); } .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> <span>Last name</span>
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next"> <input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label> </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"> <div class="nav">
<button class="ghost" data-go="home">Back</button> <button class="ghost" data-go="home">Back</button>
<button class="primary" data-next="guest-name">Continue</button> <button class="primary" data-next="guest-name">Continue</button>
+20 -3
View File
@@ -175,7 +175,8 @@ async function loadOnsite() {
(v) => `<tr> (v) => `<tr>
<td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td> <td>${v.hasPhoto ? `<img class="thumb" src="/admin/api/photo/${v.id}" alt="">` : ''}</td>
<td><strong>${esc(v.firstName)} ${esc(v.lastName)}</strong> <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>` : ''} ${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''}
<td>${esc(v.hostName)}</td> <td>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '<span class="pill off">None</span>' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</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( rows.map(
(v) => `<tr> (v) => `<tr>
${showSite ? `<td>${esc(v.siteName || '—')}</td>` : ''} ${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>${esc(v.hostName)}</td>
<td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td> <td>${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}</td>
<td>${esc(v.phone || v.email || '—')}</td> <td>${esc(v.phone || v.email || '—')}</td>
@@ -258,6 +260,7 @@ async function loadRecurring() {
rows.map( rows.map(
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}"> (p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong> <td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
${p.company ? `<small>${esc(p.company)}</small>` : ''}
${p.email ? `<small>${esc(p.email)}</small>` : ''}</td> ${p.email ? `<small>${esc(p.email)}</small>` : ''}</td>
<td class="mono">${esc(p.phone)}</td> <td class="mono">${esc(p.phone)}</td>
<td>${p.siteId ? esc(siteName(p.siteId)) : '<span class="pill off">Any site</span>'}</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('First name', 'firstName', person?.firstName)}
${field('Last name', 'lastName', person?.lastName)} ${field('Last name', 'lastName', person?.lastName)}
${field('Company or organisation (optional)', 'company', person?.company)}
${field('Mobile number (their username)', 'phone', person?.phone, 'tel')} ${field('Mobile number (their username)', 'phone', person?.phone, 'tel')}
${field('Email address', 'email', person?.email, 'email')} ${field('Email address', 'email', person?.email, 'email')}
<label class="modal-field"><span>Check held</span> <label class="modal-field"><span>Check held</span>
@@ -1155,7 +1159,20 @@ async function loadSystem() {
s.sheets.enabled s.sheets.enabled
? `<dt>On the sheet</dt><dd>${ ? `<dt>On the sheet</dt><dd>${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site` 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> </dl>
+5
View File
@@ -10,6 +10,7 @@ const state = {
mode: 'guest', mode: 'guest',
firstName: '', firstName: '',
lastName: '', lastName: '',
company: '',
hostId: null, hostId: null,
hostName: '', hostName: '',
phone: '', phone: '',
@@ -136,6 +137,7 @@ function resetState() {
mode: 'guest', mode: 'guest',
firstName: '', firstName: '',
lastName: '', lastName: '',
company: '',
hostId: null, hostId: null,
hostName: '', hostName: '',
phone: '', phone: '',
@@ -338,6 +340,7 @@ function afterPhoto() {
function buildReview() { function buildReview() {
const rows = [ const rows = [
['Name', `${state.firstName} ${state.lastName}`], ['Name', `${state.firstName} ${state.lastName}`],
...(state.company ? [['From', state.company]] : []),
['Visiting', state.hostName], ['Visiting', state.hostName],
['Mobile', state.phone || '—'], ['Mobile', state.phone || '—'],
['Email', state.email || '—'], ['Email', state.email || '—'],
@@ -369,6 +372,7 @@ async function submitSignIn() {
frequentVisitorId: state.frequentVisitorId, frequentVisitorId: state.frequentVisitorId,
firstName: state.firstName, firstName: state.firstName,
lastName: state.lastName, lastName: state.lastName,
company: state.company,
hostId: state.hostId, hostId: state.hostId,
phone: state.phone, phone: state.phone,
email: state.email, email: state.email,
@@ -520,6 +524,7 @@ $('[data-next="guest-name"]').addEventListener('click', () => {
if (!last) return say('Enter your last name.'); if (!last) return say('Enter your last name.');
state.firstName = first; state.firstName = first;
state.lastName = last; state.lastName = last;
state.company = $('#in-company').value.trim();
show('guest-host'); 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, site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
first_name TEXT NOT NULL, first_name TEXT NOT NULL,
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
company TEXT,
phone TEXT NOT NULL UNIQUE, phone TEXT NOT NULL UNIQUE,
email TEXT, email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE', 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, frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
first_name TEXT NOT NULL, first_name TEXT NOT NULL,
last_name TEXT NOT NULL, last_name TEXT NOT NULL,
company TEXT,
phone TEXT, phone TEXT,
email TEXT, email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE', check_type TEXT NOT NULL DEFAULT 'NONE',
@@ -168,6 +170,9 @@ addColumn('sites', 'colour_brand', 'TEXT');
addColumn('sites', 'colour_signout', 'TEXT'); addColumn('sites', 'colour_signout', 'TEXT');
addColumn('sites', 'colour_page', 'TEXT'); addColumn('sites', 'colour_page', 'TEXT');
addColumn('sites', 'colour_text', '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_visits_site ON visits(site_id, signed_out_at)');
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)'); 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, id: row.id,
firstName: row.first_name, firstName: row.first_name,
lastName: row.last_name, lastName: row.last_name,
company: row.company,
phone: row.phone, phone: row.phone,
email: row.email, email: row.email,
checkType: row.check_type, checkType: row.check_type,
@@ -737,6 +738,7 @@ function validateFrequent(body, { existingPhone = null, existingId = null } = {}
return { return {
firstName, firstName,
lastName, lastName,
company: clean(body?.company, 80) || null,
phone, phone,
email: email || null, email: email || null,
checkType, checkType,
@@ -759,13 +761,13 @@ router.post('/frequent', (req, res) => {
const info = db const info = db
.prepare( .prepare(
`INSERT INTO frequent_visitors `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) default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
) )
.run( .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.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes 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)); res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
} catch (err) { } catch (err) {
@@ -794,11 +796,11 @@ router.patch('/frequent/:id', (req, res) => {
} }
db.prepare( 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 = ?, check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?` photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
).run( ).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, v.defaultHostId,
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id), scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
photoPath, photoPath,
@@ -892,6 +894,7 @@ function shapeVisit(v) {
visitorType: v.visitor_type, visitorType: v.visitor_type,
firstName: v.first_name, firstName: v.first_name,
lastName: v.last_name, lastName: v.last_name,
company: v.company,
phone: v.phone, phone: v.phone,
email: v.email, email: v.email,
checkType: v.check_type, checkType: v.check_type,
@@ -932,8 +935,10 @@ router.get('/visits', (req, res) => {
params.push(`${to}T23:59:59.999Z`); params.push(`${to}T23:59:59.999Z`);
} }
if (q) { if (q) {
where.push('(last_name LIKE ? OR first_name LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)'); where.push(
params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); '(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`; 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)); res.json(db.prepare(sql).all(...params).map(shapeVisit));
@@ -969,9 +974,9 @@ router.get('/visits.csv', (req, res) => {
.all(...params); .all(...params);
const csv = toCsv([ 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) => [ ...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, 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, localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by,
v.photo_path ? 'yes' : 'no', v.photo_path ? 'yes' : 'no',
@@ -1041,6 +1046,7 @@ router.get('/pass/:id', (req, res) => {
border:1px solid var(--rule); } border:1px solid var(--rule); }
h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; } h1 { margin:0; font-size: 21px; letter-spacing:-0.01em; }
.site { margin:0 0 14px; font-size:12px; color:var(--muted); } .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; .pin { margin: 14px 0 4px; font-size: 46px; font-weight: 700; letter-spacing: 0.22em;
font-variant-numeric: tabular-nums; color: var(--deep); } font-variant-numeric: tabular-nums; color: var(--deep); }
.pin-label { margin:0 0 16px; font-size:12px; color:var(--muted); } .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"> <div class="card">
<p class="site">${esc(site ? site.name : config.siteName)}</p> <p class="site">${esc(site ? site.name : config.siteName)}</p>
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1> <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">${esc(pin)}</p>
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p> <p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
<dl> <dl>
@@ -1105,6 +1112,8 @@ router.get('/status', (req, res) => {
lastError: sheets.status.lastError, lastError: sheets.status.lastError,
tab: sheets.tabName(), tab: sheets.tabName(),
stale: sheets.isStale(), stale: sheets.isStale(),
serviceAccount: sheets.serviceAccountEmail(),
spreadsheetId: config.sheets.spreadsheetId || null,
lastOnSiteSync: sheets.status.lastOnSiteSync, lastOnSiteSync: sheets.status.lastOnSiteSync,
onSiteCount: sheets.status.onSiteCount, onSiteCount: sheets.status.onSiteCount,
onSiteError: sheets.status.onSiteError, 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 checkType = isFrequent ? frequent.check_type : clean(body.checkType, 10).toUpperCase();
const checkNumber = clean(isFrequent ? frequent.check_number : body.checkNumber, 40); const checkNumber = clean(isFrequent ? frequent.check_number : body.checkNumber, 40);
const checkExpiry = isFrequent ? frequent.check_expiry : clean(body.checkExpiry, 20); 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); const visitReason = clean(body.visitReason, 120);
if (!firstName) return res.status(400).json({ error: 'First name is required.' }); 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 const info = db
.prepare( .prepare(
`INSERT INTO visits `INSERT INTO visits
(site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, phone, email, (site_id, site_name, visitor_type, frequent_visitor_id, first_name, last_name, company,
check_type, check_number, check_expiry, host_id, host_name, visit_reason, photo_path, signed_in_at) phone, email, check_type, check_number, check_expiry, host_id, host_name, visit_reason,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` photo_path, signed_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) )
.run( .run(
site.id, site.id,
@@ -195,6 +198,7 @@ router.post('/signin', signInLimiter, (req, res) => {
isFrequent ? frequent.id : null, isFrequent ? frequent.id : null,
firstName, firstName,
lastName, lastName,
company || null,
phone || null, phone || null,
email || null, email || null,
checkType, checkType,
@@ -358,6 +362,7 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
lastName: person.last_name, lastName: person.last_name,
checkType: person.check_type, checkType: person.check_type,
checkNumber: person.check_number, checkNumber: person.check_number,
company: person.company,
defaultHostId: person.default_host_id, defaultHostId: person.default_host_id,
// Tells the kiosk it can skip the camera step entirely. // Tells the kiosk it can skip the camera step entirely.
hasPhoto: Boolean(person.photo_path), hasPhoto: Boolean(person.photo_path),
+61 -8
View File
@@ -19,6 +19,7 @@ const HEADER = [
'Site', 'Site',
'First name', 'First name',
'Last name', 'Last name',
'Company',
'Visiting', 'Visiting',
'Phone', 'Phone',
'Email', 'Email',
@@ -65,6 +66,49 @@ function getClient() {
return client; 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() { export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId); return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
} }
@@ -116,6 +160,7 @@ function onSiteRows() {
v.site_name || '', v.site_name || '',
v.first_name, v.first_name,
v.last_name, v.last_name,
v.company || '',
v.host_name, v.host_name,
v.phone || '', v.phone || '',
v.email || '', v.email || '',
@@ -146,7 +191,7 @@ export async function syncOnSite() {
await sheets.spreadsheets.values.clear({ await sheets.spreadsheets.values.clear({
spreadsheetId: config.sheets.spreadsheetId, 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({ await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId, spreadsheetId: config.sheets.spreadsheetId,
@@ -162,8 +207,10 @@ export async function syncOnSite() {
return { rows: rows.length }; return { rows: rows.length };
} catch (err) { } catch (err) {
dirty = true; dirty = true;
status.lastError = err.message; status.lastError = explain(err);
throw err; const wrapped = new Error(status.lastError);
wrapped.cause = err;
throw wrapped;
} finally { } finally {
syncing = false; syncing = false;
} }
@@ -182,11 +229,17 @@ export function mirror() {
export async function testConnection() { export async function testConnection() {
if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.'); if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.');
const sheets = getClient(); try {
const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); const sheets = getClient();
await ensureTab(sheets, { force: true }); const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId });
await syncOnSite(); await ensureTab(sheets, { force: true });
return { title: meta.data.properties.title, tab: config.sheets.onSiteTab }; 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() { 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 pad = unit * 0.07;
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115)); 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 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 // Square, matching the crop taken at the kiosk. A company adds a line under the
// wrapping onto two lines still fits above the detail rows on a 62 x 90 mm label. // name, so on a portrait badge the photo gives that line back rather than
const photoWidth = portrait ? unit * 0.52 : unit * 0.5; // 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 // 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. // 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; letter-spacing: -0.01em;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.org {
font-size: ${bodySize * 0.95}mm;
line-height: 1.2;
margin-top: ${unit * 0.02}mm;
overflow-wrap: anywhere;
}
.rows { .rows {
/* Portrait badges centre the whole block; wider ones push the detail rows to /* 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. */ the bottom edge, which is where the eye expects them beside a photo. */
margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'}; margin-top: ${portrait ? `${unit * 0.045}mm` : 'auto'};
padding-top: ${unit * 0.04}mm; padding-top: ${unit * 0.035}mm;
font-size: ${bodySize}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; } .rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; } .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="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="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
${visit.company ? `<div class="org">${esc(visit.company)}</div>` : ''}
${details} ${details}
</div> </div>
</div> </div>