commit ed7749381742d232d0d5350b01692b5a4cb9d429 Author: jessikitty Date: Mon Aug 31 09:47:36 2026 +1000 Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..002f1ea --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +data +secrets +.env +.git +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..719c73d --- /dev/null +++ b/.env.example @@ -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//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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0fa4097 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86fa1dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +data/ +secrets/ +.env +*.db +*.db-shm +*.db-wal +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..156dc1e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..506d4b6 --- /dev/null +++ b/README.md @@ -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://:8088` and the admin console on +`http://: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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c580a9 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/hosts-sample.csv b/docs/hosts-sample.csv new file mode 100644 index 0000000..2ddc4cb --- /dev/null +++ b/docs/hosts-sample.csv @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee077fc --- /dev/null +++ b/package.json @@ -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" +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..4ba3028 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,204 @@ + + + + + +Admin — visitor sign in + + + + + + + + + + + + + + + + +
Created by: Jess Rogerson (yelling commands at Claude.AI)
+ + + + diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..6d671f0 --- /dev/null +++ b/public/css/admin.css @@ -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); } diff --git a/public/css/kiosk.css b/public/css/kiosk.css new file mode 100644 index 0000000..5475b58 --- /dev/null +++ b/public/css/kiosk.css @@ -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; +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..f9efa83 --- /dev/null +++ b/public/index.html @@ -0,0 +1,236 @@ + + + + + + +Visitor sign in + + + + +
+

Visitor sign in

+

+
+ +
+ + +
+

Which site is this kiosk at?

+

This tablet remembers the answer, so you only pick once.

+
+
+ + +
+

Welcome. Are you coming in, or heading out?

+
+ + +
+ +
+ + +
+
+

What's your name?

+ + + +
+ + +
+
+

Who are you here to see?

+ +
+ +
+ + +
+
+

How can we reach you today?

+

One of these is enough. We use it to sign you out and in an emergency.

+ + + +
+ + +
+
+

Do you hold a WWCC or VIT registration?

+
+ + + +
+ + +
+ + +
+
+

Look at the camera

+

The photo stays on this site's server. It is not sent anywhere else.

+
+ + + +
+ + +
+ + +
+
+

Check these details, then sign in

+
+ +
+ + +
+

Welcome back

+ + + +
+ + +
+

Who are you here to see?

+ +
+ +
+ + +
+

Signed in

+

+

+ +
+ + +
+

Signing out

+ + + +
+ + +
+

Is this you?

+
+ +
+ + +
+

Signed out

+

+

Thanks for visiting. Travel safely.

+ +
+ +
+ + + +
+ Created by: Jess Rogerson (yelling commands at Claude.AI) + + + Admin + +
+ + + + + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..fa0dd73 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,1002 @@ +/* Visitor sign in — admin console. */ + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +let me = null; // the signed in admin +let sites = []; +let hosts = []; +let activeSiteId = 'all'; // which site the console is showing + +/* ------------------------------------------------------------ plumbing */ + +async function api(path, { method = 'GET', body } = {}) { + const res = await fetch(`/admin/api${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const error = new Error(data.error || `Request failed (${res.status}).`); + error.payload = data; + error.status = res.status; + throw error; + } + return data; +} + +/** Adds the site the console is currently scoped to. */ +function scoped(path) { + if (activeSiteId === 'all') return path; + return path + (path.includes('?') ? '&' : '?') + `siteId=${activeSiteId}`; +} + +let toastTimer = null; +function toast(message, bad = false) { + const el = $('#toast'); + el.textContent = message; + el.className = bad ? 'toast bad' : 'toast'; + el.hidden = false; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + el.hidden = true; + }, 5500); +} + +const esc = (v) => + String(v ?? '').replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); + +const stamp = (iso) => + iso + ? new Date(iso).toLocaleString('en-AU', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) + : '—'; + +function table(headings, rows, emptyMessage) { + if (!rows.length) return `

${esc(emptyMessage)}

