Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user