Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s

- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 12:52:49 -04:00
1 parent 207bfcb919
commit bdb04cdedc
35 files changed
+1860 -22

No files matched your search

+185
View File
@@ -0,0 +1,185 @@
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
// authenticator-code step. Passwords and 2FA keep working as before.
import { createHash } from 'crypto';
import {
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
} from './db.js';
import { logEvent } from './health.js';
import { sendNtfy } from './reminders.js';
const MONTH = 30 * 24 * 60 * 60 * 1000;
const APP_NAME = 'Bible Study';
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
export function describeDevice(ua = '') {
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
if (browser && os) return `${browser} on ${os}`;
return browser ?? os ?? 'Unknown device';
}
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
export function relyingParty(req, env = process.env) {
if (env.APP_URL) {
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
}
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
}
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
export function noteSignIn(req, user, method) {
const label = describeDevice(req.get('user-agent') ?? '');
req.session.device = { label, ip: req.ip, at: Date.now(), method };
if (user.loginAlerts && user.ntfyTopic) {
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
}
}
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
const fail = (res, status, error) => res.status(status).json({ error });
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
app.post('/api/auth/passkey/options', async (req, res) => {
try {
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
const { rpID } = relyingParty(req);
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/auth/passkey/options error:', err);
fail(res, 500, 'Could not start passkey sign-in.');
}
});
app.post('/api/auth/passkey/verify', async (req, res) => {
const keys = [`ip:${req.ip}`];
try {
if (isLimited(...keys)) return res.status(429).json(tooMany);
const challenge = req.session?.passkeyChallenge;
const response = req.body;
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
if (!challenge || !passkey) return failed();
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyAuthenticationResponse({
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
});
} catch { return failed(); }
if (!result.verified) return failed();
const user = getUserById(passkey.userId);
if (!user) return failed();
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
clearFailures(...keys);
req.session.regenerate((err) => {
if (err) return fail(res, 500, 'Could not create session.');
req.session.userId = user.id;
noteSignIn(req, user, 'passkey');
res.json(userPayload(user));
});
} catch (err) {
console.error('POST /api/auth/passkey/verify error:', err);
fail(res, 500, 'Could not check that passkey.');
}
});
// ---- Managing passkeys (signed in) ----
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
const { rpID } = relyingParty(req);
const options = await generateRegistrationOptions({
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
});
req.session.passkeyChallenge = options.challenge;
res.json(options);
} catch (err) {
console.error('POST /api/passkeys/options error:', err);
fail(res, 500, 'Could not start adding a passkey.');
}
});
app.post('/api/passkeys', requireAuth, async (req, res) => {
try {
const challenge = req.session.passkeyChallenge;
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
const { rpID, origin } = relyingParty(req);
let result;
try {
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
delete req.session.passkeyChallenge;
const { credential } = result.registrationInfo;
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
res.json(listPasskeys(req.session.userId).map(publicPasskey));
} catch (err) {
console.error('POST /api/passkeys error:', err);
fail(res, 500, 'Could not save that passkey.');
}
});
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
const name = String(req.body?.name ?? '').trim().slice(0, 60);
if (!name) return fail(res, 400, 'Give it a name.');
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
res.json(listPasskeys(req.session.userId).map(publicPasskey));
});
// ---- Where you are signed in ----
app.get('/api/sessions', requireAuth, (req, res) => {
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
id: sessionKey(sid), current: sid === req.sessionID,
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
res.json(rows);
});
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
if (!found) return fail(res, 404, 'That device is already signed out.');
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
destroySession(found.sid);
res.json({ ok: true });
});
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
destroyAllSessionsForUser(req.session.userId, req.sessionID);
res.json({ ok: true });
});
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
const user = getUserById(req.session.userId);
const enabled = !!req.body?.enabled;
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
setLoginAlerts(user.id, enabled);
res.json({ loginAlerts: enabled });
});
}