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

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
data
secrets
.env
.git
*.md
+88
View File
@@ -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/<THIS PART>/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
+19
View File
@@ -0,0 +1,19 @@
# Keep LF in the repo so shell scripts still run on the Ubuntu docker host,
# even when the working copy is checked out on Windows.
* text=auto eol=lf
*.sh text eol=lf
*.mjs text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
.env.example text eol=lf
# Windows-only helpers keep CRLF so Notepad and cmd behave.
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.ico binary
+9
View File
@@ -0,0 +1,9 @@
node_modules/
data/
secrets/
.env
*.db
*.db-shm
*.db-wal
*.log
.DS_Store
+42
View File
@@ -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"]
+517
View File
@@ -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://<docker-host>:8443` and the admin console on
`https://<docker-host>: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)
+28
View File
@@ -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
+37
View File
@@ -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 "$@"
+5
View File
@@ -0,0 +1,5 @@
name,email,area
Jess Rogerson,jess.rogerson@example.com,Front office
Amelia Nguyen,amelia.nguyen@example.com,Year 3
David Okafor,david.okafor@example.com,Maintenance
Priya Raman,priya.raman@example.com,Wellbeing
1 name email area
2 Jess Rogerson jess.rogerson@example.com Front office
3 Amelia Nguyen amelia.nguyen@example.com Year 3
4 David Okafor david.okafor@example.com Maintenance
5 Priya Raman priya.raman@example.com Wellbeing
+26
View File
@@ -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"
}
+142
View File
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — visitor sign in</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<!-- ------------------------------------------------------------ login -->
<!-- ---------------------------------------------------------- console -->
<div id="console">
<header class="topbar">
<strong id="site-name">Visitor admin</strong>
<nav>
<button class="tab on" data-tab="onsite">On site</button>
<button class="tab" data-tab="log">Visit log</button>
<button class="tab" data-tab="recurring">Recurring visitors <span class="badge-count" id="alert-count" hidden></span></button>
<button class="tab" data-tab="hosts">People to visit</button>
<button class="tab" data-tab="sites">Sites</button>
<button class="tab owner-only" data-tab="admins" hidden>Admins</button>
<button class="tab" data-tab="system">System</button>
</nav>
<label class="site-switch" id="site-switch" hidden>
<span>Site</span>
<select id="site-filter"></select>
</label>
<button id="logout" class="link">Sign out</button>
</header>
<p class="banner" id="expiry-banner" hidden></p>
<main>
<!-- ------------------------------------------------------ on site -->
<section class="panel on" id="panel-onsite">
<div class="panel-head">
<h2>Currently on site</h2>
<button class="ghost" id="refresh-onsite">Refresh</button>
</div>
<p class="stat" id="onsite-count"></p>
<div id="onsite-table"></div>
</section>
<!-- -------------------------------------------------------- log -->
<section class="panel" id="panel-log">
<div class="panel-head">
<h2>Visit log</h2>
<a class="ghost" id="csv-link" href="/admin/api/visits.csv">Download CSV</a>
</div>
<div class="filters">
<label><span>From</span><input type="date" id="log-from"></label>
<label><span>To</span><input type="date" id="log-to"></label>
<label class="grow"><span>Search name, company, host or contact</span><input id="log-q"></label>
<button class="ghost" id="log-search">Apply</button>
</div>
<div id="log-table"></div>
</section>
<!-- ------------------------------------------------- recurring -->
<section class="panel" id="panel-recurring">
<div class="panel-head">
<h2>Recurring visitors</h2>
<button class="primary" id="new-recurring">Add a recurring visitor</button>
</div>
<div id="expiry-summary"></div>
<div id="recurring-table"></div>
</section>
<!-- ----------------------------------------------------- hosts -->
<section class="panel" id="panel-hosts">
<div class="panel-head">
<h2>People a visitor can ask for</h2>
<button class="primary" id="new-host">Add a person</button>
</div>
<p class="hint" id="hosts-scope"></p>
<details class="import">
<summary>Import from CSV</summary>
<p class="hint">
Paste the file contents below, or choose a .csv file. Recognised column headings are
<code>name</code>, <code>email</code> and <code>area</code> (or department / team).
A single column of names works too. The import applies to the site selected above.
</p>
<input type="file" id="host-file" accept=".csv,text/csv">
<textarea id="host-csv" rows="6" placeholder="name,email,area&#10;Jess Rogerson,jess@example.com,Front office"></textarea>
<label class="inline"><input type="checkbox" id="host-replace"> Deactivate anyone at this site who is not in the file</label>
<button class="primary" id="do-host-import">Import</button>
</details>
<div id="hosts-table"></div>
</section>
<!-- ----------------------------------------------------- sites -->
<section class="panel" id="panel-sites">
<div class="panel-head">
<h2>Sites</h2>
<button class="primary owner-only" id="new-site" hidden>Add a site</button>
</div>
<p class="hint">Each site has its own name, its own list of people to visit, and its own
badge settings. Point a kiosk at one with
<code>/?site=<em>slug</em></code>, or let staff pick on first use.</p>
<div id="sites-list"></div>
</section>
<!-- ---------------------------------------------------- admins -->
<section class="panel" id="panel-admins">
<div class="panel-head">
<h2>Admin accounts</h2>
<button class="primary" id="new-admin">Invite an admin</button>
</div>
<p class="hint" id="admins-note"></p>
<div id="admins-table"></div>
</section>
<!-- ---------------------------------------------------- system -->
<section class="panel" id="panel-system">
<h2>System</h2>
<div id="system-body"></div>
<h2 class="section-gap">Your account</h2>
<div id="account-body"></div>
</section>
</main>
</div>
<dialog id="modal">
<form method="dialog" id="modal-form">
<h3 id="modal-title"></h3>
<div id="modal-body"></div>
<div class="modal-actions">
<button value="cancel" class="ghost" id="modal-cancel">Cancel</button>
<button value="save" class="primary" id="modal-save">Save</button>
</div>
</form>
</dialog>
<p class="toast" id="toast" role="status" hidden></p>
<footer class="foot">Created by: Jess Rogerson (yelling commands at Claude.AI)</footer>
<script src="/js/admin.js"></script>
</body>
</html>
+402
View File
@@ -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;
}
+402
View File
@@ -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; }
+203
View File
@@ -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; }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Visitor sign in">
<rect width="32" height="32" rx="5" fill="#0b4f4a"/>
<!-- a visitor badge: clip at the top, portrait below -->
<rect x="13" y="5" width="6" height="3" rx="1" fill="#8fd6c9"/>
<rect x="7" y="9" width="18" height="18" rx="2.5" fill="#ffffff"/>
<circle cx="16" cy="15.5" r="3.1" fill="#0b4f4a"/>
<path d="M10.4 24.2c0-3.1 2.5-5.2 5.6-5.2s5.6 2.1 5.6 5.2z" fill="#0b4f4a"/>
</svg>

After

Width:  |  Height:  |  Size: 490 B

+247
View File
@@ -0,0 +1,247 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b4f4a">
<title>Visitor sign in</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/kiosk.css">
</head>
<body>
<header class="bar align-left" id="bar">
<img class="banner" id="banner" alt="" hidden>
<p class="site" id="siteName">Visitor sign in</p>
<p class="clock" id="clock"></p>
</header>
<main id="app">
<!-- ---------------------------------------------------- site picker -->
<section class="screen" id="screen-site">
<h1 class="welcome">Which site is this kiosk at?</h1>
<p class="hint">This tablet remembers the answer, so you only pick once.</p>
<div class="host-list" id="site-list"></div>
</section>
<!-- ---------------------------------------------------------- home -->
<section class="screen" id="screen-home">
<h1 class="welcome">Welcome. Are you coming in, or heading out?</h1>
<div class="doors">
<button class="door door-in" data-go="guest-name">
<span class="door-title">Sign in</span>
<span class="door-sub">First time, or an occasional visit</span>
</button>
<button class="door door-out" data-go="signout-find">
<span class="door-title">Sign out</span>
<span class="door-sub">Leaving the site</span>
</button>
</div>
<button class="text-action" data-go="freq-pin">I have a PIN — I come here regularly</button>
</section>
<!-- ------------------------------------------------- guest: name -->
<section class="screen" id="screen-guest-name" data-step="1">
<div class="rail" data-rail></div>
<h2>What's your name?</h2>
<label class="field">
<span>First name</span>
<input id="in-first" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Last name</span>
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Company or organisation <em>(optional)</em></span>
<input id="in-company" autocomplete="organization" autocapitalize="words" enterkeyhint="next"
placeholder="Leave blank if you're not here for work">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" data-next="guest-name">Continue</button>
</div>
</section>
<!-- ------------------------------------------------- guest: host -->
<section class="screen" id="screen-guest-host" data-step="2">
<div class="rail" data-rail></div>
<h2>Who are you here to see?</h2>
<label class="field">
<span>Start typing a name to narrow the list</span>
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
</label>
<label class="field">
<span>Then choose a name</span>
<select id="host-select" class="picker"></select>
</label>
<p class="host-empty" id="host-empty" hidden></p>
<div class="nav">
<button class="ghost" data-back>Back</button>
</div>
</section>
<!-- ---------------------------------------------- guest: contact -->
<section class="screen" id="screen-guest-contact" data-step="3">
<div class="rail" data-rail></div>
<h2>How can we reach you today?</h2>
<p class="hint">One of these is enough. We use it to sign you out and in an emergency.</p>
<label class="field">
<span>Mobile number</span>
<input id="in-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field">
<span>Email address</span>
<input id="in-email" type="email" inputmode="email" autocomplete="email" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" data-next="guest-contact">Continue</button>
</div>
</section>
<!-- ------------------------------------------------ guest: check -->
<section class="screen" id="screen-guest-check" data-step="4">
<div class="rail" data-rail></div>
<h2>Do you hold a WWCC or VIT registration?</h2>
<div class="choices" id="check-choices">
<button class="choice" data-check="WWCC">Working with Children Check</button>
<button class="choice" data-check="VIT">Victorian Institute of Teaching</button>
<button class="choice" data-check="NONE">I don't have one</button>
</div>
<label class="field" id="check-number-field" hidden>
<span id="check-number-label">Card number</span>
<input id="in-check-number" autocomplete="off" autocapitalize="characters" enterkeyhint="next">
</label>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="check-continue" hidden>Continue</button>
</div>
</section>
<!-- ------------------------------------------------------- photo -->
<section class="screen" id="screen-photo" data-step="5">
<div class="rail" data-rail></div>
<h2>Look at the camera</h2>
<p class="hint">The photo stays on this site's server. It is not sent anywhere else.</p>
<div class="camera">
<video id="cam-video" playsinline muted autoplay></video>
<img id="cam-shot" alt="The photo you just took" hidden>
<canvas id="cam-canvas" hidden></canvas>
</div>
<p class="camera-error" id="cam-error" hidden></p>
<div class="nav">
<button class="ghost" data-back>Back</button>
<button class="primary" id="cam-take">Take photo</button>
<button class="ghost" id="cam-retake" hidden>Retake</button>
<button class="primary" id="cam-use" hidden>Use this photo</button>
<button class="ghost" id="cam-skip" hidden>Continue without a photo</button>
</div>
</section>
<!-- ------------------------------------------------------ review -->
<section class="screen" id="screen-review" data-step="6">
<div class="rail" data-rail></div>
<h2>Check these details, then sign in</h2>
<dl class="review" id="review-list"></dl>
<div class="nav">
<button class="ghost" data-go="home">Start over</button>
<button class="primary" id="do-signin">Sign in</button>
</div>
</section>
<!-- ------------------------------------------- recurring: the PIN -->
<section class="screen" id="screen-freq-pin">
<h2>Welcome back</h2>
<label class="field">
<span>Mobile number</span>
<input id="in-freq-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
</label>
<label class="field field-pin">
<span>4 digit PIN</span>
<input id="in-freq-pin" type="password" inputmode="numeric" maxlength="4" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-freq-auth">Continue</button>
</div>
</section>
<!-- --------------------------------------------- recurring: host -->
<section class="screen" id="screen-freq-host">
<h2 id="freq-greeting">Who are you here to see?</h2>
<label class="field">
<span>Start typing a name to narrow the list</span>
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
</label>
<label class="field">
<span>Then choose a name</span>
<select id="freq-host-select" class="picker"></select>
</label>
<p class="host-empty" id="freq-host-empty" hidden></p>
<div class="nav">
<button class="ghost" data-go="home">Cancel</button>
</div>
</section>
<!-- --------------------------------------------------- signed in -->
<section class="screen screen-done" id="screen-done-in">
<p class="mark">Signed in</p>
<h2 id="done-in-message"></h2>
<p class="hint" id="done-in-detail"></p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
<button class="ghost" id="reprint-badge" hidden>Print the badge again</button>
</div>
</section>
<!-- ------------------------------------------------- sign out: find -->
<section class="screen" id="screen-signout-find">
<h2>Signing out</h2>
<label class="field">
<span>Last name</span>
<input id="out-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
</label>
<label class="field">
<span>Mobile number or email</span>
<input id="out-contact" autocomplete="off" enterkeyhint="go">
</label>
<div class="nav">
<button class="ghost" data-go="home">Back</button>
<button class="primary" id="do-signout-find">Find my visit</button>
</div>
</section>
<!-- ------------------------------------------------ sign out: pick -->
<section class="screen" id="screen-signout-pick">
<h2>Is this you?</h2>
<div class="host-list" id="signout-list"></div>
<div class="nav">
<button class="ghost" data-go="signout-find">Back</button>
</div>
</section>
<!-- -------------------------------------------------- signed out -->
<section class="screen screen-done" id="screen-done-out">
<p class="mark">Signed out</p>
<h2 id="done-out-message"></h2>
<p class="hint">Thanks for visiting. Travel safely.</p>
<div class="nav">
<button class="primary" data-go="home">Done</button>
</div>
</section>
</main>
<p class="alert" id="alert" role="alert" hidden></p>
<footer class="foot">
<button class="foot-link" id="change-site" hidden></button>
</footer>
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
<script src="/js/kiosk.js"></script>
</body>
</html>
+1472
View File
File diff suppressed because it is too large Load Diff
+716
View File
@@ -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) =>
`<i class="${i < step ? 'done' : ''}"></i>`
).join('');
el.innerHTML = `${bars}<span>Step ${step} of ${TOTAL_STEPS}</span>`;
}
function show(name, { push = true } = {}) {
const next = document.getElementById(`screen-${name}`);
if (!next) return;
if (push && current !== name) history.push(current);
if (current === 'photo' && name !== 'photo') stopCamera();
$$('.screen').forEach((s) => s.classList.remove('on'));
next.classList.add('on');
current = name;
clearAlert();
drawRail(next);
window.scrollTo(0, 0);
const firstInput = next.querySelector('input');
if (firstInput && !('ontouchstart' in window)) firstInput.focus();
if (name === 'photo') startCamera();
if (name === 'home') resetState();
resetIdle();
}
function goBack() {
const previous = history.pop() || 'home';
show(previous, { push: false });
}
function resetState() {
Object.assign(state, {
mode: 'guest',
firstName: '',
lastName: '',
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 =
`<option value="">${escapeHtml(label)}</option>` +
matches
.map(
(h) =>
`<option value="${h.id}">${escapeHtml(h.name)}${h.area ? `${escapeHtml(h.area)}` : ''}</option>`
)
.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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
}
/* -------------------------------------------------------------- camera */
let stream = null;
async function startCamera() {
const video = $('#cam-video');
const err = $('#cam-error');
err.hidden = true;
$('#cam-shot').hidden = true;
video.hidden = false;
$('#cam-take').hidden = false;
$('#cam-retake').hidden = true;
$('#cam-use').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
state.photo = null;
if (stream) return;
try {
if (!navigator.mediaDevices?.getUserMedia) throw new Error('unsupported');
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
audio: false,
});
video.srcObject = stream;
} catch (e) {
const insecure = !window.isSecureContext;
err.hidden = false;
err.textContent = insecure
? 'The camera is blocked because this kiosk is not on a secure connection. Ask IT to serve the kiosk over HTTPS, then reload.'
: 'No camera is available on this device. Ask the front desk to sign you in.';
$('#cam-take').hidden = true;
$('#cam-skip').hidden = siteConfig.requirePhoto;
}
}
function stopCamera() {
if (!stream) return;
stream.getTracks().forEach((t) => t.stop());
stream = null;
$('#cam-video').srcObject = null;
}
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
? `<dt>Photo</dt><dd><img src="${state.photo}" alt="The photo you took"></dd>`
: '';
$('#review-list').innerHTML =
rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join('') + photoRow;
}
/* ------------------------------------------------------------ submits */
async function submitSignIn() {
// 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 = `<p class="host-empty">No sites are set up yet. An admin needs to add one first.</p>`;
} else {
list.innerHTML = sites
.map(
(s) =>
`<button class="host-option" data-site="${escapeHtml(s.slug)}">${escapeHtml(s.name)}</button>`
)
.join('');
list.querySelectorAll('[data-site]').forEach((btn) => {
btn.addEventListener('click', async () => {
rememberSite(btn.dataset.site);
await loadSiteContext();
show('home', { push: false });
});
});
}
show('site', { push: false });
}
/**
* 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) =>
`<button class="host-option" data-visit="${m.id}">
${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)}
<small>Visiting ${escapeHtml(m.hostName)} · in at ${new Date(m.signedInAt).toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })}</small>
</button>`
)
.join('');
list.querySelectorAll('[data-visit]').forEach((btn) => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const done = await api('/api/signout', { visitId: Number(btn.dataset.visit) });
$('#done-out-message').textContent = `Goodbye, ${done.firstName}.`;
show('done-out', { push: false });
setTimeout(() => {
if (current === 'done-out') show('home', { push: false });
}, 10000);
} catch (err) {
say(err.message);
btn.disabled = false;
}
});
});
show('signout-pick');
} catch (err) {
say(err.message);
} finally {
button.disabled = false;
}
});
/* Enter key moves the flow along on every screen. */
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
const screen = document.querySelector('.screen.on');
const primary = screen?.querySelector('.primary:not([hidden])');
if (primary) {
event.preventDefault();
primary.click();
}
});
/* -------------------------------------------------------------- start */
(async function init() {
try {
await loadSiteContext();
} catch {
/* fall through to the picker below */
}
// With one site the server resolves it for us; with several, ask once.
if (!siteConfig.siteChosen) {
await chooseSite();
return;
}
show('home', { push: false });
})();
+282
View File
@@ -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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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) => `<i class="${i <= index ? 'done' : ''}"></i>`).join('') +
`<span>Step ${index + 1} of ${flow.length}</span>`;
}
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) => `<li>${esc(c)}</li>`).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');
})();
+96
View File
@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Sign in — visitor admin</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/css/login.css">
</head>
<body class="auth">
<div class="auth-card">
<header class="auth-head">
<button type="button" class="auth-back" id="auth-back" hidden aria-label="Go back">&#8592;</button>
<p class="auth-site" id="auth-site">Visitor admin</p>
<div class="auth-rail" id="auth-rail" hidden></div>
<h1 id="auth-title">Sign in</h1>
<p class="auth-sub" id="auth-subtitle"></p>
</header>
<div class="auth-body" id="auth-body">
<form class="auth-screen" id="step-password">
<label>
<span>Email address</span>
<input type="email" id="login-email" name="email" autocomplete="username" autocapitalize="none" required>
</label>
<label>
<span>Password</span>
<input type="password" id="login-password" name="password" autocomplete="current-password" required>
</label>
<p class="hint" id="domain-rule" hidden></p>
<button type="submit">Continue</button>
</form>
<form class="auth-screen" id="step-2fa" hidden>
<div id="twofa-setup" hidden>
<img id="twofa-qr" alt="Two factor setup QR code" width="180" height="180">
<details class="auth-details">
<summary>Can't scan it?</summary>
<p class="hint">Type this key into your authenticator app instead:</p>
<p><code id="twofa-secret"></code></p>
</details>
</div>
<label>
<span id="twofa-label">6 digit code</span>
<input id="twofa-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="12" required>
</label>
<button type="submit">Verify</button>
</form>
<div class="auth-screen" id="step-recovery" hidden>
<ul class="recovery" id="recovery-list"></ul>
<div class="auth-row">
<button type="button" class="secondary" id="recovery-copy">Copy codes</button>
<button type="button" id="recovery-done">I've saved them</button>
</div>
</div>
<form class="auth-screen" id="step-newpassword" hidden>
<!-- Hidden but present: password managers and screen readers need to know
which account the new password belongs to. -->
<input type="text" id="pw-username" name="username" autocomplete="username"
tabindex="-1" aria-hidden="true" class="visually-hidden" readonly>
<label>
<span>Current password</span>
<input type="password" id="pw-current" name="current-password" autocomplete="current-password" required>
</label>
<label>
<span>New password</span>
<input type="password" id="pw-new" name="new-password" autocomplete="new-password" required>
</label>
<label>
<span>New password again</span>
<input type="password" id="pw-again" name="confirm-password" autocomplete="new-password" required>
</label>
<p class="hint">At least 12 characters, with upper and lower case and a number.</p>
<button type="submit">Save and continue</button>
</form>
<div class="auth-screen" id="step-setup" hidden>
<p class="hint" id="setup-message"></p>
</div>
</div>
<p class="err" id="login-error" role="alert" hidden></p>
</div>
<p class="auth-foot">
<a href="/">Back to the kiosk</a>
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
</p>
<script src="/js/login.js"></script>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
@echo off
REM Double-click friendly wrapper around push-to-gitea.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0push-to-gitea.ps1"
pause
+42
View File
@@ -0,0 +1,42 @@
# Pushes this folder into the Gitea repo created for it.
# Run from PowerShell, inside the visitor-signin folder:
# .\push-to-gitea.ps1
# If Windows blocks it: powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1
$ErrorActionPreference = 'Stop'
$Remote = 'https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git'
if (-not (Test-Path 'package.json')) {
Write-Error 'Run this from inside the visitor-signin folder.'
exit 1
}
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Write-Error 'Git is not installed or not on PATH. Install it from https://git-scm.com/download/win'
exit 1
}
# Keep line endings sane between Windows and the Ubuntu docker host.
git config --global core.autocrlf input | Out-Null
if (-not (Test-Path '.git')) {
git init -b main
} else {
Write-Host 'This folder is already a git repo, adding a commit to it.'
}
git add .
$message = 'Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA'
git commit -m $message
if (git remote | Select-String -Quiet '^origin$') {
git remote set-url origin $Remote
} else {
git remote add origin $Remote
}
git push -u origin main
Write-Host ''
Write-Host 'Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin' -ForegroundColor Green
Write-Host 'Sign in with your Gitea username and password, or a token as the password.'
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Pushes this folder into the (empty) Gitea repo created for it.
# Run once from inside the extracted visitor-signin folder: ./push-to-gitea.sh
set -euo pipefail
REMOTE="https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git"
if [ ! -f package.json ]; then
echo "Run this from inside the visitor-signin folder." >&2
exit 1
fi
git init -b main
git add .
git commit -m "Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA"
git remote add origin "$REMOTE" 2>/dev/null || git remote set-url origin "$REMOTE"
git push -u origin main
echo
echo "Pushed. Repo: https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin"
+32
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
// Container healthcheck. Works whether the app is serving http or self-signed https.
const secure = ['1', 'true', 'yes', 'on'].includes(String(process.env.HTTPS_ENABLED).toLowerCase());
const port = process.env.PORT || 3000;
const url = `${secure ? 'https' : 'http'}://127.0.0.1:${port}/healthz`;
if (secure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
process.exit(res.ok ? 0 : 1);
} catch {
process.exit(1);
}
+22
View File
@@ -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);
}
+147
View File
@@ -0,0 +1,147 @@
import crypto from 'node:crypto';
/* ------------------------------------------------------------ passwords */
// scrypt is built into Node, so there is no native module to compile in the image.
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 64, { N: 16384, r: 8, p: 1 });
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export function verifyPassword(password, stored) {
try {
const [scheme, saltB64, hashB64] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const expected = Buffer.from(hashB64, 'base64');
const actual = crypto.scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
N: 16384,
r: 8,
p: 1,
});
return crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
export function passwordProblem(password) {
const value = String(password || '');
if (value.length < 12) return 'Use at least 12 characters.';
if (!/[a-z]/.test(value) || !/[A-Z]/.test(value)) return 'Mix upper and lower case.';
if (!/\d/.test(value)) return 'Include at least one number.';
return null;
}
export function randomPassword() {
// Readable enough to hand over verbally, still 60+ bits of entropy.
const words = crypto.randomBytes(9).toString('base64url').replace(/[-_]/g, '');
return `Vs${words}9`;
}
/* ----------------------------------------------------------- base32 */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buffer) {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input) {
const clean = String(input).toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const bytes = [];
for (const char of clean) {
value = (value << 5) | ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/* -------------------------------------------------------------- TOTP */
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20));
}
function hotp(secretBuffer, counter) {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secretBuffer).update(buf).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(code % 1_000_000).padStart(6, '0');
}
export function totpCode(secret, atMs = Date.now(), stepSeconds = 30) {
return hotp(base32Decode(secret), Math.floor(atMs / 1000 / stepSeconds));
}
/** Allows one step either side, which covers a phone clock that has drifted a little. */
export function verifyTotp(secret, token, window = 1) {
const candidate = String(token || '').replace(/\D/g, '');
if (candidate.length !== 6) return false;
const counter = Math.floor(Date.now() / 1000 / 30);
const buffer = base32Decode(secret);
for (let drift = -window; drift <= window; drift += 1) {
const expected = hotp(buffer, counter + drift);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) return true;
}
return false;
}
export function otpauthUrl({ secret, email, issuer }) {
const label = encodeURIComponent(`${issuer}:${email}`);
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' });
return `otpauth://totp/${label}?${params}`;
}
/* --------------------------------------------------- recovery codes */
export function generateRecoveryCodes(count = 8) {
return Array.from({ length: count }, () =>
crypto.randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2')
);
}
const digest = (code) =>
crypto.createHash('sha256').update(String(code).toLowerCase().replace(/[^a-z0-9]/g, '')).digest('hex');
export function hashRecoveryCodes(codes) {
return JSON.stringify(codes.map(digest));
}
/** Returns the remaining codes if one matched, or null. Used codes are burnt. */
export function consumeRecoveryCode(storedJson, candidate) {
try {
const hashes = JSON.parse(storedJson || '[]');
const target = digest(candidate);
const index = hashes.indexOf(target);
if (index === -1) return null;
hashes.splice(index, 1);
return JSON.stringify(hashes);
} catch {
return null;
}
}
+166
View File
@@ -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 */
}
}
}
+96
View File
@@ -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;
+294
View File
@@ -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;
+89
View File
@@ -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;
}
+70
View File
@@ -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.'
);
}
+360
View File
@@ -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));
});
}
+1245
View File
File diff suppressed because it is too large Load Diff
+398
View File
@@ -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;
+183
View File
@@ -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();
+286
View File
@@ -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));
}
+227
View File
@@ -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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]
);
/**
* A self-contained print page sized to the site's label stock. It calls print()
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
*/
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
const width = Number(site.badge_width_mm) || 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 ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
const details = `
<div class="rows">
<div>Visiting <b>${esc(visit.host_name)}</b></div>
<div>In at <b>${esc(
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
<div>${
noCheck
? '<span class="flag">No WWCC / VIT</span>'
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
}</div>
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
</div>`;
return `<!doctype html>
<html lang="en-AU">
<head>
<meta charset="utf-8">
<title>Badge — ${esc(visit.first_name)} ${esc(visit.last_name)}</title>
<style>
@page { size: ${width}mm ${height}mm; margin: 0; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #fff; }
.badge {
width: ${width}mm;
height: ${height}mm;
padding: ${pad}mm;
display: flex;
flex-direction: ${portrait ? 'column' : 'row'};
align-items: center;
${portrait ? 'justify-content: center;' : ''}
text-align: ${portrait ? 'center' : 'left'};
gap: ${unit * 0.05}mm;
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
color: #000;
overflow: hidden;
}
.photo {
width: ${photoWidth}mm;
height: ${photoWidth}mm;
flex: 0 0 auto;
align-self: center;
object-fit: cover;
border: 0.3mm solid #000;
}
.body {
flex: ${portrait ? '0 1 auto' : '1 1 auto'};
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
${portrait ? 'align-items: center;' : ''}
}
.site {
width: 100%;
font-size: ${bodySize * 0.8}mm;
letter-spacing: 0.03em;
text-transform: uppercase;
color: ${accent};
border-bottom: 0.35mm solid ${accent};
padding-bottom: ${unit * 0.02}mm;
margin-bottom: ${unit * 0.035}mm;
}
.name {
font-size: ${nameSize}mm;
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.rows {
/* Portrait badges centre the whole block; wider ones push the detail rows to
the bottom edge, which is where the eye expects them beside a photo. */
margin-top: ${portrait ? `${unit * 0.05}mm` : 'auto'};
padding-top: ${unit * 0.04}mm;
font-size: ${bodySize}mm;
/* 1.3 rather than 1.35: on a 62 x 90 mm label a two-line name plus a custom
footer line leaves very little room, and the difference is not visible. */
line-height: 1.3;
}
.rows b { font-weight: 700; }
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
.flag {
display: inline-block;
padding: 0 ${unit * 0.03}mm;
border: 0.35mm solid ${accent};
color: ${accent};
font-weight: 700;
font-size: ${bodySize * 0.9}mm;
}
@media screen {
body { background: #e7ecf0; padding: 12mm; }
.badge { background: #fff; box-shadow: 0 2mm 6mm rgba(0,0,0,.2); margin: 0 auto; }
}
</style>
</head>
<body>
<div class="badge">
${photo}
<div class="body">
<div class="site">${esc(site.name)} &middot; Visitor</div>
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
${details}
</div>
</div>
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
</body>
</html>`;
}
export { esc as escapeHtml, localStamp };
+245
View File
@@ -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();
}
+121
View File
@@ -0,0 +1,121 @@
import db from './db.js';
import config from './config.js';
import { hashPassword, randomPassword } from './auth.js';
import { nowIso } from './util.js';
const LOCKOUT_FAILS = 6;
const LOCKOUT_MINUTES = 15;
export function domainAllowed(email) {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return true;
const domain = String(email).split('@')[1]?.toLowerCase() || '';
return allowed.some((d) => domain === d || domain.endsWith(`.${d}`));
}
export function domainRuleText() {
const allowed = config.admin.allowedDomains;
if (!allowed.length) return null;
return allowed.map((d) => `@${d}`).join(' or ');
}
export function findByEmail(email) {
return db
.prepare('SELECT * FROM admin_users WHERE email = ?')
.get(String(email).trim().toLowerCase());
}
export function findById(id) {
return db.prepare('SELECT * FROM admin_users WHERE id = ?').get(id);
}
export function countActive() {
return db.prepare('SELECT COUNT(*) AS n FROM admin_users WHERE active = 1').get().n;
}
export function shape(user) {
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
siteId: user.site_id,
twoFactorOn: Boolean(user.totp_enabled),
mustChangePassword: Boolean(user.must_change_password),
active: Boolean(user.active),
lastLoginAt: user.last_login_at,
createdAt: user.created_at,
};
}
export function createUser({ email, name, password, role = 'admin', siteId = null, mustChange = true }) {
const clean = String(email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error('Enter a valid email address.');
if (!domainAllowed(clean)) {
throw new Error(`Admin accounts must use an ${domainRuleText()} address.`);
}
if (findByEmail(clean)) throw new Error('An account already uses that email address.');
const temp = password || randomPassword();
const info = db
.prepare(
`INSERT INTO admin_users (email, name, password_hash, role, site_id, must_change_password)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(clean, String(name || '').trim() || null, hashPassword(temp), role, siteId, mustChange ? 1 : 0);
return { user: findById(info.lastInsertRowid), temporaryPassword: password ? null : temp };
}
/* ------------------------------------------------------------- lockout */
export function lockState(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
if (row?.locked_until && row.locked_until > nowIso()) return row;
return null;
}
export function noteFailure(email) {
const row = db.prepare('SELECT * FROM login_attempts WHERE email = ?').get(email);
const fails = (row?.fails || 0) + 1;
const lockedUntil =
fails >= LOCKOUT_FAILS ? new Date(Date.now() + LOCKOUT_MINUTES * 60000).toISOString() : null;
db.prepare(
`INSERT INTO login_attempts (email, fails, locked_until) VALUES (?, ?, ?)
ON CONFLICT(email) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until`
).run(email, fails, lockedUntil);
return { fails, lockedUntil };
}
export function clearFailures(email) {
db.prepare('DELETE FROM login_attempts WHERE email = ?').run(email);
}
/* ----------------------------------------------------------- bootstrap */
/** Creates the very first admin account from the environment, once. */
export function bootstrap() {
if (countActive() > 0) return;
const { bootstrapEmail, bootstrapPassword } = config.admin;
if (!bootstrapEmail || !bootstrapPassword) {
console.warn(
'[users] No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD\n' +
' in .env and restart to create the first one.'
);
return;
}
try {
createUser({
email: bootstrapEmail,
name: 'First admin',
password: bootstrapPassword,
role: 'owner',
mustChange: true,
});
console.log(`[users] created the first admin account: ${bootstrapEmail}`);
console.log('[users] you will be asked to set a new password at first sign in');
} catch (err) {
console.error('[users] could not create the first admin account:', err.message);
}
}
+113
View File
@@ -0,0 +1,113 @@
import config from './config.js';
export function normalisePhone(input) {
if (!input) return '';
let digits = String(input).replace(/[^\d+]/g, '');
if (digits.startsWith('+61')) digits = '0' + digits.slice(3);
else if (digits.startsWith('61') && digits.length === 11) digits = '0' + digits.slice(2);
return digits.replace(/\+/g, '');
}
export function normaliseEmail(input) {
return String(input || '').trim().toLowerCase();
}
export function isEmail(value) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value || '').trim());
}
export function isPhone(value) {
const d = normalisePhone(value);
return d.length >= 8 && d.length <= 15;
}
export function clean(value, max = 200) {
return String(value ?? '').trim().slice(0, max);
}
export function titleCase(value, max = 200) {
return clean(value, max).replace(/\b\p{L}/gu, (c) => c.toUpperCase());
}
const dateFormatter = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
dateStyle: 'short',
timeStyle: 'medium',
hour12: false,
});
export function localStamp(isoString) {
if (!isoString) return '';
return dateFormatter.format(new Date(isoString));
}
export function localHm(date = new Date()) {
const parts = new Intl.DateTimeFormat('en-AU', {
timeZone: config.timezone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).formatToParts(date);
const hour = parts.find((p) => p.type === 'hour').value;
const minute = parts.find((p) => p.type === 'minute').value;
return `${hour}:${minute}`;
}
export function nowIso() {
return new Date().toISOString();
}
/** Minimal RFC4180-ish CSV parser: handles quoted fields, embedded commas and newlines. */
export function parseCsv(text) {
const rows = [];
let row = [];
let field = '';
let inQuotes = false;
const src = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
for (let i = 0; i < src.length; i += 1) {
const ch = src[i];
if (inQuotes) {
if (ch === '"') {
if (src[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ',') {
row.push(field);
field = '';
} else if (ch === '\n') {
row.push(field);
rows.push(row);
row = [];
field = '';
} else {
field += ch;
}
}
if (field.length || row.length) {
row.push(field);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c.trim() !== ''));
}
export function toCsv(rows) {
return rows
.map((row) =>
row
.map((cell) => {
const value = cell === null || cell === undefined ? '' : String(cell);
return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
})
.join(',')
)
.join('\r\n');
}