commit 3278890491371fab07bf1eb0c36a4da06f0f865c Author: jessikitty Date: Fri Sep 4 14:55:59 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..a2dd262 --- /dev/null +++ b/.env.example @@ -0,0 +1,88 @@ +# ---------------------------------------------------------------- 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 + +# 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 +# Browsers block the camera on plain http unless the address is localhost, so the +# kiosk needs https. Leave this on and the container creates its own certificate +# authority and server certificate at first start, then renews the server +# certificate on its own before it lapses. +HTTPS_ENABLED=true +SECURE_COOKIES=true + +# Every name and address staff might type. These go into the certificate, so a +# missing one means a browser warning. Re-issues automatically when this changes. +HTTPS_HOSTNAMES=visitors.local,192.168.1.50 + +# Ports published on the docker host. +HOST_PORT=8443 +HOST_HTTP_PORT=8080 +# Must match HOST_PORT: used to build the http -> https redirect. +HTTPS_PUBLIC_PORT=8443 +# The in-container http helper. 0 turns it off. +HTTP_REDIRECT_PORT=3001 + +# Where the certificates live. Leave these alone unless you are supplying your own. +HTTPS_KEY=/data/certs/server.key +HTTPS_CERT=/data/certs/server.crt + +# Set TRUST_PROXY=true instead if you terminate TLS at a reverse proxy and turn +# HTTPS_ENABLED off. +TRUST_PROXY=false + +# --------------------------------------------------------- google sheets +SHEETS_ENABLED=false +# The long id from the sheet URL: docs.google.com/spreadsheets/d//edit +SHEETS_SPREADSHEET_ID= +# Append-only history of every sign in and sign out. +# Rewritten on every change: only the people currently on site. Open this one +# during an evacuation. Both tabs are created automatically if missing. +SHEETS_ONSITE_TAB=On site now +# 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 + +# ------------------------------------------------------------- printing +# Badges are rendered and printed by the server, so kiosk tablets need no driver. +# The printer's address is set per site in Admin -> Sites, not here. +# PRINT_COMMAND=brother_ql +PRINT_TIMEOUT_MS=15000 +# How long a sign in waits for the badge before falling back to the kiosk browser. +PRINT_SIGNIN_WAIT_MS=9000 + +# -------------------------------------------------------------- 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..c7f190e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# 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 util-linux \ + # Fonts for the server-rendered badge, and brother_ql to drive the label printer. + fonts-liberation python3 python3-pip \ + && pip3 install --break-system-packages --no-cache-dir "brother_ql==0.9.4" \ + && 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 +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + +RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ + && mkdir -p /data/photos /data/certs \ + && chown -R node:node /data /app + +# Starts as root only long enough to fix ownership of a bind-mounted /data, +# then the entrypoint drops to the node user before running anything. +VOLUME ["/data"] +EXPOSE 3000 3001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node scripts/healthcheck.mjs + +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b5c2cb --- /dev/null +++ b/README.md @@ -0,0 +1,517 @@ +# 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. No link to + the admin console: a kiosk is a public terminal and administration does not belong on it. + Reach the console from a staff machine at `/admin`. +- **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 | +| Company or organisation | optional, typed each visit | on file | +| Person being visited | picked from the list | picked each visit | +| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk | +| WWCC / VIT / none | typed each visit | on file | +| 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. + +The company field is optional and clearly marked as such — plenty of visitors are not from +anywhere in particular. When it is filled in it appears in the on-site list, on the evacuation +sheet, and in the visit log, and the log search matches on it, so you can pull up every visit from +one contractor. It is **not** printed on the badge — the label stays name, host, time and check +status. + +## 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 `https://:8443` and the admin console on +`https://:8443/admin`. Expect a browser warning until you install the +authority certificate — see below. + +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. + +## Branding the kiosk + +Each site can carry its own banner and colours, set under **Sites → Edit**. + +**Banner.** Upload a PNG, JPEG or WebP up to 2 MB. A PNG with a transparent background is the +one to use — it sits straight on the bar colour with nothing painted behind it, so it works +whatever colour you pick. The admin preview shows it on a checkerboard so you can see the +transparency. When a banner is set it replaces the site name in the kiosk header. Set the on-screen height in +pixels to suit the shape of your logo, and choose whether it sits **left** or **centred** — the +header is a three-column layout, so a centred logo is centred on the page rather than centred in +whatever space the clock leaves over. The setting applies to the site name too, when no banner is +uploaded. SVG is deliberately not accepted: +it can carry script, and this file is served to every kiosk. + +**Colours.** Four are settable: + +| Setting | Where it shows | +|---|---| +| Bar and buttons | The top bar, the Sign in door, primary buttons, the confirmation mark | +| Sign out | The Sign out door and the signed-out confirmation | +| Page background | Behind everything, with card and rule colours derived from it | +| Body text | Headings, answers, and the source for the softer label colour | + +Two things are still worked out rather than set. **Text on a coloured background** is chosen by +contrast, so a pale yellow bar gets dark text instead of unreadable white. And the **muted colour** +used for field labels and hints is your body text mixed towards the background only as far as it +can go while still clearing WCAG AA at 4.5:1 — a fixed grey looks fine on the default background +and vanishes on a custom one, which is the usual cause of text that blends in. + +The site editor shows the contrast ratio as you type and warns below 4.5:1. Aim for 7:1 or better +on a kiosk people read standing up. + +Leave a colour box empty to fall back to the default. Anything that is not a six-digit hex value +is ignored rather than applied. + +The admin console keeps its own neutral look, so it stays recognisable when you are switching +between sites. Printed badges stay black on white — a label printer has no colours to give. + +## 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, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a +boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text. + +The photo is square, matching the crop taken at the kiosk, and sits vertically centred. + +Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label +noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is +what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type +scales with the constraining dimension, so small labels stay readable. + +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. + +### Brother QL-820NWB + +The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a +continuous roll, 62 × 90 mm is a good visitor badge. + +It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is +the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls: + +| Roll | Size | Good for | +|---|---|---| +| DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge | +| DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose | +| DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below | +| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off | +| DK-11209 | 29 × 62 mm die-cut | Name and host only | + +**The red option.** Tick *Print the heading and the no-check warning in red* and the site name +and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check +obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders +it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly +15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing. + +### Printing from the server + +Set the printer's IP address under **Sites → Edit → Printer** and tick *Print from the server*. +The server then renders the badge itself and pushes it to the printer over the network, so: + +- a kiosk tablet needs no printer driver, no default printer and no print dialog +- adding a second kiosk means plugging in a tablet, nothing else +- the badge prints automatically the moment someone completes their sign in + +The QL-820NWB has Ethernet and Wi-Fi, so it lives on the network rather than tethered to a +tablet. Give it a **fixed IP** — a DHCP lease change would silently stop badges printing. + +Rendering happens at 300 dpi and 696 dots across, which is the printer's fixed head width on a +62 mm roll. **Bitmap preview** on the site card shows the exact image that will be sent, and +**Test print** sends a sample badge. Use both before committing a roll. + +**Rotation.** At 0° the badge is laid out across the 62 mm width and runs down the label. At 90° +it is laid out along the length and turned before printing, which is what you want when the label +hangs from its short edge. Set it per site and check the bitmap preview — the two look very +different and only one will suit how you hang them. + +**If the printer cannot be reached**, sign in still completes. The kiosk falls back to its own +browser print dialog, and the failure is shown against the site in **Admin → Sites** with the +reason. Admins can reprint any badge from the **On site** list. + +Leave *Print from the server* off and the kiosk prints through the browser as before: set the +QL-820NWB as the browser's default printer, margins to none, scaling 100%. + +## 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`. + +## HTTPS and the certificate + +The kiosk needs HTTPS: browsers block camera access on plain http unless the address is +`localhost`. Since this never faces the internet, it runs its own certificate authority. + +With `HTTPS_ENABLED=true` (the default) the container creates two things at first start: + +- **A certificate authority**, valid for ten years. Install this on each kiosk device, once. +- **A server certificate**, valid for about 13 months, signed by that authority. + +The server certificate renews itself before it lapses and reloads without a restart. Because +the authority is what the devices trust, renewal never means touching the tablets again. This is +why it isn't one plain self-signed certificate: Apple and Chrome reject server certificates +valid for much more than a year, so a single self-signed file would have to be reinstalled +everywhere every year. + +### Set it up + +List every name and address staff might type, in `.env`: + +``` +HTTPS_ENABLED=true +SECURE_COOKIES=true +HTTPS_HOSTNAMES=visitors.local,visitors.hideaway.lan,192.168.1.50 +HOST_PORT=8443 +HTTPS_PUBLIC_PORT=8443 +``` + +An address that isn't listed produces a browser warning. Change the list and restart; the +certificate reissues itself automatically, and devices that already trust the authority accept +it without any further work. + +The kiosk is then at `https://visitors.local:8443`, admin at `https://visitors.local:8443/admin`. + +### Trusting the authority on each device + +Port 8080 runs a small plain-http helper that does exactly two things: hands out the authority +certificate, and redirects everything else to https. It exists to solve the chicken-and-egg +problem of fetching the certificate you don't yet trust. + +On each tablet, browse to `http://visitors.local:8080/ca.crt` and install the downloaded file: + +| Device | Where | +|---|---| +| Windows | Double-click → Install Certificate → Local Machine → *Trusted Root Certification Authorities* | +| Android | Settings → Security → Encryption & credentials → Install a certificate → **CA certificate** | +| iPad / iPhone | Open in Safari → install the profile → then Settings → General → About → **Certificate Trust Settings** and switch it on. The second step is separate and easy to miss | +| macOS | Double-click → Keychain Access → System → set to *Always Trust* | +| Ubuntu | `sudo cp ca.crt /usr/local/share/ca-certificates/visitor-signin.crt && sudo update-ca-certificates` | + +Admins can also download it from **Admin → System → Certificate**, which shows the expiry dates +and the authority's fingerprint — check that fingerprint matches what the tablet shows during +installation. + +### Managing it later + +**Admin → System → Certificate** has *Renew the server certificate* (safe, no device changes) +and *Start a new authority* (every device must trust the new one, so only for a suspected key +leak). From a shell on the docker host, `./scripts/gen-cert.sh` and +`./scripts/gen-cert.sh --force` do the same two jobs. + +The private keys live in `data/certs/` with `0600` permissions. They are in `.gitignore` and +must never be committed. + +### If you'd rather use a reverse proxy + +Set `HTTPS_ENABLED=false` and `TRUST_PROXY=true`, terminate TLS at Nginx Proxy Manager, Traefik +or Caddy, and drop the port 8080 mapping from `docker-compose.yml`. + +## 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_LOG_TAB=Visitor log + SHEETS_ONSITE_TAB=On site now + 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**. Both tabs and their headers + are created the first time. + +### What the sheet holds + +**Only the people currently on site.** One tab, rewritten in full whenever anyone signs in or +out. Nothing is appended, so there is no history to scroll past while you are standing in a car +park counting heads — the top row says `On site now — 3 people — updated 31/08/26, 14:12`, and +everything under it is someone still in the building. + +Rewriting rather than patching is deliberate: a failed update can never leave a stale name on the +evacuation list, because whatever is on the tab is what the database said at the time shown. If a +write fails the tab is marked stale and rewritten on the next pass, once a minute. It also +refreshes every 15 minutes on its own to keep the "on site for" column honest. + +The **full visit history stays in the application** — searchable under **Visit log** in the admin +console, and downloadable as CSV. It is not sent to Google, which keeps visitor contact details +and movement history off a cloud service that only exists here for the evacuation case. + +Every row carries the site name, so one spreadsheet covers every site. + +**Bookmark the sheet on the phones that would actually be used in an evacuation, and check it +after setup.** A sheet nobody can find is not a safety measure. + +*Upgrading from an earlier version:* the old "Visitor log" tab is left alone but no longer +written to. Delete it by hand when you are ready. + +## Recurring visitors and PINs + +Each saved person is one record: **mobile number, email address and PIN are all unique**, checked +when a record is added or edited and enforced by the database. If an existing database already +contains duplicates, the startup log names who collides and the checks stay at the application +level until you fix them. + +### A photo on file + +Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded +file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting, +and the sign in completes with their pass printing immediately. + +The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a +true snapshot: replacing someone's photo later does not change what past visits show, and photo +retention cleaning up old visits can never delete a live profile photo. + +Leave the photo blank and they are asked at the kiosk as before. + +### Removing someone + +**Remove** on the recurring visitors list deletes the saved record for good — the PIN stops +working, the stored photo is deleted, and their mobile number, email and PIN become available +for someone else. + +Their **visit history is kept**. Visits store the name, contact details and host as their own +columns, so the log remains a complete record of who was in the building regardless of whether +the person is still on file. Removing someone who is currently signed in does not sign them out; +the confirmation says so, and their visit can still be closed with their last name and mobile +number at the kiosk. + +If they might come back, untick **Active** in Edit instead. That keeps the record, the PIN and +the history, but stops the PIN working at the kiosk. + +### 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. + +Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on +simultaneous recurring visitors. Deactivating someone frees theirs. + +**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 +├── certs/ the local authority and the server certificate (keys are 0600) +└── 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` | https port on the docker host, default `8443` | +| `HOST_HTTP_PORT` | http helper port, default `8080` — serves the CA and redirects | +| `HTTPS_HOSTNAMES` | every name and IP the certificate should cover | +| `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. + +## Troubleshooting + +**`pull access denied for visitor-signin`** — something ran `docker compose pull`. The image is +built here, not fetched from a registry. Use `docker compose up -d --build`. The compose file +sets `pull_policy: build` so this should not come back. + +**`EACCES: permission denied, mkdir '/data/photos'`** — the bind-mounted `./data` on the host is +owned by root, and the app runs as an unprivileged user. The container's entrypoint fixes this +itself on start; if you are on an older build, do it by hand: + +```bash +sudo chown -R 1000:1000 data secrets +docker compose up -d --build +``` + +**Changes to the code do nothing** — Compose reuses the existing image. Always +`docker compose up -d --build` after a `git pull`. + +**Google Sheet says "The caller does not have permission"** — the app authenticated fine and +Google refused the spreadsheet. Work through these in order: + +1. **Admin → System** shows the service account address. Open the sheet, press Share, paste that + address, set it to **Editor**, and untick "Notify people". This is the cause about nine times + in ten. +2. If your Google Workspace blocks sharing outside the organisation, the share will silently fail + or be refused — a service account address is external. Ask your Workspace admin to allow it, + or create the sheet in an account that permits external sharing. +3. If the sheet lives in a **Shared drive**, share the drive with the service account, not just + the file. +4. Check the spreadsheet ID matches the one in the sheet's URL. A wrong ID usually gives a 404, + but a valid ID for someone else's sheet gives this same 403. +5. Confirm the **Google Sheets API** is enabled on the project the key belongs to. A key from + project A cannot use an API enabled only on project B. + +Press **Test the sheet connection** after each step. + +**Browser still warns about the certificate** — the authority is installed but not trusted. On +iOS that is a second, separate step under Settings → General → About → Certificate Trust +Settings. On Android, use a hostname rather than a bare IP. + +## Running without Docker + +```bash +npm install +DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) \ + ADMIN_BOOTSTRAP_EMAIL=you@example.com ADMIN_BOOTSTRAP_PASSWORD=ChangeMe12345 npm start +``` + +Node 20 or newer, and `openssl` on PATH if you want the container to issue its own certificate. + +## A note on evacuation use + +The **On site now** tab is the offsite copy, and it only helps if someone can open it on a phone +while standing in a car park. Bookmark it on the relevant phones and check it actually loads for +them, not just for you. + +Check it again occasionally. A revoked service account key, or a sheet whose sharing was changed, +will queue rows silently — the head count and *Updated* time in the first row are the giveaway, +and **Admin → System** shows the last successful write and any error. + +Worth deciding now: the kiosk is on your internal network, so if the network or the container is +down, the sheet stops updating while people keep walking in. `AUTO_SIGNOUT_TIME` limits how stale +the list can get overnight, but a printed fallback at the front desk is still worth having. + +--- + +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..12f4a35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + visitor-signin: + build: . + image: visitor-signin:latest + # Built from this folder, never fetched from a registry. Without this, + # `docker compose pull` fails trying to find it on Docker Hub. + pull_policy: build + container_name: visitor-signin + restart: unless-stopped + env_file: + - .env + ports: + # The kiosk and admin console, over https. + - "${HOST_PORT:-8443}:3000" + # Plain http helper: serves /ca.crt and redirects everything else to https. + # Drop this line if you are not using the built-in certificate. + - "${HOST_HTTP_PORT:-8080}:3001" + volumes: + # Database, visitor photos and the certificates 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/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..0b65973 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# A bind-mounted ./data is created on the host as root, and the chown in the +# Dockerfile only applies to the image layer that the mount then hides. So fix +# ownership here, at runtime, before dropping to the unprivileged user. +set -e + +DATA_DIR="${DATA_DIR:-/data}" + +if [ "$(id -u)" = "0" ]; then + mkdir -p "$DATA_DIR/photos" "$DATA_DIR/certs" + + # Only touch ownership when it is actually wrong, so a large photo archive + # is not walked on every restart. + if [ "$(stat -c %u "$DATA_DIR")" != "$(id -u node)" ]; then + echo "[entrypoint] taking ownership of $DATA_DIR for the node user" + chown -R node:node "$DATA_DIR" + fi + + if command -v setpriv >/dev/null 2>&1; then + exec setpriv --reuid=node --regid=node --init-groups "$@" + elif command -v runuser >/dev/null 2>&1; then + exec runuser -u node -- "$@" + else + echo "[entrypoint] no setpriv or runuser available, staying as root" >&2 + exec "$@" + fi +fi + +# Already running as a non-root user, because compose set `user:`. Nothing to fix +# here: if the mount is not writable the app will say so plainly on start. +if [ ! -w "$DATA_DIR" ]; then + echo "[entrypoint] $DATA_DIR is not writable by $(id -un) (uid $(id -u))." >&2 + echo "[entrypoint] On the docker host run: sudo chown -R $(id -u):$(id -g) ./data" >&2 + exit 1 +fi + +exec "$@" 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..2bffc4e --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "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": { + "@napi-rs/canvas": "^1.0.8", + "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..c3f35ba --- /dev/null +++ b/public/admin.html @@ -0,0 +1,142 @@ + + + + + +Admin — visitor sign in + + + + + + + +
+
+ Visitor admin + + + +
+ + + +
+ +
+
+

