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
+6
View File
@@ -0,0 +1,6 @@
node_modules
data
secrets
.env
.git
*.md
+63
View File
@@ -0,0 +1,63 @@
# ---------------------------------------------------------------- basics
# Only used to name the very first site and to label authenticator app entries.
# Add further sites, and rename this one, from Admin -> Sites.
SITE_NAME=Hideaway Visitor Sign In
TZ=Australia/Melbourne
PORT=3000
# Port published on the docker host.
HOST_PORT=8088
# Long random string. Generate one with: openssl rand -hex 32
# Changing this invalidates admin sessions AND makes stored visitor PINs unreadable.
APP_SECRET=change-me-to-a-long-random-string
# ------------------------------------------------------- admin accounts
# Used ONCE, to create the first admin account if none exist. After the first
# sign in you will be asked to set a new password, and further admins are
# invited from the console.
ADMIN_BOOTSTRAP_EMAIL=you@example.com
ADMIN_BOOTSTRAP_PASSWORD=change-me-then-change-again
# Restrict admin sign in to one or more email domains. Blank allows any address.
# ADMIN_ALLOWED_DOMAINS=hideawaygaming.com.au,school.vic.edu.au
ADMIN_ALLOWED_DOMAINS=
# Every admin must enrol an authenticator app at first sign in.
ADMIN_REQUIRE_2FA=true
# --------------------------------------------------------------- kiosk
# Require a photo before a visitor can complete sign in.
REQUIRE_PHOTO=true
# Photos older than this are deleted from disk automatically. 0 disables the sweep.
PHOTO_RETENTION_DAYS=90
# Sign out anyone still on site at this local time. Blank turns it off.
AUTO_SIGNOUT_TIME=18:30
# Warn admins this many days before a recurring visitor's WWCC or VIT expires.
EXPIRY_WARNING_DAYS=28
# ---------------------------------------------------------------- https
# The browser will not allow camera access over plain http unless the address is
# localhost. Either terminate TLS at a reverse proxy, or turn this on and run
# scripts/gen-cert.sh to create a self-signed certificate.
HTTPS_ENABLED=false
HTTPS_KEY=/data/certs/server.key
HTTPS_CERT=/data/certs/server.crt
# Set both of these to true when running behind an HTTPS reverse proxy.
TRUST_PROXY=false
SECURE_COOKIES=false
# --------------------------------------------------------- google sheets
SHEETS_ENABLED=false
# The long id from the sheet URL: docs.google.com/spreadsheets/d/<THIS PART>/edit
SHEETS_SPREADSHEET_ID=
SHEETS_TAB_NAME=Visitor log
# Point at the mounted service account json...
GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
# ...or paste it base64 encoded instead (base64 -w0 key.json). One or the other.
GOOGLE_CREDENTIALS_B64=
SHEETS_RETRY_INTERVAL_MS=60000
# -------------------------------------------------------------- storage
DATA_DIR=/data
+19
View File
@@ -0,0 +1,19 @@
# Keep LF in the repo so shell scripts still run on the Ubuntu docker host,
# even when the working copy is checked out on Windows.
* text=auto eol=lf
*.sh text eol=lf
*.mjs text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
.env.example text eol=lf
# Windows-only helpers keep CRLF so Notepad and cmd behave.
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.ico binary
+9
View File
@@ -0,0 +1,9 @@
node_modules/
data/
secrets/
.env
*.db
*.db-shm
*.db-wal
*.log
.DS_Store
+33
View File
@@ -0,0 +1,33 @@
# better-sqlite3 is a native module, so dependencies are compiled in a build stage
# and only the finished node_modules are carried into the runtime image.
FROM node:22-bookworm-slim AS deps
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
FROM node:22-bookworm-slim
ENV NODE_ENV=production
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
COPY public ./public
COPY scripts ./scripts
RUN mkdir -p /data/photos && chown -R node:node /data /app
USER node
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node scripts/healthcheck.mjs
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "src/server.js"]
+270
View File
@@ -0,0 +1,270 @@
# Visitor sign in
A self-hosted visitor kiosk for sites that need a WWCC/VIT record and a photo at the door.
Runs in one Docker container, stores everything locally in SQLite, and mirrors every sign in
and sign out to a Google Sheet so someone outside the building can see who is on site during
an evacuation.
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out.
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
visitor can ask for, sites, admin accounts, and system status.
Handles several sites from one container, prints a badge after sign in if you want one,
warns admins before a WWCC or VIT lapses, and gives each admin their own account with
two factor.
## What it collects
| | Guest sign in | Recurring visitor |
|---|---|---|
| First and last name | typed each visit | on file |
| Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | taken at the kiosk |
| WWCC / VIT / none | typed each visit | on file |
| Mobile and/or email | at least one required | on file |
Sign out only needs a **last name** plus a **mobile number or email**, which works for both.
## Quick start
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin
cp .env.example .env
# Generate a secret and set a real admin password before you start.
openssl rand -hex 32 # paste into APP_SECRET
$EDITOR .env
docker compose up -d --build
```
The kiosk is then on `http://<docker-host>:8088` and the admin console on
`http://<docker-host>:8088/admin`.
Set `ADMIN_BOOTSTRAP_EMAIL` and `ADMIN_BOOTSTRAP_PASSWORD` in `.env` before the first start —
they create the first admin account, once. You'll be asked to enrol two factor and set a real
password at first sign in.
Then, in the admin console:
1. **Sites** → rename the first site, add more if you have them, and turn badge printing on.
2. **People to visit** → pick a site, then paste or upload your staff CSV
(see `docs/hosts-sample.csv`).
3. **Recurring visitors** → add anyone who comes regularly. A PIN is generated and a printable
card opens straight away.
4. **Admins** → invite the rest of the front office.
## Working from Windows, deploying to Ubuntu
Develop on Windows, run the container on the Ubuntu host. Two scripts are included:
```powershell
# in PowerShell, inside the visitor-signin folder
.\push-to-gitea.ps1
```
Or double-click `push-to-gitea.bat`. If PowerShell blocks the script, run
`powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1`. On the Ubuntu host,
`./push-to-gitea.sh` does the same thing.
`.gitattributes` forces LF endings for everything except the `.ps1` and `.bat` helpers, so the
shell scripts and the Dockerfile still work after a round trip through a Windows checkout —
otherwise `gen-cert.sh` fails on the host with a confusing `\r: command not found`.
Deploy on the Ubuntu host with:
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin && cp .env.example .env && nano .env
docker compose up -d --build
```
`docker compose` on Windows works too if you have Docker Desktop, but the camera, printing and
the `data/` permissions all behave more predictably on the Ubuntu host.
## Several sites, one container
Each site has its own name, its own list of people to visit, and its own badge settings.
Recurring visitors are either tied to one site or welcome at all of them.
Point a kiosk at a site once, during setup, with `http://host:8088/?site=front-gate`. The tablet
remembers the choice, so the address bar no longer matters. If you skip that, the kiosk asks
which site it is on first use — and with only one site set up, it never asks at all.
Sign in, sign out and the "already signed in" check are all scoped to the kiosk's site, so the
same person can be signed in at two sites at once without the system arguing about it.
An admin account can be limited to a single site. Those admins see only that site's visitors,
staff list and log, and cannot touch the others.
## Badge printing
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
the site, name, who they are visiting, time in, WWCC/VIT status (or a boxed **No WWCC / VIT**),
the photo if you want it, and an optional line of your own text.
Set the label size in millimetres to match your stock. Type scales with the smaller dimension,
so small labels stay legible. Common sizes:
| Stock | mm |
|---|---|
| Card size | 86 × 54 |
| Brother DK-11202 shipping | 100 × 62 |
| Brother DK-11209 small address | 62 × 29 |
| Dymo 99014 shipping | 101 × 54 |
Use **Preview badge** to check the layout in a browser before committing a roll to it. The kiosk
browser needs the label printer set as its default, with margins off and scaling at 100%.
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
button on the confirmation screen if the first one jams.
## WWCC and VIT expiry warnings
Give a recurring visitor an expiry date and the console watches it. Inside
`EXPIRY_WARNING_DAYS` (28 by default) their row turns amber; past the date it turns red, a count
appears on the **Recurring visitors** tab, and a banner sits across the top of every screen.
Nothing is blocked automatically — an expired check is a conversation to have at the desk, not
a door the software should slam. Site-scoped admins only see warnings for their own site.
## Admin accounts and two factor
Each admin signs in with their own email address and password.
- **Roles.** *Owner* manages admins and sites. *Admin* handles day to day work, optionally
limited to one site.
- **Two factor.** With `ADMIN_REQUIRE_2FA=true` (the default) every admin enrols an
authenticator app at first sign in — a QR code appears, they scan it, and eight one-shot
recovery codes are issued. Standard TOTP, so Google Authenticator, Authy, 1Password,
Bitwarden and the rest all work.
- **Domain limits.** Set `ADMIN_ALLOWED_DOMAINS=yourschool.vic.edu.au` and both invitations and
sign in refuse anything else. Subdomains of a listed domain are accepted.
- **Recovery.** An owner can reset another admin's password (a temporary one is shown on
screen, and they must change it at next sign in) or clear their two factor so they can
re-enrol on a new phone.
- **Lockout.** Six wrong passwords locks that email address for 15 minutes.
If every owner loses access, stop the container, clear the `admin_users` table with
`sqlite3 data/visitors.db "DELETE FROM admin_users;"`, and start it again — the bootstrap
account is recreated from `.env`.
## The camera needs HTTPS
Browsers refuse camera access on a plain `http://` address unless it is `localhost`. On an
internal IP the kiosk will show a message telling the visitor the camera is blocked. Pick one:
**Option A — reverse proxy (best if you already run one).** Terminate TLS at Nginx Proxy
Manager, Traefik, or Caddy and point it at the container. Then set `TRUST_PROXY=true` and
`SECURE_COOKIES=true` in `.env`.
**Option B — self-signed certificate in the container.**
```bash
./scripts/gen-cert.sh visitors.local 192.168.1.50 # your hostname, then any IPs
# set HTTPS_ENABLED=true in .env
docker compose restart
```
Then install `data/certs/server.crt` as a trusted root certificate on each kiosk tablet,
otherwise the browser warning appears every morning.
**Option C — run the browser on the same machine as the container** and point it at
`http://localhost:8088`. No certificate needed.
## Google Sheet mirroring
Every sign in and sign out appends a row. If Google is unreachable the row is queued in the
database and retried every minute, so a dropped internet connection never blocks the front desk.
1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable
the **Google Sheets API**.
2. Create a **service account**, then create a **JSON key** for it and download the file.
3. Create the spreadsheet you want to use. **Share it with the service account's email address**
(it ends in `.iam.gserviceaccount.com`) with **Editor** access. This step is the one people
forget — without it every write returns a permission error.
4. Copy the spreadsheet id out of the URL:
`docs.google.com/spreadsheets/d/`**`THIS_PART`**`/edit`.
5. Put the key file at `./secrets/google-service-account.json` (compose mounts `./secrets`
read-only), then in `.env`:
```
SHEETS_ENABLED=true
SHEETS_SPREADSHEET_ID=THIS_PART
SHEETS_TAB_NAME=Visitor log
GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
```
If you would rather not mount a file, base64 the key instead —
`base64 -w0 key.json` — and put the result in `GOOGLE_CREDENTIALS_B64`.
6. Restart, then **Admin → System → Test the sheet connection**. The header row is written
automatically the first time.
A tab name with spaces is fine. The sheet is a mirror, not the source of truth — nothing reads
back from it. Every row carries the site name, so one sheet covers all sites; filter by the
**Site** column during an evacuation.
## Recurring visitors and PINs
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
15 minutes.
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
PINs from the admin console afterwards.
## Where the data lives
Everything is under `./data` on the docker host:
```
data/
├── visitors.db SQLite: sites, visits, recurring visitors, hosts, admins, retry queue
├── visitors.db-wal write-ahead log — back this up alongside the .db
└── photos/2026/08/ JPEGs, foldered by year and month
```
Photos never leave the host. The sheet records only whether a photo exists. They are deleted
automatically after `PHOTO_RETENTION_DAYS` (90 by default), and only the admin console can view
them.
To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz data/ && docker compose start`.
## Settings worth knowing
| Variable | Does what |
|---|---|
| `REQUIRE_PHOTO` | `false` lets a visitor continue if the camera fails |
| `AUTO_SIGNOUT_TIME` | e.g. `18:30` — closes off anyone still shown as on site. Blank to disable |
| `PHOTO_RETENTION_DAYS` | `0` keeps photos forever |
| `HOST_PORT` | port published on the docker host, default `8088` |
| `EXPIRY_WARNING_DAYS` | how far ahead to warn about a WWCC or VIT, default `28` |
| `ADMIN_REQUIRE_2FA` | `false` makes two factor optional per admin |
| `ADMIN_ALLOWED_DOMAINS` | comma separated; blank allows any email domain |
The kiosk returns to the home screen after two minutes of inactivity so the next visitor never
sees the last one's details.
## Running without Docker
```bash
npm install
DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=secret npm start
```
Node 20 or newer.
## A note on evacuation use
The Google Sheet is the offsite copy, but it only helps if someone can open it on a phone during
an evacuation. Bookmark it on the relevant phones, check it after setup, and check it again
occasionally — a service account key that has been revoked will queue rows silently until
someone looks at **Admin → System**.
---
Created by: Jess Rogerson (yelling commands at Claude.AI)
+21
View File
@@ -0,0 +1,21 @@
services:
visitor-signin:
build: .
image: visitor-signin:latest
container_name: visitor-signin
restart: unless-stopped
env_file:
- .env
ports:
- "${HOST_PORT:-8088}:3000"
volumes:
# Database, visitor photos and (optionally) TLS certs live here.
- ./data:/data
# Google service account key, if you mount it as a file rather than base64 in .env.
- ./secrets:/secrets:ro
healthcheck:
test: ["CMD", "node", "scripts/healthcheck.mjs"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
+5
View File
@@ -0,0 +1,5 @@
name,email,area
Jess Rogerson,jess.rogerson@example.com,Front office
Amelia Nguyen,amelia.nguyen@example.com,Year 3
David Okafor,david.okafor@example.com,Maintenance
Priya Raman,priya.raman@example.com,Wellbeing
1 name email area
2 Jess Rogerson jess.rogerson@example.com Front office
3 Amelia Nguyen amelia.nguyen@example.com Year 3
4 David Okafor david.okafor@example.com Maintenance
5 Priya Raman priya.raman@example.com Wellbeing
+25
View File
@@ -0,0 +1,25 @@
{
"name": "visitor-signin",
"version": "1.0.0",
"description": "Internal visitor sign in/out kiosk with photo capture, recurring visitor PINs and Google Sheets mirroring.",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
},
"engines": {
"node": ">=20"
},
"dependencies": {
"better-sqlite3": "^11.5.0",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"express-rate-limit": "^7.4.1",
"express-session": "^1.18.1",
"googleapis": "^144.0.0",
"qrcode": "^1.5.4"
},
"license": "MIT"
}
+204
View File
@@ -0,0 +1,204 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — visitor sign in</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<!-- ------------------------------------------------------------ login -->
<div id="login" class="login" hidden>
<div class="login-card">
<h1 id="login-heading">Visitor admin</h1>
<form class="login-step" id="step-password">
<label>
<span>Email address</span>
<input type="email" id="login-email" autocomplete="username" autocapitalize="none">
</label>
<label>
<span>Password</span>
<input type="password" id="login-password" autocomplete="current-password">
</label>
<p class="hint" id="domain-rule" hidden></p>
<button type="submit">Continue</button>
</form>
<form class="login-step" id="step-2fa" hidden>
<p class="hint" id="twofa-intro"></p>
<div id="twofa-setup" hidden>
<img id="twofa-qr" alt="Two factor setup QR code" width="200" height="200">
<p class="hint">Can't scan it? Enter this key by hand:<br><code id="twofa-secret"></code></p>
</div>
<label>
<span id="twofa-label">6 digit code</span>
<input id="twofa-code" inputmode="numeric" autocomplete="one-time-code" maxlength="12">
</label>
<button type="submit">Verify</button>
<button type="button" class="link-quiet" id="twofa-cancel">Start again</button>
</form>
<div class="login-step" id="step-recovery" hidden>
<p class="hint">Save these recovery codes somewhere safe. Each one works once, if you ever
lose the phone with your authenticator app on it.</p>
<ul class="recovery" id="recovery-list"></ul>
<button type="button" id="recovery-done">I've saved them</button>
</div>
<form class="login-step" id="step-newpassword" hidden>
<p class="hint">Set a password only you know before you continue.</p>
<label>
<span>Current password</span>
<input type="password" id="pw-current" autocomplete="current-password">
</label>
<label>
<span>New password</span>
<input type="password" id="pw-new" autocomplete="new-password">
</label>
<label>
<span>New password again</span>
<input type="password" id="pw-again" autocomplete="new-password">
</label>
<button type="submit">Save and continue</button>
</form>
<div class="login-step" id="step-setup" hidden>
<p class="hint" id="setup-message"></p>
</div>
<p class="err" id="login-error" hidden></p>
</div>
</div>
<!-- ---------------------------------------------------------- console -->
<div id="console" hidden>
<header class="topbar">
<strong id="site-name">Visitor admin</strong>
<nav>
<button class="tab on" data-tab="onsite">On site</button>
<button class="tab" data-tab="log">Visit log</button>
<button class="tab" data-tab="recurring">Recurring visitors <span class="badge-count" id="alert-count" hidden></span></button>
<button class="tab" data-tab="hosts">People to visit</button>
<button class="tab" data-tab="sites">Sites</button>
<button class="tab owner-only" data-tab="admins" hidden>Admins</button>
<button class="tab" data-tab="system">System</button>
</nav>
<label class="site-switch" id="site-switch" hidden>
<span>Site</span>
<select id="site-filter"></select>
</label>
<button id="logout" class="link">Sign out</button>
</header>
<p class="banner" id="expiry-banner" hidden></p>
<main>
<!-- ------------------------------------------------------ on site -->
<section class="panel on" id="panel-onsite">
<div class="panel-head">
<h2>Currently on site</h2>
<button class="ghost" id="refresh-onsite">Refresh</button>
</div>
<p class="stat" id="onsite-count"></p>
<div id="onsite-table"></div>
</section>
<!-- -------------------------------------------------------- log -->
<section class="panel" id="panel-log">
<div class="panel-head">
<h2>Visit log</h2>
<a class="ghost" id="csv-link" href="/admin/api/visits.csv">Download CSV</a>
</div>
<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>
<button class="ghost" id="log-search">Apply</button>
</div>
<div id="log-table"></div>
</section>
<!-- ------------------------------------------------- recurring -->
<section class="panel" id="panel-recurring">
<div class="panel-head">
<h2>Recurring visitors</h2>
<button class="primary" id="new-recurring">Add a recurring visitor</button>
</div>
<div id="expiry-summary"></div>
<div id="recurring-table"></div>
</section>
<!-- ----------------------------------------------------- hosts -->
<section class="panel" id="panel-hosts">
<div class="panel-head">
<h2>People a visitor can ask for</h2>
<button class="primary" id="new-host">Add a person</button>
</div>
<p class="hint" id="hosts-scope"></p>
<details class="import">
<summary>Import from CSV</summary>
<p class="hint">
Paste the file contents below, or choose a .csv file. Recognised column headings are
<code>name</code>, <code>email</code> and <code>area</code> (or department / team).
A single column of names works too. The import applies to the site selected above.
</p>
<input type="file" id="host-file" accept=".csv,text/csv">
<textarea id="host-csv" rows="6" placeholder="name,email,area&#10;Jess Rogerson,jess@example.com,Front office"></textarea>
<label class="inline"><input type="checkbox" id="host-replace"> Deactivate anyone at this site who is not in the file</label>
<button class="primary" id="do-host-import">Import</button>
</details>
<div id="hosts-table"></div>
</section>
<!-- ----------------------------------------------------- sites -->
<section class="panel" id="panel-sites">
<div class="panel-head">
<h2>Sites</h2>
<button class="primary owner-only" id="new-site" hidden>Add a site</button>
</div>
<p class="hint">Each site has its own name, its own list of people to visit, and its own
badge settings. Point a kiosk at one with
<code>/?site=<em>slug</em></code>, or let staff pick on first use.</p>
<div id="sites-list"></div>
</section>
<!-- ---------------------------------------------------- admins -->
<section class="panel" id="panel-admins">
<div class="panel-head">
<h2>Admin accounts</h2>
<button class="primary" id="new-admin">Invite an admin</button>
</div>
<p class="hint" id="admins-note"></p>
<div id="admins-table"></div>
</section>
<!-- ---------------------------------------------------- system -->
<section class="panel" id="panel-system">
<h2>System</h2>
<div id="system-body"></div>
<h2 class="section-gap">Your account</h2>
<div id="account-body"></div>
</section>
</main>
</div>
<dialog id="modal">
<form method="dialog" id="modal-form">
<h3 id="modal-title"></h3>
<div id="modal-body"></div>
<div class="modal-actions">
<button value="cancel" class="ghost" id="modal-cancel">Cancel</button>
<button value="save" class="primary" id="modal-save">Save</button>
</div>
</form>
</dialog>
<p class="toast" id="toast" role="status" hidden></p>
<footer class="foot">Created by: Jess Rogerson (yelling commands at Claude.AI)</footer>
<script src="/js/admin.js"></script>
</body>
</html>
+375
View File
@@ -0,0 +1,375 @@
:root {
--paper: #eef1f4;
--card: #ffffff;
--ink: #16202b;
--muted: #5d6b7a;
--rule: #d4dce3;
--deep: #0b4f4a;
--exit: #2c4a6b;
--alert: #96162f;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 15px;
line-height: 1.5;
}
h1, h2, h3 { letter-spacing: -0.015em; font-weight: 620; }
h2 { font-size: 20px; margin: 0; }
h3 { font-size: 18px; margin: 0 0 14px; }
button, input, textarea, select { font: inherit; }
button { cursor: pointer; }
.primary {
padding: 9px 16px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.ghost {
display: inline-block;
padding: 9px 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
text-decoration: none;
}
.danger { color: var(--alert); border-color: #e3bcc4; }
.link {
border: none;
background: none;
color: #d7e4e1;
text-decoration: underline;
text-underline-offset: 3px;
}
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; }
/* --------------------------------------------------------------- login */
.login { display: grid; place-items: center; min-height: 100vh; padding: 20px; }
.login-card {
width: min(380px, 100%);
padding: 30px;
background: var(--card);
border-radius: 3px;
border-top: 5px solid var(--deep);
}
.login-card h1 { font-size: 22px; margin: 0 0 20px; }
.login-card label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
.login-card input {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--rule);
border-radius: 3px;
margin-bottom: 18px;
}
.login-card button {
width: 100%;
padding: 12px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.err { color: var(--alert); margin: 14px 0 0; font-size: 14px; }
/* -------------------------------------------------------------- chrome */
.topbar {
display: flex;
align-items: center;
gap: 22px;
flex-wrap: wrap;
padding: 12px 22px;
background: var(--deep);
color: #eef5f3;
}
.topbar nav { display: flex; gap: 4px; flex-wrap: wrap; margin-right: auto; }
.tab {
padding: 8px 14px;
border: none;
border-radius: 3px;
background: transparent;
color: #cfe0dd;
}
.tab.on { background: rgba(255, 255, 255, 0.14); color: #fff; font-weight: 600; }
main { max-width: 1100px; margin: 0 auto; padding: 26px 22px 60px; }
.panel { display: none; }
.panel.on { display: block; }
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.stat {
font-size: 40px;
font-weight: 650;
font-variant-numeric: tabular-nums;
margin: 0 0 6px;
color: var(--deep);
}
.hint { color: var(--muted); font-size: 14px; max-width: 70ch; }
/* -------------------------------------------------------------- tables */
table { width: 100%; border-collapse: collapse; background: var(--card); font-size: 14.5px; }
th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--rule); vertical-align: middle; }
th { font-weight: 600; color: var(--muted); font-size: 13.5px; }
tbody tr:last-child td { border-bottom: none; }
td.actions { text-align: right; white-space: nowrap; }
td.actions button { margin-left: 6px; padding: 6px 11px; font-size: 13.5px; }
.mono { font-variant-numeric: tabular-nums; }
.thumb { width: 42px; height: 42px; object-fit: cover; border-radius: 3px; display: block; }
.empty { padding: 26px; background: var(--card); color: var(--muted); }
.pill {
display: inline-block;
padding: 2px 9px;
border-radius: 2px;
font-size: 12.5px;
background: #e6efed;
color: var(--deep);
}
.pill.off { background: #eceff2; color: var(--muted); }
.pill.out { background: #e7edf4; color: var(--exit); }
/* ------------------------------------------------------------- filters */
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: 16px;
}
.filters label { display: block; }
.filters .grow { flex: 1 1 240px; }
.filters span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
.filters input { width: 100%; padding: 9px 11px; border: 1px solid var(--rule); border-radius: 3px; }
/* -------------------------------------------------------------- import */
.import {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 3px;
padding: 14px 16px;
margin-bottom: 20px;
}
.import summary { cursor: pointer; font-weight: 600; }
.import textarea {
width: 100%;
margin: 12px 0;
padding: 10px;
border: 1px solid var(--rule);
border-radius: 3px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
}
.import input[type="file"] { margin-top: 12px; }
.inline { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; font-size: 14px; }
/* --------------------------------------------------------------- modal */
dialog {
border: none;
border-radius: 3px;
padding: 24px;
width: min(520px, calc(100vw - 32px));
border-top: 5px solid var(--deep);
}
dialog::backdrop { background: rgba(22, 32, 43, 0.45); }
.modal-field { margin-bottom: 14px; }
.modal-field span { display: block; font-size: 13.5px; color: var(--muted); margin-bottom: 5px; }
.modal-field input, .modal-field select, .modal-field textarea {
width: 100%;
padding: 10px 11px;
border: 1px solid var(--rule);
border-radius: 3px;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.pin-reveal {
font-size: 34px;
font-weight: 700;
letter-spacing: 0.22em;
color: var(--deep);
font-variant-numeric: tabular-nums;
margin: 6px 0 16px;
}
/* -------------------------------------------------------------- system */
#system-body { background: var(--card); padding: 20px; border-radius: 3px; }
#system-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
#system-body dt { color: var(--muted); }
#system-body dd { margin: 0; }
.sys-actions { display: flex; gap: 10px; flex-wrap: wrap; }
/* --------------------------------------------------------------- toast */
.toast {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
margin: 0;
padding: 13px 18px;
border-left: 5px solid var(--deep);
border-radius: 3px;
background: #fff;
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
max-width: min(560px, calc(100% - 32px));
}
.toast.bad { border-left-color: var(--alert); }
.foot { padding: 0 22px 26px; color: var(--muted); font-size: 12.5px; text-align: center; }
/* -------------------------------------------- login steps, 2FA, extras */
.login-step { display: block; }
.login-step label { display: block; }
.login-step label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
.login-step input {
width: 100%;
padding: 11px 12px;
border: 1px solid var(--rule);
border-radius: 3px;
margin-bottom: 16px;
}
.login-step button[type="submit"],
.login-step > button {
width: 100%;
padding: 12px;
border: 1px solid var(--deep);
border-radius: 3px;
background: var(--deep);
color: #fff;
font-weight: 600;
}
.login-step .hint { margin: 0 0 16px; }
.link-quiet {
width: 100%;
margin-top: 10px;
border: none;
background: none;
color: var(--muted);
text-decoration: underline;
text-underline-offset: 3px;
font-weight: 400;
}
#twofa-qr { display: block; margin: 0 auto 14px; border: 1px solid var(--rule); }
#twofa-secret, code {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
background: #eef1f4;
padding: 1px 5px;
border-radius: 2px;
}
#twofa-code { letter-spacing: 0.3em; text-align: center; font-size: 22px; }
.recovery {
list-style: none;
margin: 0 0 18px;
padding: 14px;
border: 1px solid var(--rule);
border-radius: 3px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 14px;
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 14px;
}
/* ----------------------------------------------------- site switching */
.site-switch { display: flex; align-items: center; gap: 8px; color: #cfe0dd; font-size: 14px; }
.site-switch select {
padding: 7px 10px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 3px;
background: rgba(255, 255, 255, 0.12);
color: #fff;
}
.site-switch select option { color: var(--ink); }
.badge-count {
display: inline-block;
min-width: 20px;
margin-left: 6px;
padding: 0 6px;
border-radius: 10px;
background: var(--alert);
color: #fff;
font-size: 12px;
text-align: center;
}
/* ------------------------------------------------------ banner, cards */
.banner {
margin: 0;
padding: 12px 22px;
background: #fdf3d8;
border-bottom: 1px solid #e6d5a4;
font-size: 14.5px;
}
.banner.bad { background: #fbeaed; border-bottom-color: #e8c3cb; }
.notice {
padding: 12px 14px;
margin: 0 0 16px;
background: #fdf3d8;
border-left: 4px solid #d9a441;
font-size: 14.5px;
}
.card {
background: var(--card);
border-radius: 3px;
padding: 18px 20px;
margin-bottom: 14px;
}
.site-head { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: flex-start; }
.site-head h3 { margin: 0 0 4px; }
.site-head .hint { margin: 0; }
.site-meta { display: grid; grid-template-columns: minmax(130px, 24%) 1fr; gap: 6px 16px; margin: 14px 0 0; font-size: 14.5px; }
.site-meta dt { color: var(--muted); }
.site-meta dd { margin: 0; }
.section-gap { margin-top: 34px; }
#account-body { background: var(--card); padding: 20px; border-radius: 3px; }
#account-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
#account-body dt { color: var(--muted); }
#account-body dd { margin: 0; }
td small { display: block; color: var(--muted); font-size: 13px; }
tr.row-warn td { background: #fdf8ec; }
tr.row-bad td { background: #fdf0f2; }
.pill.warn { background: #f7e7c4; color: #7a5308; }
.pill.bad { background: #f6d5db; color: var(--alert); }
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-section { margin: 20px 0 12px; font-size: 15px; }
.pin-reveal.small { font-size: 24px; letter-spacing: 0.06em; word-break: break-all; }
#modal img { display: block; margin: 0 auto 12px; border: 1px solid var(--rule); }
+343
View File
@@ -0,0 +1,343 @@
:root {
--paper: #e7ecf0;
--card: #ffffff;
--ink: #16202b;
--muted: #5d6b7a;
--rule: #c9d3dc;
--deep: #0b4f4a;
--deep-dark: #083a36;
--exit: #2c4a6b;
--exit-dark: #1f3650;
--alert: #96162f;
--focus: #0b4f4a;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
background: var(--paper);
color: var(--ink);
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 18px;
line-height: 1.45;
-webkit-text-size-adjust: 100%;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ------------------------------------------------------------- chrome */
.bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 14px 22px;
background: var(--deep);
color: #eef5f3;
}
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
.clock { margin: 0; font-variant-numeric: tabular-nums; font-size: 16px; opacity: 0.85; }
.foot {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 14px 22px 20px;
font-size: 12.5px;
color: var(--muted);
}
.foot a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
#app {
flex: 1;
width: 100%;
max-width: 620px;
margin: 0 auto;
padding: 28px 22px 8px;
}
/* ------------------------------------------------------------ screens */
.screen { display: none; }
.screen.on { display: block; animation: rise 180ms ease-out; }
@keyframes rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.screen.on { animation: none; }
}
h1, h2 {
font-weight: 620;
letter-spacing: -0.015em;
line-height: 1.2;
}
h1 { font-size: 30px; margin: 8px 0 26px; }
h2 { font-size: 25px; margin: 4px 0 20px; }
.hint { margin: -12px 0 22px; color: var(--muted); font-size: 15px; max-width: 46ch; }
/* --------------------------------------------------------- home doors */
.welcome { max-width: 18ch; }
.doors { display: grid; gap: 14px; }
.door {
display: block;
width: 100%;
text-align: left;
padding: 26px 24px;
border: none;
border-left: 7px solid var(--deep-dark);
border-radius: 3px;
background: var(--deep);
color: #fff;
font: inherit;
cursor: pointer;
}
.door-out { background: var(--exit); border-left-color: var(--exit-dark); }
.door:active { transform: translateY(1px); }
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
.door-sub { display: block; margin-top: 4px; font-size: 15px; opacity: 0.82; }
.text-action {
display: block;
width: 100%;
margin-top: 22px;
padding: 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: transparent;
color: var(--ink);
font: inherit;
cursor: pointer;
}
.text-action:hover { background: var(--card); }
/* ------------------------------------------------------- step rail */
.rail {
display: flex;
align-items: center;
gap: 7px;
margin-bottom: 18px;
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.rail i {
display: block;
width: 26px;
height: 3px;
background: var(--rule);
border-radius: 2px;
}
.rail i.done { background: var(--deep); }
.rail span { margin-left: 6px; }
/* ---------------------------------------------------------- fields */
.field { display: block; margin-bottom: 18px; }
.field > span {
display: block;
margin-bottom: 6px;
font-size: 14.5px;
color: var(--muted);
}
.field input {
width: 100%;
padding: 15px 14px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 20px;
}
.field input:focus-visible,
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.field-pin input {
font-size: 30px;
letter-spacing: 0.5em;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* --------------------------------------------------------- choices */
.choices { display: grid; gap: 10px; margin-bottom: 20px; }
.choice, .host-option {
display: block;
width: 100%;
text-align: left;
padding: 18px 16px;
border: 1px solid var(--rule);
border-radius: 3px;
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 18px;
cursor: pointer;
}
.choice[aria-pressed="true"] {
border-color: var(--deep);
box-shadow: inset 0 0 0 1px var(--deep);
background: #f2f8f6;
}
.host-list {
max-height: 46vh;
overflow-y: auto;
display: grid;
gap: 8px;
margin-bottom: 20px;
-webkit-overflow-scrolling: touch;
}
.host-option small { display: block; color: var(--muted); font-size: 14px; }
.host-empty { color: var(--muted); font-size: 15px; padding: 10px 2px; }
/* ---------------------------------------------------------- camera */
.camera {
position: relative;
aspect-ratio: 4 / 3;
background: #0f1720;
border-radius: 3px;
overflow: hidden;
margin-bottom: 18px;
}
.camera video, .camera img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scaleX(-1);
}
.camera-error {
margin: -8px 0 18px;
padding: 14px;
border-left: 4px solid var(--alert);
background: #fff;
color: var(--ink);
font-size: 15px;
}
/* ---------------------------------------------------------- review */
.review {
display: grid;
grid-template-columns: minmax(120px, 34%) 1fr;
gap: 10px 16px;
margin: 0 0 24px;
padding-top: 16px;
border-top: 1px solid var(--rule);
font-size: 16.5px;
}
.review dt { color: var(--muted); }
.review dd { margin: 0; }
.review img { width: 78px; border-radius: 3px; display: block; }
/* ------------------------------------------------------------- done */
.screen-done .mark {
display: inline-block;
margin: 8px 0 14px;
padding: 7px 14px;
border-radius: 2px;
background: var(--deep);
color: #fff;
font-size: 14px;
font-weight: 600;
}
#screen-done-out .mark { background: var(--exit); }
/* ------------------------------------------------------------ buttons */
.nav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 26px;
}
.nav button {
flex: 1 1 auto;
min-height: 62px;
padding: 16px 22px;
border-radius: 3px;
font: inherit;
font-size: 19px;
cursor: pointer;
}
.nav .primary {
border: 1px solid var(--deep);
background: var(--deep);
color: #fff;
font-weight: 600;
}
.nav .ghost {
flex: 0 1 auto;
border: 1px solid var(--rule);
background: transparent;
color: var(--muted);
}
.nav button[disabled] { opacity: 0.55; cursor: progress; }
/* ------------------------------------------------------------- alert */
.alert {
position: fixed;
left: 50%;
bottom: 22px;
transform: translateX(-50%);
width: min(560px, calc(100% - 32px));
margin: 0;
padding: 16px 18px;
border-left: 5px solid var(--alert);
border-radius: 3px;
background: #fff;
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
font-size: 16px;
}
@media (max-width: 420px) {
h1 { font-size: 26px; }
h2 { font-size: 22px; }
.door-title { font-size: 23px; }
}
/* -------------------------------------------------- site + badge bits */
.foot-link {
border: none;
background: none;
padding: 0 12px 0 0;
color: var(--muted);
font: inherit;
font-size: 12.5px;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
#badge-frame {
position: fixed;
width: 0;
height: 0;
border: 0;
visibility: hidden;
}
+236
View File
@@ -0,0 +1,236 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b4f4a">
<title>Visitor sign in</title>
<link rel="stylesheet" href="/css/kiosk.css">
</head>
<body>
<header class="bar">
<p class="site" id="siteName">Visitor sign in</p>
<p class="clock" id="clock"></p>
</header>
<main id="app">
<!-- ---------------------------------------------------- site picker -->
<section class="screen" id="screen-site">
<h1 class="welcome">Which site is this kiosk at?</h1>
<p class="hint">This tablet remembers the answer, so you only pick once.</p>
<div class="host-list" id="site-list"></div>
</section>
<!-- ---------------------------------------------------------- home -->
<section class="screen" id="screen-home">
<h1 class="welcome">Welcome. Are you coming in, or heading out?</h1>
<div class="doors">
<button class="door door-in" data-go="guest-name">
<span class="door-title">Sign in</span>
<span class="door-sub">First time, or an occasional visit</span>
</button>
<button class="door door-out" data-go="signout-find">
<span class="door-title">Sign out</span>
<span class="door-sub">Leaving the site</span>
</button>
</div>
<button class="text-action" data-go="freq-pin">I have a PIN — I come here regularly</button>
</section>
<!-- ------------------------------------------------- guest: name -->
<section class="screen" id="screen-guest-name" data-step="1">
<div class="rail" data-rail></div>
<h2>What's your name?</h2>
<label class="field">
<span>First name</span>
<input id="in-first" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Last name</span>
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" data-next="guest-name">Continue</button>
</div>
</section>
<!-- ------------------------------------------------- guest: host -->
<section class="screen" id="screen-guest-host" data-step="2">
<div class="rail" data-rail></div>
<h2>Who are you here to see?</h2>
<label class="field">
<span>Start typing a name</span>
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
</label>
<div class="host-list" id="host-list" role="listbox" aria-label="People you can visit"></div>
<div class="nav">
<button class="ghost" data-back>Back</button>
</div>
</section>
<!-- ---------------------------------------------- guest: contact -->
<section class="screen" id="screen-guest-contact" data-step="3">
<div class="rail" data-rail></div>
<h2>How can we reach you today?</h2>
<p class="hint">One of these is enough. We use it to sign you out and in an emergency.</p>
<label class="field">
<span>Mobile number</span>
<input id="in-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field">
<span>Email address</span>
<input id="in-email" type="email" inputmode="email" autocomplete="email" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" data-next="guest-contact">Continue</button>
</div>
</section>
<!-- ------------------------------------------------ guest: check -->
<section class="screen" id="screen-guest-check" data-step="4">
<div class="rail" data-rail></div>
<h2>Do you hold a WWCC or VIT registration?</h2>
<div class="choices" id="check-choices">
<button class="choice" data-check="WWCC">Working with Children Check</button>
<button class="choice" data-check="VIT">Victorian Institute of Teaching</button>
<button class="choice" data-check="NONE">I don't have one</button>
</div>
<label class="field" id="check-number-field" hidden>
<span id="check-number-label">Card number</span>
<input id="in-check-number" autocomplete="off" autocapitalize="characters" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="check-continue" hidden>Continue</button>
</div>
</section>
<!-- ------------------------------------------------------- photo -->
<section class="screen" id="screen-photo" data-step="5">
<div class="rail" data-rail></div>
<h2>Look at the camera</h2>
<p class="hint">The photo stays on this site's server. It is not sent anywhere else.</p>
<div class="camera">
<video id="cam-video" playsinline muted autoplay></video>
<img id="cam-shot" alt="The photo you just took" hidden>
<canvas id="cam-canvas" hidden></canvas>
</div>
<p class="camera-error" id="cam-error" hidden></p>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="cam-take">Take photo</button>
<button class="ghost" id="cam-retake" hidden>Retake</button>
<button class="primary" id="cam-use" hidden>Use this photo</button>
<button class="ghost" id="cam-skip" hidden>Continue without a photo</button>
</div>
</section>
<!-- ------------------------------------------------------ review -->
<section class="screen" id="screen-review" data-step="6">
<div class="rail" data-rail></div>
<h2>Check these details, then sign in</h2>
<dl class="review" id="review-list"></dl>
<div class="nav">
<button class="ghost" data-go="home">Start over</button>
<button class="primary" id="do-signin">Sign in</button>
</div>
</section>
<!-- ------------------------------------------- recurring: the PIN -->
<section class="screen" id="screen-freq-pin">
<h2>Welcome back</h2>
<label class="field">
<span>Mobile number</span>
<input id="in-freq-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field field-pin">
<span>4 digit PIN</span>
<input id="in-freq-pin" type="password" inputmode="numeric" maxlength="4" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-freq-auth">Continue</button>
</div>
</section>
<!-- --------------------------------------------- recurring: host -->
<section class="screen" id="screen-freq-host">
<h2 id="freq-greeting">Who are you here to see?</h2>
<label class="field">
<span>Start typing a name</span>
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
</label>
<div class="host-list" id="freq-host-list" role="listbox" aria-label="People you can visit"></div>
<div class="nav">
<button class="ghost" data-go="home">Cancel</button>
</div>
</section>
<!-- --------------------------------------------------- signed in -->
<section class="screen screen-done" id="screen-done-in">
<p class="mark">Signed in</p>
<h2 id="done-in-message"></h2>
<p class="hint" id="done-in-detail"></p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
<button class="ghost" id="reprint-badge" hidden>Print the badge again</button>
</div>
</section>
<!-- ------------------------------------------------- sign out: find -->
<section class="screen" id="screen-signout-find">
<h2>Signing out</h2>
<label class="field">
<span>Last name</span>
<input id="out-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Mobile number or email</span>
<input id="out-contact" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-signout-find">Find my visit</button>
</div>
</section>
<!-- ------------------------------------------------ sign out: pick -->
<section class="screen" id="screen-signout-pick">
<h2>Is this you?</h2>
<div class="host-list" id="signout-list"></div>
<div class="nav">
<button class="ghost" data-go="signout-find">Back</button>
</div>
</section>
<!-- -------------------------------------------------- signed out -->
<section class="screen screen-done" id="screen-done-out">
<p class="mark">Signed out</p>
<h2 id="done-out-message"></h2>
<p class="hint">Thanks for visiting. Travel safely.</p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
</div>
</section>
</main>
<p class="alert" id="alert" role="alert" hidden></p>
<footer class="foot">
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
<span>
<button class="foot-link" id="change-site" hidden></button>
<a href="/admin">Admin</a>
</span>
</footer>
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
<script src="/js/kiosk.js"></script>
</body>
</html>
+1002
View File
File diff suppressed because it is too large Load Diff
+591
View File
@@ -0,0 +1,591 @@
/* Visitor kiosk — single page flow controller. */
const TOTAL_STEPS = 6;
const IDLE_MS = 120000;
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
mode: 'guest',
firstName: '',
lastName: '',
hostId: null,
hostName: '',
phone: '',
email: '',
checkType: '',
checkNumber: '',
photo: null,
frequentVisitorId: null,
};
let hosts = [];
let siteConfig = { requirePhoto: true, siteName: 'Visitor sign in', multiSite: false, site: null };
let history = [];
let current = 'home';
let idleTimer = null;
let lastBadgeUrl = null;
/* ------------------------------------------------------------- site */
// Which entrance this tablet belongs to. A ?site=slug in the address wins and is
// remembered, so a kiosk can be pointed at a site once during setup.
const SITE_KEY = 'visitorKioskSite';
function storedSite() {
const fromUrl = new URLSearchParams(location.search).get('site');
if (fromUrl) {
try {
localStorage.setItem(SITE_KEY, fromUrl);
} catch {
/* private browsing */
}
return fromUrl;
}
try {
return localStorage.getItem(SITE_KEY) || '';
} catch {
return '';
}
}
let siteSlug = storedSite();
function rememberSite(slug) {
siteSlug = slug;
try {
localStorage.setItem(SITE_KEY, slug);
} catch {
/* private browsing */
}
}
/* ------------------------------------------------------------ plumbing */
async function api(path, body) {
const url = body ? path : path + (path.includes('?') ? '&' : '?') + `site=${encodeURIComponent(siteSlug)}`;
const res = await fetch(url, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify({ ...body, site: siteSlug }) : undefined,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Something went wrong. Try the front desk.');
return data;
}
let alertTimer = null;
function say(message) {
const box = $('#alert');
box.textContent = message;
box.hidden = false;
clearTimeout(alertTimer);
alertTimer = setTimeout(() => {
box.hidden = true;
}, 6000);
}
function clearAlert() {
$('#alert').hidden = true;
}
function drawRail(screen) {
const el = screen.querySelector('[data-rail]');
if (!el) return;
if (state.mode === 'frequent') {
el.innerHTML = '';
return;
}
const step = Number(screen.dataset.step || 0);
const bars = Array.from({ length: TOTAL_STEPS }, (_, i) =>
`<i class="${i < step ? 'done' : ''}"></i>`
).join('');
el.innerHTML = `${bars}<span>Step ${step} of ${TOTAL_STEPS}</span>`;
}
function show(name, { push = true } = {}) {
const next = document.getElementById(`screen-${name}`);
if (!next) return;
if (push && current !== name) history.push(current);
if (current === 'photo' && name !== 'photo') stopCamera();
$$('.screen').forEach((s) => s.classList.remove('on'));
next.classList.add('on');
current = name;
clearAlert();
drawRail(next);
window.scrollTo(0, 0);
const firstInput = next.querySelector('input');
if (firstInput && !('ontouchstart' in window)) firstInput.focus();
if (name === 'photo') startCamera();
if (name === 'home') resetState();
resetIdle();
}
function goBack() {
const previous = history.pop() || 'home';
show(previous, { push: false });
}
function resetState() {
Object.assign(state, {
mode: 'guest',
firstName: '',
lastName: '',
hostId: null,
hostName: '',
phone: '',
email: '',
checkType: '',
checkNumber: '',
photo: null,
frequentVisitorId: null,
});
history = [];
$$('#app input').forEach((i) => {
i.value = '';
});
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
$('#check-number-field').hidden = true;
$('#check-continue').hidden = true;
}
function resetIdle() {
clearTimeout(idleTimer);
if (current === 'home') return;
idleTimer = setTimeout(() => show('home', { push: false }), IDLE_MS);
}
['click', 'keydown', 'touchstart'].forEach((evt) =>
document.addEventListener(evt, resetIdle, { passive: true })
);
/* --------------------------------------------------------------- clock */
function tickClock() {
$('#clock').textContent = new Date().toLocaleString('en-AU', {
weekday: 'short',
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
setInterval(tickClock, 15000);
tickClock();
/* --------------------------------------------------------------- hosts */
function renderHosts(listEl, searchValue, onPick) {
const term = searchValue.trim().toLowerCase();
const matches = term
? hosts.filter((h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term))
: hosts;
if (!matches.length) {
listEl.innerHTML = `<p class="host-empty">No one matches that. Check the spelling, or ask the front desk.</p>`;
return;
}
listEl.innerHTML = matches
.slice(0, 60)
.map(
(h) =>
`<button class="host-option" data-host-id="${h.id}" data-host-name="${escapeHtml(h.name)}">
${escapeHtml(h.name)}${h.area ? `<small>${escapeHtml(h.area)}</small>` : ''}
</button>`
)
.join('');
listEl.querySelectorAll('[data-host-id]').forEach((btn) => {
btn.addEventListener('click', () => {
state.hostId = Number(btn.dataset.hostId);
state.hostName = btn.dataset.hostName;
onPick();
});
});
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
/* -------------------------------------------------------------- camera */
let stream = null;
async function startCamera() {
const video = $('#cam-video');
const err = $('#cam-error');
err.hidden = true;
$('#cam-shot').hidden = true;
video.hidden = false;
$('#cam-take').hidden = false;
$('#cam-retake').hidden = true;
$('#cam-use').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
state.photo = null;
if (stream) return;
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('unsupported');
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
} catch (e) {
const insecure = !window.isSecureContext;
err.hidden = false;
err.textContent = insecure
? 'The camera is blocked because this kiosk is not on a secure connection. Ask IT to serve the kiosk over HTTPS, then reload.'
: 'No camera is available on this device. Ask the front desk to sign you in.';
$('#cam-take').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
}
}
function stopCamera() {
if (!stream) return;
stream.getTracks().forEach((t) => t.stop());
stream = null;
$('#cam-video').srcObject = null;
}
function capture() {
const video = $('#cam-video');
const canvas = $('#cam-canvas');
const width = 720;
const height = Math.round((video.videoHeight / video.videoWidth) * width) || 540;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, width, height);
state.photo = canvas.toDataURL('image/jpeg', 0.72);
const shot = $('#cam-shot');
shot.src = state.photo;
shot.hidden = false;
video.hidden = true;
$('#cam-take').hidden = true;
$('#cam-skip').hidden = true;
$('#cam-retake').hidden = false;
$('#cam-use').hidden = false;
}
/* ---------------------------------------------------------- validation */
const emailOk = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
const phoneOk = (v) => v.replace(/[^\d]/g, '').length >= 8;
function afterPhoto() {
if (state.mode === 'frequent') {
submitSignIn();
} else {
buildReview();
show('review');
}
}
function buildReview() {
const rows = [
['Name', `${state.firstName} ${state.lastName}`],
['Visiting', state.hostName],
['Mobile', state.phone || '—'],
['Email', state.email || '—'],
[
'Check',
state.checkType === 'NONE' ? 'None held' : `${state.checkType} ${state.checkNumber}`,
],
];
const photoRow = state.photo
? `<dt>Photo</dt><dd><img src="${state.photo}" alt="The photo you took"></dd>`
: '';
$('#review-list').innerHTML =
rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join('') + photoRow;
}
/* ------------------------------------------------------------ submits */
async function submitSignIn() {
const button = state.mode === 'frequent' ? $('#cam-use') : $('#do-signin');
button.disabled = true;
try {
const result = await api('/api/signin', {
mode: state.mode,
frequentVisitorId: state.frequentVisitorId,
firstName: state.firstName,
lastName: state.lastName,
hostId: state.hostId,
phone: state.phone,
email: state.email,
checkType: state.checkType,
checkNumber: state.checkNumber,
photo: state.photo,
});
stopCamera();
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
$('#done-in-detail').textContent = result.badgeUrl
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
lastBadgeUrl = result.badgeUrl;
$('#reprint-badge').hidden = !result.badgeUrl;
if (result.badgeUrl) printBadge(result.badgeUrl);
show('done-in', { push: false });
setTimeout(() => {
if (current === 'done-in') show('home', { push: false });
}, 12000);
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
}
/* --------------------------------------------------------------- badge */
/**
* The badge page prints itself once loaded, so dropping it into a hidden iframe
* gives one label without the visitor seeing a print dialog on most kiosks.
*/
function printBadge(url) {
const frame = $('#badge-frame');
frame.src = `${url}?t=${Date.now()}`;
}
$('#reprint-badge').addEventListener('click', () => {
if (lastBadgeUrl) printBadge(lastBadgeUrl);
});
/* ---------------------------------------------------------- site picker */
async function chooseSite() {
const sites = await api('/api/sites');
const list = $('#site-list');
if (!sites.length) {
list.innerHTML = `<p class="host-empty">No sites are set up yet. An admin needs to add one first.</p>`;
} else {
list.innerHTML = sites
.map(
(s) =>
`<button class="host-option" data-site="${escapeHtml(s.slug)}">${escapeHtml(s.name)}</button>`
)
.join('');
list.querySelectorAll('[data-site]').forEach((btn) => {
btn.addEventListener('click', async () => {
rememberSite(btn.dataset.site);
await loadSiteContext();
show('home', { push: false });
});
});
}
show('site', { push: false });
}
async function loadSiteContext() {
siteConfig = await api('/api/config');
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
document.title = name;
$('#siteName').textContent = name;
const change = $('#change-site');
change.hidden = !siteConfig.multiSite;
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
hosts = await api('/api/hosts');
renderHosts($('#host-list'), '', () => show('guest-contact'));
}
$('#change-site').addEventListener('click', chooseSite);
/* --------------------------------------------------------------- wiring */
document.addEventListener('click', (event) => {
const go = event.target.closest('[data-go]');
if (go) {
const target = go.dataset.go;
if (target === 'home') {
show('home', { push: false });
} else if (target === 'guest-name') {
state.mode = 'guest';
show('guest-name');
} else {
show(target);
}
return;
}
if (event.target.closest('[data-back]')) goBack();
});
$('[data-next="guest-name"]').addEventListener('click', () => {
const first = $('#in-first').value.trim();
const last = $('#in-last').value.trim();
if (!first) return say('Enter your first name.');
if (!last) return say('Enter your last name.');
state.firstName = first;
state.lastName = last;
show('guest-host');
});
$('#in-host-search').addEventListener('input', (e) =>
renderHosts($('#host-list'), e.target.value, () => show('guest-contact'))
);
$('[data-next="guest-contact"]').addEventListener('click', () => {
const phone = $('#in-phone').value.trim();
const email = $('#in-email').value.trim();
if (!phone && !email) return say('Add a mobile number or an email address.');
if (phone && !phoneOk(phone)) return say('That mobile number looks too short.');
if (email && !emailOk(email)) return say('That email address does not look right.');
state.phone = phone;
state.email = email;
show('guest-check');
});
$$('.choice').forEach((btn) => {
btn.addEventListener('click', () => {
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
state.checkType = btn.dataset.check;
const needsNumber = state.checkType !== 'NONE';
$('#check-number-field').hidden = !needsNumber;
$('#check-number-label').textContent =
state.checkType === 'WWCC' ? 'WWCC card number' : 'VIT registration number';
$('#check-continue').hidden = false;
if (needsNumber) $('#in-check-number').focus();
});
});
$('#check-continue').addEventListener('click', () => {
if (state.checkType !== 'NONE') {
const number = $('#in-check-number').value.trim();
if (!number) return say('Enter the number on your card.');
state.checkNumber = number;
} else {
state.checkNumber = '';
}
show('photo');
});
$('#cam-take').addEventListener('click', capture);
$('#cam-retake').addEventListener('click', () => startCamera());
$('#cam-use').addEventListener('click', afterPhoto);
$('#cam-skip').addEventListener('click', () => {
state.photo = null;
afterPhoto();
});
$('#do-signin').addEventListener('click', submitSignIn);
/* recurring visitors */
$('#do-freq-auth').addEventListener('click', async () => {
const phone = $('#in-freq-phone').value.trim();
const pin = $('#in-freq-pin').value.trim();
if (!phoneOk(phone)) return say('Enter the mobile number on your card.');
if (!/^\d{4}$/.test(pin)) return say('Your PIN is 4 digits.');
const button = $('#do-freq-auth');
button.disabled = true;
try {
const person = await api('/api/frequent/auth', { phone, pin });
if (person.openVisit) {
say(`${person.firstName}, you are already signed in. Use Sign out instead.`);
return;
}
state.mode = 'frequent';
state.frequentVisitorId = person.id;
state.firstName = person.firstName;
state.lastName = person.lastName;
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
$('#in-freq-host-search').value = '';
renderHosts($('#freq-host-list'), '', () => show('photo'));
show('freq-host');
} catch (err) {
say(err.message);
$('#in-freq-pin').value = '';
} finally {
button.disabled = false;
}
});
$('#in-freq-host-search').addEventListener('input', (e) =>
renderHosts($('#freq-host-list'), e.target.value, () => show('photo'))
);
/* sign out */
$('#do-signout-find').addEventListener('click', async () => {
const lastName = $('#out-last').value.trim();
const contact = $('#out-contact').value.trim();
if (!lastName) return say('Enter your last name.');
if (!contact) return say('Enter your mobile number or email.');
const button = $('#do-signout-find');
button.disabled = true;
try {
const matches = await api('/api/signout/lookup', { lastName, contact });
const list = $('#signout-list');
list.innerHTML = matches
.map(
(m) =>
`<button class="host-option" data-visit="${m.id}">
${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)}
<small>Visiting ${escapeHtml(m.hostName)} · in at ${new Date(m.signedInAt).toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })}</small>
</button>`
)
.join('');
list.querySelectorAll('[data-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const done = await api('/api/signout', { visitId: Number(btn.dataset.visit) });
$('#done-out-message').textContent = `Goodbye, ${done.firstName}.`;
show('done-out', { push: false });
setTimeout(() => {
if (current === 'done-out') show('home', { push: false });
}, 10000);
} catch (err) {
say(err.message);
btn.disabled = false;
}
});
});
show('signout-pick');
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
});
/* Enter key moves the flow along on every screen. */
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
const screen = document.querySelector('.screen.on');
const primary = screen?.querySelector('.primary:not([hidden])');
if (primary) {
event.preventDefault();
primary.click();
}
});
/* -------------------------------------------------------------- start */
(async function init() {
try {
await loadSiteContext();
} catch {
/* fall through to the picker below */
}
// With one site the server resolves it for us; with several, ask once.
if (!siteConfig.siteChosen) {
await chooseSite();
return;
}
show('home', { push: false });
})();
+4
View File
@@ -0,0 +1,4 @@
@echo off
REM Double-click friendly wrapper around push-to-gitea.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0push-to-gitea.ps1"
pause
+42
View File
@@ -0,0 +1,42 @@
# Pushes this folder into the Gitea repo created for it.
# Run from PowerShell, inside the visitor-signin folder:
# .\push-to-gitea.ps1
# If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
$ErrorActionPreference = 'Stop'
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
if (-not (Test-Path 'package.json')) {
Write-Error 'Run this from inside the visitor-signin folder.'
exit 1
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Error 'Git is not installed or not on PATH. Install it from https://git-scm.com/download/win'
exit 1
}
# Keep line endings sane between Windows and the Ubuntu docker host.
git config --global core.autocrlf input | Out-Null
if (-not (Test-Path '.git')) {
git init -b main
} else {
Write-Host 'This folder is already a git repo, adding a commit to it.'
}
git add .
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
git commit -m $message
if (git remote | Select-String -Quiet '^origin$') {
git remote set-url origin $Remote
} else {
git remote add origin $Remote
}
git push -u origin main
Write-Host ''
Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Pushes this folder into the (empty) Gitea repo created for it.
# Run once from inside the extracted visitor-signin folder: ./push-to-gitea.sh
set -euo pipefail
REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git"
if [ ! -f package.json ]; then
echo "Run this from inside the visitor-signin folder." >&2
exit 1
fi
git init -b main
git add .
git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
git push -u origin main
echo
echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Creates a self-signed certificate so the kiosk can use the camera over https.
# Give it the address staff will actually type, e.g. ./gen-cert.sh visitors.local 192.168.1.50
set -euo pipefail
OUT_DIR="${OUT_DIR:-./data/certs}"
PRIMARY="${1:-visitors.local}"
shift || true
mkdir -p "$OUT_DIR"
ALT="DNS:${PRIMARY}"
INDEX=1
for extra in "$@"; do
if [[ "$extra" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
ALT="${ALT},IP:${extra}"
else
ALT="${ALT},DNS:${extra}"
fi
INDEX=$((INDEX + 1))
done
ALT="${ALT},DNS:localhost,IP:127.0.0.1"
openssl req -x509 -nodes -newkey rsa:2048 -days 1095 \
-keyout "${OUT_DIR}/server.key" \
-out "${OUT_DIR}/server.crt" \
-subj "/C=AU/ST=Victoria/L=Melbourne/O=Visitor Sign In/CN=${PRIMARY}" \
-addext "subjectAltName=${ALT}" \
-addext "basicConstraints=CA:FALSE" \
-addext "keyUsage=digitalSignature,keyEncipherment" \
-addext "extendedKeyUsage=serverAuth"
chmod 600 "${OUT_DIR}/server.key"
echo
echo "Certificate written to ${OUT_DIR}"
echo "Names covered: ${ALT}"
echo
echo "Next: set HTTPS_ENABLED=true in .env, then restart the container."
echo "Install ${OUT_DIR}/server.crt as a trusted root on each kiosk device to stop the warning."
+13
View File
@@ -0,0 +1,13 @@
// Container healthcheck. Works whether the app is serving http or self-signed https.
const secure = ['1', 'true', 'yes', 'on'].includes(String(process.env.HTTPS_ENABLED).toLowerCase());
const port = process.env.PORT || 3000;
const url = `${secure ? 'https' : 'http'}://127.0.0.1:${port}/healthz`;
if (secure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
process.exit(res.ok ? 0 : 1);
} catch {
process.exit(1);
}
+147
View File
@@ -0,0 +1,147 @@
import crypto from 'node:crypto';
/* ------------------------------------------------------------ passwords */
// scrypt is built into Node, so there is no native module to compile in the image.
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 64, { N: 16384, r: 8, p: 1 });
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export function verifyPassword(password, stored) {
try {
const [scheme, saltB64, hashB64] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const expected = Buffer.from(hashB64, 'base64');
const actual = crypto.scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
N: 16384,
r: 8,
p: 1,
});
return crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
export function passwordProblem(password) {
const value = String(password || '');
if (value.length < 12) return 'Use at least 12 characters.';
if (!/[a-z]/.test(value) || !/[A-Z]/.test(value)) return 'Mix upper and lower case.';
if (!/\d/.test(value)) return 'Include at least one number.';
return null;
}
export function randomPassword() {
// Readable enough to hand over verbally, still 60+ bits of entropy.
const words = crypto.randomBytes(9).toString('base64url').replace(/[-_]/g, '');
return `Vs${words}9`;
}
/* ----------------------------------------------------------- base32 */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buffer) {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input) {
const clean = String(input).toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const bytes = [];
for (const char of clean) {
value = (value << 5) | ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/* -------------------------------------------------------------- TOTP */
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20));
}
function hotp(secretBuffer, counter) {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secretBuffer).update(buf).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(code % 1_000_000).padStart(6, '0');
}
export function totpCode(secret, atMs = Date.now(), stepSeconds = 30) {
return hotp(base32Decode(secret), Math.floor(atMs / 1000 / stepSeconds));
}
/** Allows one step either side, which covers a phone clock that has drifted a little. */
export function verifyTotp(secret, token, window = 1) {
const candidate = String(token || '').replace(/\D/g, '');
if (candidate.length !== 6) return false;
const counter = Math.floor(Date.now() / 1000 / 30);
const buffer = base32Decode(secret);
for (let drift = -window; drift <= window; drift += 1) {
const expected = hotp(buffer, counter + drift);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) return true;
}
return false;
}
export function otpauthUrl({ secret, email, issuer }) {
const label = encodeURIComponent(`${issuer}:${email}`);
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' });
return `otpauth://totp/${label}?${params}`;
}
/* --------------------------------------------------- recovery codes */
export function generateRecoveryCodes(count = 8) {
return Array.from({ length: count }, () =>
crypto.randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2')
);
}
const digest = (code) =>
crypto.createHash('sha256').update(String(code).toLowerCase().replace(/[^a-z0-9]/g, '')).digest('hex');
export function hashRecoveryCodes(codes) {
return JSON.stringify(codes.map(digest));
}
/** Returns the remaining codes if one matched, or null. Used codes are burnt. */
export function consumeRecoveryCode(storedJson, candidate) {
try {
const hashes = JSON.parse(storedJson || '[]');
const target = digest(candidate);
const index = hashes.indexOf(target);
if (index === -1) return null;
hashes.splice(index, 1);
return JSON.stringify(hashes);
} catch {
return null;
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'dotenv/config';
import path from 'node:path';
import crypto from 'node:crypto';
function bool(value, fallback) {
if (value === undefined || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
function int(value, fallback) {
const n = Number.parseInt(value, 10);
return Number.isFinite(n) ? n : fallback;
}
const dataDir = process.env.DATA_DIR || '/data';
if (!process.env.APP_SECRET) {
console.warn(
'[config] APP_SECRET is not set. A random one is being generated for this process only.\n' +
' Sessions will drop and stored visitor PINs will become unreadable on restart.\n' +
' Set APP_SECRET in your .env before going live.'
);
}
export const config = {
port: int(process.env.PORT, 3000),
siteName: process.env.SITE_NAME || 'Visitor sign in',
timezone: process.env.TZ || 'Australia/Melbourne',
dataDir,
dbPath: process.env.DB_PATH || path.join(dataDir, 'visitors.db'),
photoDir: process.env.PHOTO_DIR || path.join(dataDir, 'photos'),
appSecret: process.env.APP_SECRET || crypto.randomBytes(32).toString('hex'),
trustProxy: bool(process.env.TRUST_PROXY, false),
secureCookies: bool(process.env.SECURE_COOKIES, false),
admin: {
// Used once, to create the first account if the user table is empty.
bootstrapEmail: (process.env.ADMIN_BOOTSTRAP_EMAIL || '').trim().toLowerCase(),
bootstrapPassword: process.env.ADMIN_BOOTSTRAP_PASSWORD || process.env.ADMIN_PASSWORD || '',
// Blank allows any address. Otherwise a comma separated list, e.g. "school.vic.edu.au".
allowedDomains: (process.env.ADMIN_ALLOWED_DOMAINS || '')
.split(',')
.map((d) => d.trim().toLowerCase().replace(/^@/, ''))
.filter(Boolean),
require2fa: bool(process.env.ADMIN_REQUIRE_2FA, true),
},
// Admins are warned this many days before a WWCC or VIT expires.
expiryWarningDays: int(process.env.EXPIRY_WARNING_DAYS, 28),
requirePhoto: bool(process.env.REQUIRE_PHOTO, true),
photoRetentionDays: int(process.env.PHOTO_RETENTION_DAYS, 90),
// Blank disables the nightly sweep. Format "HH:MM" in local time.
autoSignOutTime: process.env.AUTO_SIGNOUT_TIME || '',
https: {
enabled: bool(process.env.HTTPS_ENABLED, false),
keyPath: process.env.HTTPS_KEY || path.join(dataDir, 'certs', 'server.key'),
certPath: process.env.HTTPS_CERT || path.join(dataDir, 'certs', 'server.crt'),
},
sheets: {
enabled: bool(process.env.SHEETS_ENABLED, false),
spreadsheetId: process.env.SHEETS_SPREADSHEET_ID || '',
tabName: process.env.SHEETS_TAB_NAME || 'Visitor log',
// Either a path to the service account JSON, or the JSON itself base64 encoded.
credentialsPath: process.env.GOOGLE_CREDENTIALS_PATH || '',
credentialsB64: process.env.GOOGLE_CREDENTIALS_B64 || '',
retryIntervalMs: int(process.env.SHEETS_RETRY_INTERVAL_MS, 60000),
},
};
export default config;
+170
View File
@@ -0,0 +1,170 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import config from './config.js';
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.photoDir, { recursive: true });
export const db = new Database(config.dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
badge_enabled INTEGER NOT NULL DEFAULT 0,
badge_width_mm REAL NOT NULL DEFAULT 86,
badge_height_mm REAL NOT NULL DEFAULT 54,
badge_show_photo INTEGER NOT NULL DEFAULT 1,
badge_note TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT,
area TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS frequent_visitors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
check_expiry TEXT,
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
pin_enc TEXT NOT NULL,
notes TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
site_name TEXT,
visitor_type TEXT NOT NULL,
frequent_visitor_id INTEGER REFERENCES frequent_visitors(id) ON DELETE SET NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT,
email TEXT,
check_type TEXT NOT NULL DEFAULT 'NONE',
check_number TEXT,
check_expiry TEXT,
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
host_name TEXT NOT NULL,
visit_reason TEXT,
photo_path TEXT,
signed_in_at TEXT NOT NULL,
signed_out_at TEXT,
signed_out_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_visits_open ON visits(signed_out_at, last_name);
CREATE INDEX IF NOT EXISTS idx_visits_in ON visits(signed_in_at);
CREATE TABLE IF NOT EXISTS admin_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT,
password_hash TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
recovery_codes TEXT,
role TEXT NOT NULL DEFAULT 'admin',
site_id INTEGER REFERENCES sites(id) ON DELETE SET NULL,
must_change_password INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS login_attempts (
email TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS sheet_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS pin_attempts (
phone TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
`);
/* ---------------------------------------------------------- migrations */
function hasColumn(table, column) {
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
}
function addColumn(table, column, definition) {
if (!hasColumn(table, column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
console.log(`[db] added ${table}.${column}`);
}
}
// Multi-site arrived after the first release, so these run once on an existing database.
addColumn('hosts', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE CASCADE');
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
addColumn('visits', 'site_name', 'TEXT');
addColumn('visits', 'check_expiry', 'TEXT');
// NULL site_id on a recurring visitor means they are welcome at every site.
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
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)');
/* -------------------------------------------------------- default site */
const siteCount = db.prepare('SELECT COUNT(*) AS n FROM sites').get().n;
if (siteCount === 0) {
db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(config.siteName, 'main');
console.log(`[db] created the first site: ${config.siteName}`);
}
const firstSite = db.prepare('SELECT id, name FROM sites ORDER BY id LIMIT 1').get();
db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(key, value) {
db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value));
}
export default db;
+66
View File
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import config from './config.js';
import db from './db.js';
const MAX_BYTES = 4 * 1024 * 1024;
/**
* Accepts a data URL from the kiosk camera and writes it to disk.
* Returns a path relative to config.photoDir, or null if there was no photo.
*/
export function savePhoto(dataUrl) {
if (!dataUrl) return null;
const match = /^data:image\/(jpeg|jpg|png|webp);base64,([A-Za-z0-9+/=]+)$/.exec(
String(dataUrl).trim()
);
if (!match) throw new Error('Photo could not be read. Retake it and try again.');
const ext = match[1] === 'jpg' ? 'jpeg' : match[1];
const buffer = Buffer.from(match[2], 'base64');
if (buffer.length > MAX_BYTES) throw new Error('Photo is too large.');
const now = new Date();
const folder = path.join(String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'));
const dir = path.join(config.photoDir, folder);
fs.mkdirSync(dir, { recursive: true });
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}.${ext}`;
fs.writeFileSync(path.join(dir, name), buffer);
return path.join(folder, name);
}
export function photoAbsolutePath(relative) {
if (!relative) return null;
const resolved = path.resolve(config.photoDir, relative);
if (!resolved.startsWith(path.resolve(config.photoDir))) return null;
return fs.existsSync(resolved) ? resolved : null;
}
export function deletePhoto(relative) {
const abs = photoAbsolutePath(relative);
if (abs) {
try {
fs.unlinkSync(abs);
} catch {
/* already gone */
}
}
}
/** Deletes photo files older than the retention window and clears their DB reference. */
export function purgeOldPhotos() {
if (!config.photoRetentionDays || config.photoRetentionDays <= 0) return 0;
const cutoff = new Date(Date.now() - config.photoRetentionDays * 86400000).toISOString();
const rows = db
.prepare('SELECT id, photo_path FROM visits WHERE photo_path IS NOT NULL AND signed_in_at < ?')
.all(cutoff);
const clear = db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?');
for (const row of rows) {
deletePhoto(row.photo_path);
clear.run(row.id);
}
if (rows.length) console.log(`[photos] purged ${rows.length} photo(s) past retention`);
return rows.length;
}
+54
View File
@@ -0,0 +1,54 @@
import crypto from 'node:crypto';
import config from './config.js';
// PINs are 4 digits, so a hash gives almost no protection against an attacker who
// already has the database file (10,000 candidates brute-forces instantly).
// They are stored encrypted instead, which gives the same practical protection and
// lets an admin reprint a visitor's pass without resetting their PIN.
// Brute force against the running app is handled by lockout in routes/kiosk.js.
const key = crypto.createHash('sha256').update(config.appSecret).digest();
export function encryptPin(pin) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(String(pin), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join('.');
}
export function decryptPin(stored) {
try {
const [ivB64, tagB64, dataB64] = String(stored).split('.');
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(ivB64, 'base64')
);
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataB64, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
return null;
}
}
export function verifyPin(stored, candidate) {
const actual = decryptPin(stored);
if (actual === null) return false;
const a = Buffer.from(actual);
const b = Buffer.from(String(candidate));
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
export function generatePin() {
// Avoids the handful of PINs people will misread on a printed pass.
const banned = new Set(['0000', '1111', '1234', '4321', '9999']);
let pin;
do {
pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
} while (banned.has(pin));
return pin;
}
+942
View File
@@ -0,0 +1,942 @@
import express from 'express';
import rateLimit from 'express-rate-limit';
import QRCode from 'qrcode';
import fs from 'node:fs';
import db from '../db.js';
import config from '../config.js';
import { decryptPin, encryptPin, generatePin } from '../pins.js';
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
import * as sheets from '../sheets.js';
import * as users from '../users.js';
import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js';
import {
consumeRecoveryCode,
generateRecoveryCodes,
generateTotpSecret,
hashPassword,
hashRecoveryCodes,
otpauthUrl,
passwordProblem,
verifyPassword,
verifyTotp,
} from '../auth.js';
import {
clean,
isEmail,
isPhone,
localStamp,
normaliseEmail,
normalisePhone,
nowIso,
parseCsv,
titleCase,
toCsv,
} from '../util.js';
const router = express.Router();
const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']);
const loginLimiter = rateLimit({ windowMs: 15 * 60000, max: 20, standardHeaders: true });
/* ------------------------------------------------------------ sessions */
function currentUser(req) {
if (!req.session?.adminUserId) return null;
const user = users.findById(req.session.adminUserId);
return user && user.active ? user : null;
}
function requireAdmin(req, res, next) {
const user = currentUser(req);
if (!user) return res.status(401).json({ error: 'Sign in to the admin console first.' });
req.user = user;
// Someone on a temporary password can only change it or sign out.
if (user.must_change_password && !req.path.startsWith('/account/password') && req.path !== '/logout') {
return res.status(403).json({ error: 'Set a new password before continuing.', mustChangePassword: true });
}
next();
}
function requireOwner(req, res, next) {
if (req.user.role !== 'owner') {
return res.status(403).json({ error: 'Only an owner account can do that.' });
}
next();
}
/** null means "every site". Otherwise the single site this admin is limited to. */
function scopedSiteId(req) {
return req.user.site_id || null;
}
function assertSiteAllowed(req, siteId) {
const scope = scopedSiteId(req);
if (scope && Number(siteId) !== scope) {
const error = new Error('That site is outside your access.');
error.status = 403;
throw error;
}
}
/** Adds a site filter to a WHERE clause built from `where`/`params`. */
function applySiteFilter(req, where, params) {
const scope = scopedSiteId(req);
const requested = req.query.siteId && req.query.siteId !== 'all' ? Number(req.query.siteId) : null;
const siteId = scope || requested;
if (siteId) {
where.push('site_id = ?');
params.push(siteId);
}
}
/* ---------------------------------------------------------------- login */
router.post('/login', loginLimiter, (req, res) => {
const email = normaliseEmail(req.body?.email);
const password = String(req.body?.password || '');
const generic = { error: 'That email address and password do not match.' };
if (!email || !password) return res.status(400).json({ error: 'Enter your email and password.' });
const locked = users.lockState(email);
if (locked) {
return res.status(429).json({ error: 'Too many attempts. Try again in 15 minutes.' });
}
if (!users.domainAllowed(email)) {
return res.status(403).json({ error: `Sign in with an ${users.domainRuleText()} address.` });
}
const user = users.findByEmail(email);
if (!user || !user.active || !verifyPassword(password, user.password_hash)) {
users.noteFailure(email);
return res.status(401).json(generic);
}
users.clearFailures(email);
if (user.totp_enabled) {
req.session.pendingUserId = user.id;
return res.json({ status: 'twoFactorRequired' });
}
if (config.admin.require2fa) {
req.session.pendingUserId = user.id;
return startTwoFactorSetup(req, res, user);
}
return completeLogin(req, res, user);
});
function completeLogin(req, res, user) {
delete req.session.pendingUserId;
delete req.session.pendingTotpSecret;
req.session.adminUserId = user.id;
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
res.json({
status: user.must_change_password ? 'passwordChangeRequired' : 'ok',
user: users.shape(user),
});
}
async function startTwoFactorSetup(req, res, user) {
const secret = generateTotpSecret();
req.session.pendingTotpSecret = secret;
const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName });
const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 });
res.json({ status: 'twoFactorSetup', secret, qr });
}
router.post('/login/2fa', loginLimiter, (req, res) => {
const user = req.session.pendingUserId ? users.findById(req.session.pendingUserId) : null;
if (!user) return res.status(401).json({ error: 'Start again from the sign in screen.' });
const code = clean(req.body?.code, 20);
// Enrolling: the secret is only saved once a real code from the app proves it works.
if (req.session.pendingTotpSecret) {
if (!verifyTotp(req.session.pendingTotpSecret, code)) {
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
}
const recovery = generateRecoveryCodes();
db.prepare(
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
).run(req.session.pendingTotpSecret, hashRecoveryCodes(recovery), user.id);
delete req.session.pendingTotpSecret;
req.session.adminUserId = user.id;
delete req.session.pendingUserId;
db.prepare('UPDATE admin_users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
return res.json({
status: users.findById(user.id).must_change_password ? 'passwordChangeRequired' : 'ok',
recoveryCodes: recovery,
user: users.shape(users.findById(user.id)),
});
}
if (verifyTotp(user.totp_secret, code)) {
users.clearFailures(user.email);
return completeLogin(req, res, user);
}
// Recovery codes are one shot each.
const remaining = consumeRecoveryCode(user.recovery_codes, code);
if (remaining !== null) {
db.prepare('UPDATE admin_users SET recovery_codes = ? WHERE id = ?').run(remaining, user.id);
const left = JSON.parse(remaining).length;
req.session.adminUserId = user.id;
delete req.session.pendingUserId;
return res.json({
status: 'ok',
usedRecoveryCode: true,
recoveryCodesLeft: left,
user: users.shape(user),
});
}
users.noteFailure(user.email);
res.status(401).json({ error: 'That code is not right.' });
});
router.post('/logout', (req, res) => {
req.session.destroy(() => res.json({ ok: true }));
});
router.get('/session', (req, res) => {
const user = currentUser(req);
const anyUsers = users.countActive() > 0;
res.json({
admin: Boolean(user),
setupNeeded: !anyUsers,
user: user ? users.shape(user) : null,
siteName: config.siteName,
domainRule: users.domainRuleText(),
require2fa: config.admin.require2fa,
mustChangePassword: Boolean(user?.must_change_password),
});
});
router.use(requireAdmin);
/* -------------------------------------------------------------- account */
router.post('/account/password', (req, res) => {
const current = String(req.body?.currentPassword || '');
const next = String(req.body?.newPassword || '');
if (!verifyPassword(current, req.user.password_hash)) {
return res.status(401).json({ error: 'Your current password is not right.' });
}
const problem = passwordProblem(next);
if (problem) return res.status(400).json({ error: problem });
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 0 WHERE id = ?').run(
hashPassword(next),
req.user.id
);
res.json({ ok: true });
});
router.post('/account/2fa/start', async (req, res) => {
const secret = generateTotpSecret();
req.session.selfTotpSecret = secret;
const url = otpauthUrl({ secret, email: req.user.email, issuer: config.siteName });
res.json({ secret, qr: await QRCode.toDataURL(url, { margin: 1, width: 240 }) });
});
router.post('/account/2fa/enable', (req, res) => {
const secret = req.session.selfTotpSecret;
if (!secret) return res.status(400).json({ error: 'Start the setup again.' });
if (!verifyTotp(secret, clean(req.body?.code, 20))) {
return res.status(401).json({ error: 'That code did not match. Try the next one.' });
}
const recovery = generateRecoveryCodes();
db.prepare(
'UPDATE admin_users SET totp_secret = ?, totp_enabled = 1, recovery_codes = ? WHERE id = ?'
).run(secret, hashRecoveryCodes(recovery), req.user.id);
delete req.session.selfTotpSecret;
res.json({ ok: true, recoveryCodes: recovery });
});
router.post('/account/2fa/disable', (req, res) => {
if (config.admin.require2fa) {
return res.status(403).json({ error: 'Two factor is required for every admin on this server.' });
}
if (!verifyPassword(String(req.body?.password || ''), req.user.password_hash)) {
return res.status(401).json({ error: 'Your password is not right.' });
}
db.prepare(
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
).run(req.user.id);
res.json({ ok: true });
});
/* ---------------------------------------------------------------- users */
router.get('/users', requireOwner, (req, res) => {
const rows = db.prepare('SELECT * FROM admin_users ORDER BY email').all();
res.json(rows.map(users.shape));
});
router.post('/users', requireOwner, (req, res) => {
try {
const siteId = req.body?.siteId ? Number(req.body.siteId) : null;
if (siteId && !db.prepare('SELECT id FROM sites WHERE id = ?').get(siteId)) {
return res.status(400).json({ error: 'That site does not exist.' });
}
const { user, temporaryPassword } = users.createUser({
email: req.body?.email,
name: req.body?.name,
role: req.body?.role === 'owner' ? 'owner' : 'admin',
siteId,
});
res.json({ ...users.shape(user), temporaryPassword });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.patch('/users/:id', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
const makingInactive = req.body?.active === false;
const demoting = req.body?.role && req.body.role !== 'owner' && target.role === 'owner';
if ((makingInactive || demoting) && target.id === req.user.id) {
return res.status(400).json({ error: 'You cannot lock yourself out of your own account.' });
}
const owners = db
.prepare("SELECT COUNT(*) AS n FROM admin_users WHERE role = 'owner' AND active = 1").get().n;
if ((makingInactive || demoting) && target.role === 'owner' && owners <= 1) {
return res.status(400).json({ error: 'Keep at least one active owner account.' });
}
db.prepare('UPDATE admin_users SET name = ?, role = ?, site_id = ?, active = ? WHERE id = ?').run(
req.body?.name !== undefined ? clean(req.body.name, 80) || null : target.name,
req.body?.role === 'owner' ? 'owner' : req.body?.role === 'admin' ? 'admin' : target.role,
req.body?.siteId !== undefined ? (req.body.siteId ? Number(req.body.siteId) : null) : target.site_id,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : target.active,
target.id
);
res.json(users.shape(users.findById(target.id)));
});
router.post('/users/:id/reset-password', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
const temporary = req.body?.password || undefined;
const problem = temporary ? passwordProblem(temporary) : null;
if (problem) return res.status(400).json({ error: problem });
const password = temporary || `Vs${Math.random().toString(36).slice(2, 10)}9A`;
db.prepare('UPDATE admin_users SET password_hash = ?, must_change_password = 1 WHERE id = ?').run(
hashPassword(password),
target.id
);
users.clearFailures(target.email);
res.json({ ok: true, temporaryPassword: password });
});
router.post('/users/:id/reset-2fa', requireOwner, (req, res) => {
const target = users.findById(req.params.id);
if (!target) return res.status(404).json({ error: 'Not found.' });
db.prepare(
'UPDATE admin_users SET totp_secret = NULL, totp_enabled = 0, recovery_codes = NULL WHERE id = ?'
).run(target.id);
res.json({ ok: true });
});
/* ---------------------------------------------------------------- sites */
router.get('/sites', (req, res) => {
const scope = scopedSiteId(req);
const rows = listSites().filter((s) => !scope || s.id === scope);
res.json(rows.map(shapeSite));
});
router.post('/sites', requireOwner, (req, res) => {
const name = clean(req.body?.name, 100);
if (!name) return res.status(400).json({ error: 'Give the site a name.' });
const slug = uniqueSlug(req.body?.slug || name);
const info = db.prepare('INSERT INTO sites (name, slug) VALUES (?, ?)').run(name, slug);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(info.lastInsertRowid)));
});
router.patch('/sites/:id', (req, res) => {
try {
assertSiteAllowed(req, req.params.id);
} catch (err) {
return res.status(err.status || 403).json({ error: err.message });
}
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).json({ error: 'Not found.' });
const badge = req.body?.badge || {};
db.prepare(
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
).run(
clean(req.body?.name ?? site.name, 100) || site.name,
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : site.active,
badge.enabled !== undefined ? (badge.enabled ? 1 : 0) : site.badge_enabled,
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
, site.id);
res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id)));
});
router.get('/sites/:id/badge-preview', (req, res) => {
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id);
if (!site) return res.status(404).send('Not found.');
const sample = {
first_name: 'Sample',
last_name: 'Visitor',
host_name: 'Jess Rogerson',
check_type: 'WWCC',
check_number: 'WWC1234567E',
signed_in_at: nowIso(),
};
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(badgeHtml(sample, site, { autoPrint: false }));
});
/* ---------------------------------------------------------------- hosts */
function hostSiteId(req) {
const scope = scopedSiteId(req);
const asked = req.body?.siteId ?? req.query.siteId;
const siteId = scope || (asked && asked !== 'all' ? Number(asked) : null);
return siteId;
}
router.get('/hosts', (req, res) => {
const siteId = hostSiteId(req);
const sql = siteId
? 'SELECT * FROM hosts WHERE site_id = ? ORDER BY name COLLATE NOCASE'
: 'SELECT * FROM hosts ORDER BY name COLLATE NOCASE';
const rows = siteId ? db.prepare(sql).all(siteId) : db.prepare(sql).all();
res.json(rows);
});
router.post('/hosts', (req, res) => {
const name = titleCase(req.body?.name, 120);
const siteId = hostSiteId(req);
if (!name) return res.status(400).json({ error: 'Name is required.' });
if (!siteId) return res.status(400).json({ error: 'Choose which site this person belongs to.' });
try {
assertSiteAllowed(req, siteId);
} catch (err) {
return res.status(403).json({ error: err.message });
}
const info = db
.prepare('INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)')
.run(name, normaliseEmail(req.body?.email) || null, clean(req.body?.area, 80) || null, siteId);
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(info.lastInsertRowid));
});
router.patch('/hosts/:id', (req, res) => {
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
if (!host) return res.status(404).json({ error: 'Not found.' });
try {
assertSiteAllowed(req, host.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE hosts SET name = ?, email = ?, area = ?, active = ? WHERE id = ?').run(
titleCase(req.body?.name ?? host.name, 120),
req.body?.email !== undefined ? normaliseEmail(req.body.email) || null : host.email,
req.body?.area !== undefined ? clean(req.body.area, 80) || null : host.area,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : host.active,
host.id
);
res.json(db.prepare('SELECT * FROM hosts WHERE id = ?').get(host.id));
});
router.delete('/hosts/:id', (req, res) => {
const host = db.prepare('SELECT * FROM hosts WHERE id = ?').get(req.params.id);
if (!host) return res.status(404).json({ error: 'Not found.' });
try {
assertSiteAllowed(req, host.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE hosts SET active = 0 WHERE id = ?').run(host.id);
res.json({ ok: true });
});
/**
* CSV import, scoped to one site. Headings understood: name, email, area
* (or department / team / role). A single unnamed column is treated as the name.
*/
router.post('/hosts/import', (req, res) => {
const siteId = hostSiteId(req);
if (!siteId) return res.status(400).json({ error: 'Choose which site this list belongs to.' });
try {
assertSiteAllowed(req, siteId);
} catch (err) {
return res.status(403).json({ error: err.message });
}
const rows = parseCsv(req.body?.csv || '');
if (!rows.length) return res.status(400).json({ error: 'That CSV had no rows in it.' });
const header = rows[0].map((h) => h.trim().toLowerCase());
const looksLikeHeader = header.some((h) =>
['name', 'full name', 'staff', 'email', 'area', 'department', 'team'].includes(h)
);
const body = looksLikeHeader ? rows.slice(1) : rows;
const idx = {
name: looksLikeHeader ? header.findIndex((h) => ['name', 'full name', 'staff'].includes(h)) : 0,
email: looksLikeHeader ? header.findIndex((h) => h === 'email') : -1,
area: looksLikeHeader
? header.findIndex((h) => ['area', 'department', 'team', 'role'].includes(h))
: -1,
};
if (idx.name < 0) idx.name = 0;
const replace = Boolean(req.body?.replace);
const insert = db.prepare(
'INSERT INTO hosts (name, email, area, site_id, active) VALUES (?, ?, ?, ?, 1)'
);
const existing = db.prepare('SELECT id FROM hosts WHERE lower(name) = lower(?) AND site_id = ?');
const reactivate = db.prepare('UPDATE hosts SET active = 1, email = ?, area = ? WHERE id = ?');
let added = 0;
let updated = 0;
db.transaction(() => {
if (replace) db.prepare('UPDATE hosts SET active = 0 WHERE site_id = ?').run(siteId);
for (const row of body) {
const name = titleCase(row[idx.name], 120);
if (!name) continue;
const email = idx.email >= 0 ? normaliseEmail(row[idx.email]) || null : null;
const area = idx.area >= 0 ? clean(row[idx.area], 80) || null : null;
const found = existing.get(name, siteId);
if (found) {
reactivate.run(email, area, found.id);
updated += 1;
} else {
insert.run(name, email, area, siteId);
added += 1;
}
}
})();
const total = db
.prepare('SELECT COUNT(*) AS n FROM hosts WHERE active = 1 AND site_id = ?')
.get(siteId).n;
res.json({ added, updated, total });
});
/* ------------------------------------------------- recurring visitors */
function shapeFrequent(row, includePin = false) {
return {
id: row.id,
firstName: row.first_name,
lastName: row.last_name,
phone: row.phone,
email: row.email,
checkType: row.check_type,
checkNumber: row.check_number,
checkExpiry: row.check_expiry,
defaultHostId: row.default_host_id,
siteId: row.site_id,
notes: row.notes,
active: Boolean(row.active),
createdAt: row.created_at,
expiry: expiryState(row.check_type, row.check_expiry),
...(includePin ? { pin: decryptPin(row.pin_enc) } : {}),
};
}
/** Days until a WWCC or VIT lapses, plus a plain status an admin can act on. */
export function expiryState(checkType, checkExpiry) {
if (checkType === 'NONE' || !checkExpiry) return { status: 'none', daysLeft: null };
const due = new Date(`${checkExpiry}T23:59:59`);
if (Number.isNaN(due.getTime())) return { status: 'none', daysLeft: null };
const daysLeft = Math.ceil((due.getTime() - Date.now()) / 86400000);
if (daysLeft < 0) return { status: 'expired', daysLeft };
if (daysLeft <= config.expiryWarningDays) return { status: 'expiring', daysLeft };
return { status: 'ok', daysLeft };
}
router.get('/frequent', (req, res) => {
const scope = scopedSiteId(req);
const rows = db
.prepare(
`SELECT * FROM frequent_visitors
${scope ? 'WHERE site_id IS NULL OR site_id = ?' : ''}
ORDER BY last_name COLLATE NOCASE, first_name COLLATE NOCASE`
)
.all(...(scope ? [scope] : []));
res.json(rows.map((r) => shapeFrequent(r)));
});
router.get('/frequent/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
res.json(shapeFrequent(row, true));
});
/** Everyone whose check lapses inside the warning window, or already has. */
router.get('/alerts', (req, res) => {
const scope = scopedSiteId(req);
const rows = db
.prepare(
`SELECT * FROM frequent_visitors
WHERE active = 1 AND check_type <> 'NONE' AND check_expiry IS NOT NULL
${scope ? 'AND (site_id IS NULL OR site_id = ?)' : ''}`
)
.all(...(scope ? [scope] : []))
.map((r) => shapeFrequent(r))
.filter((r) => r.expiry.status === 'expiring' || r.expiry.status === 'expired')
.sort((a, b) => a.expiry.daysLeft - b.expiry.daysLeft);
res.json({
warningDays: config.expiryWarningDays,
expired: rows.filter((r) => r.expiry.status === 'expired'),
expiring: rows.filter((r) => r.expiry.status === 'expiring'),
});
});
function validateFrequent(body, { existingPhone = null } = {}) {
const firstName = titleCase(body?.firstName, 60);
const lastName = titleCase(body?.lastName, 60);
const phone = normalisePhone(body?.phone);
const email = normaliseEmail(body?.email);
const checkType = clean(body?.checkType, 10).toUpperCase() || 'NONE';
if (!firstName || !lastName) throw new Error('First and last name are required.');
if (!isPhone(phone)) throw new Error('A valid mobile number is required — it is their username.');
if (email && !isEmail(email)) throw new Error('That email address is not valid.');
if (!CHECK_TYPES.has(checkType)) throw new Error('Check type must be WWCC, VIT or NONE.');
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
throw new Error(`A ${checkType} number is required.`);
}
if (phone !== existingPhone) {
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
}
return {
firstName,
lastName,
phone,
email: email || null,
checkType,
checkNumber: clean(body?.checkNumber, 40) || null,
checkExpiry: clean(body?.checkExpiry, 20) || null,
defaultHostId: body?.defaultHostId ? Number(body.defaultHostId) : null,
siteId: body?.siteId ? Number(body.siteId) : null,
notes: clean(body?.notes, 300) || null,
};
}
router.post('/frequent', (req, res) => {
try {
const v = validateFrequent(req.body);
const scope = scopedSiteId(req);
const siteId = scope || v.siteId;
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
const info = db
.prepare(
`INSERT INTO frequent_visitors
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
default_host_id, site_id, pin_enc, notes, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
)
.run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId, siteId, encryptPin(pin), v.notes
);
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.patch('/frequent/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
try {
const v = validateFrequent({ ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone });
const scope = scopedSiteId(req);
db.prepare(
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
).run(
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
v.defaultHostId,
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
v.notes,
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
row.id
);
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(row.id), true));
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/frequent/:id/pin', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Not found.' });
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
encryptPin(pin),
row.id
);
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
res.json({ ok: true, pin });
});
router.delete('/frequent/:id', (req, res) => {
db.prepare('UPDATE frequent_visitors SET active = 0 WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
/* ------------------------------------------------------------- visits */
function shapeVisit(v) {
return {
id: v.id,
siteId: v.site_id,
siteName: v.site_name,
visitorType: v.visitor_type,
firstName: v.first_name,
lastName: v.last_name,
phone: v.phone,
email: v.email,
checkType: v.check_type,
checkNumber: v.check_number,
hostName: v.host_name,
visitReason: v.visit_reason,
hasPhoto: Boolean(v.photo_path),
signedInAt: v.signed_in_at,
signedOutAt: v.signed_out_at,
signedOutBy: v.signed_out_by,
};
}
router.get('/onsite', (req, res) => {
const where = ['signed_out_at IS NULL'];
const params = [];
applySiteFilter(req, where, params);
const rows = db
.prepare(`SELECT * FROM visits WHERE ${where.join(' AND ')} ORDER BY signed_in_at`)
.all(...params);
res.json(rows.map(shapeVisit));
});
router.get('/visits', (req, res) => {
const where = [];
const params = [];
applySiteFilter(req, where, params);
const from = clean(req.query.from, 10);
const to = clean(req.query.to, 10);
const q = clean(req.query.q, 60);
if (from) {
where.push('signed_in_at >= ?');
params.push(`${from}T00:00:00.000Z`);
}
if (to) {
where.push('signed_in_at <= ?');
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}%`);
}
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));
});
router.post('/visits/:id/signout', (req, res) => {
const visit = db
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
.get(req.params.id);
if (!visit) return res.status(404).json({ error: 'That visit is already closed.' });
try {
if (scopedSiteId(req)) assertSiteAllowed(req, visit.site_id);
} catch (err) {
return res.status(403).json({ error: err.message });
}
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
nowIso(),
'admin',
visit.id
);
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT');
res.json({ ok: true });
});
router.get('/visits.csv', (req, res) => {
const where = [];
const params = [];
applySiteFilter(req, where, params);
const rows = db
.prepare(
`SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC`
)
.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'],
...rows.map((v) => [
v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, 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',
]),
]);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="visits-${new Date().toISOString().slice(0, 10)}.csv"`);
res.send(csv);
});
router.get('/photo/:id', (req, res) => {
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
const abs = visit && photoAbsolutePath(visit.photo_path);
if (!abs) return res.status(404).send('No photo on file.');
res.setHeader('Cache-Control', 'private, max-age=300');
res.sendFile(abs);
});
router.delete('/photo/:id', (req, res) => {
const visit = db.prepare('SELECT photo_path FROM visits WHERE id = ?').get(req.params.id);
if (visit?.photo_path) {
deletePhoto(visit.photo_path);
db.prepare('UPDATE visits SET photo_path = NULL WHERE id = ?').run(req.params.id);
}
res.json({ ok: true });
});
/** Reprint a badge for someone already on site. */
router.get('/badge/:id', (req, res) => {
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id);
if (!visit) return res.status(404).send('Not found.');
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
if (!site) return res.status(404).send('That site no longer exists.');
let photoUrl = null;
const abs = photoAbsolutePath(visit.photo_path);
if (abs && site.badge_show_photo) {
photoUrl = `data:image/jpeg;base64,${fs.readFileSync(abs).toString('base64')}`;
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(badgeHtml(visit, site, { autoPrint: true, photoUrl }));
});
/* --------------------------------------------------- printable PIN card */
router.get('/pass/:id', (req, res) => {
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).send('Not found.');
const pin = decryptPin(row.pin_enc) || '????';
const host = row.default_host_id
? db.prepare('SELECT name FROM hosts WHERE id = ?').get(row.default_host_id)
: null;
const site = row.site_id ? db.prepare('SELECT name FROM sites WHERE id = ?').get(row.site_id) : null;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Sign in card — ${esc(row.first_name)} ${esc(row.last_name)}</title>
<style>
:root { --ink:#16202b; --muted:#5d6b7a; --rule:#c9d3dc; --deep:#0b4f4a; }
* { box-sizing: border-box; }
body { margin:0; padding:24px; background:#eef1f4; color:var(--ink);
font-family: "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.card { width: 105mm; min-height: 74mm; margin: 0 auto; background:#fff; padding: 12mm 11mm;
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); }
.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); }
dl { display:grid; grid-template-columns: 34mm 1fr; gap:5px 10px; margin:0;
font-size:12.5px; border-top:1px solid var(--rule); padding-top:10px; }
dt { color: var(--muted); }
dd { margin:0; }
.how { margin-top:12px; font-size:11.5px; color:var(--muted); line-height:1.5; }
.no-print { text-align:center; margin: 18px 0; }
button { font:inherit; padding:10px 20px; border:1px solid var(--deep); background:var(--deep);
color:#fff; border-radius:2px; cursor:pointer; }
@media print {
body { background:#fff; padding:0; }
.card { border:none; }
.no-print { display:none; }
}
</style>
</head>
<body>
<div class="no-print"><button onclick="window.print()">Print this card</button></div>
<div class="card">
<p class="site">${esc(site ? site.name : config.siteName)}</p>
<h1>${esc(row.first_name)} ${esc(row.last_name)}</h1>
<p class="pin">${esc(pin)}</p>
<p class="pin-label">Your PIN. Keep this card, it is not sent to you again.</p>
<dl>
<dt>Mobile (your username)</dt><dd>${esc(row.phone)}</dd>
<dt>Check on file</dt><dd>${row.check_type === 'NONE' ? 'None recorded' : `${esc(row.check_type)} ${esc(row.check_number || '')}`}</dd>
${row.check_expiry ? `<dt>Expires</dt><dd>${esc(row.check_expiry)}</dd>` : ''}
${site ? `<dt>Site</dt><dd>${esc(site.name)}</dd>` : '<dt>Site</dt><dd>Any site</dd>'}
${host ? `<dt>Usually visiting</dt><dd>${esc(host.name)}</dd>` : ''}
<dt>Issued</dt><dd>${esc(localStamp(nowIso()))}</dd>
</dl>
<p class="how">At the kiosk, tap <strong>I have a PIN</strong>, enter your mobile number
and this PIN, pick who you are visiting, and take a photo. Sign out with your last name and
mobile number on the way out.</p>
</div>
</body>
</html>`);
});
/* ------------------------------------------------------------- system */
router.get('/status', (req, res) => {
const scope = scopedSiteId(req);
res.json({
siteName: config.siteName,
timezone: config.timezone,
requirePhoto: config.requirePhoto,
photoRetentionDays: config.photoRetentionDays,
autoSignOutTime: config.autoSignOutTime || null,
expiryWarningDays: config.expiryWarningDays,
require2fa: config.admin.require2fa,
domainRule: users.domainRuleText(),
siteCount: listSites({ activeOnly: true }).length,
onSite: scope
? db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL AND site_id = ?').get(scope).n
: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n,
sheets: {
enabled: sheets.isEnabled(),
queued: sheets.queueDepth(),
lastOk: sheets.status.lastOk,
lastError: sheets.status.lastError,
},
});
});
router.post('/sheets/test', async (req, res) => {
try {
res.json({ ok: true, ...(await sheets.testConnection()) });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/sheets/flush', async (req, res) => {
try {
res.json(await sheets.flushQueue());
} catch (err) {
res.status(400).json({ error: err.message });
}
});
router.post('/photos/purge', (req, res) => {
res.json({ purged: purgeOldPhotos() });
});
export default router;
+343
View File
@@ -0,0 +1,343 @@
import express from 'express';
import rateLimit from 'express-rate-limit';
import db from '../db.js';
import config from '../config.js';
import { savePhoto, photoAbsolutePath } from '../photos.js';
import { mirror } from '../sheets.js';
import { verifyPin } from '../pins.js';
import { listSites, resolveSite, badgeHtml } from '../sites.js';
import fs from 'node:fs';
import {
clean,
isEmail,
isPhone,
normaliseEmail,
normalisePhone,
nowIso,
titleCase,
} from '../util.js';
const router = express.Router();
const CHECK_TYPES = new Set(['WWCC', 'VIT', 'NONE']);
const LOCKOUT_FAILS = 5;
const LOCKOUT_MINUTES = 15;
const BADGE_WINDOW_MS = 10 * 60 * 1000;
const signInLimiter = rateLimit({ windowMs: 60000, max: 20, standardHeaders: true });
const pinLimiter = rateLimit({ windowMs: 60000, max: 12, standardHeaders: true });
/** Every kiosk request carries a site, either as ?site=slug or in the body. */
function siteFrom(req) {
return resolveSite(req.query.site ?? req.body?.site ?? req.body?.siteId);
}
router.get('/sites', (req, res) => {
res.json(listSites({ activeOnly: true }).map((s) => ({ id: s.id, name: s.name, slug: s.slug })));
});
router.get('/config', (req, res) => {
const sites = listSites({ activeOnly: true });
const site = siteFrom(req);
res.json({
multiSite: sites.length > 1,
siteChosen: Boolean(site),
site: site
? { id: site.id, name: site.name, slug: site.slug, badgeEnabled: Boolean(site.badge_enabled) }
: null,
siteName: site ? site.name : config.siteName,
requirePhoto: config.requirePhoto,
});
});
router.get('/hosts', (req, res) => {
const site = siteFrom(req);
if (!site) return res.json([]);
res.json(
db
.prepare(
'SELECT id, name, area FROM hosts WHERE active = 1 AND site_id = ? ORDER BY name COLLATE NOCASE'
)
.all(site.id)
);
});
function contactOk(phone, email) {
return (phone && isPhone(phone)) || (email && isEmail(email));
}
/** "Already here" is judged on contact details, whatever name was typed this time. */
function openVisitByContact(siteId, phone, email) {
return db
.prepare(
`SELECT * FROM visits
WHERE signed_out_at IS NULL AND site_id = ?
AND ((? <> '' AND phone = ?) OR (? <> '' AND lower(email) = ?))`
)
.all(siteId, phone, phone, email, email);
}
function openVisitFor(siteId, lastName, phone, email) {
return db
.prepare(
`SELECT * FROM visits
WHERE signed_out_at IS NULL AND site_id = ?
AND lower(last_name) = lower(?)
AND ((? <> '' AND phone = ?) OR (? <> '' AND lower(email) = ?))
ORDER BY signed_in_at DESC`
)
.all(siteId, lastName, phone, phone, email, email);
}
/* -------------------------------------------------------------- sign in */
router.post('/signin', signInLimiter, (req, res) => {
try {
const body = req.body || {};
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const isFrequent = body.mode === 'frequent';
let frequent = null;
if (isFrequent) {
frequent = db
.prepare('SELECT * FROM frequent_visitors WHERE id = ? AND active = 1')
.get(body.frequentVisitorId);
if (!frequent) {
return res.status(400).json({ error: 'That recurring visitor record is no longer active.' });
}
if (frequent.site_id && frequent.site_id !== site.id) {
return res.status(403).json({ error: 'Your record is not set up for this site.' });
}
// The kiosk must prove it just passed the PIN check for this person.
if (req.session.frequentVisitorId !== frequent.id) {
return res.status(401).json({ error: 'Enter your PIN again to continue.' });
}
}
const firstName = titleCase(isFrequent ? frequent.first_name : body.firstName, 60);
const lastName = titleCase(isFrequent ? frequent.last_name : body.lastName, 60);
const phone = normalisePhone(isFrequent ? frequent.phone : body.phone);
const email = normaliseEmail(isFrequent ? frequent.email : body.email);
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);
const visitReason = clean(body.visitReason, 120);
if (!firstName) return res.status(400).json({ error: 'First name is required.' });
if (!lastName) return res.status(400).json({ error: 'Last name is required.' });
if (!CHECK_TYPES.has(checkType)) {
return res.status(400).json({ error: 'Choose WWCC, VIT, or "I don\'t have one".' });
}
if (checkType !== 'NONE' && !checkNumber) {
return res.status(400).json({ error: `Enter your ${checkType} number.` });
}
if (!contactOk(phone, email)) {
return res
.status(400)
.json({ error: 'Add a mobile number or an email address so we can reach you.' });
}
const host = db
.prepare('SELECT * FROM hosts WHERE id = ? AND active = 1 AND site_id = ?')
.get(body.hostId, site.id);
if (!host) return res.status(400).json({ error: 'Choose the person you are visiting.' });
if (openVisitByContact(site.id, phone, email).length) {
return res.status(409).json({
error: `${firstName}, you are already signed in. See the front desk if that looks wrong.`,
});
}
let photoPath = null;
if (body.photo) {
photoPath = savePhoto(body.photo);
} else if (config.requirePhoto) {
return res.status(400).json({ error: 'A photo is required to sign in.' });
}
const signedInAt = nowIso();
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
site.id,
site.name,
isFrequent ? 'frequent' : 'guest',
isFrequent ? frequent.id : null,
firstName,
lastName,
phone || null,
email || null,
checkType,
checkNumber || null,
checkExpiry || null,
host.id,
host.name,
visitReason || null,
photoPath,
signedInAt
);
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(info.lastInsertRowid);
mirror(visit, 'SIGN IN');
delete req.session.frequentVisitorId;
// Lets this kiosk session fetch the badge for the visit it just created.
req.session.badgeVisitId = visit.id;
req.session.badgeIssuedAt = Date.now();
res.json({
ok: true,
firstName,
hostName: host.name,
signedInAt,
visitId: visit.id,
badgeUrl: site.badge_enabled ? `/api/badge/${visit.id}` : null,
});
} catch (err) {
console.error('[signin]', err);
res.status(400).json({ error: err.message || 'Sign in could not be completed.' });
}
});
/* --------------------------------------------------------------- badge */
router.get('/badge/:id', (req, res) => {
const visitId = Number(req.params.id);
const fresh =
req.session.badgeVisitId === visitId &&
Date.now() - (req.session.badgeIssuedAt || 0) < BADGE_WINDOW_MS;
if (!fresh) return res.status(403).send('That badge is no longer available at this kiosk.');
const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(visitId);
if (!visit) return res.status(404).send('Not found.');
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id);
if (!site || !site.badge_enabled) return res.status(404).send('Badges are off for this site.');
let photoUrl = null;
const abs = photoAbsolutePath(visit.photo_path);
if (abs && site.badge_show_photo) {
// Inlined so the badge prints even if the image request is slow or blocked.
photoUrl = `data:image/jpeg;base64,${fs.readFileSync(abs).toString('base64')}`;
}
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(badgeHtml(visit, site, { autoPrint: true, photoUrl }));
});
/* ------------------------------------------------------------- sign out */
router.post('/signout/lookup', signInLimiter, (req, res) => {
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const lastName = clean(req.body?.lastName, 60);
const contactRaw = clean(req.body?.contact, 120);
if (!lastName) return res.status(400).json({ error: 'Enter your last name.' });
if (!contactRaw) return res.status(400).json({ error: 'Enter your mobile number or email.' });
const phone = isPhone(contactRaw) ? normalisePhone(contactRaw) : '';
const email = isEmail(contactRaw) ? normaliseEmail(contactRaw) : '';
if (!phone && !email) {
return res.status(400).json({ error: 'That does not look like a mobile number or email.' });
}
const rows = openVisitFor(site.id, lastName, phone, email);
if (!rows.length) {
return res.status(404).json({
error: 'No open visit matches those details. Check the spelling, or ask the front desk.',
});
}
res.json(
rows.map((v) => ({
id: v.id,
firstName: v.first_name,
lastName: v.last_name,
hostName: v.host_name,
signedInAt: v.signed_in_at,
}))
);
});
router.post('/signout', signInLimiter, (req, res) => {
const visit = db
.prepare('SELECT * FROM visits WHERE id = ? AND signed_out_at IS NULL')
.get(req.body?.visitId);
if (!visit) return res.status(404).json({ error: 'That visit is already closed.' });
const signedOutAt = nowIso();
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
signedOutAt,
'visitor',
visit.id
);
mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT');
res.json({ ok: true, firstName: visit.first_name, signedOutAt });
});
/* ---------------------------------------------------- recurring visitor */
router.post('/frequent/auth', pinLimiter, (req, res) => {
const site = siteFrom(req);
if (!site) return res.status(400).json({ error: 'This kiosk has no site selected.' });
const phone = normalisePhone(req.body?.phone);
const pin = clean(req.body?.pin, 8);
if (!phone || !/^\d{4}$/.test(pin)) {
return res.status(400).json({ error: 'Enter your mobile number and 4 digit PIN.' });
}
const attempt = db.prepare('SELECT * FROM pin_attempts WHERE phone = ?').get(phone);
if (attempt?.locked_until && attempt.locked_until > nowIso()) {
return res
.status(429)
.json({ error: 'Too many wrong PINs. Wait 15 minutes or see the front desk.' });
}
const person = db
.prepare('SELECT * FROM frequent_visitors WHERE phone = ? AND active = 1')
.get(phone);
if (!person || !verifyPin(person.pin_enc, pin)) {
const fails = (attempt?.fails || 0) + 1;
const lockedUntil =
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
db.prepare(
`INSERT INTO pin_attempts (phone, fails, locked_until) VALUES (?, ?, ?)
ON CONFLICT(phone) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
).run(phone, fails, lockedUntil);
return res.status(401).json({ error: 'That mobile number and PIN do not match.' });
}
if (person.site_id && person.site_id !== site.id) {
return res.status(403).json({ error: 'Your record is not set up for this site.' });
}
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(phone);
req.session.frequentVisitorId = person.id;
const open = db
.prepare(
'SELECT id, host_name, signed_in_at FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL'
)
.get(person.id);
res.json({
id: person.id,
firstName: person.first_name,
lastName: person.last_name,
checkType: person.check_type,
checkNumber: person.check_number,
defaultHostId: person.default_host_id,
openVisit: open || null,
});
});
export default router;
+111
View File
@@ -0,0 +1,111 @@
import express from 'express';
import session from 'express-session';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import config from './config.js';
import db from './db.js';
import kioskRoutes from './routes/kiosk.js';
import adminRoutes from './routes/admin.js';
import * as sheets from './sheets.js';
import * as users from './users.js';
import { purgeOldPhotos } from './photos.js';
import { localHm, nowIso } from './util.js';
users.bootstrap();
const here = path.dirname(fileURLToPath(import.meta.url));
const publicDir = path.join(here, '..', 'public');
const app = express();
if (config.trustProxy) app.set('trust proxy', 1);
app.disable('x-powered-by');
// Photos arrive as base64 data URLs in the sign-in payload.
app.use(express.json({ limit: '8mb' }));
app.use(
session({
secret: config.appSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: config.secureCookies,
maxAge: 8 * 60 * 60 * 1000,
},
})
);
app.use('/api', kioskRoutes);
app.use('/admin/api', adminRoutes);
app.get('/healthz', (req, res) => {
res.json({ ok: true, onSite: db.prepare('SELECT COUNT(*) AS n FROM visits WHERE signed_out_at IS NULL').get().n });
});
app.use(express.static(publicDir, { extensions: ['html'] }));
app.get('/admin', (req, res) => res.sendFile(path.join(publicDir, 'admin.html')));
app.use((req, res) => res.status(404).sendFile(path.join(publicDir, 'index.html')));
app.use((err, req, res, next) => {
console.error('[error]', err);
res.status(500).json({ error: 'Something went wrong on the server.' });
});
/* ------------------------------------------------------- background jobs */
sheets.startWorker();
setInterval(purgeOldPhotos, 24 * 60 * 60 * 1000).unref();
purgeOldPhotos();
if (config.autoSignOutTime) {
let lastRunDay = '';
setInterval(() => {
const today = new Date().toISOString().slice(0, 10);
if (lastRunDay === today) return;
if (localHm() < config.autoSignOutTime) return;
lastRunDay = today;
const open = db.prepare('SELECT * FROM visits WHERE signed_out_at IS NULL').all();
for (const visit of open) {
db.prepare('UPDATE visits SET signed_out_at = ?, signed_out_by = ? WHERE id = ?').run(
nowIso(),
'auto',
visit.id
);
sheets.mirror(db.prepare('SELECT * FROM visits WHERE id = ?').get(visit.id), 'SIGN OUT (AUTO)');
}
if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`);
}, 60000).unref();
}
/* ------------------------------------------------------------- listen */
function start() {
if (config.https.enabled) {
if (!fs.existsSync(config.https.keyPath) || !fs.existsSync(config.https.certPath)) {
console.error(
`[https] certificate not found at ${config.https.certPath}. Run scripts/gen-cert.sh first.`
);
process.exit(1);
}
https
.createServer(
{ key: fs.readFileSync(config.https.keyPath), cert: fs.readFileSync(config.https.certPath) },
app
)
.listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`);
});
} else {
http.createServer(app).listen(config.port, () => {
console.log(`[server] ${config.siteName} listening on http://0.0.0.0:${config.port}`);
console.log('[server] camera capture needs HTTPS or localhost — see README before rolling out');
});
}
}
start();
+174
View File
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import { google } from 'googleapis';
import config from './config.js';
import db from './db.js';
import { localStamp } from './util.js';
const HEADER = [
'Timestamp',
'Site',
'Action',
'Visitor type',
'First name',
'Last name',
'Phone',
'Email',
'Check type',
'Check number',
'Visiting',
'Signed in',
'Signed out',
'Photo on file',
'Visit ID',
];
let client = null;
let headerChecked = false;
export const status = { configured: false, lastOk: null, lastError: null };
function loadCredentials() {
if (config.sheets.credentialsB64) {
return JSON.parse(Buffer.from(config.sheets.credentialsB64, 'base64').toString('utf8'));
}
if (config.sheets.credentialsPath && fs.existsSync(config.sheets.credentialsPath)) {
return JSON.parse(fs.readFileSync(config.sheets.credentialsPath, 'utf8'));
}
throw new Error('No Google service account credentials found.');
}
function getClient() {
if (client) return client;
const creds = loadCredentials();
const auth = new google.auth.JWT({
email: creds.client_email,
key: creds.private_key,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
client = google.sheets({ version: 'v4', auth });
return client;
}
export function isEnabled() {
return Boolean(config.sheets.enabled && config.sheets.spreadsheetId);
}
async function ensureHeader(sheets) {
if (headerChecked) return;
const range = `${config.sheets.tabName}!A1:O1`;
const res = await sheets.spreadsheets.values.get({
spreadsheetId: config.sheets.spreadsheetId,
range,
});
if (!res.data.values || res.data.values.length === 0) {
await sheets.spreadsheets.values.update({
spreadsheetId: config.sheets.spreadsheetId,
range,
valueInputOption: 'RAW',
requestBody: { values: [HEADER] },
});
}
headerChecked = true;
}
/** Builds the row that gets mirrored to the sheet for one sign in or sign out event. */
export function rowForVisit(visit, action) {
return [
localStamp(new Date().toISOString()),
visit.site_name || '',
action,
visit.visitor_type === 'frequent' ? 'Recurring' : 'Guest',
visit.first_name,
visit.last_name,
visit.phone || '',
visit.email || '',
visit.check_type === 'NONE' ? 'None' : visit.check_type,
visit.check_number || '',
visit.host_name,
localStamp(visit.signed_in_at),
visit.signed_out_at ? localStamp(visit.signed_out_at) : '',
visit.photo_path ? 'Yes' : 'No',
String(visit.id),
];
}
async function append(row) {
const sheets = getClient();
await ensureHeader(sheets);
await sheets.spreadsheets.values.append({
spreadsheetId: config.sheets.spreadsheetId,
range: `${config.sheets.tabName}!A:O`,
valueInputOption: 'USER_ENTERED',
insertDataOption: 'INSERT_ROWS',
requestBody: { values: [row] },
});
}
function enqueue(row) {
db.prepare('INSERT INTO sheet_queue (payload) VALUES (?)').run(JSON.stringify(row));
}
/** Fire and forget: never let a Sheets outage block someone at the front desk. */
export function mirror(visit, action) {
if (!isEnabled()) return;
const row = rowForVisit(visit, action);
append(row)
.then(() => {
status.lastOk = new Date().toISOString();
status.lastError = null;
})
.catch((err) => {
status.lastError = err.message;
console.error('[sheets] append failed, queued for retry:', err.message);
enqueue(row);
});
}
export async function flushQueue() {
if (!isEnabled()) return { sent: 0, remaining: 0 };
const rows = db.prepare('SELECT * FROM sheet_queue ORDER BY id LIMIT 50').all();
let sent = 0;
for (const item of rows) {
try {
await append(JSON.parse(item.payload));
db.prepare('DELETE FROM sheet_queue WHERE id = ?').run(item.id);
sent += 1;
status.lastOk = new Date().toISOString();
status.lastError = null;
} catch (err) {
db.prepare('UPDATE sheet_queue SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(
err.message,
item.id
);
status.lastError = err.message;
break; // Sheets is still unhappy; try again on the next tick.
}
}
const remaining = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
return { sent, remaining };
}
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 ensureHeader(sheets);
status.lastOk = new Date().toISOString();
status.lastError = null;
return { title: meta.data.properties.title };
}
export function queueDepth() {
return db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n;
}
export function startWorker() {
if (!isEnabled()) {
console.log('[sheets] mirroring disabled');
return;
}
status.configured = true;
setInterval(() => {
flushQueue().catch((err) => console.error('[sheets] flush error:', err.message));
}, config.sheets.retryIntervalMs).unref();
console.log('[sheets] mirroring enabled ->', config.sheets.spreadsheetId);
}
+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 };
+121
View File
@@ -0,0 +1,121 @@
import db from './db.js';
import config from './config.js';
import { hashPassword, randomPassword } from './auth.js';
import { nowIso } from './util.js';
const LOCKOUT_FAILS = 6;
const LOCKOUT_MINUTES = 15;
export function domainAllowed(email) {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return true;
const domain = String(email).split('@')[1]?.toLowerCase() || '';
return allowed.some((d) => domain === d || domain.endsWith(`.${d}`));
}
export function domainRuleText() {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return null;
return allowed.map((d) => `@${d}`).join(' or ');
}
export function findByEmail(email) {
return db
.prepare('SELECT * FROM admin_users WHERE email = ?')
.get(String(email).trim().toLowerCase());
}
export function findById(id) {
return db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
}
export function countActive() {
return db.prepare('SELECT COUNT(*) AS n FROM admin_users WHERE active = 1').get().n;
}
export function shape(user) {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
siteId: user.site_id,
twoFactorOn: Boolean(user.totp_enabled),
mustChangePassword: Boolean(user.must_change_password),
active: Boolean(user.active),
lastLoginAt: user.last_login_at,
createdAt: user.created_at,
};
}
export function createUser({ email, name, password, role = 'admin', siteId = null, mustChange = true }) {
const clean = String(email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error('Enter a valid email address.');
if (!domainAllowed(clean)) {
throw new Error(`Admin accounts must use an ${domainRuleText()} address.`);
}
if (findByEmail(clean)) throw new Error('An account already uses that email address.');
const temp = password || randomPassword();
const info = db
.prepare(
`INSERT INTO admin_users (email, name, password_hash, role, site_id, must_change_password)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(clean, String(name || '').trim() || null, hashPassword(temp), role, siteId, mustChange ? 1 : 0);
return { user: findById(info.lastInsertRowid), temporaryPassword: password ? null : temp };
}
/* ------------------------------------------------------------- lockout */
export function lockState(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
if (row?.locked_until && row.locked_until > nowIso()) return row;
return null;
}
export function noteFailure(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
const fails = (row?.fails || 0) + 1;
const lockedUntil =
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
db.prepare(
`INSERT INTO login_attempts (email, fails, locked_until) VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
).run(email, fails, lockedUntil);
return { fails, lockedUntil };
}
export function clearFailures(email) {
db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email);
}
/* ----------------------------------------------------------- bootstrap */
/** Creates the very first admin account from the environment, once. */
export function bootstrap() {
if (countActive() > 0) return;
const { bootstrapEmail, bootstrapPassword } = config.admin;
if (!bootstrapEmail || !bootstrapPassword) {
console.warn(
'[users] No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD\n' +
' in .env and restart to create the first one.'
);
return;
}
try {
createUser({
email: bootstrapEmail,
name: 'First admin',
password: bootstrapPassword,
role: 'owner',
mustChange: true,
});
console.log(`[users] created the first admin account: ${bootstrapEmail}`);
console.log('[users] you will be asked to set a new password at first sign in');
} catch (err) {
console.error('[users] could not create the first admin account:', err.message);
}
}
+113
View File
@@ -0,0 +1,113 @@
import config from './config.js';
export function normalisePhone(input) {
if (!input) return '';
let digits = String(input).replace(/[^\d+]/g, '');
if (digits.startsWith('+61')) digits = '0' + digits.slice(3);
else if (digits.startsWith('61') && digits.length === 11) digits = '0' + digits.slice(2);
return digits.replace(/\+/g, '');
}
export function normaliseEmail(input) {
return String(input || '').trim().toLowerCase();
}
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
}
export function isPhone(value) {
const d = normalisePhone(value);
return d.length >= 8 && d.length <= 15;
}
export function clean(value, max = 200) {
return String(value ?? '').trim().slice(0, max);
}
export function titleCase(value, max = 200) {
return clean(value, max).replace(/\b\p{L}/gu, (c) => c.toUpperCase());
}
const dateFormatter = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
dateStyle: 'short',
timeStyle: 'medium',
hour12: false,
});
export function localStamp(isoString) {
if (!isoString) return '';
return dateFormatter.format(new Date(isoString));
}
export function localHm(date = new Date()) {
const parts = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const hour = parts.find((p) => p.type === 'hour').value;
const minute = parts.find((p) => p.type === 'minute').value;
return `${hour}:${minute}`;
}
export function nowIso() {
return new Date().toISOString();
}
/** Minimal RFC4180-ish CSV parser: handles quoted fields, embedded commas and newlines. */
export function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const src = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (inQuotes) {
if (ch === '"') {
if (src[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += ch;
}
}
if (field.length || row.length) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c.trim() !== ''));
}
export function toCsv(rows) {
return rows
.map((row) =>
row
.map((cell) => {
const value = cell === null || cell === undefined ? '' : String(cell);
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
})
.join(',')
)
.join('\r\n');
}