Public Access
271 lines
12 KiB
Markdown
271 lines
12 KiB
Markdown
# 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)
|