Currently on site

+ +
+

+
+
+ + +
+
+

Visit log

+ Download CSV +
+
+ + + + +
+
+
+ + +
+
+

Recurring visitors

+ +
+
+
+
+ + +
+
+

People a visitor can ask for

+ +
+

+
+ Import from CSV +

+ Paste the file contents below, or choose a .csv file. Recognised column headings are + name, email and area (or department / team). + A single column of names works too. The import applies to the site selected above. +

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

Sites

+ +
+

Each site has its own name, its own list of people to visit, and its own + badge settings. Point a kiosk at one with + /?site=slug, or let staff pick on first use.

+
+
+ + +
+
+

Admin accounts

+ +
+

+
+
+ + +
+

System

+
+

Your account

+
+
+
+
+ + + + + + + +
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..1cbdab1 --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,402 @@ +:root { + --paper: #eef1f4; + --card: #ffffff; + --ink: #16202b; + --muted: #5d6b7a; + --rule: #d4dce3; + --deep: #0b4f4a; + --exit: #2c4a6b; + --alert: #96162f; +} + +* { box-sizing: border-box; } + +/* Author display rules beat the browser's [hidden] { display: none }, and several + elements here are toggled with that attribute. */ +[hidden] { display: none !important; } + +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; } + +/* -------------------------------------------------------------- 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; } + +/* ----------------------------------------------------- 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); } + +.fingerprint { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-all; +} +#system-body h3 { margin-bottom: 14px; } + +/* ------------------------------------------------- visitor photo editor */ + +.photo-editor { margin-bottom: 6px; } + +.photo-frame { + position: relative; + width: 152px; + /* Square, to match the kiosk camera and the frame printed on the badge. */ + aspect-ratio: 1 / 1; + margin-bottom: 12px; + background: #eef1f4; + border: 1px solid var(--rule); + border-radius: 3px; + overflow: hidden; + display: grid; + place-items: center; +} +.photo-frame img, +.photo-frame video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.photo-frame video { transform: scaleX(-1); } +.photo-empty { margin: 0; padding: 0 10px; color: var(--muted); font-size: 13px; text-align: center; } + +.photo-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.photo-actions button, .photo-upload { padding: 8px 13px; font-size: 14px; } +.photo-upload { cursor: pointer; } + +.thumb-empty { + width: 42px; + height: 42px; + border-radius: 3px; + background: repeating-linear-gradient(45deg, #eef1f4, #eef1f4 5px, #e3e8ed 5px, #e3e8ed 10px); + display: block; +} + +.hint.warn { + padding: 9px 11px; + border-left: 4px solid #d9a441; + background: #fdf3d8; + color: var(--ink); +} + +.plain-list { margin: 0 0 14px; padding-left: 20px; font-size: 14.5px; } +.plain-list li { margin-bottom: 5px; } + +/* The confirmation for a destructive action should not look like a Save. */ +#modal.destructive { border-top-color: var(--alert); } +#modal.destructive .primary { border-color: var(--alert); background: var(--alert); } + +/* ----------------------------------------------------- branding editor */ + +.banner-frame { + /* A checkerboard, so a transparent PNG reads as transparent rather than white. */ + background-color: #fff; + background-image: + linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%), + linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%); + background-size: 14px 14px; + background-position: 0 0, 7px 7px; + border: 1px solid var(--rule); + border-radius: 3px; + padding: 10px; + margin-bottom: 12px; + min-height: 76px; + display: grid; + place-items: center; +} +.banner-frame img { max-width: 100%; max-height: 90px; display: block; } +.banner-empty { margin: 0; color: var(--muted); font-size: 13px; } + +.colour-row { display: flex; gap: 8px; align-items: center; } +.colour-row input[type="color"] { + width: 42px; + height: 40px; + padding: 2px; + border: 1px solid var(--rule); + border-radius: 3px; + background: none; + cursor: pointer; +} +.colour-row input[type="text"] { + flex: 1 1 auto; + min-width: 0; + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 13px; +} +.modal-row-3 { grid-template-columns: 1fr 1fr 1fr; gap: 10px; } + +.swatch { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 2px; + border: 1px solid var(--rule); + vertical-align: -2px; + margin-right: 2px; +} diff --git a/public/css/kiosk.css b/public/css/kiosk.css new file mode 100644 index 0000000..fd8177b --- /dev/null +++ b/public/css/kiosk.css @@ -0,0 +1,402 @@ +:root { + --paper: #e7ecf0; + --card: #ffffff; + --ink: #16202b; + --muted: #5d6b7a; + --rule: #c9d3dc; + --deep: #0b4f4a; + --deep-dark: #083a36; + --on-brand: #ffffff; + --exit: #2c4a6b; + --exit-dark: #1f3650; + --on-exit: #ffffff; + --alert: #96162f; + --focus: #0b4f4a; +} + +* { box-sizing: border-box; } + +/* The rules below set display on elements that are toggled with the hidden + attribute, and an author rule beats the browser's [hidden] { display: none }. + Without this the captured still renders underneath the live camera feed. */ +[hidden] { display: none !important; } + +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 { + /* Three tracks, so the banner can sit centred on the page rather than centred + in the space the clock happens to leave over. */ + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 16px; + padding: 14px 22px; + background: var(--deep); + color: var(--on-brand); +} +.bar .clock { grid-column: 3; justify-self: end; } +.bar.align-left .banner, +.bar.align-left .site { grid-column: 1; justify-self: start; } +.bar.align-center .banner, +.bar.align-center .site { grid-column: 2; justify-self: center; } + +/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs + are the point, so nothing is painted behind it. */ +.banner { + max-height: 64px; + max-width: min(60vw, 460px); + width: auto; + object-fit: contain; + display: block; +} + +.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: flex-end; + gap: 16px; + min-height: 20px; + 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: var(--on-brand); + font: inherit; + cursor: pointer; +} +.door-out { background: var(--exit); border-left-color: var(--exit-dark); color: var(--on-exit); } +.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); } +.text-action, .field input, .picker, .choice, .host-option { color: var(--ink); } + +/* ------------------------------------------------------- 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; + margin: -6px 0 18px; + padding: 12px 14px; + background: var(--card); + border-left: 4px solid var(--rule); + border-radius: 3px; +} + +/* ---------------------------------------------------------- camera */ + +.camera { + position: relative; + /* Square, matching the crop that is saved and the frame printed on the badge, + so the visitor sees exactly what ends up on their pass. */ + aspect-ratio: 1 / 1; + width: 100%; + max-width: 380px; + margin: 0 auto 18px; + background: #0f1720; + border-radius: 3px; + overflow: hidden; +} +.camera video, .camera img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +/* The live feed is mirrored because that is how people expect to see themselves. + The captured still is not: it shows what will actually print. */ +.camera video { 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; aspect-ratio: 1 / 1; object-fit: cover; 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: var(--on-brand); + font-size: 14px; + font-weight: 600; +} +#screen-done-out .mark { background: var(--exit); color: var(--on-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: var(--on-brand); + 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; + 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; +} + +/* --------------------------------------------------- host dropdown */ + +.picker { + width: 100%; + padding: 15px 14px; + border: 1px solid var(--rule); + border-radius: 3px; + background: var(--card); + color: var(--ink); + font: inherit; + font-size: 20px; + /* Tall enough to be a comfortable touch target on a tablet. */ + min-height: 58px; +} +.picker:disabled { color: var(--muted); } + +/* An optional field says so quietly, without shouting for attention. */ +.field > span em { font-style: normal; opacity: 0.75; } diff --git a/public/css/login.css b/public/css/login.css new file mode 100644 index 0000000..ed17666 --- /dev/null +++ b/public/css/login.css @@ -0,0 +1,203 @@ +:root { + --paper: #e7ecf0; + --card: #ffffff; + --ink: #16202b; + --muted: #5d6b7a; + --rule: #d4dce3; + --deep: #0b4f4a; + --alert: #96162f; +} + +* { box-sizing: border-box; } + +/* Author display rules beat the browser's [hidden] { display: none }, and the + sign in screens are toggled with that attribute. */ +[hidden] { display: none !important; } + +body { + margin: 0; + min-height: 100vh; + 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; + display: grid; + place-items: center; + align-content: center; + padding: 24px 20px 40px; +} + +button, input { font: inherit; } +button { cursor: pointer; } +:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; } + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* ---------------------------------------------------------------- card */ + +.auth-card { + width: min(400px, 100%); + padding: 26px 30px 30px; + background: var(--card); + border-radius: 3px; + border-top: 5px solid var(--deep); + box-shadow: 0 12px 34px rgba(22, 32, 43, 0.1); +} + +.auth-head { position: relative; margin-bottom: 20px; } +.auth-site { + margin: 0 0 14px; + font-size: 12.5px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} +.auth-head h1 { font-size: 22px; font-weight: 620; letter-spacing: -0.015em; margin: 0 0 6px; } +.auth-sub { margin: 0; color: var(--muted); font-size: 14px; } + +.auth-back { + position: absolute; + top: -2px; + right: 0; + width: 34px; + height: 34px; + border: 1px solid var(--rule); + border-radius: 3px; + background: transparent; + color: var(--muted); + font-size: 16px; + line-height: 1; +} +.auth-back:hover { background: var(--paper); color: var(--ink); } + +.auth-rail { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; + font-size: 12.5px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.auth-rail i { display: block; width: 22px; height: 3px; border-radius: 2px; background: var(--rule); } +.auth-rail i.done { background: var(--deep); } +.auth-rail span { margin-left: 5px; } + +/* A floor under the body means most steps occupy the same box, so moving between + them swaps content rather than visibly growing the card. */ +.auth-body { min-height: 232px; } + +.auth-screen { display: block; } +.auth-screen.entering { animation: screen-in 200ms cubic-bezier(0.2, 0, 0.2, 1); } + +@keyframes screen-in { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .auth-screen.entering { animation: none; } +} + +/* -------------------------------------------------------------- fields */ + +.auth-screen label { display: block; } +.auth-screen label span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 14px; +} +.auth-screen input { + width: 100%; + padding: 11px 12px; + border: 1px solid var(--rule); + border-radius: 3px; + margin-bottom: 16px; + background: #fff; + color: var(--ink); +} +.auth-screen button[type="submit"], +.auth-screen > button, +.auth-row button { + width: 100%; + padding: 12px; + border: 1px solid var(--deep); + border-radius: 3px; + background: var(--deep); + color: #fff; + font-weight: 600; +} +.auth-screen button[disabled] { opacity: 0.6; cursor: progress; } +.auth-screen .hint { margin: -4px 0 16px; font-size: 13.5px; color: var(--muted); } + +.auth-row { display: flex; gap: 10px; } +.auth-row .secondary { + border-color: var(--rule); + background: transparent; + color: var(--ink); + font-weight: 400; +} + +#twofa-qr { display: block; margin: 0 auto 14px; border: 1px solid var(--rule); border-radius: 3px; } +#twofa-code { + letter-spacing: 0.32em; + text-align: center; + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.auth-details { margin: 0 0 18px; font-size: 13.5px; } +.auth-details summary { cursor: pointer; color: var(--muted); } +.auth-details .hint { margin: 10px 0 6px; } +code { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 13px; + background: var(--paper); + padding: 2px 6px; + border-radius: 2px; + word-break: break-all; +} + +.recovery { + list-style: none; + margin: 0 0 16px; + 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; +} + +.err { + margin: 18px 0 0; + padding: 11px 13px; + border-left: 4px solid var(--alert); + background: #fbeaed; + font-size: 14px; +} + +.auth-foot { + display: flex; + flex-direction: column; + gap: 6px; + align-items: center; + margin: 20px 0 0; + color: var(--muted); + font-size: 12.5px; + text-align: center; +} +.auth-foot a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; } diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..979cd72 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6132d1a --- /dev/null +++ b/public/index.html @@ -0,0 +1,247 @@ + + + + + + +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.

+ +
+ +
+ + + +
+ +
+ + + + + + diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..bf5b541 --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,1472 @@ +/* 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) { + // The session lapsed or was signed out in another tab. + if (res.status === 401 || data.mustChangePassword) { + toLogin(); + } + 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 ``; +} + +/* ------------------------------------------------------------ session */ +// Signing in happens on /admin/login, a page of its own. If the session is gone +// or expires mid-use, go back there rather than trying to render a form here. + +function toLogin() { + window.location.href = '/admin/login'; +} + +$('#logout').addEventListener('click', async () => { + await api('/logout', { method: 'POST' }).catch(() => {}); + toLogin(); +}); + +/* ---------------------------------------------------------------- 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' : ''} + ${v.company ? `${esc(v.company)}` : ''} + ${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')) + ); + $$('[data-print]').forEach((btn) => + btn.addEventListener('click', async () => { + btn.disabled = true; + try { + await api(`/visits/${btn.dataset.print}/print`, { method: 'POST' }); + toast('Sent to the printer.'); + } catch (err) { + toast(err.message, true); + } finally { + btn.disabled = false; + } + }) + ); +} + +$('#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)} + ${v.company ? `${esc(v.company)}` : ''} + ${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.company ? `${esc(p.company)}` : ''} + ${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}`)); + }) + ); + $$('[data-remove-freq]').forEach((btn) => + btn.addEventListener('click', async () => { + try { + confirmRemoveFrequent(await api(`/frequent/${btn.dataset.removeFreq}`)); + } catch (err) { + toast(err.message, true); + } + }) + ); +} + +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, destructive = false, onOpen, onClose } = {} +) { + $('#modal-title').textContent = title; + $('#modal-body').innerHTML = bodyHtml; + $('#modal-save').textContent = saveLabel; + $('#modal-cancel').hidden = hideCancel; + const modal = $('#modal'); + modal.classList.toggle('destructive', destructive); + modal.returnValue = ''; + modal.showModal(); + onOpen?.(); + modal.onclose = () => { + $('#modal-cancel').hidden = false; + $('#modal-save').textContent = 'Save'; + modal.classList.remove('destructive'); + onClose?.(); + if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form'))); + }; +} + +/* --------------------------------------------------- visitor photo editor */ +// A recurring visitor can have a photo kept on file, so the kiosk never asks them +// to pose again. It can come from this machine's camera or from a file. + +const photoEditor = { dataUrl: null, remove: false, stream: null }; + +function photoEditorMarkup(person) { + const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null; + return ` +
+
+ Photo on file + +

No photo on file

+
+
+ + + + +
+

With a photo saved here, this visitor signs in with their PIN and their + pass prints straight away — the kiosk does not ask them to pose.

+
`; +} + +function wirePhotoEditor() { + photoEditor.dataUrl = null; + photoEditor.remove = false; + + const preview = $('#photo-preview'); + const video = $('#photo-video'); + const empty = $('#photo-empty'); + + const showImage = (src) => { + preview.src = src; + preview.hidden = false; + video.hidden = true; + empty.hidden = true; + $('#photo-shoot').hidden = true; + $('#photo-camera').textContent = 'Retake'; + $('#photo-clear').hidden = false; + }; + + $('#photo-camera').addEventListener('click', async () => { + try { + photoEditor.stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 960 } }, + audio: false, + }); + video.srcObject = photoEditor.stream; + video.hidden = false; + preview.hidden = true; + empty.hidden = true; + $('#photo-shoot').hidden = false; + } catch { + toast('No camera available on this machine. Upload a file instead.', true); + } + }); + + $('#photo-shoot').addEventListener('click', () => { + // Square, cropped from the centre, to match the kiosk camera and the badge. + const side = Math.min(video.videoWidth, video.videoHeight); + if (!side) return toast('The camera is not ready yet. Try again in a moment.', true); + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 640; + canvas + .getContext('2d') + .drawImage(video, (video.videoWidth - side) / 2, (video.videoHeight - side) / 2, side, side, 0, 0, 640, 640); + photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72); + photoEditor.remove = false; + stopPhotoCamera(); + showImage(photoEditor.dataUrl); + }); + + $('#photo-file').addEventListener('change', async (event) => { + const file = event.target.files[0]; + if (!file) return; + if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true); + const reader = new FileReader(); + reader.onload = () => { + photoEditor.dataUrl = reader.result; + photoEditor.remove = false; + stopPhotoCamera(); + showImage(reader.result); + }; + reader.readAsDataURL(file); + }); + + $('#photo-clear').addEventListener('click', () => { + photoEditor.dataUrl = null; + photoEditor.remove = true; + stopPhotoCamera(); + preview.hidden = true; + video.hidden = true; + empty.hidden = false; + $('#photo-shoot').hidden = true; + $('#photo-clear').hidden = true; + $('#photo-camera').textContent = 'Use the camera'; + }); +} + +function stopPhotoCamera() { + if (!photoEditor.stream) return; + photoEditor.stream.getTracks().forEach((t) => t.stop()); + photoEditor.stream = null; + const video = $('#photo-video'); + if (video) video.srcObject = null; +} + +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('Company or organisation (optional)', 'company', person?.company)} + ${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)} + + ${photoEditorMarkup(person)} + ${editing ? `` : ''} + `, + async (form) => { + const payload = Object.fromEntries(form.entries()); + payload.active = editing ? form.has('active') : true; + if (!payload.pin) delete payload.pin; + if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl; + if (photoEditor.remove) payload.removePhoto = true; + 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); + } + }, + { onOpen: wirePhotoEditor, onClose: stopPhotoCamera } + ); +} + +/** + * Removing a saved visitor is permanent, so the confirmation spells out what + * happens: the record and its PIN go, the visit history stays. Deactivating is + * offered alongside for the common case of someone who has simply stopped coming. + */ +function confirmRemoveFrequent(person) { + const name = `${person.firstName} ${person.lastName}`; + openModal( + `Remove ${name}?`, + `${ + person.onSite + ? `

${esc(person.firstName)} is signed in right now. Removing the + record will not sign them out — their visit stays open and they can still sign out + with their last name and mobile number.

` + : '' + } +
    +
  • Their saved record, PIN and photo are deleted for good.
  • +
  • Their ${person.visitCount} past ${person.visitCount === 1 ? 'visit stays' : 'visits stay'} in the visit log.
  • +
  • ${esc(person.phone)}${person.email ? ` and ${esc(person.email)}` : ''} become available for someone else.
  • +
  • Any card they are carrying stops working.
  • +
+

If they might come back, untick Active in Edit instead — + that keeps the record and their history intact.

`, + async () => { + try { + const result = await api(`/frequent/${person.id}?force=1`, { method: 'DELETE' }); + toast(`${result.name} removed. ${result.visitsKept} past visit(s) kept in the log.`); + loadRecurring(); + } catch (err) { + toast(err.message, true); + } + }, + { saveLabel: 'Remove permanently', destructive: 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)}

+
+
+ + + + ${s.printer.enabled ? `` : ''} +
+
+
+
Badge printing
+
${ + s.badge.enabled + ? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}` + : 'Off' + }
+ ${s.badge.note ? `
Badge note
${esc(s.badge.note)}
` : ''} +
Printer
+
${ + s.printer.enabled && s.printer.host + ? `${esc(s.printer.model)} at ${esc(s.printer.host)}:${s.printer.port}${ + s.printer.rotate ? `, rotated ${s.printer.rotate}°` : '' + }${ + s.printerStatus + ? s.printerStatus.ok + ? ` last print ok, ${stamp(s.printerStatus.at)}` + : ` ${esc(s.printerStatus.message)}` + : '' + }` + : 'Printed by the kiosk browser' + }
+
Kiosk branding
+
+ ${s.branding.hasBanner ? `Banner set, ${s.branding.bannerAlign === 'center' ? 'centred' : 'left'}` : 'No banner'} · + + + +
+
+
` + ) + .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') + ) + ); + $$('[data-bitmap]').forEach((btn) => + btn.addEventListener('click', () => + window.open(`/admin/api/sites/${btn.dataset.bitmap}/badge-bitmap`, '_blank') + ) + ); + $$('[data-test-print]').forEach((btn) => + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = 'Printing…'; + try { + await api(`/sites/${btn.dataset.testPrint}/test-print`, { method: 'POST' }); + toast('Sent to the printer.'); + } catch (err) { + toast(err.message, true); + } finally { + btn.disabled = false; + btn.textContent = 'Test print'; + loadSites(); + } + }) + ); +} + +/** + * Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes + * 12–62 mm wide media and prints up to 60.96 mm across, so anything wider than + * 62 mm is for a different printer. + */ +const LABEL_PRESETS = [ + { id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true }, + { id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true }, + { id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true }, + { id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false }, + { id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false }, + { id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false }, + { id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true }, + { id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true }, +]; + +function openSiteModal(site) { + openModal( + `Edit ${site.name}`, + `${field('Site name', 'name', site.name)} + ${field('Kiosk slug', 'slug', site.slug)} + + + + + + + + +

