diff --git a/Dockerfile b/Dockerfile
index 2f751e4..b4c38c4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,7 +11,7 @@ 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 \
+ && apt-get install -y --no-install-recommends openssl ca-certificates tini util-linux \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -20,14 +20,19 @@ COPY package.json ./
COPY src ./src
COPY public ./public
COPY scripts ./scripts
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
-RUN mkdir -p /data/photos && chown -R node:node /data /app
-USER node
+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", "--"]
+ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "src/server.js"]
diff --git a/README.md b/README.md
index 2238eee..562e44a 100644
--- a/README.md
+++ b/README.md
@@ -319,6 +319,28 @@ To back up: `docker compose stop && tar czf visitor-backup-$(date +%F).tar.gz da
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`.
+
+**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
diff --git a/docker-compose.yml b/docker-compose.yml
index 90957f2..12f4a35 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,6 +2,9 @@ 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:
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100644
index 0000000..0b65973
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -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 "$@"
diff --git a/public/admin.html b/public/admin.html
index 4ba3028..1dbdd37 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -9,67 +9,78 @@
-
-
-
Visitor admin
+
+
+
+
+
+
Visitor admin
+
+
-
+
+
-
diff --git a/public/css/admin.css b/public/css/admin.css
index f02e5db..30301b8 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -55,36 +55,6 @@ button { cursor: pointer; }
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; }
-/* --------------------------------------------------------------- login */
-
-.login { display: grid; place-items: center; min-height: 100vh; padding: 20px; }
-.login-card {
- width: min(380px, 100%);
- padding: 30px;
- background: var(--card);
- border-radius: 3px;
- border-top: 5px solid var(--deep);
-}
-.login-card h1 { font-size: 22px; margin: 0 0 20px; }
-.login-card label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
-.login-card input {
- width: 100%;
- padding: 11px 12px;
- border: 1px solid var(--rule);
- border-radius: 3px;
- margin-bottom: 18px;
-}
-.login-card button {
- width: 100%;
- padding: 12px;
- border: 1px solid var(--deep);
- border-radius: 3px;
- background: var(--deep);
- color: #fff;
- font-weight: 600;
-}
-.err { color: var(--alert); margin: 14px 0 0; font-size: 14px; }
-
/* -------------------------------------------------------------- chrome */
.topbar {
@@ -244,62 +214,6 @@ dialog::backdrop { background: rgba(22, 32, 43, 0.45); }
.foot { padding: 0 22px 26px; color: var(--muted); font-size: 12.5px; text-align: center; }
-/* -------------------------------------------- login steps, 2FA, extras */
-
-.login-step { display: block; }
-.login-step label { display: block; }
-.login-step label span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 14px; }
-.login-step input {
- width: 100%;
- padding: 11px 12px;
- border: 1px solid var(--rule);
- border-radius: 3px;
- margin-bottom: 16px;
-}
-.login-step button[type="submit"],
-.login-step > button {
- width: 100%;
- padding: 12px;
- border: 1px solid var(--deep);
- border-radius: 3px;
- background: var(--deep);
- color: #fff;
- font-weight: 600;
-}
-.login-step .hint { margin: 0 0 16px; }
-.link-quiet {
- width: 100%;
- margin-top: 10px;
- border: none;
- background: none;
- color: var(--muted);
- text-decoration: underline;
- text-underline-offset: 3px;
- font-weight: 400;
-}
-#twofa-qr { display: block; margin: 0 auto 14px; border: 1px solid var(--rule); }
-#twofa-secret, code {
- font-family: ui-monospace, Menlo, Consolas, monospace;
- font-size: 13px;
- background: #eef1f4;
- padding: 1px 5px;
- border-radius: 2px;
-}
-#twofa-code { letter-spacing: 0.3em; text-align: center; font-size: 22px; }
-
-.recovery {
- list-style: none;
- margin: 0 0 18px;
- 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;
-}
-
/* ----------------------------------------------------- site switching */
.site-switch { display: flex; align-items: center; gap: 8px; color: #cfe0dd; font-size: 14px; }
@@ -380,3 +294,163 @@ tr.row-bad td { background: #fdf0f2; }
word-break: break-all;
}
#system-body h3 { margin-bottom: 14px; }
+
+/* ============================================================ sign in ==
+ The sign in flow is a series of small pages, not one long form. Each screen
+ gets the same fixed-width card, its own title, and an animated height change
+ so moving between them reads as turning a page.
+ ===================================================================== */
+
+.auth {
+ display: grid;
+ place-items: center;
+ align-content: center;
+ min-height: 100vh;
+ padding: 24px 20px 40px;
+}
+
+.auth-card {
+ width: min(400px, 100%);
+ padding: 28px 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: 22px; }
+.auth-head h1 { font-size: 22px; margin: 0 0 6px; }
+.auth-sub { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
+
+.auth-back {
+ position: absolute;
+ top: -4px;
+ 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); }
+
+/* Progress through the sequence, only shown when there is more than one page. */
+.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; }
+
+.auth-body {
+ overflow: hidden;
+ transition: height 220ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.auth-screen { display: block; }
+.auth-screen.entering { animation: auth-in 240ms cubic-bezier(0.2, 0, 0.2, 1); }
+
+@keyframes auth-in {
+ from { opacity: 0; transform: translateX(14px); }
+ to { opacity: 1; transform: none; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .auth-body { transition: none; }
+ .auth-screen.entering { animation: none; }
+}
+
+.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; }
+
+.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; }
+.auth-details code { word-break: break-all; display: inline-block; }
+
+.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;
+ color: var(--ink);
+ font-size: 14px;
+}
+
+.auth-foot { margin: 20px 0 0; color: var(--muted); font-size: 12.5px; text-align: center; }
diff --git a/public/js/admin.js b/public/js/admin.js
index 224e5ba..694de21 100644
--- a/public/js/admin.js
+++ b/public/js/admin.js
@@ -79,20 +79,130 @@ function loginError(message) {
el.hidden = !message;
}
-function loginStep(id) {
- $$('.login-step').forEach((s) => {
- s.hidden = s.id !== id;
- });
- loginError('');
- const input = document.getElementById(id)?.querySelector('input');
- if (input) input.focus();
+/* --------------------------------------------------------- auth screens */
+// The sign in flow is a sequence of small pages rather than one form that grows
+// as it goes. Each screen carries its own title and instruction, the header shows
+// where you are in the sequence, and the card animates between the two heights so
+// a tall screen like the QR code reads as a new page, not a form unfolding.
+
+const AUTH_SCREENS = {
+ 'step-password': {
+ title: 'Sign in',
+ subtitle: 'Use the email address your account was set up with.',
+ focus: '#login-email',
+ },
+ 'step-2fa-verify': {
+ screen: 'step-2fa',
+ title: 'Two factor',
+ subtitle: 'Enter the current code from your authenticator app.',
+ focus: '#twofa-code',
+ back: 'restart',
+ },
+ 'step-2fa-setup': {
+ screen: '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: 'restart',
+ },
+ 'step-recovery': {
+ title: 'Recovery codes',
+ subtitle: 'Each of these works once, if you ever lose the phone with your authenticator on it. Save them somewhere safe 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.',
+ },
+};
+
+// Which screens this particular sign in will pass through, so the header can say
+// "2 of 3" honestly rather than guessing.
+let authFlow = ['step-password'];
+let authCurrent = 'step-password';
+
+function setAuthFlow(steps) {
+ authFlow = steps;
}
+function renderAuthRail(key) {
+ const rail = $('#auth-rail');
+ const index = authFlow.indexOf(key);
+ if (authFlow.length < 2 || index < 0) {
+ rail.hidden = true;
+ return;
+ }
+ rail.hidden = false;
+ rail.innerHTML =
+ authFlow.map((_, i) => ``).join('') +
+ `Step ${index + 1} of ${authFlow.length}`;
+}
+
+function loginStep(key) {
+ const meta = AUTH_SCREENS[key] || AUTH_SCREENS['step-password'];
+ const targetId = meta.screen || key;
+ const body = $('#auth-body');
+ const previousHeight = body.offsetHeight;
+
+ authCurrent = key;
+ $('#auth-title').textContent = meta.title;
+ $('#auth-subtitle').textContent = meta.subtitle || '';
+ $('#auth-subtitle').hidden = !meta.subtitle;
+ $('#auth-back').hidden = !meta.back;
+ renderAuthRail(key);
+ loginError('');
+
+ $$('.auth-screen').forEach((el) => {
+ el.hidden = el.id !== targetId;
+ el.classList.remove('entering');
+ });
+
+ const entering = document.getElementById(targetId);
+ entering.classList.add('entering');
+
+ // Animate between the old and new heights so the card feels like it is turning
+ // a page instead of jumping.
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (!reduce && previousHeight) {
+ const nextHeight = body.scrollHeight;
+ body.style.height = `${previousHeight}px`;
+ requestAnimationFrame(() => {
+ body.style.height = `${nextHeight}px`;
+ });
+ body.addEventListener(
+ 'transitionend',
+ () => {
+ body.style.height = '';
+ },
+ { once: true }
+ );
+ }
+
+ const focusTarget = meta.focus ? $(meta.focus) : entering.querySelector('input');
+ if (focusTarget) setTimeout(() => focusTarget.focus(), 60);
+}
+
+$('#auth-back').addEventListener('click', async () => {
+ if ((AUTH_SCREENS[authCurrent] || {}).back === 'restart') {
+ await api('/logout', { method: 'POST' }).catch(() => {});
+ $('#twofa-code').value = '';
+ $('#login-password').value = '';
+ setAuthFlow(['step-password']);
+ loginStep('step-password');
+ }
+});
+
/* ---------------------------------------------------------- login flow */
$('#step-password').addEventListener('submit', async (event) => {
event.preventDefault();
loginError('');
+ const button = event.target.querySelector('button[type="submit"]');
+ button.disabled = true;
try {
const result = await api('/login', {
method: 'POST',
@@ -101,34 +211,52 @@ $('#step-password').addEventListener('submit', async (event) => {
handleLoginResult(result);
} catch (err) {
loginError(err.message);
+ } finally {
+ button.disabled = false;
}
});
function handleLoginResult(result) {
if (result.status === 'twoFactorSetup') {
- $('#twofa-intro').textContent =
- 'Two factor is required here. Scan this with an authenticator app (Google Authenticator, Authy, 1Password), then enter the code it shows.';
$('#twofa-setup').hidden = false;
$('#twofa-qr').src = result.qr;
$('#twofa-secret').textContent = result.secret;
$('#twofa-label').textContent = '6 digit code from the app';
- loginStep('step-2fa');
+ // Enrolling always adds a recovery codes page, and the server tells us whether a
+ // password change follows. Declaring the whole path now means the step counter
+ // never changes its total halfway through.
+ setAuthFlow([
+ 'step-password',
+ 'step-2fa-setup',
+ 'step-recovery',
+ ...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
+ ]);
+ loginStep('step-2fa-setup');
return;
}
if (result.status === 'twoFactorRequired') {
- $('#twofa-intro').textContent = 'Enter the code from your authenticator app.';
$('#twofa-setup').hidden = true;
$('#twofa-label').textContent = '6 digit code, or a recovery code';
- loginStep('step-2fa');
+ setAuthFlow([
+ 'step-password',
+ 'step-2fa-verify',
+ ...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
+ ]);
+ loginStep('step-2fa-verify');
return;
}
if (result.recoveryCodes) {
- $('#recovery-list').innerHTML = result.recoveryCodes.map((c) => `