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,6 @@
|
||||
node_modules
|
||||
data
|
||||
secrets
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
data/
|
||||
secrets/
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.log
|
||||
.DS_Store
|
||||
+42
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
|
@@ -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"
|
||||
}
|
||||
@@ -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 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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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 |
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 });
|
||||
})();
|
||||
@@ -0,0 +1,282 @@
|
||||
/* Visitor sign in — admin login page.
|
||||
*
|
||||
* A page of its own, not a panel hidden inside the console. When it finishes it
|
||||
* navigates to /admin, so the console loads fresh with no login markup in it.
|
||||
*/
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const esc = (v) =>
|
||||
String(v ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const res = await fetch(`/admin/api${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = $('#login-error');
|
||||
el.textContent = message || '';
|
||||
el.hidden = !message;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- screens */
|
||||
|
||||
const SCREENS = {
|
||||
'step-password': {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Use the email address your account was set up with.',
|
||||
focus: '#login-email',
|
||||
},
|
||||
'step-2fa-verify': {
|
||||
node: 'step-2fa',
|
||||
title: 'Two factor',
|
||||
subtitle: 'Enter the current code from your authenticator app.',
|
||||
focus: '#twofa-code',
|
||||
back: true,
|
||||
},
|
||||
'step-2fa-setup': {
|
||||
node: 'step-2fa',
|
||||
title: 'Set up two factor',
|
||||
subtitle:
|
||||
'Scan this with Google Authenticator, Authy, 1Password or similar, then enter the code it shows.',
|
||||
focus: '#twofa-code',
|
||||
back: true,
|
||||
},
|
||||
'step-recovery': {
|
||||
title: 'Recovery codes',
|
||||
subtitle:
|
||||
'Each works once, if you lose the phone with your authenticator on it. Save them now — they are not shown again.',
|
||||
},
|
||||
'step-newpassword': {
|
||||
title: 'Choose a password',
|
||||
subtitle: 'Set one only you know before you continue.',
|
||||
focus: '#pw-current',
|
||||
},
|
||||
'step-setup': {
|
||||
title: 'Not set up yet',
|
||||
subtitle: 'No admin account exists on this server.',
|
||||
},
|
||||
};
|
||||
|
||||
let flow = ['step-password'];
|
||||
let currentKey = 'step-password';
|
||||
let recoveryCodes = [];
|
||||
|
||||
function setFlow(steps) {
|
||||
flow = steps;
|
||||
}
|
||||
|
||||
function renderRail(key) {
|
||||
const rail = $('#auth-rail');
|
||||
const index = flow.indexOf(key);
|
||||
if (flow.length < 2 || index < 0) {
|
||||
rail.hidden = true;
|
||||
return;
|
||||
}
|
||||
rail.hidden = false;
|
||||
rail.innerHTML =
|
||||
flow.map((_, i) => `<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');
|
||||
})();
|
||||
@@ -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">←</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>
|
||||
@@ -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
|
||||
@@ -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.'
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,227 @@
|
||||
import db from './db.js';
|
||||
import { clean, localStamp } from './util.js';
|
||||
import { themeFor } from './branding.js';
|
||||
|
||||
export function listSites({ activeOnly = false } = {}) {
|
||||
const sql = `SELECT * FROM sites ${activeOnly ? 'WHERE active = 1' : ''} ORDER BY name COLLATE NOCASE`;
|
||||
return db.prepare(sql).all();
|
||||
}
|
||||
|
||||
export function getSite(idOrSlug) {
|
||||
if (idOrSlug === undefined || idOrSlug === null || idOrSlug === '') return null;
|
||||
const asNumber = Number(idOrSlug);
|
||||
if (Number.isInteger(asNumber) && String(asNumber) === String(idOrSlug)) {
|
||||
return db.prepare('SELECT * FROM sites WHERE id = ?').get(asNumber) || null;
|
||||
}
|
||||
return db.prepare('SELECT * FROM sites WHERE slug = ?').get(String(idOrSlug).toLowerCase()) || null;
|
||||
}
|
||||
|
||||
/** Falls back to the only active site, which keeps single-site installs simple. */
|
||||
export function resolveSite(idOrSlug) {
|
||||
const found = getSite(idOrSlug);
|
||||
if (found && found.active) return found;
|
||||
const active = listSites({ activeOnly: true });
|
||||
return active.length === 1 ? active[0] : found || null;
|
||||
}
|
||||
|
||||
export function slugify(value) {
|
||||
return clean(value, 60)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function uniqueSlug(base, excludeId = null) {
|
||||
let slug = slugify(base) || 'site';
|
||||
let n = 2;
|
||||
while (true) {
|
||||
const clash = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
|
||||
if (!clash || clash.id === excludeId) return slug;
|
||||
slug = `${slugify(base)}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function shapeSite(site) {
|
||||
return {
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
slug: site.slug,
|
||||
active: Boolean(site.active),
|
||||
branding: {
|
||||
hasBanner: Boolean(site.banner_path),
|
||||
bannerHeight: site.banner_height || 64,
|
||||
bannerAlign: site.banner_align || 'left',
|
||||
brand: site.colour_brand,
|
||||
signout: site.colour_signout,
|
||||
page: site.colour_page,
|
||||
text: site.colour_text,
|
||||
theme: themeFor(site),
|
||||
},
|
||||
printer: {
|
||||
enabled: Boolean(site.printer_enabled),
|
||||
host: site.printer_host,
|
||||
port: site.printer_port || 9100,
|
||||
model: site.printer_model || 'QL-820NWB',
|
||||
rotate: site.printer_rotate || 0,
|
||||
},
|
||||
badge: {
|
||||
enabled: Boolean(site.badge_enabled),
|
||||
widthMm: site.badge_width_mm,
|
||||
heightMm: site.badge_height_mm,
|
||||
showPhoto: Boolean(site.badge_show_photo),
|
||||
accent: Boolean(site.badge_accent),
|
||||
note: site.badge_note,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- badge */
|
||||
|
||||
const esc = (value) =>
|
||||
String(value ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
|
||||
/**
|
||||
* A self-contained print page sized to the site's label stock. It calls print()
|
||||
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
|
||||
*/
|
||||
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
|
||||
const width = Number(site.badge_width_mm) || 62;
|
||||
const height = Number(site.badge_height_mm) || 100;
|
||||
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
|
||||
|
||||
// A label noticeably taller than it is wide gets a stacked layout. That is the
|
||||
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
|
||||
// fixes the width and the length runs down the badge.
|
||||
const portrait = height >= width * 1.2;
|
||||
|
||||
// Type scales with the dimension that constrains it: the width on a portrait
|
||||
// badge, the shorter side on a wide one. Keeps small stock legible.
|
||||
const unit = portrait ? width : Math.min(width, height);
|
||||
const pad = unit * 0.07;
|
||||
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
|
||||
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
|
||||
// Square, matching the crop taken at the kiosk.
|
||||
const photoWidth = portrait ? unit * 0.52 : unit * 0.5;
|
||||
|
||||
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
|
||||
// else it prints as grey, so it is off unless the site opts in.
|
||||
const accent = site.badge_accent ? '#d00019' : '#000';
|
||||
const timeIn = new Date(visit.signed_in_at);
|
||||
const noCheck = visit.check_type === 'NONE';
|
||||
|
||||
const photo = showPhoto ? `<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)} · 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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user