Red needs a two-colour roll such as the Brother DK-22251. On any other + roll it prints as grey. Two-colour printing is also much slower than black alone.

+ + + + +

With this on, the kiosk does not print at all — the server sends the badge + to the printer over the network, so a tablet needs no driver and no default printer. At 90° + the badge is laid out along the length of the label and turned, which reads correctly when + the label hangs from its short edge. Check it with Bitmap preview before + using a roll.

+ ${field('Line printed at the bottom', 'note', site.badge.note)} + + + + ${colourField('Body text', 'text', site.branding.text, site.branding.theme.ink)} +

+

Text on the bar and on buttons is chosen automatically for contrast, so a + pale brand colour gets dark text rather than white. Labels and hints are a softened version + of the body text, kept readable against the background. Clear a box for the default.

`, + 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'), + accent: form.has('accent'), + note: data.note, + }, + printer: { + enabled: form.has('printerEnabled'), + host: data.printerHost, + port: Number(data.printerPort) || 9100, + model: data.printerModel, + rotate: Number(data.printerRotate) || 0, + }, + branding: { + brand: data.brand || null, + signout: data.signout || null, + page: data.page || null, + text: data.text || null, + bannerHeight: Number(data.bannerHeight) || 64, + bannerAlign: data.bannerAlign, + }, + }, + }); + if (bannerEditor.dataUrl) { + await api(`/sites/${site.id}/banner`, { + method: 'POST', + body: { image: bannerEditor.dataUrl }, + }); + } else if (bannerEditor.remove) { + await api(`/sites/${site.id}/banner`, { method: 'DELETE' }); + } + toast('Site saved. Reload the kiosk to see the change.'); + loadSites(); + } catch (err) { + toast(err.message, true); + } + }, + { + onOpen: () => { + wireBadgePreset(); + wireBannerEditor(); + }, + } + ); +} + +/** + * A colour box paired with a text field, so a colour can be picked by eye or + * pasted from a brand guide, and cleared entirely to fall back to the default. + */ +function colourField(label, name, value, fallback) { + const current = value || ''; + return ``; +} + +/** WCAG contrast ratio, mirroring the server so the console can warn as you type. */ +function contrastRatio(a, b) { + const lum = (hex) => { + const [r, g, bl] = [1, 3, 5] + .map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + .map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +const bannerEditor = { dataUrl: null, remove: false }; + +function wireBannerEditor() { + bannerEditor.dataUrl = null; + bannerEditor.remove = false; + + // Keep the swatch and the hex box in step, in both directions. + $$('#modal-form [data-colour-for]').forEach((swatch) => { + const text = $(`#modal-form [name="${swatch.dataset.colourFor}"]`); + swatch.addEventListener('input', () => { + text.value = swatch.value; + }); + text.addEventListener('input', () => { + if (/^#[0-9a-fA-F]{6}$/.test(text.value.trim())) swatch.value = text.value.trim(); + }); + }); + + // Live contrast readout, because a colour that looks fine in a swatch can be + // unreadable as body text. + const pageInput = $('#modal-form [name="page"]'); + const textInput = $('#modal-form [name="text"]'); + const note = $('#contrast-note'); + + const showContrast = () => { + const page = pageInput.value.trim() || pageInput.placeholder; + const text = textInput.value.trim() || textInput.placeholder; + if (!/^#[0-9a-fA-F]{6}$/.test(page) || !/^#[0-9a-fA-F]{6}$/.test(text)) { + note.hidden = true; + return; + } + const ratio = contrastRatio(text, page); + note.hidden = false; + if (ratio >= 7) { + note.className = 'hint'; + note.textContent = `Contrast ${ratio.toFixed(1)}:1 — comfortable at arm's length.`; + } else if (ratio >= 4.5) { + note.className = 'hint'; + note.textContent = `Contrast ${ratio.toFixed(1)}:1 — readable, but aim for 7:1 on a kiosk people read standing up.`; + } else { + note.className = 'hint warn'; + note.textContent = `Contrast only ${ratio.toFixed(1)}:1. This will be hard to read — pick a darker or lighter body text.`; + } + }; + + [pageInput, textInput].forEach((el) => el.addEventListener('input', showContrast)); + showContrast(); + + $('#banner-file').addEventListener('change', (event) => { + const file = event.target.files[0]; + if (!file) return; + if (file.size > 2 * 1024 * 1024) return toast('That image is over 2 MB. Use a smaller one.', true); + const reader = new FileReader(); + reader.onload = () => { + bannerEditor.dataUrl = reader.result; + bannerEditor.remove = false; + $('#banner-preview').src = reader.result; + $('#banner-preview').hidden = false; + $('#banner-empty').hidden = true; + $('#banner-clear').hidden = false; + }; + reader.readAsDataURL(file); + }); + + $('#banner-clear').addEventListener('click', () => { + bannerEditor.dataUrl = null; + bannerEditor.remove = true; + $('#banner-preview').hidden = true; + $('#banner-preview').removeAttribute('src'); + $('#banner-empty').hidden = false; + $('#banner-clear').hidden = true; + }); +} + +function wireBadgePreset() { + const width = $('#modal-form [name="widthMm"]'); + const height = $('#modal-form [name="heightMm"]'); + const warning = $('#badge-warning'); + + const check = () => { + const w = Number(width.value); + const h = Number(height.value); + if (w > 62) { + warning.hidden = false; + warning.className = 'hint warn'; + warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`; + } else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) { + warning.hidden = false; + warning.className = 'hint warn'; + warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.'; + } else { + warning.hidden = true; + } + }; + + $('#badge-preset').addEventListener('change', (event) => { + const preset = LABEL_PRESETS.find((p) => p.id === event.target.value); + if (!preset) return; + width.value = preset.w; + height.value = preset.h; + $('#badge-photo').checked = preset.photo; + $('#badge-accent').checked = Boolean(preset.accent); + check(); + }); + + [width, height].forEach((el) => el.addEventListener('input', () => { + $('#badge-preset').value = ''; + check(); + })); + $('#badge-photo').addEventListener('change', check); + check(); +} + +$('#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 + ? `Mirroring who is on site to the "${esc(s.sheets.tab)}" tab${ + s.sheets.stale ? ' waiting to retry' : '' + }${s.sheets.lastError ? `
${esc(s.sheets.lastError)}` : ''}` + : 'Turned off in the environment file.' + }
+ ${ + s.sheets.enabled + ? `
On the sheet
${ + s.sheets.onSiteCount === null ? 'Not written yet' : `${s.sheets.onSiteCount} on site` + }, last written ${stamp(s.sheets.lastOk)}
+
Service account
+
${ + s.sheets.serviceAccount + ? `${esc(s.sheets.serviceAccount)} +
The spreadsheet must be shared with this address, with Editor access.` + : 'No key file could be read' + }
+
Spreadsheet
+
${ + s.sheets.spreadsheetId + ? `open the sheet` + : 'SHEETS_SPREADSHEET_ID is not set' + }
` + : '' + } +
+
+ + + +
+

Certificate

+ ${renderTls(s.tls)}`; + + $('#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-resync').addEventListener('click', async () => { + try { + const r = await api('/sheets/resync', { method: 'POST' }); + toast( + r.skipped + ? 'Sheet mirroring is off.' + : `Sheet rewritten with ${r.rows} ${r.rows === 1 ? 'person' : 'people'} on site.` + ); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + + $('#renew-cert')?.addEventListener('click', async () => { + try { + const r = await api('/tls/renew', { method: 'POST', body: { newCa: false } }); + toast( + r.info.server + ? `Certificate good until ${new Date(r.info.server.validTo).toLocaleDateString('en-AU')}.` + : 'Certificate checked.' + ); + loadSystem(); + } catch (err) { + toast(err.message, true); + } + }); + + $('#new-ca')?.addEventListener('click', async () => { + const warning = + 'Create a brand new certificate authority?' + + '\n\n' + + 'Every kiosk device will show a warning until you install the new CA file on it. ' + + 'Only do this if the old key may have leaked.'; + if (!confirm(warning)) return; + try { + await api('/tls/renew', { method: 'POST', body: { newCa: true } }); + toast('New authority created. Install it on every kiosk device.'); + 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); + $$('.owner-only').forEach((el) => { + el.hidden = me.role !== 'owner'; + }); +} + +function renderTls(tls) { + if (!tls?.enabled) { + return `

HTTPS is off, so the kiosk camera will only work on localhost. + Set HTTPS_ENABLED=true in the environment file and restart.

`; + } + if (!tls.server) { + return '

HTTPS is on but no certificate could be read.

'; + } + const soon = tls.server.daysLeft < 30; + return ` +
+
Server certificate
+
Valid until ${new Date(tls.server.validTo).toLocaleDateString('en-AU')} + ${tls.server.daysLeft} days
+
Valid for
${esc(tls.server.names.join(', '))}
+
Authority expires
+
${tls.ca ? new Date(tls.ca.validTo).toLocaleDateString('en-AU') : '—'} + ${tls.ca ? `${tls.ca.daysLeft} days` : ''}
+
CA fingerprint
${esc(tls.ca?.fingerprint || '—')}
+
+

Install the CA file on each kiosk device once. The server certificate renews + itself before it lapses, and devices that trust the authority keep working without being + touched again.

