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

This commit is contained in:
2026-09-02 16:21:42 +10:00
parent 74792b2764
commit ff9f51eaf9
10 changed files with 52 additions and 13 deletions
+5 -2
View File
@@ -108,8 +108,11 @@ Each site can carry its own banner and colours, set under **Sites → Edit**.
**Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the **Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the
one to use — it sits straight on the bar colour with nothing painted behind it, so it works one to use — it sits straight on the bar colour with nothing painted behind it, so it works
whatever colour you pick. The admin preview shows it on a checkerboard so you can see the whatever colour you pick. The admin preview shows it on a checkerboard so you can see the
transparency. When a banner is set it replaces the site name in the kiosk header; set the transparency. When a banner is set it replaces the site name in the kiosk header. Set the on-screen height in
on-screen height in pixels to suit the shape of your logo. SVG is deliberately not accepted: pixels to suit the shape of your logo, and choose whether it sits **left** or **centred** — the
header is a three-column layout, so a centred logo is centred on the page rather than centred in
whatever space the clock leaves over. The setting applies to the site name too, when no banner is
uploaded. SVG is deliberately not accepted:
it can carry script, and this file is served to every kiosk. it can carry script, and this file is served to every kiosk.
**Colours.** Three are settable: **Colours.** Three are settable:
+11 -4
View File
@@ -41,20 +41,27 @@ body {
/* ------------------------------------------------------------- chrome */ /* ------------------------------------------------------------- chrome */
.bar { .bar {
display: flex; /* Three tracks, so the banner can sit centred on the page rather than centred
align-items: baseline; in the space the clock happens to leave over. */
justify-content: space-between; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px; gap: 16px;
padding: 14px 22px; padding: 14px 22px;
background: var(--deep); background: var(--deep);
color: var(--on-brand); color: var(--on-brand);
} }
.bar .clock { grid-column: 3; justify-self: end; }
.bar.align-left .banner,
.bar.align-left .site { grid-column: 1; justify-self: start; }
.bar.align-center .banner,
.bar.align-center .site { grid-column: 2; justify-self: center; }
/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs /* An uploaded banner sits in the bar in place of the site name. Transparent PNGs
are the point, so nothing is painted behind it. */ are the point, so nothing is painted behind it. */
.banner { .banner {
max-height: 64px; max-height: 64px;
max-width: min(70%, 460px); max-width: min(60vw, 460px);
width: auto; width: auto;
object-fit: contain; object-fit: contain;
display: block; display: block;
+1 -1
View File
@@ -10,7 +10,7 @@
</head> </head>
<body> <body>
<header class="bar"> <header class="bar align-left" id="bar">
<img class="banner" id="banner" alt="" hidden> <img class="banner" id="banner" alt="" hidden>
<p class="site" id="siteName">Visitor sign in</p> <p class="site" id="siteName">Visitor sign in</p>
<p class="clock" id="clock"></p> <p class="clock" id="clock"></p>
+10 -2
View File
@@ -681,7 +681,7 @@ async function loadSites() {
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''} ${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
<dt>Kiosk branding</dt> <dt>Kiosk branding</dt>
<dd> <dd>
${s.branding.hasBanner ? 'Banner set' : 'No banner'} · ${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} ·
<span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span> <span class="swatch" style="background:${esc(s.branding.theme.brand)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span> <span class="swatch" style="background:${esc(s.branding.theme.signout)}"></span>
<span class="swatch" style="background:${esc(s.branding.theme.page)}"></span> <span class="swatch" style="background:${esc(s.branding.theme.page)}"></span>
@@ -763,7 +763,14 @@ function openSiteModal(site) {
</div> </div>
<p class="hint">A PNG with a transparent background works best — it sits straight on the <p class="hint">A PNG with a transparent background works best — it sits straight on the
bar colour with nothing painted behind it. Up to 2 MB.</p> bar colour with nothing painted behind it. Up to 2 MB.</p>
${field('Banner height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')} <div class="modal-row">
${field('Height on screen (px)', 'bannerHeight', site.branding.bannerHeight, 'number')}
<label class="modal-field"><span>Position in the bar</span>
<select name="bannerAlign">
<option value="left" ${site.branding.bannerAlign !== 'center' ? 'selected' : ''}>Left</option>
<option value="center" ${site.branding.bannerAlign === 'center' ? 'selected' : ''}>Centred</option>
</select></label>
</div>
</div> </div>
<div class="modal-row modal-row-3"> <div class="modal-row modal-row-3">
${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')} ${colourField('Bar and buttons', 'brand', site.branding.brand, '#0b4f4a')}
@@ -794,6 +801,7 @@ function openSiteModal(site) {
signout: data.signout || null, signout: data.signout || null,
page: data.page || null, page: data.page || null,
bannerHeight: Number(data.bannerHeight) || 64, bannerHeight: Number(data.bannerHeight) || 64,
bannerAlign: data.bannerAlign,
}, },
}, },
}); });
+6 -2
View File
@@ -440,7 +440,7 @@ async function chooseSite() {
* the shades and the text colours that sit on them are derived server-side so a * the shades and the text colours that sit on them are derived server-side so a
* dark logo colour cannot end up with dark text on it. * dark logo colour cannot end up with dark text on it.
*/ */
function applyTheme(theme, banner) { function applyTheme(theme, banner, align = 'left') {
if (theme) { if (theme) {
const root = document.documentElement.style; const root = document.documentElement.style;
root.setProperty('--deep', theme.brand); root.setProperty('--deep', theme.brand);
@@ -458,6 +458,10 @@ function applyTheme(theme, banner) {
} }
const img = $('#banner'); const img = $('#banner');
const bar = $('#bar');
bar.classList.toggle('align-center', align === 'center');
bar.classList.toggle('align-left', align !== 'center');
if (banner?.url) { if (banner?.url) {
img.src = banner.url; img.src = banner.url;
img.style.maxHeight = `${banner.height}px`; img.style.maxHeight = `${banner.height}px`;
@@ -477,7 +481,7 @@ async function loadSiteContext() {
document.title = name; document.title = name;
$('#siteName').textContent = name; $('#siteName').textContent = name;
$('#banner').alt = name; $('#banner').alt = name;
applyTheme(siteConfig.theme, siteConfig.banner); applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign);
const change = $('#change-site'); const change = $('#change-site');
change.hidden = !siteConfig.multiSite; change.hidden = !siteConfig.multiSite;
+7
View File
@@ -83,6 +83,13 @@ export function themeFor(site) {
/* -------------------------------------------------------------- banner */ /* -------------------------------------------------------------- banner */
const ALIGNMENTS = new Set(['left', 'center']);
export function normaliseAlign(value, fallback = 'left') {
const clean = String(value || '').trim().toLowerCase();
return ALIGNMENTS.has(clean) ? clean : fallback;
}
/** Accepts a data URL from the admin console and writes it to disk. */ /** Accepts a data URL from the admin console and writes it to disk. */
export function saveBanner(siteId, dataUrl) { export function saveBanner(siteId, dataUrl) {
const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec( const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
+2
View File
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS sites (
badge_note TEXT, badge_note TEXT,
banner_path TEXT, banner_path TEXT,
banner_height INTEGER NOT NULL DEFAULT 64, banner_height INTEGER NOT NULL DEFAULT 64,
banner_align TEXT NOT NULL DEFAULT 'left',
colour_brand TEXT, colour_brand TEXT,
colour_signout TEXT, colour_signout TEXT,
colour_page TEXT, colour_page TEXT,
@@ -161,6 +162,7 @@ addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
// Per-site branding on the kiosk. // Per-site branding on the kiosk.
addColumn('sites', 'banner_path', 'TEXT'); addColumn('sites', 'banner_path', 'TEXT');
addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64'); addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64');
addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'");
addColumn('sites', 'colour_brand', 'TEXT'); 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');
+7 -2
View File
@@ -14,6 +14,7 @@ import {
DEFAULT_THEME, DEFAULT_THEME,
bannerAbsolutePath, bannerAbsolutePath,
deleteBanner, deleteBanner,
normaliseAlign,
normaliseColour, normaliseColour,
saveBanner, saveBanner,
} from '../branding.js'; } from '../branding.js';
@@ -387,7 +388,8 @@ router.patch('/sites/:id', (req, res) => {
db.prepare( db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?,
colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ? WHERE id = ?` colour_brand = ?, colour_signout = ?, colour_page = ?, banner_height = ?,
banner_align = ? WHERE id = ?`
).run( ).run(
clean(req.body?.name ?? site.name, 100) || site.name, clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug, req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
@@ -409,7 +411,10 @@ router.patch('/sites/:id', (req, res) => {
branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page, branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page,
branding.bannerHeight !== undefined branding.bannerHeight !== undefined
? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64)) ? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64))
: site.banner_height : site.banner_height,
branding.bannerAlign !== undefined
? normaliseAlign(branding.bannerAlign, site.banner_align)
: site.banner_align
, site.id); , site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
+2
View File
@@ -54,6 +54,8 @@ router.get('/config', (req, res) => {
banner: site?.banner_path banner: site?.banner_path
? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 } ? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 }
: null, : null,
// Applies to the site name too, so the header looks the same either way.
headerAlign: site?.banner_align || 'left',
}); });
}); });
+1
View File
@@ -52,6 +52,7 @@ export function shapeSite(site) {
branding: { branding: {
hasBanner: Boolean(site.banner_path), hasBanner: Boolean(site.banner_path),
bannerHeight: site.banner_height || 64, bannerHeight: site.banner_height || 64,
bannerAlign: site.banner_align || 'left',
brand: site.colour_brand, brand: site.colour_brand,
signout: site.colour_signout, signout: site.colour_signout,
page: site.colour_page, page: site.colour_page,