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

This commit is contained in:
2026-08-31 09:47:36 +10:00
commit ed77493817
32 changed files with 5799 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
import db from './db.js';
import { clean, localStamp } from './util.js';
export function listSites({ activeOnly = false } = {}) {
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
return db.prepare(sql).all();
}
export function getSite(idOrSlug) {
if (idOrSlug === undefined || idOrSlug === null || idOrSlug === '') return null;
const asNumber = Number(idOrSlug);
if (Number.isInteger(asNumber) && String(asNumber) === String(idOrSlug)) {
return db.prepare('SELECT * FROM sites WHERE id = ?').get(asNumber) || null;
}
return db.prepare('SELECT * FROM sites WHERE slug = ?').get(String(idOrSlug).toLowerCase()) || null;
}
/** Falls back to the only active site, which keeps single-site installs simple. */
export function resolveSite(idOrSlug) {
const found = getSite(idOrSlug);
if (found && found.active) return found;
const active = listSites({ activeOnly: true });
return active.length === 1 ? active[0] : found || null;
}
export function slugify(value) {
return clean(value, 60)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40);
}
export function uniqueSlug(base, excludeId = null) {
let slug = slugify(base) || 'site';
let n = 2;
while (true) {
const clash = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
if (!clash || clash.id === excludeId) return slug;
slug = `${slugify(base)}-${n}`;
n += 1;
}
}
export function shapeSite(site) {
return {
id: site.id,
name: site.name,
slug: site.slug,
active: Boolean(site.active),
badge: {
enabled: Boolean(site.badge_enabled),
widthMm: site.badge_width_mm,
heightMm: site.badge_height_mm,
showPhoto: Boolean(site.badge_show_photo),
note: site.badge_note,
},
};
}
/* --------------------------------------------------------------- badge */
const esc = (value) =>
String(value ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
/**
* A self-contained print page sized to the site's label stock. It calls print()
* 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 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);
const timeIn = new Date(visit.signed_in_at);
return `<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<title>Badge — ${esc(visit.first_name)} ${esc(visit.last_name)}</title>
<style>
@page { size: ${width}mm ${height}mm; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; }
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
display: flex;
gap: ${unit * 0.07}mm;
align-items: stretch;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${unit * 0.42}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; }
.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;
}
.name {
font-size: ${nameSize}mm;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows { margin-top: auto; 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; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.3mm solid #000;
font-size: ${bodySize * 0.85}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
.badge { background: #fff; box-shadow: 0 2mm 6mm rgba(0,0,0,.2); margin: 0 auto; }
}
</style>
</head>
<body>
<div class="badge">
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
<div class="body">
<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>
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
</body>
</html>`;
}
export { esc as escapeHtml, localStamp };