+
+ Download the CA certificate + + +
`; +} + +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'); + + // The server redirects an unauthenticated /admin to the login page, so reaching + // here without a session means it lapsed between the page load and this call. + if (!session.admin || session.mustChangePassword) return toLogin(); + + me = { ...session.user, domainRule: session.domainRule }; + $('#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..0dbc5f2 --- /dev/null +++ b/public/js/kiosk.js @@ -0,0 +1,716 @@ +/* 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: '', + company: '', + hostId: null, + hostName: '', + phone: '', + email: '', + checkType: '', + checkNumber: '', + photo: null, + frequentVisitorId: null, + hasStoredPhoto: false, +}; + +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: '', + company: '', + hostId: null, + hostName: '', + phone: '', + email: '', + checkType: '', + checkNumber: '', + photo: null, + frequentVisitorId: null, + hasStoredPhoto: false, + }); + history = []; + $$('#app input').forEach((i) => { + i.value = ''; + }); + $$('#app select').forEach((sel) => { + sel.selectedIndex = 0; + }); + $$('.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 */ + +/** + * Who you are here to see: type to narrow, then choose from the dropdown. The + * dropdown is a native select, so a tablet gives it a proper full-screen picker + * with its own scrolling, which handles a long staff list better than a page of + * buttons ever did. + */ +function renderHosts(selectEl, searchValue, onPick, emptyEl = null) { + const term = String(searchValue || '').trim().toLowerCase(); + const matches = term + ? hosts.filter( + (h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term) + ) + : hosts; + + const label = !hosts.length + ? 'Nobody has been added yet' + : matches.length === hosts.length + ? `Choose one of ${hosts.length}` + : `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`; + + selectEl.innerHTML = + `` + + matches + .map( + (h) => + `` + ) + .join(''); + selectEl.disabled = matches.length === 0; + + selectEl.onchange = () => { + const picked = hosts.find((h) => h.id === Number(selectEl.value)); + if (!picked) return; + state.hostId = picked.id; + state.hostName = picked.name; + onPick(); + }; + + if (emptyEl) { + emptyEl.hidden = matches.length > 0; + emptyEl.textContent = hosts.length + ? 'No one matches that. Check the spelling, or ask the front desk.' + : 'No one has been added for this site yet. Please see the front desk.'; + } + + // Typing a name and pressing enter should just work when only one person is left. + selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : ''; +} + +/** Enter in the filter box picks the person when the filter leaves exactly one. */ +function pickOnlyMatch(selectEl) { + const only = selectEl.dataset.only; + if (!only) return false; + selectEl.value = only; + selectEl.dispatchEvent(new Event('change')); + return true; +} + +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; +} + +const PHOTO_SIZE = 640; + +/** + * Takes a square photo, cropped from the centre of whatever shape the camera + * gives us. The preview frame, the saved file and the space on the badge are all + * square, so nothing is stretched and what the visitor sees is what prints. + */ +function capture() { + const video = $('#cam-video'); + const canvas = $('#cam-canvas'); + const side = Math.min(video.videoWidth, video.videoHeight); + if (!side) return say('The camera is not ready yet. Try again in a moment.'); + + const sx = (video.videoWidth - side) / 2; + const sy = (video.videoHeight - side) / 2; + canvas.width = PHOTO_SIZE; + canvas.height = PHOTO_SIZE; + canvas.getContext('2d').drawImage(video, sx, sy, side, side, 0, 0, PHOTO_SIZE, PHOTO_SIZE); + 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}`], + ...(state.company ? [['From', state.company]] : []), + ['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() { + // Called from the review screen, the camera screen, or straight from the host + // list when a recurring visitor already has a photo on file. + const button = + (current === 'photo' && $('#cam-use')) || + (current === 'review' && $('#do-signin')) || + null; + if (button) button.disabled = true; + try { + const result = await api('/api/signin', { + mode: state.mode, + frequentVisitorId: state.frequentVisitorId, + firstName: state.firstName, + lastName: state.lastName, + company: state.company, + 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}.`; + const printing = result.serverPrinted || result.badgeUrl; + $('#done-in-detail').textContent = printing + ? `${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.`; + // With server printing the badge is already coming out of the label printer, + // so the kiosk neither prints nor offers to. + 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 { + if (button) 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 }); +} + +/** + * Applies the site's colours as CSS variables. Only three are chosen by an admin; + * the shades and the text colours that sit on them are derived server-side so a + * dark logo colour cannot end up with dark text on it. + */ +function applyTheme(theme, banner, align = 'left') { + if (theme) { + const root = document.documentElement.style; + root.setProperty('--deep', theme.brand); + root.setProperty('--deep-dark', theme.brandDark); + root.setProperty('--on-brand', theme.onBrand); + root.setProperty('--exit', theme.signout); + root.setProperty('--exit-dark', theme.signoutDark); + root.setProperty('--on-exit', theme.onSignout); + root.setProperty('--paper', theme.page); + root.setProperty('--card', theme.card); + root.setProperty('--ink', theme.ink); + root.setProperty('--muted', theme.muted); + root.setProperty('--rule', theme.rule); + root.setProperty('--focus', theme.brand); + document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand); + } + + const img = $('#banner'); + const bar = $('#bar'); + bar.classList.toggle('align-center', align === 'center'); + bar.classList.toggle('align-left', align !== 'center'); + + if (banner?.url) { + img.src = banner.url; + img.style.maxHeight = `${banner.height}px`; + img.hidden = false; + // The banner carries the branding, so the name beside it would just repeat it. + $('#siteName').hidden = true; + } else { + img.hidden = true; + img.removeAttribute('src'); + $('#siteName').hidden = false; + } +} + +async function loadSiteContext() { + siteConfig = await api('/api/config'); + const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName; + document.title = name; + $('#siteName').textContent = name; + $('#banner').alt = name; + applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign); + + 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-select'), '', () => show('guest-contact'), $('#host-empty')); +} + +$('#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; + state.company = $('#in-company').value.trim(); + show('guest-host'); +}); + +$('#in-host-search').addEventListener('input', (e) => + renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty')) +); +$('#in-host-search').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + pickOnlyMatch($('#host-select')); + } +}); + +$('[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; + // With a photo already on file there is nothing to pose for: picking a host + // completes the sign in and the pass prints straight away. + state.hasStoredPhoto = Boolean(person.hasPhoto); + $('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`; + $('#in-freq-host-search').value = ''; + renderHosts( + $('#freq-host-select'), + '', + () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), + $('#freq-host-empty') + ); + 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-select'), + e.target.value, + () => (state.hasStoredPhoto ? submitSignIn() : show('photo')), + $('#freq-host-empty') + ) +); +$('#in-freq-host-search').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + pickOnlyMatch($('#freq-host-select')); + } +}); + +/* 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/public/js/login.js b/public/js/login.js new file mode 100644 index 0000000..8a823c2 --- /dev/null +++ b/public/js/login.js @@ -0,0 +1,282 @@ +/* Visitor sign in — admin login page. + * + * A page of its own, not a panel hidden inside the console. When it finishes it + * navigates to /admin, so the console loads fresh with no login markup in it. + */ + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + +const esc = (v) => + String(v ?? '').replace(/[&<>"']/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] + ); + +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) throw new Error(data.error || `Request failed (${res.status}).`); + return data; +} + +function showError(message) { + const el = $('#login-error'); + el.textContent = message || ''; + el.hidden = !message; +} + +/* ------------------------------------------------------------- screens */ + +const SCREENS = { + 'step-password': { + title: 'Sign in', + subtitle: 'Use the email address your account was set up with.', + focus: '#login-email', + }, + 'step-2fa-verify': { + node: 'step-2fa', + title: 'Two factor', + subtitle: 'Enter the current code from your authenticator app.', + focus: '#twofa-code', + back: true, + }, + 'step-2fa-setup': { + node: 'step-2fa', + title: 'Set up two factor', + subtitle: + 'Scan this with Google Authenticator, Authy, 1Password or similar, then enter the code it shows.', + focus: '#twofa-code', + back: true, + }, + 'step-recovery': { + title: 'Recovery codes', + subtitle: + 'Each works once, if you lose the phone with your authenticator on it. Save them now — they are not shown again.', + }, + 'step-newpassword': { + title: 'Choose a password', + subtitle: 'Set one only you know before you continue.', + focus: '#pw-current', + }, + 'step-setup': { + title: 'Not set up yet', + subtitle: 'No admin account exists on this server.', + }, +}; + +let flow = ['step-password']; +let currentKey = 'step-password'; +let recoveryCodes = []; + +function setFlow(steps) { + flow = steps; +} + +function renderRail(key) { + const rail = $('#auth-rail'); + const index = flow.indexOf(key); + if (flow.length < 2 || index < 0) { + rail.hidden = true; + return; + } + rail.hidden = false; + rail.innerHTML = + flow.map((_, i) => ``).join('') + + `Step ${index + 1} of ${flow.length}`; +} + +function goto(key) { + const meta = SCREENS[key] || SCREENS['step-password']; + const nodeId = meta.node || key; + + currentKey = key; + $('#auth-title').textContent = meta.title; + $('#auth-subtitle').textContent = meta.subtitle || ''; + $('#auth-subtitle').hidden = !meta.subtitle; + $('#auth-back').hidden = !meta.back; + renderRail(key); + showError(''); + + $$('.auth-screen').forEach((el) => { + el.hidden = el.id !== nodeId; + el.classList.remove('entering'); + }); + + const entering = document.getElementById(nodeId); + void entering.offsetWidth; // restart the animation if the same node is reused + entering.classList.add('entering'); + + const focusTarget = meta.focus ? $(meta.focus) : entering.querySelector('input'); + if (focusTarget) setTimeout(() => focusTarget.focus(), 50); +} + +$('#auth-back').addEventListener('click', async () => { + if (!SCREENS[currentKey]?.back) return; + await api('/logout', { method: 'POST' }).catch(() => {}); + $('#twofa-code').value = ''; + $('#login-password').value = ''; + setFlow(['step-password']); + goto('step-password'); +}); + +/* ---------------------------------------------------------- the journey */ + +function handle(result) { + if (result.status === 'twoFactorSetup') { + $('#twofa-setup').hidden = false; + $('#twofa-qr').src = result.qr; + $('#twofa-secret').textContent = result.secret; + $('#twofa-label').textContent = '6 digit code from the app'; + // Declared in full now, so the step counter never changes its total midway. + setFlow([ + 'step-password', + 'step-2fa-setup', + 'step-recovery', + ...(result.passwordChangeToFollow ? ['step-newpassword'] : []), + ]); + goto('step-2fa-setup'); + return; + } + + if (result.status === 'twoFactorRequired') { + $('#twofa-setup').hidden = true; + $('#twofa-label').textContent = '6 digit code, or a recovery code'; + setFlow([ + 'step-password', + 'step-2fa-verify', + ...(result.passwordChangeToFollow ? ['step-newpassword'] : []), + ]); + goto('step-2fa-verify'); + return; + } + + if (result.recoveryCodes) { + recoveryCodes = result.recoveryCodes; + $('#recovery-list').innerHTML = recoveryCodes.map((c) => `
  • ${esc(c)}
  • `).join(''); + $('#recovery-done').dataset.next = result.status; + goto('step-recovery'); + return; + } + + if (result.status === 'passwordChangeRequired') { + if (!flow.includes('step-newpassword')) setFlow([...flow, 'step-newpassword']); + goto('step-newpassword'); + return; + } + + done(); +} + +/** Leaves the login page entirely; the console loads as a fresh document. */ +function done() { + window.location.href = '/admin'; +} + +$('#step-password').addEventListener('submit', async (event) => { + event.preventDefault(); + showError(''); + const button = event.target.querySelector('button[type="submit"]'); + button.disabled = true; + try { + const email = $('#login-email').value.trim(); + $('#pw-username').value = email; + handle(await api('/login', { method: 'POST', body: { email, password: $('#login-password').value } })); + } catch (err) { + showError(err.message); + } finally { + button.disabled = false; + } +}); + +$('#step-2fa').addEventListener('submit', async (event) => { + event.preventDefault(); + showError(''); + const button = event.target.querySelector('button[type="submit"]'); + button.disabled = true; + try { + const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } }); + $('#twofa-code').value = ''; + handle(result); + } catch (err) { + showError(err.message); + $('#twofa-code').select(); + } finally { + button.disabled = false; + } +}); + +$('#recovery-copy').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(recoveryCodes.join('\n')); + $('#recovery-copy').textContent = 'Copied'; + setTimeout(() => { + $('#recovery-copy').textContent = 'Copy codes'; + }, 2500); + } catch { + showError('Copying was blocked by the browser. Write the codes down instead.'); + } +}); + +$('#recovery-done').addEventListener('click', () => { + if ($('#recovery-done').dataset.next === 'passwordChangeRequired') goto('step-newpassword'); + else done(); +}); + +$('#step-newpassword').addEventListener('submit', async (event) => { + event.preventDefault(); + showError(''); + if ($('#pw-new').value !== $('#pw-again').value) { + return showError('The two new passwords do not match.'); + } + const button = event.target.querySelector('button[type="submit"]'); + button.disabled = true; + try { + await api('/account/password', { + method: 'POST', + body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value }, + }); + done(); + } catch (err) { + showError(err.message); + } finally { + button.disabled = false; + } +}); + +/* ---------------------------------------------------------------- start */ + +(async function init() { + try { + const session = await api('/session'); + $('#auth-site').textContent = session.siteName || 'Visitor admin'; + document.title = `Sign in — ${session.siteName || 'visitor admin'}`; + + if (session.domainRule) { + $('#domain-rule').textContent = `Use your ${session.domainRule} address.`; + $('#domain-rule').hidden = false; + } + + if (session.admin && session.mustChangePassword) { + $('#pw-username').value = session.user?.email || ''; + setFlow(['step-newpassword']); + goto('step-newpassword'); + return; + } + if (session.admin) return done(); + + if (session.setupNeeded) { + $('#setup-message').textContent = + 'Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first account.'; + setFlow(['step-setup']); + goto('step-setup'); + return; + } + } catch { + /* the server is unreachable; the sign in page still renders */ + } + goto('step-password'); +})(); diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..9790177 --- /dev/null +++ b/public/login.html @@ -0,0 +1,96 @@ + + + + + + +Sign in — visitor admin + + + + + +
    +
    + +

    Visitor admin

    + +

    Sign in

    +

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

    + Back to the kiosk + Created by: Jess Rogerson (yelling commands at Claude.AI) +

    + + + + 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..778dc01 --- /dev/null +++ b/scripts/gen-cert.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Creates the kiosk certificates: a long lived local authority, and a server +# certificate signed by it. Install the authority on each kiosk device once. +# +# ./scripts/gen-cert.sh use HTTPS_HOSTNAMES from .env +# ./scripts/gen-cert.sh visitors.local 10.0.0.5 override the names +# ./scripts/gen-cert.sh --force replace the authority too +# +# The server normally does this by itself on start, so you only need this to +# change the address list or to inspect the result before going live. +set -euo pipefail +cd "$(dirname "$0")/.." + +FORCE="" +NAMES=() +for arg in "$@"; do + if [ "$arg" = "--force" ]; then FORCE="--force"; else NAMES+=("$arg"); fi +done + +if [ ${#NAMES[@]} -gt 0 ]; then + HTTPS_HOSTNAMES="$(IFS=,; echo "${NAMES[*]}")" + export HTTPS_HOSTNAMES + echo "Using names: ${HTTPS_HOSTNAMES}" +fi + +if docker compose ps --status running 2>/dev/null | grep -q visitor-signin; then + docker compose exec -T visitor-signin node scripts/make-cert.mjs $FORCE + echo "Restarting so the new certificate is served..." + docker compose restart visitor-signin +else + node scripts/make-cert.mjs $FORCE +fi 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/scripts/make-cert.mjs b/scripts/make-cert.mjs new file mode 100644 index 0000000..8455624 --- /dev/null +++ b/scripts/make-cert.mjs @@ -0,0 +1,22 @@ +// Creates or renews the kiosk certificates without starting the server. +// node scripts/make-cert.mjs renew the server certificate if needed +// node scripts/make-cert.mjs --force new certificate authority as well +import { ensureCertificates, describe } from '../src/tls.js'; + +const force = process.argv.includes('--force'); + +try { + ensureCertificates({ force }); + const info = describe(); + console.log(''); + console.log('Certificate authority :', info.caPath); + console.log(' fingerprint :', info.ca?.fingerprint); + console.log(' expires :', info.ca?.validTo, `(${info.ca?.daysLeft} days)`); + console.log('Server certificate :', info.server?.validTo, `(${info.server?.daysLeft} days)`); + console.log(' valid for :', (info.server?.names || []).join(', ')); + console.log(''); + console.log('Install the authority certificate on each kiosk device, then restart the container.'); +} catch (err) { + console.error('Could not create certificates:', err.message); + 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/branding.js b/src/branding.js new file mode 100644 index 0000000..779148c --- /dev/null +++ b/src/branding.js @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import config from './config.js'; + +/** + * Per-site branding: an uploaded banner and a small set of colours. + * + * Only three colours are settable, and the rest of the palette is derived from + * them. Exposing every colour would let someone produce an unreadable kiosk, and + * the one that matters most — the text on a coloured bar — is chosen by contrast + * rather than left to chance. + */ + +export const DEFAULT_THEME = { + brand: '#0b4f4a', + signout: '#2c4a6b', + page: '#e7ecf0', + text: '#16202b', +}; + +const BANNER_DIR = path.join(config.dataDir, 'branding'); +const MAX_BANNER_BYTES = 2 * 1024 * 1024; + +fs.mkdirSync(BANNER_DIR, { recursive: true }); + +/* -------------------------------------------------------------- colour */ + +export function isHexColour(value) { + return /^#[0-9a-f]{6}$/i.test(String(value || '').trim()); +} + +export function normaliseColour(value, fallback) { + return isHexColour(value) ? String(value).trim().toLowerCase() : fallback; +} + +function toRgb(hex) { + return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); +} + +/** Relative luminance, per WCAG, used to pick readable text over a colour. */ +function luminance(hex) { + const [r, g, b] = toRgb(hex).map((channel) => { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** White or near-black, whichever is easier to read on the given background. */ +export function readableOn(hex) { + return luminance(hex) > 0.45 ? '#16202b' : '#ffffff'; +} + +/** WCAG contrast ratio between two colours, from 1 (identical) to 21. */ +export function contrastRatio(a, b) { + const la = luminance(a); + const lb = luminance(b); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +function mix(a, b, amount) { + const [ar, ag, ab] = toRgb(a); + const [br, bg, bb] = toRgb(b); + const channel = (x, y) => Math.round(x + (y - x) * amount); + return `#${[channel(ar, br), channel(ag, bg), channel(ab, bb)] + .map((c) => c.toString(16).padStart(2, '0')) + .join('')}`; +} + +/** + * A softer version of the body text for labels and hints. It is mixed towards the + * background only as far as it can go while still clearing WCAG AA at 4.5:1 — + * a fixed grey looks fine on the default background and disappears on a custom one. + */ +export function mutedFor(text, page) { + for (const amount of [0.45, 0.38, 0.3, 0.22, 0.14]) { + const candidate = mix(text, page, amount); + if (contrastRatio(candidate, page) >= 4.5) return candidate; + } + return text; +} + +/** Shifts a colour towards black (negative) or white (positive). */ +export function shade(hex, amount) { + const channels = toRgb(hex).map((channel) => { + const target = amount < 0 ? 0 : 255; + const value = Math.round(channel + (target - channel) * Math.abs(amount)); + return Math.max(0, Math.min(255, value)); + }); + return `#${channels.map((c) => c.toString(16).padStart(2, '0')).join('')}`; +} + +/** The full palette the kiosk needs, derived from what an admin actually set. */ +export function themeFor(site) { + const brand = normaliseColour(site?.colour_brand, DEFAULT_THEME.brand); + const signout = normaliseColour(site?.colour_signout, DEFAULT_THEME.signout); + const page = normaliseColour(site?.colour_page, DEFAULT_THEME.page); + // Body text: whatever was chosen, or readable-by-default against the page. + const ink = normaliseColour(site?.colour_text, readableOn(page) === '#ffffff' ? '#f2f5f7' : '#16202b'); + return { + brand, + brandDark: shade(brand, -0.25), + onBrand: readableOn(brand), + signout, + signoutDark: shade(signout, -0.25), + onSignout: readableOn(signout), + page, + // A card needs to lift off the page whether the page is light or dark. + card: luminance(page) > 0.5 ? '#ffffff' : shade(page, 0.12), + ink, + muted: mutedFor(ink, page), + rule: luminance(page) > 0.5 ? shade(page, -0.12) : shade(page, 0.2), + // Surfaced so the admin console can warn about an unreadable combination. + textContrast: Number(contrastRatio(ink, page).toFixed(2)), + }; +} + +/* -------------------------------------------------------------- banner */ + +const ALIGNMENTS = new Set(['left', 'center']); + +export function normaliseAlign(value, fallback = 'left') { + const clean = String(value || '').trim().toLowerCase(); + return ALIGNMENTS.has(clean) ? clean : fallback; +} + +/** Accepts a data URL from the admin console and writes it to disk. */ +export function saveBanner(siteId, dataUrl) { + const match = /^data:image\/(png|jpeg|jpg|webp);base64,([A-Za-z0-9+/=]+)$/.exec( + String(dataUrl || '').trim() + ); + if (!match) { + // SVG is deliberately not accepted: it can carry script, and this file is + // served to every kiosk. + throw new Error('Use a PNG, JPEG or WebP image. PNG keeps transparency.'); + } + + const buffer = Buffer.from(match[2], 'base64'); + if (buffer.length > MAX_BANNER_BYTES) throw new Error('That image is over 2 MB. Use a smaller one.'); + + const ext = match[1] === 'jpg' ? 'jpeg' : match[1]; + const name = `site-${siteId}-${crypto.randomBytes(4).toString('hex')}.${ext}`; + fs.mkdirSync(BANNER_DIR, { recursive: true }); + fs.writeFileSync(path.join(BANNER_DIR, name), buffer); + return name; +} + +export function bannerAbsolutePath(name) { + if (!name) return null; + const resolved = path.resolve(BANNER_DIR, name); + if (!resolved.startsWith(path.resolve(BANNER_DIR))) return null; + return fs.existsSync(resolved) ? resolved : null; +} + +export function deleteBanner(name) { + const abs = bannerAbsolutePath(name); + if (abs) { + try { + fs.unlinkSync(abs); + } catch { + /* already gone */ + } + } +} diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..b106529 --- /dev/null +++ b/src/config.js @@ -0,0 +1,96 @@ +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), + }, + + printing: { + // brother_ql drives the label printer over the network. Overridable so a + // wrapper or a different binary can be swapped in. + command: process.env.PRINT_COMMAND || 'brother_ql', + timeoutMs: int(process.env.PRINT_TIMEOUT_MS, 15000), + // How long a sign in waits for the badge before falling back to the browser. + signInWaitMs: int(process.env.PRINT_SIGNIN_WAIT_MS, 9000), + }, + + // 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'), + // Names and addresses staff will actually type. Baked into the certificate. + hostnames: (process.env.HTTPS_HOSTNAMES || 'visitors.local') + .split(',') + .map((h) => h.trim()) + .filter(Boolean), + // A plain http listener that serves the CA certificate and redirects + // everything else to https. 0 turns it off. + redirectPort: int(process.env.HTTP_REDIRECT_PORT, 3001), + // The https port as published on the docker host, used when redirecting. + publicPort: int(process.env.HTTPS_PUBLIC_PORT, 8443), + }, + + sheets: { + enabled: bool(process.env.SHEETS_ENABLED, false), + spreadsheetId: process.env.SHEETS_SPREADSHEET_ID || '', + // Append-only history of every sign in and sign out. + logTab: process.env.SHEETS_LOG_TAB || process.env.SHEETS_TAB_NAME || 'Visitor log', + // Rewritten on every change: just the people currently on site. + onSiteTab: process.env.SHEETS_ONSITE_TAB || 'On site now', + // 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..583ad13 --- /dev/null +++ b/src/db.js @@ -0,0 +1,294 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import config from './config.js'; +import { decryptPin, pinLookup } from './pins.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 62, + badge_height_mm REAL NOT NULL DEFAULT 100, + badge_show_photo INTEGER NOT NULL DEFAULT 1, + badge_accent INTEGER NOT NULL DEFAULT 0, + badge_note TEXT, + printer_enabled INTEGER NOT NULL DEFAULT 0, + printer_host TEXT, + printer_port INTEGER NOT NULL DEFAULT 9100, + printer_model TEXT NOT NULL DEFAULT 'QL-820NWB', + printer_rotate INTEGER NOT NULL DEFAULT 0, + banner_path TEXT, + banner_height INTEGER NOT NULL DEFAULT 64, + banner_align TEXT NOT NULL DEFAULT 'left', + colour_brand TEXT, + colour_signout TEXT, + colour_page TEXT, + colour_text 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, + company TEXT, + 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, + pin_lookup TEXT, + photo_path TEXT, + 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, + company TEXT, + 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'); +addColumn('visits', 'photo_path', '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'); +// A photo kept on file, so a regular visitor is not asked to pose every visit. +addColumn('frequent_visitors', 'photo_path', 'TEXT'); +// PINs are stored encrypted with a random IV, so the same PIN encrypts differently +// every time and cannot be compared. This deterministic digest makes the uniqueness +// check and the index possible. +addColumn('frequent_visitors', 'pin_lookup', 'TEXT'); +// Two-colour printing, for rolls like the Brother DK-22251. +addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0'); +// Per-site branding on the kiosk. +addColumn('sites', 'banner_path', 'TEXT'); +addColumn('sites', 'banner_height', 'INTEGER NOT NULL DEFAULT 64'); +addColumn('sites', 'banner_align', "TEXT NOT NULL DEFAULT 'left'"); +addColumn('sites', 'colour_brand', 'TEXT'); +addColumn('sites', 'colour_signout', 'TEXT'); +addColumn('sites', 'colour_page', 'TEXT'); +addColumn('sites', 'colour_text', 'TEXT'); +// Optional "who are you from", handy for contractors and visiting staff. +addColumn('visits', 'company', 'TEXT'); +// Server-side printing, so a kiosk needs no printer driver of its own. +addColumn('sites', 'printer_enabled', 'INTEGER NOT NULL DEFAULT 0'); +addColumn('sites', 'printer_host', 'TEXT'); +addColumn('sites', 'printer_port', 'INTEGER NOT NULL DEFAULT 9100'); +addColumn('sites', 'printer_model', "TEXT NOT NULL DEFAULT 'QL-820NWB'"); +addColumn('sites', 'printer_rotate', 'INTEGER NOT NULL DEFAULT 0'); +addColumn('frequent_visitors', 'company', 'TEXT'); + +db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)'); +db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)'); + +/* -------------------------------------------------------- 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); + +/* -------------------------------------------------- one record per person */ + +/** + * Existing records predate the PIN digest, so fill it in once. Without this, + * a legacy visitor's PIN would be invisible to the uniqueness check and could be + * handed out to somebody else. + */ +const needingLookup = db + .prepare('SELECT id, pin_enc FROM frequent_visitors WHERE pin_lookup IS NULL') + .all(); +if (needingLookup.length) { + const setLookup = db.prepare('UPDATE frequent_visitors SET pin_lookup = ? WHERE id = ?'); + let filled = 0; + for (const row of needingLookup) { + const pin = decryptPin(row.pin_enc); + if (pin) { + setLookup.run(pinLookup(pin), row.id); + filled += 1; + } + } + console.log(`[db] indexed ${filled} existing PIN(s) for the uniqueness check`); +} + +/** Names any records that already collide, so an admin knows who to fix. */ +function reportDuplicates(column, label) { + const rows = db + .prepare( + `SELECT ${column} AS value, GROUP_CONCAT(first_name || ' ' || last_name, ', ') AS people + FROM frequent_visitors + WHERE ${column} IS NOT NULL AND ${column} <> '' + GROUP BY ${column} HAVING COUNT(*) > 1` + ) + .all(); + for (const row of rows) { + console.warn(`[db] duplicate ${label} shared by: ${row.people}`); + } + return rows.length; +} + +const duplicates = + reportDuplicates('lower(email)', 'email address') + reportDuplicates('pin_lookup', 'PIN'); +if (duplicates) { + console.warn( + '[db] Fix the records above in Admin -> Recurring visitors. Until then the database ' + + 'cannot enforce uniqueness, though new and edited records are still checked.' + ); +} + +/** + * Saved people must be unique on mobile number, email address and PIN. The phone + * column has carried a UNIQUE constraint from the start; these add the other two. + * Existing data may already contain duplicates, so a failure here is reported + * rather than thrown — the application-level checks still refuse new collisions. + */ +function addUniqueIndex(name, sql, what) { + try { + db.exec(sql); + } catch (err) { + console.warn( + `[db] could not enforce unique ${what}: ${err.message}\n` + + ` Existing records collide. Fix them in Admin -> Recurring visitors; ` + + `new and edited records are still checked.` + ); + } +} + +addUniqueIndex( + 'idx_freq_email', + `CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_email + ON frequent_visitors(lower(email)) WHERE email IS NOT NULL AND email <> ''`, + 'email addresses' +); + +addUniqueIndex( + 'idx_freq_pin', + `CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_pin + ON frequent_visitors(pin_lookup) WHERE pin_lookup IS NOT NULL`, + 'PINs' +); + +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..b9729a7 --- /dev/null +++ b/src/photos.js @@ -0,0 +1,89 @@ +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); +} + +/** + * Copies a recurring visitor's stored photo into a new file for one visit. + * + * A copy rather than a shared reference on purpose: the visit record is a snapshot + * of who was in the building that day, so replacing someone's profile photo later + * must not retroactively change what every past visit shows. It also keeps photo + * retention simple — purging old visits can never delete a live profile photo. + */ +export function copyStoredPhoto(relative) { + const source = photoAbsolutePath(relative); + if (!source) return null; + + 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 ext = path.extname(source) || '.jpeg'; + const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}${ext}`; + fs.copyFileSync(source, path.join(dir, name)); + 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..4239e23 --- /dev/null +++ b/src/pins.js @@ -0,0 +1,70 @@ +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); +} + +// PINs people will misread on a printed pass, or guess first. +const BANNED_PINS = new Set(['0000', '1111', '1234', '4321', '9999', '1122', '2580']); + +/** + * A deterministic digest of a PIN, so two records can be compared without either + * being decrypted. Keyed with APP_SECRET, so the database alone does not let + * anyone build a lookup table of all 10,000 possibilities. + */ +export function pinLookup(pin) { + return crypto.createHmac('sha256', key).update(String(pin)).digest('hex'); +} + +/** + * A PIN nobody else holds. `isTaken` is passed in by the caller so this module + * stays free of database knowledge. + */ +export function generatePin(isTaken = () => false) { + for (let attempt = 0; attempt < 200; attempt += 1) { + const pin = String(crypto.randomInt(0, 10000)).padStart(4, '0'); + if (!BANNED_PINS.has(pin) && !isTaken(pin)) return pin; + } + throw new Error( + 'No unused 4 digit PIN could be found. Deactivate some old recurring visitors first.' + ); +} diff --git a/src/printer.js b/src/printer.js new file mode 100644 index 0000000..cb73a8a --- /dev/null +++ b/src/printer.js @@ -0,0 +1,360 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas'; +import config from './config.js'; +import { photoAbsolutePath } from './photos.js'; + +/** + * Printing happens on the server, not in the kiosk browser. + * + * The badge is drawn to a bitmap here and pushed straight to the printer over the + * network, so the tablet at the door needs no printer driver, no default printer + * and no print dialog — and a second kiosk can be added without configuring + * anything on it. + * + * The QL-820NWB prints 696 dots across a 62 mm roll at 300 dpi. That figure is + * fixed by the printer, so the bitmap is always 696 wide however the badge is + * laid out; rotation is applied to the finished image, not to the layout. + */ + +const DPI = 300; +const DOTS_ACROSS_62MM = 696; +const FONT = 'Liberation Sans, DejaVu Sans, Arial, sans-serif'; + +const mm = (value) => Math.round((value / 25.4) * DPI); + +/** Per-site outcome of the last print, surfaced in the admin console. */ +const lastResult = new Map(); + +export function printerStatus(siteId) { + return lastResult.get(Number(siteId)) || null; +} + +function note(siteId, ok, message) { + lastResult.set(Number(siteId), { ok, message, at: new Date().toISOString() }); +} + +export function isConfigured(site) { + return Boolean(site?.printer_enabled && site?.printer_host); +} + +/** brother_ql's label id. The two-colour roll is a different label to the plain one. */ +export function labelFor(site) { + return site?.badge_accent ? '62red' : '62'; +} + +/* ------------------------------------------------------------ rendering */ + +function wrapText(ctx, text, maxWidth, maxLines) { + const words = String(text || '').split(/\s+/).filter(Boolean); + const lines = []; + let line = ''; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (ctx.measureText(candidate).width <= maxWidth || !line) { + line = candidate; + } else { + lines.push(line); + line = word; + if (lines.length === maxLines - 1) break; + } + } + if (line) lines.push(line); + return lines.slice(0, maxLines); +} + +/** + * Draws the badge at its designed size in dots. Mirrors the browser badge so the + * preview and the printed label agree. + */ +async function drawBadge(ctx, widthDots, heightDots, visit, site, accent, startY = null) { + const unit = Math.min(widthDots, heightDots); + const pad = Math.round(unit * 0.07); + const black = '#000000'; + const red = accent ? '#ff0000' : '#000000'; + + if (startY !== null) { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, widthDots, heightDots); + } + + const portrait = heightDots >= widthDots * 1.2; + const nameSize = Math.max(mm(3.2), Math.round(unit * (portrait ? 0.105 : 0.115))); + const bodySize = Math.max(mm(2.0), Math.round(unit * (portrait ? 0.055 : 0.062))); + + let photo = null; + const abs = site.badge_show_photo ? photoAbsolutePath(visit.photo_path) : null; + if (abs) { + try { + photo = await loadImage(abs); + } catch { + photo = null; + } + } + + const photoSize = photo ? Math.round(unit * (portrait ? 0.52 : 0.5)) : 0; + let cursorY = startY === null ? pad : startY; + let textLeft = pad; + let textWidth = widthDots - pad * 2; + + const paint = startY !== null; + + if (photo) { + if (portrait) { + const x = Math.round((widthDots - photoSize) / 2); + if (paint) ctx.drawImage(photo, x, cursorY, photoSize, photoSize); + if (paint) { + ctx.strokeStyle = black; + ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); + ctx.strokeRect(x, cursorY, photoSize, photoSize); + } + cursorY += photoSize + Math.round(unit * 0.05); + } else { + const y = Math.round((heightDots - photoSize) / 2); + if (paint) { + ctx.drawImage(photo, pad, y, photoSize, photoSize); + ctx.strokeStyle = black; + ctx.lineWidth = Math.max(2, Math.round(mm(0.3))); + ctx.strokeRect(pad, y, photoSize, photoSize); + } + textLeft = pad + photoSize + Math.round(unit * 0.05); + textWidth = widthDots - textLeft - pad; + } + } + + ctx.textBaseline = 'top'; + ctx.textAlign = portrait ? 'center' : 'left'; + const centreX = portrait ? widthDots / 2 : textLeft; + + // Site name, with a rule under it. + ctx.fillStyle = red; + ctx.font = `${Math.round(bodySize * 0.8)}px ${FONT}`; + if (paint) ctx.fillText(`${site.name.toUpperCase()} · VISITOR`, centreX, cursorY, textWidth); + cursorY += Math.round(bodySize * 0.8 * 1.3); + if (paint) ctx.fillRect(textLeft, cursorY, textWidth, Math.max(2, Math.round(mm(0.35)))); + cursorY += Math.round(unit * 0.04); + + // Name, wrapped to at most two lines. + ctx.fillStyle = black; + ctx.font = `bold ${nameSize}px ${FONT}`; + const nameLines = wrapText(ctx, `${visit.first_name} ${visit.last_name}`, textWidth, 2); + for (const line of nameLines) { + if (paint) ctx.fillText(line, centreX, cursorY, textWidth); + cursorY += Math.round(nameSize * 1.05); + } + cursorY += Math.round(unit * 0.04); + + // Detail rows. + const timeIn = new Date(visit.signed_in_at); + const rows = [ + `Visiting ${visit.host_name}`, + `In at ${timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })} on ${timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' })}`, + ]; + + ctx.font = `${bodySize}px ${FONT}`; + ctx.fillStyle = black; + for (const row of rows) { + for (const line of wrapText(ctx, row, textWidth, 2)) { + if (paint) ctx.fillText(line, centreX, cursorY, textWidth); + cursorY += Math.round(bodySize * 1.3); + } + } + + // Check status: boxed and in the accent colour when they hold nothing. + if (visit.check_type === 'NONE') { + const label = 'No WWCC / VIT'; + ctx.font = `bold ${Math.round(bodySize * 0.95)}px ${FONT}`; + const w = ctx.measureText(label).width + bodySize; + const x = portrait ? Math.round((widthDots - w) / 2) : textLeft; + const h = Math.round(bodySize * 1.5); + if (paint) { + ctx.strokeStyle = red; + ctx.lineWidth = Math.max(2, Math.round(mm(0.35))); + ctx.strokeRect(x, cursorY, w, h); + ctx.fillStyle = red; + ctx.textAlign = 'center'; + ctx.fillText(label, x + w / 2, cursorY + Math.round(bodySize * 0.25)); + ctx.textAlign = portrait ? 'center' : 'left'; + } + cursorY += h + Math.round(bodySize * 0.3); + } else { + ctx.fillStyle = black; + ctx.font = `${bodySize}px ${FONT}`; + if (paint) { + ctx.fillText(`${visit.check_type} ${visit.check_number || ''}`.trim(), centreX, cursorY, textWidth); + } + cursorY += Math.round(bodySize * 1.3); + } + + if (site.badge_note) { + ctx.fillStyle = black; + ctx.font = `${Math.round(bodySize * 0.85)}px ${FONT}`; + for (const line of wrapText(ctx, site.badge_note, textWidth, 2)) { + if (paint) ctx.fillText(line, centreX, cursorY, textWidth); + cursorY += Math.round(bodySize * 1.1); + } + } + + return cursorY - (startY === null ? pad : startY); +} + +/** + * Produces the PNG that gets sent to the printer. + * + * The bitmap is always 696 dots across, because that is the printer's fixed head + * width on a 62 mm roll. With rotation the badge is laid out along the length of + * the label instead and the finished image is turned, so the content still lands + * within those 696 dots. + */ +export async function renderBadgePng(visit, site) { + const rotate = Number(site.printer_rotate) || 0; + const lengthMm = Number(site.badge_height_mm) || 90; + const turned = rotate === 90 || rotate === 270; + + const acrossDots = DOTS_ACROSS_62MM; + const alongDots = mm(lengthMm); + + // Design canvas: swapped when the badge is laid out along the label. + const designW = turned ? alongDots : acrossDots; + const designH = turned ? acrossDots : alongDots; + + const design = createCanvas(designW, designH); + const ctx = design.getContext('2d'); + const accent = Boolean(site.badge_accent); + + // Measure first, then draw the block centred down the label. Without this the + // content hugs the top and leaves a wide blank strip at the bottom of every badge. + const used = await drawBadge(ctx, designW, designH, visit, site, accent, null); + const pad = Math.round(Math.min(designW, designH) * 0.07); + const startY = Math.max(pad, Math.round((designH - used) / 2)); + await drawBadge(ctx, designW, designH, visit, site, accent, startY); + + if (!rotate) return design.toBuffer('image/png'); + + const out = createCanvas(turned ? acrossDots : designW, turned ? alongDots : designH); + const outCtx = out.getContext('2d'); + outCtx.fillStyle = '#ffffff'; + outCtx.fillRect(0, 0, out.width, out.height); + outCtx.translate(out.width / 2, out.height / 2); + outCtx.rotate((rotate * Math.PI) / 180); + outCtx.drawImage(design, -designW / 2, -designH / 2); + return out.toBuffer('image/png'); +} + +/* -------------------------------------------------------------- sending */ + +/** + * brother_ql reports failures as a Python traceback. Nobody at a front desk can + * act on that, so the useful last line is pulled out and the common network + * failures are rewritten as something with a next step. + */ +function explainPrintError(output, host) { + const lines = String(output || '') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !/^deprecation warning/i.test(l)); + const last = lines[lines.length - 1] || ''; + + if (/Connection refused/i.test(last)) { + return `${host} refused the connection. Check the printer is switched on and that port 9100 is the right one.`; + } + if (/timed out|timeout/i.test(last)) { + return `${host} did not answer. Check the IP address and that the printer is on the same network as the server.`; + } + if (/No route to host|Network is unreachable/i.test(last)) { + return `${host} cannot be reached from the server. Check the address and any firewall between them.`; + } + if (/Name or service not known|getaddrinfo/i.test(last)) { + return `${host} could not be resolved. Use the printer's IP address rather than a name.`; + } + if (/Unknown label|label/i.test(last) && /identifier/i.test(last)) { + return 'The printer rejected the label size. Check the roll loaded matches the badge settings.'; + } + return last || 'The printer did not accept the job.'; +} + +function runBrotherQl(args, timeoutMs) { + return new Promise((resolve, reject) => { + execFile( + config.printing.command, + args, + { timeout: timeoutMs, env: { ...process.env, BROTHER_QL_PRINTER: '', BROTHER_QL_MODEL: '' } }, + (err, stdout, stderr) => { + const output = `${stdout || ''}${stderr || ''}`.trim(); + if (err) { + if (err.code === 'ENOENT') { + return reject( + new Error( + `${config.printing.command} is not installed in the container. Rebuild the image, or set PRINT_COMMAND.` + ) + ); + } + if (err.killed) return reject(new Error('The printer did not respond in time.')); + return reject(new Error(output || err.message)); + } + resolve(output); + } + ); + }); +} + +/** + * Renders and prints one badge. Resolves with a short description on success and + * rejects with something an admin can act on. + */ +export async function printBadge(visit, site) { + if (!isConfigured(site)) throw new Error('Server printing is not turned on for this site.'); + + const png = await renderBadgePng(visit, site); + const file = path.join(os.tmpdir(), `badge-${crypto.randomBytes(6).toString('hex')}.png`); + fs.writeFileSync(file, png); + + const port = Number(site.printer_port) || 9100; + const target = `tcp://${site.printer_host}:${port}`; + + try { + await runBrotherQl( + [ + '--backend', 'network', + '--model', site.printer_model || 'QL-820NWB', + '--printer', target, + 'print', + '--label', labelFor(site), + file, + ], + config.printing.timeoutMs + ); + note(site.id, true, `Printed to ${site.printer_host}`); + return { ok: true, target }; + } catch (err) { + const friendly = explainPrintError(err.message, site.printer_host); + note(site.id, false, friendly); + throw new Error(friendly); + } finally { + fs.rm(file, { force: true }, () => {}); + } +} + +/** A sample badge, for checking the printer and the layout without a real visit. */ +export function sampleVisit(site) { + return { + id: 0, + first_name: 'Sample', + last_name: 'Visitor', + host_name: 'Jess Rogerson', + check_type: 'NONE', + check_number: null, + photo_path: null, + signed_in_at: new Date().toISOString(), + site_name: site.name, + }; +} + +export function available() { + return new Promise((resolve) => { + execFile(config.printing.command, ['--version'], (err) => resolve(!err)); + }); +} diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..e6d9fca --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,1245 @@ +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, pinLookup } from '../pins.js'; +import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js'; +import * as sheets from '../sheets.js'; +import * as tls from '../tls.js'; +import * as printer from '../printer.js'; +import * as users from '../users.js'; +import { badgeHtml, listSites, shapeSite, uniqueSlug, escapeHtml as esc } from '../sites.js'; +import { + DEFAULT_THEME, + bannerAbsolutePath, + deleteBanner, + normaliseAlign, + normaliseColour, + saveBanner, +} from '../branding.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; + // Told up front so the sign in pages can show an honest "step 2 of 3". + return res.json({ + status: 'twoFactorRequired', + passwordChangeToFollow: Boolean(user.must_change_password), + }); + } + 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, + passwordChangeToFollow: Boolean(user.must_change_password), + }); +} + +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((row) => ({ + ...shapeSite(row), + printerStatus: printer.printerStatus(row.id), + })) + ); +}); + +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 || {}; + const branding = req.body?.branding || {}; + const printerCfg = req.body?.printer || {}; + db.prepare( + `UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?, + badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ?, + colour_brand = ?, colour_signout = ?, colour_page = ?, colour_text = ?, + banner_height = ?, banner_align = ?, printer_enabled = ?, printer_host = ?, + printer_port = ?, printer_model = ?, printer_rotate = ? 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.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent, + badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note, + // A blank colour means "use the default", so it is stored as NULL rather than + // being quietly frozen at whatever the default happens to be today. + branding.brand !== undefined + ? normaliseColour(branding.brand, null) + : site.colour_brand, + branding.signout !== undefined + ? normaliseColour(branding.signout, null) + : site.colour_signout, + branding.page !== undefined ? normaliseColour(branding.page, null) : site.colour_page, + branding.text !== undefined ? normaliseColour(branding.text, null) : site.colour_text, + branding.bannerHeight !== undefined + ? Math.min(200, Math.max(24, Number(branding.bannerHeight) || 64)) + : site.banner_height, + branding.bannerAlign !== undefined + ? normaliseAlign(branding.bannerAlign, site.banner_align) + : site.banner_align, + printerCfg.enabled !== undefined ? (printerCfg.enabled ? 1 : 0) : site.printer_enabled, + printerCfg.host !== undefined ? clean(printerCfg.host, 120) || null : site.printer_host, + printerCfg.port !== undefined + ? Math.min(65535, Math.max(1, Number(printerCfg.port) || 9100)) + : site.printer_port, + printerCfg.model !== undefined ? clean(printerCfg.model, 40) || 'QL-820NWB' : site.printer_model, + printerCfg.rotate !== undefined + ? ([0, 90, 180, 270].includes(Number(printerCfg.rotate)) ? Number(printerCfg.rotate) : 0) + : site.printer_rotate + , site.id); + + res.json(shapeSite(db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id))); +}); + +/* ------------------------------------------------------------- branding */ + +router.post('/sites/:id/banner', (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Not found.' }); + try { + assertSiteAllowed(req, site.id); + const name = saveBanner(site.id, req.body?.image); + if (site.banner_path) deleteBanner(site.banner_path); + db.prepare('UPDATE sites SET banner_path = ? WHERE id = ?').run(name, site.id); + res.json({ ok: true, url: `/api/branding/${site.id}/banner?t=${Date.now()}` }); + } catch (err) { + res.status(err.status || 400).json({ error: err.message }); + } +}); + +router.delete('/sites/:id/banner', (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Not found.' }); + try { + assertSiteAllowed(req, site.id); + } catch (err) { + return res.status(403).json({ error: err.message }); + } + if (site.banner_path) deleteBanner(site.banner_path); + db.prepare('UPDATE sites SET banner_path = NULL WHERE id = ?').run(site.id); + res.json({ ok: true }); +}); + +router.get('/sites/:id/banner', (req, res) => { + const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id); + const abs = site && bannerAbsolutePath(site.banner_path); + if (!abs) return res.status(404).send('No banner set.'); + res.sendFile(abs); +}); + +/** + * The exact bitmap that would be sent to the printer, so the layout and the + * rotation can be checked without using a label. + */ +router.get('/sites/:id/badge-bitmap', async (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).send('Not found.'); + try { + const visit = req.query.visitId + ? db.prepare('SELECT * FROM visits WHERE id = ?').get(req.query.visitId) + : printer.sampleVisit(site); + if (!visit) return res.status(404).send('No such visit.'); + const png = await printer.renderBadgePng(visit, site); + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'no-store'); + res.send(png); + } catch (err) { + res.status(500).send(`Could not render the badge: ${err.message}`); + } +}); + +router.post('/sites/:id/test-print', async (req, res) => { + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); + if (!site) return res.status(404).json({ error: 'Not found.' }); + try { + assertSiteAllowed(req, site.id); + } catch (err) { + return res.status(403).json({ error: err.message }); + } + try { + const result = await printer.printBadge(printer.sampleVisit(site), site); + res.json({ ok: true, ...result }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +/** Reprints a real visitor's badge on the server's printer. */ +router.post('/visits/:id/print', async (req, res) => { + const visit = db.prepare('SELECT * FROM visits WHERE id = ?').get(req.params.id); + if (!visit) return res.status(404).json({ error: 'Not found.' }); + const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(visit.site_id); + if (!site) return res.status(404).json({ error: 'That site no longer exists.' }); + if (!printer.isConfigured(site)) { + return res.status(400).json({ error: 'Server printing is not turned on for this site.' }); + } + try { + await printer.printBadge(visit, site); + res.json({ ok: true }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +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, + company: row.company, + 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, + hasPhoto: Boolean(row.photo_path), + 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), + // Context for the removal confirmation. + visitCount: db + .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') + .get(row.id).n, + onSite: Boolean( + db + .prepare('SELECT 1 FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') + .get(row.id) + ), + }); +}); + +/** 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'), + }); +}); + +/** True when another recurring visitor already holds this PIN. */ +function pinTaken(pin, excludeId = null) { + const row = db + .prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?') + .get(pinLookup(pin)); + return Boolean(row) && row.id !== excludeId; +} + +function allocatePin(requested, excludeId = null) { + if (requested && /^\d{4}$/.test(String(requested))) { + if (pinTaken(String(requested), excludeId)) { + throw new Error('Another recurring visitor already uses that PIN. Choose a different one.'); + } + return String(requested); + } + return generatePin((candidate) => pinTaken(candidate, excludeId)); +} + +function validateFrequent(body, { existingPhone = null, existingId = 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.`); + } + // One record per person: mobile number, email and PIN must each be unique. + const phoneClash = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?') + .get(phone); + if (phoneClash && phoneClash.id !== existingId) { + throw new Error( + `${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.` + ); + } + if (email) { + const emailClash = db + .prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?') + .get(email); + if (emailClash && emailClash.id !== existingId) { + throw new Error( + `${emailClash.first_name} ${emailClash.last_name} already uses that email address.` + ); + } + } + return { + firstName, + lastName, + company: clean(body?.company, 80) || null, + 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 = allocatePin(req.body?.pin); + const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null; + + const info = db + .prepare( + `INSERT INTO frequent_visitors + (first_name, last_name, company, phone, email, check_type, check_number, check_expiry, + default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)` + ) + .run( + v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, + v.checkExpiry, v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes + ); + res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true)); + } catch (err) { + 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, existingId: row.id } + ); + const scope = scopedSiteId(req); + + // A new photo replaces the old file; removePhoto clears it entirely. + let photoPath = row.photo_path; + if (req.body?.photo) { + photoPath = savePhoto(req.body.photo); + if (row.photo_path) deletePhoto(row.photo_path); + } else if (req.body?.removePhoto) { + if (row.photo_path) deletePhoto(row.photo_path); + photoPath = null; + } + + db.prepare( + `UPDATE frequent_visitors SET first_name = ?, last_name = ?, company = ?, phone = ?, email = ?, + check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?, + photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?` + ).run( + v.firstName, v.lastName, v.company, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry, + v.defaultHostId, + scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id), + photoPath, + 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 }); + } +}); + +/** The photo kept on file for a recurring visitor. */ +router.get('/frequent/:id/photo', (req, res) => { + const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id); + const abs = row && photoAbsolutePath(row.photo_path); + if (!abs) return res.status(404).send('No photo on file.'); + res.setHeader('Cache-Control', 'private, max-age=60'); + res.sendFile(abs); +}); + +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.' }); + let pin; + try { + pin = allocatePin(req.body?.pin, row.id); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + db.prepare( + "UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?" + ).run(encryptPin(pin), pinLookup(pin), row.id); + db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); + res.json({ ok: true, pin }); +}); + +/** + * Removes a recurring visitor for good. + * + * Their visit history is deliberately kept: visits store the name, contact details + * and host as their own columns, so the log stays a complete record of who was in + * the building even after the person's saved record is gone. Deleting the record + * frees their mobile number, email and PIN for someone else. + * + * To keep someone on file but stop them signing in, untick Active instead. + */ +router.delete('/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.' }); + + const scope = scopedSiteId(req); + if (scope && row.site_id && row.site_id !== scope) { + return res.status(403).json({ error: 'That visitor belongs to another site.' }); + } + + const openVisit = db + .prepare('SELECT id FROM visits WHERE frequent_visitor_id = ? AND signed_out_at IS NULL') + .get(row.id); + if (openVisit && !req.query.force) { + return res.status(409).json({ + error: `${row.first_name} is signed in right now. Sign them out first, or confirm to remove anyway.`, + onSite: true, + }); + } + + const visitCount = db + .prepare('SELECT COUNT(*) AS n FROM visits WHERE frequent_visitor_id = ?') + .get(row.id).n; + + if (row.photo_path) deletePhoto(row.photo_path); + db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone); + // visits.frequent_visitor_id is ON DELETE SET NULL, so the history survives. + db.prepare('DELETE FROM frequent_visitors WHERE id = ?').run(row.id); + + res.json({ + ok: true, + name: `${row.first_name} ${row.last_name}`, + visitsKept: visitCount, + }); +}); + +/* ------------------------------------------------------------- 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, + company: v.company, + 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 company LIKE ? OR host_name LIKE ? OR phone LIKE ? OR email LIKE ?)' + ); + params.push(`%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`, `%${q}%`); + } + const sql = `SELECT * FROM visits ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY signed_in_at DESC LIMIT 500`; + res.json(db.prepare(sql).all(...params).map(shapeVisit)); +}); + +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(); + 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', 'Company', 'Phone', 'Email', 'Check type', 'Check number', 'Visiting', 'Reason', 'Signed in', 'Signed out', 'Closed by', 'Photo'], + ...rows.map((v) => [ + v.id, v.site_name, v.visitor_type, v.first_name, v.last_name, v.company, v.phone, v.email, + v.check_type, v.check_number, v.host_name, v.visit_reason, + localStamp(v.signed_in_at), localStamp(v.signed_out_at), v.signed_out_by, + v.photo_path ? 'yes' : 'no', + ]), + ]); + 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)}

    + ${row.company ? `

    ${esc(row.company)}

    ` : ''} +

    ${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(), + lastOk: sheets.status.lastOk, + lastError: sheets.status.lastError, + tab: sheets.tabName(), + stale: sheets.isStale(), + serviceAccount: sheets.serviceAccountEmail(), + spreadsheetId: config.sheets.spreadsheetId || null, + lastOnSiteSync: sheets.status.lastOnSiteSync, + onSiteCount: sheets.status.onSiteCount, + onSiteError: sheets.status.onSiteError, + }, + tls: tls.describe(), + }); +}); + +router.post('/sheets/resync', async (req, res) => { + try { + res.json({ ok: true, ...(await sheets.syncOnSite()) }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +router.post('/sheets/test', async (req, res) => { + try { + res.json({ ok: true, ...(await sheets.testConnection()) }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +/* ---------------------------------------------------------------- tls */ + +router.get('/tls', (req, res) => { + res.json(tls.describe()); +}); + +/** The CA certificate is public by design — it is what tablets need to trust. */ +router.get('/tls/ca.crt', (req, res) => { + const ca = tls.caCertificate(); + if (!ca) return res.status(404).send('No certificate authority has been generated.'); + res.setHeader('Content-Type', 'application/x-x509-ca-cert'); + res.setHeader('Content-Disposition', 'attachment; filename="visitor-signin-ca.crt"'); + res.send(ca); +}); + +router.post('/tls/renew', requireOwner, (req, res) => { + try { + // A brand new CA means every kiosk device has to trust it again, so it is + // deliberately a separate, explicit choice. + const newCa = Boolean(req.body?.newCa); + tls.ensureCertificates({ force: newCa }); + const reload = req.app.get('reloadTls'); + const reloaded = reload ? reload() : false; + res.json({ ok: true, reloaded, newCa, info: tls.describe() }); + } 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..9e172f7 --- /dev/null +++ b/src/routes/kiosk.js @@ -0,0 +1,398 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import db from '../db.js'; +import config from '../config.js'; +import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js'; +import { mirror } from '../sheets.js'; +import { verifyPin } from '../pins.js'; +import { listSites, resolveSite, badgeHtml } from '../sites.js'; +import { themeFor, bannerAbsolutePath } from '../branding.js'; +import * as printer from '../printer.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, + // Branding for this kiosk: colours are applied as CSS variables and the + // banner replaces the site name in the top bar. + theme: themeFor(site), + banner: site?.banner_path + ? { url: `/api/branding/${site.id}/banner`, height: site.banner_height || 64 } + : null, + // Applies to the site name too, so the header looks the same either way. + headerAlign: site?.banner_align || 'left', + }); +}); + +/** The site banner. Public, because the kiosk shows it before anyone signs in. */ +router.get('/branding/:id/banner', (req, res) => { + const site = db.prepare('SELECT banner_path FROM sites WHERE id = ?').get(req.params.id); + const abs = site && bannerAbsolutePath(site.banner_path); + if (!abs) return res.status(404).send('No banner set.'); + res.setHeader('Cache-Control', 'public, max-age=300'); + res.sendFile(abs); +}); + +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 */ + +/** Rejects rather than hanging the front desk on a printer that never answers. */ +function withTimeout(promise, ms) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error('Printing timed out.')), ms)), + ]); +} + +router.post('/signin', signInLimiter, async (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); + // Optional: plenty of visitors are not from anywhere in particular. + const company = clean(isFrequent ? frequent.company : body.company, 80); + const visitReason = clean(body.visitReason, 120); + + if (!firstName) return res.status(400).json({ error: 'First name is required.' }); + 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.`, + }); + } + + // A recurring visitor with a photo on file is not asked to pose again; the + // stored photo is copied onto this visit as its own snapshot. + let photoPath = null; + if (body.photo) { + photoPath = savePhoto(body.photo); + } else if (isFrequent && frequent.photo_path) { + photoPath = copyStoredPhoto(frequent.photo_path); + } + if (!photoPath && 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, company, + 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, + company || null, + 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(); + delete req.session.frequentVisitorId; + + // With a networked printer the server does the printing, so the tablet needs + // no driver and no default printer. It is awaited briefly rather than fired + // and forgotten: if the printer is unreachable the kiosk falls back to its + // own print dialog instead of the visitor walking off without a badge. + let serverPrinted = false; + if (site.badge_enabled && printer.isConfigured(site)) { + try { + await withTimeout(printer.printBadge(visit, site), config.printing.signInWaitMs); + serverPrinted = true; + } catch (err) { + console.error('[print] badge failed, kiosk will fall back:', err.message); + } + } + + // 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, + serverPrinted, + // Only offered when the server did not already print it. + badgeUrl: site.badge_enabled && !serverPrinted ? `/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(); + + 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, + company: person.company, + defaultHostId: person.default_host_id, + // Tells the kiosk it can skip the camera step entirely. + hasPhoto: Boolean(person.photo_path), + openVisit: open || null, + }); +}); + +export default router; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..ffb5740 --- /dev/null +++ b/src/server.js @@ -0,0 +1,183 @@ +import express from 'express'; +import session from 'express-session'; +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 * as tls from './tls.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 }); +}); + +/* ------------------------------------------------------------ admin pages */ +// The console and the sign in screen are separate documents, so these must be +// declared before express.static or it would serve them itself and skip the +// redirect that keeps an unauthenticated browser off the console. + +function sessionUser(req) { + if (!req.session?.adminUserId) return null; + const user = users.findById(req.session.adminUserId); + return user && user.active ? user : null; +} + +app.get('/admin', (req, res) => { + const user = sessionUser(req); + if (!user || user.must_change_password) return res.redirect('/admin/login'); + res.sendFile(path.join(publicDir, 'admin.html')); +}); + +app.get('/admin/login', (req, res) => { + const user = sessionUser(req); + if (user && !user.must_change_password) return res.redirect('/admin'); + res.sendFile(path.join(publicDir, 'login.html')); +}); + +// Nobody should land on the raw filenames; keep one address per page. +app.get(['/admin.html', '/login.html'], (req, res) => res.redirect('/admin')); + +app.use(express.static(publicDir, { extensions: ['html'], index: false })); +app.get('/favicon.ico', (req, res) => res.redirect(301, '/favicon.svg')); +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(); + } + if (open.length) console.log(`[auto] signed out ${open.length} visitor(s) still on site`); + }, 60000).unref(); +} + +/* ------------------------------------------------------------- listen */ + +/** + * A plain http listener that does two jobs: hands out the CA certificate (so a new + * tablet can fetch it without first trusting the very certificate it is missing), + * and pushes everything else to https. + */ +function startRedirectServer() { + const port = config.https.redirectPort; + if (!port) return; + + http + .createServer((req, res) => { + if (req.url === '/ca.crt' || req.url === '/ca.pem') { + const ca = tls.caCertificate(); + if (!ca) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + return res.end('No certificate authority has been generated yet.'); + } + res.writeHead(200, { + 'Content-Type': 'application/x-x509-ca-cert', + 'Content-Disposition': 'attachment; filename="visitor-signin-ca.crt"', + }); + return res.end(ca); + } + + const host = String(req.headers.host || '').split(':')[0]; + const target = `https://${host}:${config.https.publicPort}${req.url}`; + res.writeHead(302, { Location: target }); + res.end(`Moved to ${target}`); + }) + .listen(port, () => { + console.log(`[server] http helper on port ${port} — serves /ca.crt, redirects to https`); + }); +} + +function start() { + if (!config.https.enabled) { + 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'); + }); + return; + } + + let material; + try { + material = tls.ensureCertificates(); + } catch (err) { + console.error(`[tls] ${err.message}`); + process.exit(1); + } + + let server = https.createServer({ key: material.key, cert: material.cert }, app); + server.listen(config.port, () => { + const names = material.info.server?.names?.join(', ') || 'this host'; + console.log(`[server] ${config.siteName} listening on https://0.0.0.0:${config.port}`); + console.log(`[tls] certificate valid for ${names}`); + console.log(`[tls] expires ${material.info.server?.validTo} (${material.info.server?.daysLeft} days)`); + console.log('[tls] install the CA on each kiosk device — see README'); + }); + + // Swap the certificate in without dropping the listener when it renews. + tls.scheduleRenewal(() => { + const fresh = tls.ensureCertificates(); + server.setSecureContext({ key: fresh.key, cert: fresh.cert }); + console.log('[tls] certificate renewed and reloaded without a restart'); + }); + + startRedirectServer(); +} + +start(); diff --git a/src/sheets.js b/src/sheets.js new file mode 100644 index 0000000..3914ad4 --- /dev/null +++ b/src/sheets.js @@ -0,0 +1,286 @@ +import fs from 'node:fs'; +import { google } from 'googleapis'; +import config from './config.js'; +import db from './db.js'; +import { localStamp } from './util.js'; + +/** + * The spreadsheet is an evacuation list and nothing else. + * + * One tab, rewritten in full whenever anyone signs in or out, holding only the + * people currently in the building. It is never appended to, so there is no + * history to scroll past while standing in a car park counting heads. + * + * The full visit history stays in the application's own database, where it is + * searchable in the admin console and exportable as CSV. + */ + +const HEADER = [ + 'Site', + 'First name', + 'Last name', + 'Company', + 'Visiting', + 'Phone', + 'Email', + 'Check', + 'Signed in', + 'On site for', + 'Visit ID', +]; + +const MAX_ROWS = 1000; + +let client = null; +let tabPromise = null; +let dirty = false; +let syncing = false; + +export const status = { + lastOk: null, + lastError: null, + onSiteCount: null, +}; + +/* ----------------------------------------------------------- connection */ + +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; +} + +/** + * The service account's own address. Nothing works until the spreadsheet is + * shared with it, and it is buried in a JSON key file nobody wants to open on a + * server, so the admin console shows it. + */ +export function serviceAccountEmail() { + try { + return loadCredentials().client_email || null; + } catch { + return null; + } +} + +/** + * Google's own wording for these failures says what went wrong but never what to + * do about it, so the common ones are rewritten with the actual next step. + */ +function explain(err) { + const code = err?.code || err?.response?.status; + const raw = String(err?.message || ''); + const email = serviceAccountEmail(); + + if (code === 403 && /caller does not have permission|permission/i.test(raw)) { + return ( + `The service account cannot open this spreadsheet. Share the sheet with ` + + `${email || 'the service account address'} and give it Editor access.` + ); + } + if (code === 403 && /has not been used|accessNotConfigured|disabled/i.test(raw)) { + return 'The Google Sheets API is not enabled on that Google Cloud project. Enable it, then wait a minute and retry.'; + } + if (code === 404) { + return 'No spreadsheet was found with that ID. Check SHEETS_SPREADSHEET_ID against the sheet URL.'; + } + if (code === 400 && /Unable to parse range/i.test(raw)) { + return `The tab "${config.sheets.onSiteTab}" could not be addressed. Check SHEETS_ONSITE_TAB matches the tab name exactly.`; + } + if (/invalid_grant|Invalid JWT|clock/i.test(raw)) { + return "Google rejected the credentials. Check the server's clock is correct and the service account key has not been deleted."; + } + return raw || 'Unknown error talking to Google Sheets.'; +} + +export function isEnabled() { + return Boolean(config.sheets.enabled && config.sheets.spreadsheetId); +} + +/** + * Creates the tab if it is missing. Memoised as a promise rather than a boolean: + * two syncs starting at once would otherwise both decide it was missing. + */ +function ensureTab(sheets, { force = false } = {}) { + if (force) tabPromise = null; + if (!tabPromise) { + tabPromise = doEnsureTab(sheets).catch((err) => { + tabPromise = null; + throw err; + }); + } + return tabPromise; +} + +async function doEnsureTab(sheets) { + const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); + const titles = meta.data.sheets.map((s) => s.properties.title); + if (!titles.includes(config.sheets.onSiteTab)) { + await sheets.spreadsheets.batchUpdate({ + spreadsheetId: config.sheets.spreadsheetId, + requestBody: { + requests: [{ addSheet: { properties: { title: config.sheets.onSiteTab } } }], + }, + }); + console.log(`[sheets] created tab "${config.sheets.onSiteTab}"`); + } +} + +/* --------------------------------------------------------- who is here */ + +function humanDuration(fromIso) { + const minutes = Math.max(0, Math.round((Date.now() - new Date(fromIso).getTime()) / 60000)); + if (minutes < 60) return `${minutes} min`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`; +} + +function onSiteRows() { + return db + .prepare('SELECT * FROM visits WHERE signed_out_at IS NULL ORDER BY site_name, signed_in_at') + .all() + .slice(0, MAX_ROWS) + .map((v) => [ + v.site_name || '', + v.first_name, + v.last_name, + v.company || '', + v.host_name, + v.phone || '', + v.email || '', + v.check_type === 'NONE' ? 'None' : `${v.check_type} ${v.check_number || ''}`.trim(), + localStamp(v.signed_in_at), + humanDuration(v.signed_in_at), + String(v.id), + ]); +} + +/** + * Replaces the whole tab with the current state. Rewriting rather than patching + * means a missed update can never leave a stale name on the evacuation list: + * whatever is on the tab is what the database says right now. + */ +export async function syncOnSite() { + if (!isEnabled()) return { skipped: true }; + if (syncing) { + dirty = true; + return { skipped: true }; + } + syncing = true; + try { + const sheets = getClient(); + await ensureTab(sheets); + const rows = onSiteRows(); + const banner = `On site now — ${rows.length} ${rows.length === 1 ? 'person' : 'people'} — updated ${localStamp(new Date().toISOString())}`; + + await sheets.spreadsheets.values.clear({ + spreadsheetId: config.sheets.spreadsheetId, + range: `${config.sheets.onSiteTab}!A1:K${MAX_ROWS + 10}`, + }); + await sheets.spreadsheets.values.update({ + spreadsheetId: config.sheets.spreadsheetId, + range: `${config.sheets.onSiteTab}!A1`, + valueInputOption: 'RAW', + requestBody: { values: [[banner], HEADER, ...rows] }, + }); + + dirty = false; + status.lastOk = new Date().toISOString(); + status.lastError = null; + status.onSiteCount = rows.length; + return { rows: rows.length }; + } catch (err) { + dirty = true; + status.lastError = explain(err); + const wrapped = new Error(status.lastError); + wrapped.cause = err; + throw wrapped; + } finally { + syncing = false; + } +} + +/** + * Called after every sign in and sign out. Fire and forget: a Sheets outage must + * never hold up someone standing at the front desk. A failure leaves the tab + * marked stale and the worker retries. + */ +export function mirror() { + if (!isEnabled()) return; + dirty = true; + syncOnSite().catch((err) => console.error('[sheets] sync failed, will retry:', err.message)); +} + +export async function testConnection() { + if (!isEnabled()) throw new Error('Google Sheets mirroring is turned off in the environment.'); + try { + const sheets = getClient(); + const meta = await sheets.spreadsheets.get({ spreadsheetId: config.sheets.spreadsheetId }); + await ensureTab(sheets, { force: true }); + await syncOnSite(); + status.lastError = null; + return { title: meta.data.properties.title, tab: config.sheets.onSiteTab }; + } catch (err) { + status.lastError = explain(err); + throw new Error(status.lastError); + } +} + +export function tabName() { + return config.sheets.onSiteTab; +} + +export function isStale() { + return dirty; +} + +export function startWorker() { + if (!isEnabled()) { + console.log('[sheets] mirroring disabled'); + return; + } + console.log( + `[sheets] mirroring who is on site to ${config.sheets.spreadsheetId} ("${config.sheets.onSiteTab}")` + ); + + // Rows left over from the older append-only log are no longer sent anywhere. + const stale = db.prepare('SELECT COUNT(*) AS n FROM sheet_queue').get().n; + if (stale) { + db.prepare('DELETE FROM sheet_queue').run(); + console.log( + `[sheets] discarded ${stale} queued history row(s): the sheet now holds only who is on site. ` + + 'The full history is still in the visit log.' + ); + } + + // Retry anything that failed, and keep the "on site for" column honest. + setInterval(() => { + if (dirty) { + syncOnSite().catch((err) => console.error('[sheets] retry failed:', err.message)); + } + }, config.sheets.retryIntervalMs).unref(); + + setInterval(() => { + syncOnSite().catch(() => { + /* the retry above will pick it up */ + }); + }, 15 * 60 * 1000).unref(); + + syncOnSite().catch((err) => console.error('[sheets] initial sync failed:', err.message)); +} diff --git a/src/sites.js b/src/sites.js new file mode 100644 index 0000000..ac71b7e --- /dev/null +++ b/src/sites.js @@ -0,0 +1,227 @@ +import db from './db.js'; +import { clean, localStamp } from './util.js'; +import { themeFor } from './branding.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), + branding: { + hasBanner: Boolean(site.banner_path), + bannerHeight: site.banner_height || 64, + bannerAlign: site.banner_align || 'left', + brand: site.colour_brand, + signout: site.colour_signout, + page: site.colour_page, + text: site.colour_text, + theme: themeFor(site), + }, + printer: { + enabled: Boolean(site.printer_enabled), + host: site.printer_host, + port: site.printer_port || 9100, + model: site.printer_model || 'QL-820NWB', + rotate: site.printer_rotate || 0, + }, + badge: { + enabled: Boolean(site.badge_enabled), + widthMm: site.badge_width_mm, + heightMm: site.badge_height_mm, + showPhoto: Boolean(site.badge_show_photo), + accent: Boolean(site.badge_accent), + 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) || 62; + const height = Number(site.badge_height_mm) || 100; + const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl); + + // A label noticeably taller than it is wide gets a stacked layout. That is the + // normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll + // fixes the width and the length runs down the badge. + const portrait = height >= width * 1.2; + + // Type scales with the dimension that constrains it: the width on a portrait + // badge, the shorter side on a wide one. Keeps small stock legible. + const unit = portrait ? width : Math.min(width, height); + const pad = unit * 0.07; + const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115)); + const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062)); + // Square, matching the crop taken at the kiosk. + const photoWidth = portrait ? unit * 0.52 : unit * 0.5; + + // Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere + // else it prints as grey, so it is off unless the site opts in. + const accent = site.badge_accent ? '#d00019' : '#000'; + const timeIn = new Date(visit.signed_in_at); + const noCheck = visit.check_type === 'NONE'; + + const photo = showPhoto ? `` : ''; + const details = ` +
    +
    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' }))}
    +
    ${ + noCheck + ? 'No WWCC / VIT' + : `${esc(visit.check_type)} ${esc(visit.check_number || '')}` + }
    + ${site.badge_note ? `
    ${esc(site.badge_note)}
    ` : ''} +
    `; + + return ` + + + +Badge — ${esc(visit.first_name)} ${esc(visit.last_name)} + + + +
    + ${photo} +
    +
    ${esc(site.name)} · Visitor
    +
    ${esc(visit.first_name)} ${esc(visit.last_name)}
    + ${details} +
    +
    +${autoPrint ? '' : ''} + +`; +} + +export { esc as escapeHtml, localStamp }; diff --git a/src/tls.js b/src/tls.js new file mode 100644 index 0000000..4d93e4c --- /dev/null +++ b/src/tls.js @@ -0,0 +1,245 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import config from './config.js'; + +/** + * Certificates for an internal-only kiosk. + * + * Two certificates, not one. A long lived CA that you install on each kiosk tablet + * once, and a short lived server certificate signed by it. Renewing the server + * certificate then never means touching the tablets again — which matters, because + * Apple and Chrome reject server certificates valid for much more than a year, so a + * single self-signed certificate would have to be reinstalled everywhere annually. + */ + +const CA_DAYS = 3650; +const SERVER_DAYS = 398; +const RENEW_WITHIN_DAYS = 30; + +function certDir() { + return path.dirname(config.https.certPath); +} + +function paths() { + const dir = certDir(); + return { + dir, + caKey: path.join(dir, 'ca.key'), + caCert: path.join(dir, 'ca.crt'), + key: config.https.keyPath, + cert: config.https.certPath, + // Records which configured names the current certificate was issued for. + names: path.join(dir, '.hostnames.json'), + }; +} + +function openssl(args, options = {}) { + return execFileSync('openssl', args, { stdio: ['ignore', 'pipe', 'pipe'], ...options }); +} + +export function opensslAvailable() { + try { + openssl(['version']); + return true; + } catch { + return false; + } +} + +/** Every name and address a browser might use to reach this kiosk. */ +export function subjectAltNames() { + const dns = new Set(['localhost']); + const ips = new Set(['127.0.0.1']); + + for (const entry of config.https.hostnames) { + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(entry)) ips.add(entry); + else dns.add(entry.toLowerCase()); + } + + // The container's own addresses, so hitting it directly still validates. + for (const list of Object.values(os.networkInterfaces())) { + for (const iface of list || []) { + if (iface.family === 'IPv4' && !iface.internal) ips.add(iface.address); + } + } + + return [ + ...[...dns].map((d) => `DNS:${d}`), + ...[...ips].map((i) => `IP:${i}`), + ]; +} + +function readCert(file) { + try { + return new crypto.X509Certificate(fs.readFileSync(file)); + } catch { + return null; + } +} + +function daysUntil(date) { + return Math.floor((new Date(date).getTime() - Date.now()) / 86400000); +} + +/** The SANs actually baked into a certificate, normalised for comparison. */ +function certSans(cert) { + if (!cert?.subjectAltName) return []; + return cert.subjectAltName + .split(',') + .map((s) => s.trim().replace(/^IP Address:/, 'IP:')) + .sort(); +} + +function createCa(p) { + fs.mkdirSync(p.dir, { recursive: true }); + openssl([ + 'req', '-x509', '-nodes', '-newkey', 'rsa:2048', + '-days', String(CA_DAYS), + '-keyout', p.caKey, + '-out', p.caCert, + '-subj', `/C=AU/O=${config.siteName}/CN=${config.siteName} Local CA`, + '-addext', 'basicConstraints=critical,CA:TRUE,pathlen:0', + '-addext', 'keyUsage=critical,keyCertSign,cRLSign', + ]); + fs.chmodSync(p.caKey, 0o600); + console.log(`[tls] created a local certificate authority at ${p.caCert}`); +} + +function createServerCert(p, sans) { + const primary = config.https.hostnames[0] || os.hostname() || 'visitors.local'; + const csr = path.join(p.dir, 'server.csr'); + const ext = path.join(p.dir, 'server.ext'); + + fs.writeFileSync( + ext, + [ + `subjectAltName=${sans.join(',')}`, + 'basicConstraints=CA:FALSE', + 'keyUsage=critical,digitalSignature,keyEncipherment', + 'extendedKeyUsage=serverAuth', + ].join('\n') + ); + + openssl([ + 'req', '-nodes', '-newkey', 'rsa:2048', + '-keyout', p.key, + '-out', csr, + '-subj', `/C=AU/O=${config.siteName}/CN=${primary}`, + ]); + + openssl([ + 'x509', '-req', + '-in', csr, + '-CA', p.caCert, + '-CAkey', p.caKey, + '-CAcreateserial', + '-out', p.cert, + '-days', String(SERVER_DAYS), + '-sha256', + '-extfile', ext, + ]); + + fs.chmodSync(p.key, 0o600); + fs.rmSync(csr, { force: true }); + fs.rmSync(ext, { force: true }); + console.log(`[tls] issued a server certificate for ${sans.join(', ')}`); +} + +/** + * Makes sure a usable certificate is on disk, creating or renewing as needed. + * Returns the material for https.createServer plus a summary for the admin console. + */ +export function ensureCertificates({ force = false } = {}) { + const p = paths(); + + if (!opensslAvailable()) { + throw new Error( + 'openssl is not available, so a certificate cannot be generated. Supply your own ' + + 'certificate at HTTPS_CERT and HTTPS_KEY, or terminate TLS at a reverse proxy.' + ); + } + + fs.mkdirSync(p.dir, { recursive: true }); + + if (force || !fs.existsSync(p.caCert) || !fs.existsSync(p.caKey)) { + createCa(p); + } + + const wanted = subjectAltNames().sort(); + const existing = readCert(p.cert); + + // Compare against the configured names only. The container's own IP is in the + // certificate too, and Docker hands out a different one on most restarts, so + // comparing the full SAN list would reissue the certificate on every boot. + const configuredNow = [...config.https.hostnames].sort().join(','); + let configuredBefore = null; + try { + configuredBefore = JSON.parse(fs.readFileSync(p.names, 'utf8')).sort().join(','); + } catch { + configuredBefore = null; + } + + let reason = null; + if (force) reason = 'asked to regenerate'; + else if (!existing || !fs.existsSync(p.key)) reason = 'no certificate on disk'; + else if (daysUntil(existing.validTo) < RENEW_WITHIN_DAYS) reason = 'certificate is close to expiry'; + else if (configuredBefore !== configuredNow) reason = 'HTTPS_HOSTNAMES changed'; + + if (reason) { + console.log(`[tls] renewing the server certificate: ${reason}`); + createServerCert(p, wanted); + fs.writeFileSync(p.names, JSON.stringify(config.https.hostnames)); + } + + return { + key: fs.readFileSync(p.key), + cert: fs.readFileSync(p.cert), + caPath: p.caCert, + info: describe(), + }; +} + +export function describe() { + const p = paths(); + const server = readCert(p.cert); + const ca = readCert(p.caCert); + return { + enabled: config.https.enabled, + server: server && { + validFrom: server.validFrom, + validTo: server.validTo, + daysLeft: daysUntil(server.validTo), + names: certSans(server), + fingerprint: server.fingerprint256, + }, + ca: ca && { + validTo: ca.validTo, + daysLeft: daysUntil(ca.validTo), + fingerprint: ca.fingerprint256, + subject: ca.subject, + }, + caPath: fs.existsSync(p.caCert) ? p.caCert : null, + }; +} + +export function caCertificate() { + const p = paths(); + return fs.existsSync(p.caCert) ? fs.readFileSync(p.caCert) : null; +} + +/** Renewal is cheap, so check daily rather than only at boot. */ +export function scheduleRenewal(onRenewed) { + setInterval(() => { + try { + const before = describe().server?.validTo; + ensureCertificates(); + const after = describe().server?.validTo; + if (before !== after) onRenewed?.(); + } catch (err) { + console.error('[tls] renewal check failed:', err.message); + } + }, 24 * 60 * 60 * 1000).unref(); +} 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'); +}