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

This commit is contained in:
2026-08-31 14:54:17 +10:00
parent b23ad422d0
commit 14678e13e8
8 changed files with 463 additions and 160 deletions
+9 -4
View File
@@ -11,7 +11,7 @@ RUN npm install --omit=dev
FROM node:22-bookworm-slim FROM node:22-bookworm-slim
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -20,14 +20,19 @@ COPY package.json ./
COPY src ./src COPY src ./src
COPY public ./public COPY public ./public
COPY scripts ./scripts COPY scripts ./scripts
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /data/photos && chown -R node:node /data /app RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
USER node && 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"] VOLUME ["/data"]
EXPOSE 3000 3001 EXPOSE 3000 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node scripts/healthcheck.mjs CMD node scripts/healthcheck.mjs
ENTRYPOINT ["/usr/bin/tini", "--"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "src/server.js"] CMD ["node", "src/server.js"]
+22
View File
@@ -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 The kiosk returns to the home screen after two minutes of inactivity so the next visitor never
sees the last one's details. 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 ## Running without Docker
```bash ```bash
+3
View File
@@ -2,6 +2,9 @@ services:
visitor-signin: visitor-signin:
build: . build: .
image: visitor-signin:latest 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 container_name: visitor-signin
restart: unless-stopped restart: unless-stopped
env_file: env_file:
+37
View File
@@ -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 "$@"
+63 -52
View File
@@ -9,67 +9,78 @@
<body> <body>
<!-- ------------------------------------------------------------ login --> <!-- ------------------------------------------------------------ login -->
<div id="login" class="login" hidden> <div id="login" class="auth" hidden>
<div class="login-card"> <div class="auth-card">
<h1 id="login-heading">Visitor admin</h1> <header class="auth-head">
<button type="button" class="auth-back" id="auth-back" hidden aria-label="Go back">&#8592;</button>
<div class="auth-rail" id="auth-rail" hidden></div>
<h1 id="auth-title">Visitor admin</h1>
<p class="auth-sub" id="auth-subtitle"></p>
</header>
<form class="login-step" id="step-password"> <div class="auth-body" id="auth-body">
<label> <form class="auth-screen" id="step-password">
<span>Email address</span> <label>
<input type="email" id="login-email" autocomplete="username" autocapitalize="none"> <span>Email address</span>
</label> <input type="email" id="login-email" autocomplete="username" autocapitalize="none">
<label> </label>
<span>Password</span> <label>
<input type="password" id="login-password" autocomplete="current-password"> <span>Password</span>
</label> <input type="password" id="login-password" autocomplete="current-password">
<p class="hint" id="domain-rule" hidden></p> </label>
<button type="submit">Continue</button> <p class="hint" id="domain-rule" hidden></p>
</form> <button type="submit">Continue</button>
</form>
<form class="login-step" id="step-2fa" hidden> <form class="auth-screen" id="step-2fa" hidden>
<p class="hint" id="twofa-intro"></p> <div id="twofa-setup" hidden>
<div id="twofa-setup" hidden> <img id="twofa-qr" alt="Two factor setup QR code" width="180" height="180">
<img id="twofa-qr" alt="Two factor setup QR code" width="200" height="200"> <details class="auth-details">
<p class="hint">Can't scan it? Enter this key by hand:<br><code id="twofa-secret"></code></p> <summary>Can't scan it?</summary>
<p class="hint">Enter this key in your authenticator app by hand:</p>
<p><code id="twofa-secret"></code></p>
</details>
</div>
<label>
<span id="twofa-label">6 digit code</span>
<input id="twofa-code" inputmode="numeric" autocomplete="one-time-code" maxlength="12">
</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> </div>
<label>
<span id="twofa-label">6 digit code</span>
<input id="twofa-code" inputmode="numeric" autocomplete="one-time-code" maxlength="12">
</label>
<button type="submit">Verify</button>
<button type="button" class="link-quiet" id="twofa-cancel">Start again</button>
</form>
<div class="login-step" id="step-recovery" hidden> <form class="auth-screen" id="step-newpassword" hidden>
<p class="hint">Save these recovery codes somewhere safe. Each one works once, if you ever <label>
lose the phone with your authenticator app on it.</p> <span>Current password</span>
<ul class="recovery" id="recovery-list"></ul> <input type="password" id="pw-current" autocomplete="current-password">
<button type="button" id="recovery-done">I've saved them</button> </label>
</div> <label>
<span>New password</span>
<input type="password" id="pw-new" autocomplete="new-password">
</label>
<label>
<span>New password again</span>
<input type="password" id="pw-again" autocomplete="new-password">
</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>
<form class="login-step" id="step-newpassword" hidden> <div class="auth-screen" id="step-setup" hidden>
<p class="hint">Set a password only you know before you continue.</p> <p class="hint" id="setup-message"></p>
<label> </div>
<span>Current password</span>
<input type="password" id="pw-current" autocomplete="current-password">
</label>
<label>
<span>New password</span>
<input type="password" id="pw-new" autocomplete="new-password">
</label>
<label>
<span>New password again</span>
<input type="password" id="pw-again" autocomplete="new-password">
</label>
<button type="submit">Save and continue</button>
</form>
<div class="login-step" id="step-setup" hidden>
<p class="hint" id="setup-message"></p>
</div> </div>
<p class="err" id="login-error" hidden></p> <p class="err" id="login-error" hidden></p>
</div> </div>
<p class="auth-foot">Created by: Jess Rogerson (yelling commands at Claude.AI)</p>
</div> </div>
<!-- ---------------------------------------------------------- console --> <!-- ---------------------------------------------------------- console -->
+160 -86
View File
@@ -55,36 +55,6 @@ button { cursor: pointer; }
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; } :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 */ /* -------------------------------------------------------------- chrome */
.topbar { .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; } .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 switching */
.site-switch { display: flex; align-items: center; gap: 8px; color: #cfe0dd; font-size: 14px; } .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; word-break: break-all;
} }
#system-body h3 { margin-bottom: 14px; } #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; }
+158 -16
View File
@@ -79,20 +79,130 @@ function loginError(message) {
el.hidden = !message; el.hidden = !message;
} }
function loginStep(id) { /* --------------------------------------------------------- auth screens */
$$('.login-step').forEach((s) => { // The sign in flow is a sequence of small pages rather than one form that grows
s.hidden = s.id !== id; // 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
loginError(''); // a tall screen like the QR code reads as a new page, not a form unfolding.
const input = document.getElementById(id)?.querySelector('input');
if (input) input.focus(); 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) => `<i class="${i <= index ? 'done' : ''}"></i>`).join('') +
`<span>Step ${index + 1} of ${authFlow.length}</span>`;
}
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 */ /* ---------------------------------------------------------- login flow */
$('#step-password').addEventListener('submit', async (event) => { $('#step-password').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
loginError(''); loginError('');
const button = event.target.querySelector('button[type="submit"]');
button.disabled = true;
try { try {
const result = await api('/login', { const result = await api('/login', {
method: 'POST', method: 'POST',
@@ -101,34 +211,52 @@ $('#step-password').addEventListener('submit', async (event) => {
handleLoginResult(result); handleLoginResult(result);
} catch (err) { } catch (err) {
loginError(err.message); loginError(err.message);
} finally {
button.disabled = false;
} }
}); });
function handleLoginResult(result) { function handleLoginResult(result) {
if (result.status === 'twoFactorSetup') { 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-setup').hidden = false;
$('#twofa-qr').src = result.qr; $('#twofa-qr').src = result.qr;
$('#twofa-secret').textContent = result.secret; $('#twofa-secret').textContent = result.secret;
$('#twofa-label').textContent = '6 digit code from the app'; $('#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; return;
} }
if (result.status === 'twoFactorRequired') { if (result.status === 'twoFactorRequired') {
$('#twofa-intro').textContent = 'Enter the code from your authenticator app.';
$('#twofa-setup').hidden = true; $('#twofa-setup').hidden = true;
$('#twofa-label').textContent = '6 digit code, or a recovery code'; $('#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; return;
} }
if (result.recoveryCodes) { if (result.recoveryCodes) {
$('#recovery-list').innerHTML = result.recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join(''); recoveryCodes = result.recoveryCodes;
$('#recovery-list').innerHTML = recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join('');
$('#recovery-done').dataset.next = result.status; $('#recovery-done').dataset.next = result.status;
if (result.status === 'passwordChangeRequired' && !authFlow.includes('step-newpassword')) {
setAuthFlow([...authFlow, 'step-newpassword']);
}
loginStep('step-recovery'); loginStep('step-recovery');
return; return;
} }
if (result.status === 'passwordChangeRequired') { if (result.status === 'passwordChangeRequired') {
if (!authFlow.includes('step-newpassword')) setAuthFlow([...authFlow, 'step-newpassword']);
loginStep('step-newpassword'); loginStep('step-newpassword');
return; return;
} }
@@ -141,18 +269,29 @@ function handleLoginResult(result) {
$('#step-2fa').addEventListener('submit', async (event) => { $('#step-2fa').addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
loginError(''); loginError('');
const button = event.target.querySelector('button[type="submit"]');
button.disabled = true;
try { try {
const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } }); const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } });
$('#twofa-code').value = ''; $('#twofa-code').value = '';
handleLoginResult(result); handleLoginResult(result);
} catch (err) { } catch (err) {
loginError(err.message); loginError(err.message);
$('#twofa-code').select();
} finally {
button.disabled = false;
} }
}); });
$('#twofa-cancel').addEventListener('click', async () => { let recoveryCodes = [];
await api('/logout', { method: 'POST' }).catch(() => {});
loginStep('step-password'); $('#recovery-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(recoveryCodes.join('\n'));
toast('Recovery codes copied.');
} catch {
toast('Copying was blocked. Write them down instead.', true);
}
}); });
$('#recovery-done').addEventListener('click', () => { $('#recovery-done').addEventListener('click', () => {
@@ -1059,8 +1198,10 @@ async function boot() {
if (session.setupNeeded) { if (session.setupNeeded) {
$('#setup-message').textContent = $('#setup-message').textContent =
'No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first one.'; 'No admin accounts exist yet. Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first one.';
setAuthFlow(['step-setup']);
loginStep('step-setup'); loginStep('step-setup');
} else { } else {
setAuthFlow(['step-password']);
loginStep('step-password'); loginStep('step-password');
} }
return; return;
@@ -1069,6 +1210,7 @@ async function boot() {
if (session.mustChangePassword) { if (session.mustChangePassword) {
$('#login').hidden = false; $('#login').hidden = false;
$('#console').hidden = true; $('#console').hidden = true;
if (!authFlow.includes('step-newpassword')) setAuthFlow([...authFlow, 'step-newpassword']);
loginStep('step-newpassword'); loginStep('step-newpassword');
return; return;
} }
+11 -2
View File
@@ -116,7 +116,11 @@ router.post('/login', loginLimiter, (req, res) => {
if (user.totp_enabled) { if (user.totp_enabled) {
req.session.pendingUserId = user.id; req.session.pendingUserId = user.id;
return res.json({ status: 'twoFactorRequired' }); // Told up front so the sign in pages can show an honest "step 2 of 3".
return res.json({
status: 'twoFactorRequired',
passwordChangeToFollow: Boolean(user.must_change_password),
});
} }
if (config.admin.require2fa) { if (config.admin.require2fa) {
req.session.pendingUserId = user.id; req.session.pendingUserId = user.id;
@@ -141,7 +145,12 @@ async function startTwoFactorSetup(req, res, user) {
req.session.pendingTotpSecret = secret; req.session.pendingTotpSecret = secret;
const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName }); const url = otpauthUrl({ secret, email: user.email, issuer: config.siteName });
const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 }); const qr = await QRCode.toDataURL(url, { margin: 1, width: 240 });
res.json({ status: 'twoFactorSetup', secret, qr }); res.json({
status: 'twoFactorSetup',
secret,
qr,
passwordChangeToFollow: Boolean(user.must_change_password),
});
} }
router.post('/login/2fa', loginLimiter, (req, res) => { router.post('/login/2fa', loginLimiter, (req, res) => {