diff --git a/README.md b/README.md
index a926be9..9afd3be 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/public/admin.html b/public/admin.html
index 746c628..c3f35ba 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -52,7 +52,7 @@
From
To
- Search name, host or contact
+ Search name, company, host or contact
Apply
diff --git a/public/css/kiosk.css b/public/css/kiosk.css
index cb7b668..fd8177b 100644
--- a/public/css/kiosk.css
+++ b/public/css/kiosk.css
@@ -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; }
diff --git a/public/index.html b/public/index.html
index 714aaf2..6132d1a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -53,6 +53,11 @@
Last name
+
+ Company or organisation (optional)
+
+
Back
Continue
diff --git a/public/js/admin.js b/public/js/admin.js
index 3e5b3ea..2b81a5d 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -175,7 +175,8 @@ async function loadOnsite() {
(v) => `
${v.hasPhoto ? ` ` : ''}
${esc(v.firstName)} ${esc(v.lastName)}
- ${v.visitorType === 'frequent' ? 'Recurring ' : ''}
+ ${v.visitorType === 'frequent' ? 'Recurring ' : ''}
+ ${v.company ? `${esc(v.company)} ` : ''}
${showSite ? `${esc(v.siteName || '—')} ` : ''}
${esc(v.hostName)}
${v.checkType === 'NONE' ? 'None ' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}
@@ -220,7 +221,8 @@ async function loadLog() {
rows.map(
(v) => `
${showSite ? `${esc(v.siteName || '—')} ` : ''}
- ${esc(v.firstName)} ${esc(v.lastName)}
+ ${esc(v.firstName)} ${esc(v.lastName)}
+ ${v.company ? `${esc(v.company)} ` : ''}
${esc(v.hostName)}
${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`}
${esc(v.phone || v.email || '—')}
@@ -258,6 +260,7 @@ async function loadRecurring() {
rows.map(
(p) => `
${esc(p.firstName)} ${esc(p.lastName)}
+ ${p.company ? `${esc(p.company)} ` : ''}
${p.email ? `${esc(p.email)} ` : ''}
${esc(p.phone)}
${p.siteId ? esc(siteName(p.siteId)) : 'Any site '}
@@ -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')}
Check held
@@ -1155,7 +1159,20 @@ async function loadSystem() {
s.sheets.enabled
? `On the sheet ${
s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site`
- }, last written ${stamp(s.sheets.lastOk)} `
+ }, last written ${stamp(s.sheets.lastOk)}
+ Service account
+ ${
+ s.sheets.serviceAccount
+ ? `${esc(s.sheets.serviceAccount)}
+ The spreadsheet must be shared with this address, with Editor access. `
+ : 'No key file could be read '
+ }
+ Spreadsheet
+ ${
+ s.sheets.spreadsheetId
+ ? `open the sheet `
+ : 'SHEETS_SPREADSHEET_ID is not set '
+ } `
: ''
}
diff --git a/public/js/kiosk.js b/public/js/kiosk.js
index 001e0a0..5598fb1 100644
--- a/public/js/kiosk.js
+++ b/public/js/kiosk.js
@@ -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');
});
diff --git a/src/db.js b/src/db.js
index b7d0e30..0715da1 100644
--- a/src/db.js
+++ b/src/db.js
@@ -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)');
diff --git a/src/routes/admin.js b/src/routes/admin.js
index f5484ef..f5bf22e 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -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) => {
${esc(site ? site.name : config.siteName)}
${esc(row.first_name)} ${esc(row.last_name)}
+ ${row.company ? `
${esc(row.company)}
` : ''}
${esc(pin)}
Your PIN. Keep this card, it is not sent to you again.
@@ -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,
diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js
index 1b4d35e..e8c2190 100644
--- a/src/routes/kiosk.js
+++ b/src/routes/kiosk.js
@@ -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),
diff --git a/src/sheets.js b/src/sheets.js
index 900307a..3914ad4 100644
--- a/src/sheets.js
+++ b/src/sheets.js
@@ -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() {
diff --git a/src/sites.js b/src/sites.js
index 390b62b..114ab57 100644
--- a/src/sites.js
+++ b/src/sites.js
@@ -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 } = {
${esc(site.name)} · Visitor
${esc(visit.first_name)} ${esc(visit.last_name)}
+ ${visit.company ? `
${esc(visit.company)}
` : ''}
${details}