`; + return ` + ${headings.map((h) => ``).join('')} + ${rows.join('')} +
${esc(h)}
`; +} + +function field(label, name, value = '', type = 'text') { + return ``; +} + +function loginError(message) { + const el = $('#login-error'); + el.textContent = message; + el.hidden = !message; +} + +function loginStep(id) { + $$('.login-step').forEach((s) => { + s.hidden = s.id !== id; + }); + loginError(''); + const input = document.getElementById(id)?.querySelector('input'); + if (input) input.focus(); +} + +/* ---------------------------------------------------------- login flow */ + +$('#step-password').addEventListener('submit', async (event) => { + event.preventDefault(); + loginError(''); + try { + const result = await api('/login', { + method: 'POST', + body: { email: $('#login-email').value, password: $('#login-password').value }, + }); + handleLoginResult(result); + } catch (err) { + loginError(err.message); + } +}); + +function handleLoginResult(result) { + if (result.status === 'twoFactorSetup') { + $('#twofa-intro').textContent = + 'Two factor is required here. Scan this with an authenticator app (Google Authenticator, Authy, 1Password), then enter the code it shows.'; + $('#twofa-setup').hidden = false; + $('#twofa-qr').src = result.qr; + $('#twofa-secret').textContent = result.secret; + $('#twofa-label').textContent = '6 digit code from the app'; + loginStep('step-2fa'); + return; + } + if (result.status === 'twoFactorRequired') { + $('#twofa-intro').textContent = 'Enter the code from your authenticator app.'; + $('#twofa-setup').hidden = true; + $('#twofa-label').textContent = '6 digit code, or a recovery code'; + loginStep('step-2fa'); + return; + } + if (result.recoveryCodes) { + $('#recovery-list').innerHTML = result.recoveryCodes.map((c) => `
  • ${esc(c)}
  • `).join(''); + $('#recovery-done').dataset.next = result.status; + loginStep('step-recovery'); + return; + } + if (result.status === 'passwordChangeRequired') { + loginStep('step-newpassword'); + return; + } + if (result.usedRecoveryCode) { + toast(`Recovery code used. ${result.recoveryCodesLeft} left — reset two factor soon.`); + } + boot(); +} + +$('#step-2fa').addEventListener('submit', async (event) => { + event.preventDefault(); + loginError(''); + try { + const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } }); + $('#twofa-code').value = ''; + handleLoginResult(result); + } catch (err) { + loginError(err.message); + } +}); + +$('#twofa-cancel').addEventListener('click', async () => { + await api('/logout', { method: 'POST' }).catch(() => {}); + loginStep('step-password'); +}); + +$('#recovery-done').addEventListener('click', () => { + if ($('#recovery-done').dataset.next === 'passwordChangeRequired') loginStep('step-newpassword'); + else boot(); +}); + +$('#step-newpassword').addEventListener('submit', async (event) => { + event.preventDefault(); + loginError(''); + if ($('#pw-new').value !== $('#pw-again').value) return loginError('The two new passwords differ.'); + try { + await api('/account/password', { + method: 'POST', + body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value }, + }); + toast('Password updated.'); + boot(); + } catch (err) { + loginError(err.message); + } +}); + +$('#logout').addEventListener('click', async () => { + await api('/logout', { method: 'POST' }); + location.reload(); +}); + +/* ---------------------------------------------------------------- tabs */ + +$$('.tab').forEach((tab) => { + tab.addEventListener('click', () => { + $$('.tab').forEach((t) => t.classList.toggle('on', t === tab)); + $$('.panel').forEach((p) => p.classList.toggle('on', p.id === `panel-${tab.dataset.tab}`)); + loadTab(tab.dataset.tab); + }); +}); + +function currentTab() { + return $('.tab.on')?.dataset.tab || 'onsite'; +} + +function loadTab(name) { + const loaders = { + onsite: loadOnsite, + log: loadLog, + recurring: loadRecurring, + hosts: loadHosts, + sites: loadSites, + admins: loadAdmins, + system: loadSystem, + }; + loaders[name]?.().catch((err) => toast(err.message, true)); +} + +$('#site-filter').addEventListener('change', (event) => { + activeSiteId = event.target.value; + $('#csv-link').href = scoped('/admin/api/visits.csv'); + loadAlerts(); + loadTab(currentTab()); +}); + +function siteName(id) { + return sites.find((s) => s.id === id)?.name || '—'; +} + +/* ------------------------------------------------------------- alerts */ + +async function loadAlerts() { + const data = await api(scoped('/alerts')); + const total = data.expired.length + data.expiring.length; + const count = $('#alert-count'); + count.hidden = total === 0; + count.textContent = total; + + const banner = $('#expiry-banner'); + if (!total) { + banner.hidden = true; + } else { + const parts = []; + if (data.expired.length) parts.push(`${data.expired.length} expired`); + if (data.expiring.length) parts.push(`${data.expiring.length} expiring within ${data.warningDays} days`); + banner.hidden = false; + banner.className = data.expired.length ? 'banner bad' : 'banner'; + banner.textContent = `WWCC / VIT checks need attention: ${parts.join(', ')}.`; + } + return data; +} + +function expiryCell(expiry, checkExpiry) { + if (!checkExpiry) return 'No date'; + if (expiry.status === 'expired') { + return `Expired ${Math.abs(expiry.daysLeft)}d ago`; + } + if (expiry.status === 'expiring') { + return `${expiry.daysLeft}d left`; + } + return `${esc(checkExpiry)}`; +} + +/* -------------------------------------------------------------- on site */ + +async function loadOnsite() { + const rows = await api(scoped('/onsite')); + $('#onsite-count').textContent = rows.length; + const showSite = activeSiteId === 'all' && sites.length > 1; + + $('#onsite-table').innerHTML = table( + ['', 'Visitor', showSite ? 'Site' : 'Visiting', ...(showSite ? ['Visiting'] : []), 'Check', 'Contact', 'Signed in', ''], + rows.map( + (v) => ` + ${v.hasPhoto ? `` : ''} + ${esc(v.firstName)} ${esc(v.lastName)} + ${v.visitorType === 'frequent' ? 'Recurring' : ''} + ${showSite ? `${esc(v.siteName || '—')}` : ''} + ${esc(v.hostName)} + ${v.checkType === 'NONE' ? 'None' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`} + ${esc(v.phone || v.email || '—')} + ${stamp(v.signedInAt)} + + + + + ` + ), + 'Nobody is signed in right now.' + ); + + $$('[data-signout]').forEach((btn) => + btn.addEventListener('click', async () => { + await api(`/visits/${btn.dataset.signout}/signout`, { method: 'POST' }); + toast('Signed out.'); + loadOnsite(); + }) + ); + $$('[data-badge]').forEach((btn) => + btn.addEventListener('click', () => window.open(`/admin/api/badge/${btn.dataset.badge}`, '_blank')) + ); +} + +$('#refresh-onsite').addEventListener('click', () => loadOnsite()); + +/* ------------------------------------------------------------ visit log */ + +async function loadLog() { + const params = new URLSearchParams(); + if ($('#log-from').value) params.set('from', $('#log-from').value); + if ($('#log-to').value) params.set('to', $('#log-to').value); + if ($('#log-q').value.trim()) params.set('q', $('#log-q').value.trim()); + + const rows = await api(scoped(`/visits?${params}`)); + const showSite = activeSiteId === 'all' && sites.length > 1; + + $('#log-table').innerHTML = table( + [...(showSite ? ['Site'] : []), 'Visitor', 'Visiting', 'Check', 'Contact', 'In', 'Out', 'Photo'], + rows.map( + (v) => ` + ${showSite ? `${esc(v.siteName || '—')}` : ''} + ${esc(v.firstName)} ${esc(v.lastName)} + ${esc(v.hostName)} + ${v.checkType === 'NONE' ? '—' : `${esc(v.checkType)} ${esc(v.checkNumber || '')}`} + ${esc(v.phone || v.email || '—')} + ${stamp(v.signedInAt)} + ${ + v.signedOutAt + ? `${stamp(v.signedOutAt)}${v.signedOutBy && v.signedOutBy !== 'visitor' ? ` ${esc(v.signedOutBy)}` : ''}` + : 'On site' + } + ${v.hasPhoto ? `View` : '—'} + ` + ), + 'No visits match those filters.' + ); +} + +$('#log-search').addEventListener('click', () => loadLog()); +$('#log-q').addEventListener('keydown', (e) => { + if (e.key === 'Enter') loadLog(); +}); + +/* ------------------------------------------------- recurring visitors */ + +async function loadRecurring() { + const [rows, alerts] = await Promise.all([api(scoped('/frequent')), loadAlerts()]); + + $('#expiry-summary').innerHTML = + alerts.expired.length + alerts.expiring.length + ? `

    ${alerts.expired.length} expired, ${alerts.expiring.length} expiring within + ${alerts.warningDays} days. Ask them for an updated card before their next visit.

    ` + : ''; + + $('#recurring-table').innerHTML = table( + ['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''], + rows.map( + (p) => ` + ${esc(p.firstName)} ${esc(p.lastName)} + ${p.email ? `${esc(p.email)}` : ''} + ${esc(p.phone)} + ${p.siteId ? esc(siteName(p.siteId)) : 'Any site'} + ${p.checkType === 'NONE' ? 'None' : `${esc(p.checkType)} ${esc(p.checkNumber || '')}`} + ${p.checkType === 'NONE' ? '—' : expiryCell(p.expiry, p.checkExpiry)} + ${p.active ? 'Active' : 'Inactive'} + + + + + + ` + ), + 'No recurring visitors yet. Add one so they can sign in with a PIN.' + ); + + $$('[data-pass]').forEach((btn) => + btn.addEventListener('click', () => window.open(`/admin/api/pass/${btn.dataset.pass}`, '_blank')) + ); + $$('[data-pin]').forEach((btn) => + btn.addEventListener('click', async () => { + if (!confirm('Issue a new PIN? The old one stops working straight away.')) return; + const { pin } = await api(`/frequent/${btn.dataset.pin}/pin`, { method: 'POST' }); + showPin(pin, btn.dataset.pin); + }) + ); + $$('[data-edit-freq]').forEach((btn) => + btn.addEventListener('click', async () => { + openRecurringModal(await api(`/frequent/${btn.dataset.editFreq}`)); + }) + ); +} + +function hostOptions(selectedId) { + return ( + '' + + hosts + .filter((h) => h.active) + .map( + (h) => + `` + ) + .join('') + ); +} + +function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) { + return ( + `` + + sites + .map( + (s) => + `` + ) + .join('') + ); +} + +function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) { + $('#modal-title').textContent = title; + $('#modal-body').innerHTML = bodyHtml; + $('#modal-save').textContent = saveLabel; + $('#modal-cancel').hidden = hideCancel; + const modal = $('#modal'); + modal.returnValue = ''; + modal.showModal(); + modal.onclose = () => { + $('#modal-cancel').hidden = false; + $('#modal-save').textContent = 'Save'; + if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form'))); + }; +} + +function openRecurringModal(person = null) { + const editing = Boolean(person); + openModal( + editing ? `Edit ${person.firstName} ${person.lastName}` : 'New recurring visitor', + ` + ${field('First name', 'firstName', person?.firstName)} + ${field('Last name', 'lastName', person?.lastName)} + ${field('Mobile number (their username)', 'phone', person?.phone, 'tel')} + ${field('Email address', 'email', person?.email, 'email')} + + ${field('Check number', 'checkNumber', person?.checkNumber)} + ${field('Check expires', 'checkExpiry', person?.checkExpiry, 'date')} + + + ${field('PIN (leave blank to generate one)', 'pin', '')} + ${field('Notes', 'notes', person?.notes)} + ${editing ? `` : ''} + `, + async (form) => { + const payload = Object.fromEntries(form.entries()); + payload.active = editing ? form.has('active') : true; + if (!payload.pin) delete payload.pin; + try { + const saved = editing + ? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload }) + : await api('/frequent', { method: 'POST', body: payload }); + loadRecurring(); + if (editing) toast('Saved.'); + else showPin(saved.pin, saved.id); + } catch (err) { + toast(err.message, true); + } + } + ); +} + +function showPin(pin, id) { + openModal( + 'PIN issued', + `

    ${esc(pin)}

    +

    Print the card now, or write this down. You can reprint it later from the + recurring visitors list.

    `, + () => window.open(`/admin/api/pass/${id}`, '_blank'), + { saveLabel: 'Print card' } + ); +} + +$('#new-recurring').addEventListener('click', () => openRecurringModal()); + +/* --------------------------------------------------------------- hosts */ + +async function loadHosts() { + hosts = await api(scoped('/hosts')); + $('#hosts-scope').textContent = + activeSiteId === 'all' + ? 'Showing every site. Pick a single site above before adding or importing people.' + : `Showing ${siteName(Number(activeSiteId))}.`; + + const showSite = activeSiteId === 'all' && sites.length > 1; + $('#hosts-table').innerHTML = table( + [...(showSite ? ['Site'] : []), 'Name', 'Area', 'Email', 'Status', ''], + hosts.map( + (h) => ` + ${showSite ? `${esc(siteName(h.site_id))}` : ''} + ${esc(h.name)} + ${esc(h.area || '—')} + ${esc(h.email || '—')} + ${h.active ? 'Shown' : 'Hidden'} + + + + + ` + ), + 'No one is listed yet. Add people, or import a CSV, so visitors can say who they are seeing.' + ); + + $$('[data-edit-host]').forEach((btn) => + btn.addEventListener('click', () => + openHostModal(hosts.find((h) => h.id === Number(btn.dataset.editHost))) + ) + ); + $$('[data-toggle-host]').forEach((btn) => + btn.addEventListener('click', async () => { + const host = hosts.find((h) => h.id === Number(btn.dataset.toggleHost)); + await api(`/hosts/${host.id}`, { method: 'PATCH', body: { active: !host.active } }); + loadHosts(); + }) + ); +} + +function openHostModal(host = null) { + if (!host && activeSiteId === 'all' && sites.length > 1) { + return toast('Choose a single site above first, so the person lands in the right list.', true); + } + openModal( + host ? `Edit ${host.name}` : `Add a person to ${siteName(Number(activeSiteId))}`, + `${field('Name', 'name', host?.name)} + ${field('Area, team or role', 'area', host?.area)} + ${field('Email address', 'email', host?.email, 'email')}`, + async (form) => { + const payload = Object.fromEntries(form.entries()); + try { + if (host) await api(`/hosts/${host.id}`, { method: 'PATCH', body: payload }); + else await api('/hosts', { method: 'POST', body: { ...payload, siteId: activeSiteId } }); + loadHosts(); + toast('Saved.'); + } catch (err) { + toast(err.message, true); + } + } + ); +} + +$('#new-host').addEventListener('click', () => openHostModal()); + +$('#host-file').addEventListener('change', async (event) => { + const file = event.target.files[0]; + if (file) $('#host-csv').value = await file.text(); +}); + +$('#do-host-import').addEventListener('click', async () => { + const csv = $('#host-csv').value.trim(); + if (!csv) return toast('Paste a CSV or choose a file first.', true); + if (activeSiteId === 'all' && sites.length > 1) { + return toast('Choose a single site above before importing.', true); + } + try { + const result = await api('/hosts/import', { + method: 'POST', + body: { csv, replace: $('#host-replace').checked, siteId: activeSiteId }, + }); + toast(`${result.added} added, ${result.updated} updated. ${result.total} people listed.`); + $('#host-csv').value = ''; + loadHosts(); + } catch (err) { + toast(err.message, true); + } +}); + +/* --------------------------------------------------------------- sites */ + +async function loadSites() { + sites = await api('/sites'); + renderSiteFilter(); + + $('#sites-list').innerHTML = sites + .map( + (s) => `
    +
    +
    +

    ${esc(s.name)} ${s.active ? '' : 'Inactive'}

    +

    Kiosk address: /?site=${esc(s.slug)}

    +
    +
    + + +
    +
    +
    +
    Badge printing
    +
    ${ + s.badge.enabled + ? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}` + : 'Off' + }
    + ${s.badge.note ? `
    Badge note
    ${esc(s.badge.note)}
    ` : ''} +
    +
    ` + ) + .join(''); + + $$('[data-edit-site]').forEach((btn) => + btn.addEventListener('click', () => + openSiteModal(sites.find((s) => s.id === Number(btn.dataset.editSite))) + ) + ); + $$('[data-preview-badge]').forEach((btn) => + btn.addEventListener('click', () => + window.open(`/admin/api/sites/${btn.dataset.previewBadge}/badge-preview`, '_blank') + ) + ); +} + +function openSiteModal(site) { + openModal( + `Edit ${site.name}`, + `${field('Site name', 'name', site.name)} + ${field('Kiosk slug', 'slug', site.slug)} + + + + + + ${field('Line printed at the bottom', 'note', site.badge.note)} +

    Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother), + 101 × 54 mm (Dymo). Preview before you commit a roll to it.

    `, + async (form) => { + const data = Object.fromEntries(form.entries()); + try { + await api(`/sites/${site.id}`, { + method: 'PATCH', + body: { + name: data.name, + slug: data.slug, + active: form.has('active'), + badge: { + enabled: form.has('badgeEnabled'), + widthMm: Number(data.widthMm), + heightMm: Number(data.heightMm), + showPhoto: form.has('showPhoto'), + note: data.note, + }, + }, + }); + toast('Site saved.'); + loadSites(); + } catch (err) { + toast(err.message, true); + } + } + ); +} + +$('#new-site').addEventListener('click', () => { + openModal('Add a site', field('Site name', 'name', ''), async (form) => { + try { + await api('/sites', { method: 'POST', body: { name: form.get('name') } }); + toast('Site added. Set its badge options next.'); + loadSites(); + } catch (err) { + toast(err.message, true); + } + }); +}); + +function renderSiteFilter() { + const select = $('#site-filter'); + const scopedToOne = Boolean(me?.siteId); + $('#site-switch').hidden = sites.length < 2 && !scopedToOne; + select.innerHTML = + (scopedToOne ? '' : '') + + sites.map((s) => ``).join(''); + if (scopedToOne) activeSiteId = String(me.siteId); + select.value = activeSiteId; + $('#csv-link').href = scoped('/admin/api/visits.csv'); +} + +/* -------------------------------------------------------------- admins */ + +async function loadAdmins() { + if (me.role !== 'owner') { + $('#admins-table').innerHTML = '

    Only an owner account can manage admins.

    '; + return; + } + const rows = await api('/users'); + $('#admins-note').textContent = me.domainRule + ? `New accounts must use an ${me.domainRule} address.` + : 'Any email address can be used for an admin account.'; + + $('#admins-table').innerHTML = table( + ['Email', 'Name', 'Role', 'Site', 'Two factor', 'Last sign in', ''], + rows.map( + (u) => ` + ${esc(u.email)}${u.active ? '' : ' Disabled'} + ${esc(u.name || '—')} + ${esc(u.role)} + ${u.siteId ? esc(siteName(u.siteId)) : 'All sites'} + ${u.twoFactorOn ? 'On' : 'Not set up'} + ${stamp(u.lastLoginAt)} + + + + + + ` + ), + 'No admin accounts.' + ); + + $$('[data-edit-user]').forEach((btn) => + btn.addEventListener('click', () => { + const user = rows.find((u) => u.id === Number(btn.dataset.editUser)); + openModal( + `Edit ${user.email}`, + `${field('Name', 'name', user.name)} + + + `, + async (form) => { + try { + await api(`/users/${user.id}`, { + method: 'PATCH', + body: { + name: form.get('name'), + role: form.get('role'), + siteId: form.get('siteId') || null, + active: form.has('active'), + }, + }); + toast('Saved.'); + loadAdmins(); + } catch (err) { + toast(err.message, true); + } + } + ); + }) + ); + + $$('[data-reset-pw]').forEach((btn) => + btn.addEventListener('click', async () => { + if (!confirm('Reset this password? They will have to set a new one at next sign in.')) return; + const { temporaryPassword } = await api(`/users/${btn.dataset.resetPw}/reset-password`, { + method: 'POST', + }); + openModal( + 'Temporary password', + `

    ${esc(temporaryPassword)}

    +

    Give this to them in person or over the phone. They will be asked to + change it as soon as they sign in.

    `, + null, + { saveLabel: 'Done', hideCancel: true } + ); + }) + ); + + $$('[data-reset-2fa]').forEach((btn) => + btn.addEventListener('click', async () => { + if (!confirm('Clear their two factor setup? They will enrol again at next sign in.')) return; + await api(`/users/${btn.dataset.reset2fa}/reset-2fa`, { method: 'POST' }); + toast('Two factor cleared.'); + loadAdmins(); + }) + ); +} + +$('#new-admin').addEventListener('click', () => { + openModal( + 'Invite an admin', + `${field('Email address', 'email', '', 'email')} + ${field('Name', 'name', '')} + + `, + async (form) => { + try { + const created = await api('/users', { + method: 'POST', + body: { + email: form.get('email'), + name: form.get('name'), + role: form.get('role'), + siteId: form.get('siteId') || null, + }, + }); + loadAdmins(); + openModal( + 'Account created', + `

    Temporary password for ${esc(created.email)}:

    +

    ${esc(created.temporaryPassword)}

    +

    They will set their own password and enrol two factor at first sign in.

    `, + null, + { saveLabel: 'Done', hideCancel: true } + ); + } catch (err) { + toast(err.message, true); + } + } + ); +}); + +/* -------------------------------------------------------------- system */ + +async function loadSystem() { + const s = await api(scoped('/status')); + $('#system-body').innerHTML = ` +
    +
    Time zone
    ${esc(s.timezone)}
    +
    Sites
    ${s.siteCount}
    +
    Photo required
    ${s.requirePhoto ? 'Yes' : 'No'}
    +
    Photos kept for
    ${s.photoRetentionDays} days
    +
    Expiry warning
    ${s.expiryWarningDays} days before a WWCC or VIT lapses
    +
    Nightly auto sign out
    ${s.autoSignOutTime ? esc(s.autoSignOutTime) : 'Off'}
    +
    Two factor
    ${s.require2fa ? 'Required for every admin' : 'Optional'}
    +
    Admin email domain
    ${s.domainRule ? esc(s.domainRule) : 'Any address'}
    +
    On site now
    ${s.onSite}
    +
    Google Sheet
    ${ + s.sheets.enabled + ? `Connected. ${s.sheets.queued} row(s) waiting to send.${s.sheets.lastError ? ` Last error: ${esc(s.sheets.lastError)}` : ''}` + : 'Turned off in the environment file.' + }
    +
    Last sheet write
    ${stamp(s.sheets.lastOk)}
    +
    +
    + + + +
    `; + + $('#sheet-test').addEventListener('click', async () => { + try { + const r = await api('/sheets/test', { method: 'POST' }); + toast(`Connected to "${r.title}".`); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + $('#sheet-flush').addEventListener('click', async () => { + try { + const r = await api('/sheets/flush', { method: 'POST' }); + toast(`${r.sent} sent, ${r.remaining} still queued.`); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + $('#photo-purge').addEventListener('click', async () => { + if (!confirm('Delete photos older than the retention window? This cannot be undone.')) return; + const r = await api('/photos/purge', { method: 'POST' }); + toast(`${r.purged} photo(s) deleted.`); + }); + + renderAccount(s); +} + +function renderAccount(status) { + $('#account-body').innerHTML = ` +
    +
    Signed in as
    ${esc(me.email)}
    +
    Role
    ${esc(me.role)}${me.siteId ? ` — ${esc(siteName(me.siteId))} only` : ''}
    +
    Two factor
    ${me.twoFactorOn ? 'On' : 'Not set up'}
    +
    +
    + + ${me.twoFactorOn + ? status.require2fa + ? '' + : '' + : ''} +
    `; + + $('#change-password').addEventListener('click', () => { + openModal( + 'Change your password', + `${field('Current password', 'currentPassword', '', 'password')} + ${field('New password', 'newPassword', '', 'password')} +

    At least 12 characters, with upper and lower case and a number.

    `, + async (form) => { + try { + await api('/account/password', { + method: 'POST', + body: { + currentPassword: form.get('currentPassword'), + newPassword: form.get('newPassword'), + }, + }); + toast('Password changed.'); + } catch (err) { + toast(err.message, true); + } + } + ); + }); + + $('#enable-2fa')?.addEventListener('click', async () => { + const { qr, secret } = await api('/account/2fa/start', { method: 'POST' }); + openModal( + 'Set up two factor', + `

    Scan this with your authenticator app, then enter the code it shows.

    + Two factor QR code +

    Or enter this key by hand: ${esc(secret)}

    + ${field('6 digit code', 'code', '')}`, + async (form) => { + try { + const r = await api('/account/2fa/enable', { method: 'POST', body: { code: form.get('code') } }); + me.twoFactorOn = true; + openModal( + 'Recovery codes', + `

    Each of these works once if you lose your phone. Save them somewhere safe.

    +
      ${r.recoveryCodes.map((c) => `
    • ${esc(c)}
    • `).join('')}
    `, + null, + { saveLabel: 'Done', hideCancel: true } + ); + } catch (err) { + toast(err.message, true); + } + }, + { saveLabel: 'Turn on' } + ); + }); + + $('#disable-2fa')?.addEventListener('click', () => { + openModal( + 'Turn off two factor', + `

    Confirm with your password.

    + ${field('Password', 'password', '', 'password')}`, + async (form) => { + try { + await api('/account/2fa/disable', { method: 'POST', body: { password: form.get('password') } }); + me.twoFactorOn = false; + toast('Two factor turned off.'); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }, + { saveLabel: 'Turn off' } + ); + }); +} + +/* ---------------------------------------------------------------- boot */ + +async function boot() { + const session = await api('/session'); + + if (!session.admin) { + $('#login').hidden = false; + $('#console').hidden = true; + if (session.domainRule) { + $('#domain-rule').textContent = `Use your ${session.domainRule} address.`; + $('#domain-rule').hidden = false; + } + if (session.setupNeeded) { + $('#setup-message').textContent = + 'No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first one.'; + loginStep('step-setup'); + } else { + loginStep('step-password'); + } + return; + } + + if (session.mustChangePassword) { + $('#login').hidden = false; + $('#console').hidden = true; + loginStep('step-newpassword'); + return; + } + + me = { ...session.user, domainRule: session.domainRule }; + $('#login').hidden = true; + $('#console').hidden = false; + $('#site-name').textContent = session.siteName; + document.title = `Admin — ${session.siteName}`; + $$('.owner-only').forEach((el) => { + el.hidden = me.role !== 'owner'; + }); + + sites = await api('/sites'); + renderSiteFilter(); + hosts = await api(scoped('/hosts')); + await loadAlerts().catch(() => {}); + loadOnsite(); +} + +boot(); diff --git a/public/js/kiosk.js b/public/js/kiosk.js new file mode 100644 index 0000000..f9da10c --- /dev/null +++ b/public/js/kiosk.js @@ -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) => + `` + ).join(''); + el.innerHTML = `${bars}Step ${step} of ${TOTAL_STEPS}`; +} + +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 = `

    No one matches that. Check the spelling, or ask the front desk.

    `; + return; + } + listEl.innerHTML = matches + .slice(0, 60) + .map( + (h) => + `` + ) + .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) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 + ? `
    Photo
    The photo you took
    ` + : ''; + $('#review-list').innerHTML = + rows.map(([k, v]) => `
    ${escapeHtml(k)}
    ${escapeHtml(v)}
    `).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 = `

    No sites are set up yet. An admin needs to add one first.

    `; + } else { + list.innerHTML = sites + .map( + (s) => + `` + ) + .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) => + `` + ) + .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 }); +})(); diff --git a/push-to-gitea.bat b/push-to-gitea.bat new file mode 100644 index 0000000..f67f2dc --- /dev/null +++ b/push-to-gitea.bat @@ -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 diff --git a/push-to-gitea.ps1 b/push-to-gitea.ps1 new file mode 100644 index 0000000..013c27f --- /dev/null +++ b/push-to-gitea.ps1 @@ -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.' diff --git a/push-to-gitea.sh b/push-to-gitea.sh new file mode 100644 index 0000000..145283f --- /dev/null +++ b/push-to-gitea.sh @@ -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" diff --git a/scripts/gen-cert.sh b/scripts/gen-cert.sh new file mode 100644 index 0000000..9dfdc5e --- /dev/null +++ b/scripts/gen-cert.sh @@ -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." diff --git a/scripts/healthcheck.mjs b/scripts/healthcheck.mjs new file mode 100644 index 0000000..720b05b --- /dev/null +++ b/scripts/healthcheck.mjs @@ -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); +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..2e7bc87 --- /dev/null +++ b/src/auth.js @@ -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; + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..48fb91a --- /dev/null +++ b/src/config.js @@ -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; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..c555742 --- /dev/null +++ b/src/db.js @@ -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; diff --git a/src/photos.js b/src/photos.js new file mode 100644 index 0000000..32251ad --- /dev/null +++ b/src/photos.js @@ -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; +} diff --git a/src/pins.js b/src/pins.js new file mode 100644 index 0000000..b76afcc --- /dev/null +++ b/src/pins.js @@ -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; +} diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..bb05305 --- /dev/null +++ b/src/routes/admin.js @@ -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(` + + + +Sign in card — ${esc(row.first_name)} ${esc(row.last_name)} + + + +
    +
    +

    ${esc(site ? site.name : config.siteName)}

    +

    ${esc(row.first_name)} ${esc(row.last_name)}

    +

    ${esc(pin)}

    +

    Your PIN. Keep this card, it is not sent to you again.

    +
    +
    Mobile (your username)
    ${esc(row.phone)}
    +
    Check on file
    ${row.check_type === 'NONE' ? 'None recorded' : `${esc(row.check_type)} ${esc(row.check_number || '')}`}
    + ${row.check_expiry ? `
    Expires
    ${esc(row.check_expiry)}
    ` : ''} + ${site ? `
    Site
    ${esc(site.name)}
    ` : '
    Site
    Any site
    '} + ${host ? `
    Usually visiting
    ${esc(host.name)}
    ` : ''} +
    Issued
    ${esc(localStamp(nowIso()))}
    +
    +

    At the kiosk, tap I have a PIN, 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.

    +
    + +`); +}); + +/* ------------------------------------------------------------- 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; diff --git a/src/routes/kiosk.js b/src/routes/kiosk.js new file mode 100644 index 0000000..020bb2d --- /dev/null +++ b/src/routes/kiosk.js @@ -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; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..aa0a2f1 --- /dev/null +++ b/src/server.js @@ -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(); diff --git a/src/sheets.js b/src/sheets.js new file mode 100644 index 0000000..5217224 --- /dev/null +++ b/src/sheets.js @@ -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); +} diff --git a/src/sites.js b/src/sites.js new file mode 100644 index 0000000..6fcacb9 --- /dev/null +++ b/src/sites.js @@ -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) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ` + + + +Badge — ${esc(visit.first_name)} ${esc(visit.last_name)} + + + +
    + ${showPhoto ? `` : ''} +
    +
    ${esc(site.name)} · VISITOR
    +
    ${esc(visit.first_name)} ${esc(visit.last_name)}
    +
    +
    Visiting ${esc(visit.host_name)}
    +
    In at ${esc( + timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false }) + )} on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}
    +
    ${ + visit.check_type === 'NONE' + ? 'No WWCC / VIT' + : `${esc(visit.check_type)} ${esc(visit.check_number || '')}` + }
    + ${site.badge_note ? `
    ${esc(site.badge_note)}
    ` : ''} +
    +
    +
    +${autoPrint ? '' : ''} + +`; +} + +export { esc as escapeHtml, localStamp }; diff --git a/src/users.js b/src/users.js new file mode 100644 index 0000000..ba3e76d --- /dev/null +++ b/src/users.js @@ -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); + } +} diff --git a/src/util.js b/src/util.js new file mode 100644 index 0000000..7e14bba --- /dev/null +++ b/src/util.js @@ -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'); +}