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

This commit is contained in:
2026-08-31 09:47:36 +10:00
commit ed77493817
32 changed files with 5799 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
# Visitor sign in
A self-hosted visitor kiosk for sites that need a WWCC/VIT record and a photo at the door.
Runs in one Docker container, stores everything locally in SQLite, and mirrors every sign in
and sign out to a Google Sheet so someone outside the building can see who is on site during
an evacuation.
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out.
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
visitor can ask for, sites, admin accounts, and system status.
Handles several sites from one container, prints a badge after sign in if you want one,
warns admins before a WWCC or VIT lapses, and gives each admin their own account with
two factor.
## What it collects
| | Guest sign in | Recurring visitor |
|---|---|---|
| First and last name | typed each visit | on file |
| Person being visited | picked from the list | picked each visit |
| Photo | taken at the kiosk | taken at the kiosk |
| WWCC / VIT / none | typed each visit | on file |
| Mobile and/or email | at least one required | on file |
Sign out only needs a **last name** plus a **mobile number or email**, which works for both.
## Quick start
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin
cp .env.example .env
# Generate a secret and set a real admin password before you start.
openssl rand -hex 32 # paste into APP_SECRET
$EDITOR .env
docker compose up -d --build
```
The kiosk is then on `http://<docker-host>:8088` and the admin console on
`http://<docker-host>:8088/admin`.
Set `ADMIN_BOOTSTRAP_EMAIL` and `ADMIN_BOOTSTRAP_PASSWORD` in `.env` before the first start —
they create the first admin account, once. You'll be asked to enrol two factor and set a real
password at first sign in.
Then, in the admin console:
1. **Sites** → rename the first site, add more if you have them, and turn badge printing on.
2. **People to visit** → pick a site, then paste or upload your staff CSV
(see `docs/hosts-sample.csv`).
3. **Recurring visitors** → add anyone who comes regularly. A PIN is generated and a printable
card opens straight away.
4. **Admins** → invite the rest of the front office.
## Working from Windows, deploying to Ubuntu
Develop on Windows, run the container on the Ubuntu host. Two scripts are included:
```powershell
# in PowerShell, inside the visitor-signin folder
.\push-to-gitea.ps1
```
Or double-click `push-to-gitea.bat`. If PowerShell blocks the script, run
`powershell -ExecutionPolicy Bypass -File .\push-to-gitea.ps1`. On the Ubuntu host,
`./push-to-gitea.sh` does the same thing.
`.gitattributes` forces LF endings for everything except the `.ps1` and `.bat` helpers, so the
shell scripts and the Dockerfile still work after a round trip through a Windows checkout —
otherwise `gen-cert.sh` fails on the host with a confusing `\r: command not found`.
Deploy on the Ubuntu host with:
```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/visitor-signin.git
cd visitor-signin && cp .env.example .env && nano .env
docker compose up -d --build
```
`docker compose` on Windows works too if you have Docker Desktop, but the camera, printing and
the `data/` permissions all behave more predictably on the Ubuntu host.
## Several sites, one container
Each site has its own name, its own list of people to visit, and its own badge settings.
Recurring visitors are either tied to one site or welcome at all of them.
Point a kiosk at a site once, during setup, with `http://host:8088/?site=front-gate`. The tablet
remembers the choice, so the address bar no longer matters. If you skip that, the kiosk asks
which site it is on first use — and with only one site set up, it never asks at all.
Sign in, sign out and the "already signed in" check are all scoped to the kiosk's site, so the
same person can be signed in at two sites at once without the system arguing about it.
An admin account can be limited to a single site. Those admins see only that site's visitors,
staff list and log, and cannot touch the others.
## Badge printing
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
the site, name, who they are visiting, time in, WWCC/VIT status (or a boxed **No WWCC / VIT**),
the photo if you want it, and an optional line of your own text.
Set the label size in millimetres to match your stock. Type scales with the smaller dimension,
so small labels stay legible. Common sizes:
| Stock | mm |
|---|---|
| Card size | 86 × 54 |
| Brother DK-11202 shipping | 100 × 62 |
| Brother DK-11209 small address | 62 × 29 |
| Dymo 99014 shipping | 101 × 54 |
Use **Preview badge** to check the layout in a browser before committing a roll to it. The kiosk
browser needs the label printer set as its default, with margins off and scaling at 100%.
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
button on the confirmation screen if the first one jams.
## WWCC and VIT expiry warnings
Give a recurring visitor an expiry date and the console watches it. Inside
`EXPIRY_WARNING_DAYS` (28 by default) their row turns amber; past the date it turns red, a count
appears on the **Recurring visitors** tab, and a banner sits across the top of every screen.
Nothing is blocked automatically — an expired check is a conversation to have at the desk, not
a door the software should slam. Site-scoped admins only see warnings for their own site.
## Admin accounts and two factor
Each admin signs in with their own email address and password.
- **Roles.** *Owner* manages admins and sites. *Admin* handles day to day work, optionally
limited to one site.
- **Two factor.** With `ADMIN_REQUIRE_2FA=true` (the default) every admin enrols an
authenticator app at first sign in — a QR code appears, they scan it, and eight one-shot
recovery codes are issued. Standard TOTP, so Google Authenticator, Authy, 1Password,
Bitwarden and the rest all work.
- **Domain limits.** Set `ADMIN_ALLOWED_DOMAINS=yourschool.vic.edu.au` and both invitations and
sign in refuse anything else. Subdomains of a listed domain are accepted.
- **Recovery.** An owner can reset another admin's password (a temporary one is shown on
screen, and they must change it at next sign in) or clear their two factor so they can
re-enrol on a new phone.
- **Lockout.** Six wrong passwords locks that email address for 15 minutes.
If every owner loses access, stop the container, clear the `admin_users` table with
`sqlite3 data/visitors.db "DELETE FROM admin_users;"`, and start it again — the bootstrap
account is recreated from `.env`.
## The camera needs HTTPS
Browsers refuse camera access on a plain `http://` address unless it is `localhost`. On an
internal IP the kiosk will show a message telling the visitor the camera is blocked. Pick one:
**Option A — reverse proxy (best if you already run one).** Terminate TLS at Nginx Proxy
Manager, Traefik, or Caddy and point it at the container. Then set `TRUST_PROXY=true` and
`SECURE_COOKIES=true` in `.env`.
**Option B — self-signed certificate in the container.**
```bash
./scripts/gen-cert.sh visitors.local 192.168.1.50 # your hostname, then any IPs
# set HTTPS_ENABLED=true in .env
docker compose restart
```
Then install `data/certs/server.crt` as a trusted root certificate on each kiosk tablet,
otherwise the browser warning appears every morning.
**Option C — run the browser on the same machine as the container** and point it at
`http://localhost:8088`. No certificate needed.
## Google Sheet mirroring
Every sign in and sign out appends a row. If Google is unreachable the row is queued in the
database and retried every minute, so a dropped internet connection never blocks the front desk.
1. In [Google Cloud Console](https://console.cloud.google.com/), create a project and enable
the **Google Sheets API**.
2. Create a **service account**, then create a **JSON key** for it and download the file.
3. Create the spreadsheet you want to use. **Share it with the service account's email address**
(it ends in `.iam.gserviceaccount.com`) with **Editor** access. This step is the one people
forget — without it every write returns a permission error.
4. Copy the spreadsheet id out of the URL:
`docs.google.com/spreadsheets/d/`**`THIS_PART`**`/edit`.
5. Put the key file at `./secrets/google-service-account.json` (compose mounts `./secrets`
read-only), then in `.env`:
```
SHEETS_ENABLED=true
SHEETS_SPREADSHEET_ID=THIS_PART
SHEETS_TAB_NAME=Visitor log
GOOGLE_CREDENTIALS_PATH=/secrets/google-service-account.json
```
If you would rather not mount a file, base64 the key instead —
`base64 -w0 key.json` — and put the result in `GOOGLE_CREDENTIALS_B64`.
6. Restart, then **Admin → System → Test the sheet connection**. The header row is written
automatically the first time.
A tab name with spaces is fine. The sheet is a mirror, not the source of truth — nothing reads
back from it. Every row carries the site name, so one sheet covers all sites; filter by the
**Site** column during an evacuation.
## Recurring visitors and PINs
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
15 minutes.
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
PINs from the admin console afterwards.
## Where the data lives
Everything is under `./data` on the docker host:
```
data/
├── visitors.db SQLite: sites, visits, recurring visitors, hosts, admins, retry queue
├── visitors.db-wal write-ahead log — back this up alongside the .db
└── photos/2026/08/ JPEGs, foldered by year and month
```
Photos never leave the host. The sheet records only whether a photo exists. They are deleted
automatically after `PHOTO_RETENTION_DAYS` (90 by default), and only the admin console can view
them.
To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz data/ && docker compose start`.
## Settings worth knowing
| Variable | Does what |
|---|---|
| `REQUIRE_PHOTO` | `false` lets a visitor continue if the camera fails |
| `AUTO_SIGNOUT_TIME` | e.g. `18:30` — closes off anyone still shown as on site. Blank to disable |
| `PHOTO_RETENTION_DAYS` | `0` keeps photos forever |
| `HOST_PORT` | port published on the docker host, default `8088` |
| `EXPIRY_WARNING_DAYS` | how far ahead to warn about a WWCC or VIT, default `28` |
| `ADMIN_REQUIRE_2FA` | `false` makes two factor optional per admin |
| `ADMIN_ALLOWED_DOMAINS` | comma separated; blank allows any email domain |
The kiosk returns to the home screen after two minutes of inactivity so the next visitor never
sees the last one's details.
## Running without Docker
```bash
npm install
DATA_DIR=./data APP_SECRET=$(openssl rand -hex 32) ADMIN_PASSWORD=secret npm start
```
Node 20 or newer.
## A note on evacuation use
The Google Sheet is the offsite copy, but it only helps if someone can open it on a phone during
an evacuation. Bookmark it on the relevant phones, check it after setup, and check it again
occasionally — a service account key that has been revoked will queue rows silently until
someone looks at **Admin → System**.
---
Created by: Jess Rogerson (yelling commands at Claude.AI)