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

+60 -2
View File
@@ -1,5 +1,5 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { copyFileSync, mkdirSync, rmSync } from 'fs';
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -17,6 +17,18 @@ export function backupDatabase(destPath) {
return db.backup(destPath);
}
/** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */
export function replaceDatabase(srcFile) {
db.close();
try {
copyFileSync(srcFile, DB_PATH);
rmSync(`${DB_PATH}-wal`, { force: true });
rmSync(`${DB_PATH}-shm`, { force: true });
} finally {
initDb();
}
}
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
@@ -48,6 +60,18 @@ export function initDb() {
sess TEXT NOT NULL,
expires INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS passkeys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
name TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
`);
// Older databases predate multi-user support — add the ownership column.
@@ -82,6 +106,9 @@ export function initDb() {
`);
}
// Optional "new sign-in" notification, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0');
// Optional daily verse of the day, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'votd_enabled')) {
db.exec(`
@@ -273,6 +300,7 @@ function parseUserRow(row) {
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
prayerEnabled: !!row.prayerEnabled,
loginAlerts: !!row.loginAlerts,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
};
}
@@ -284,7 +312,7 @@ const USER_SELECT = `
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
FROM users
`;
@@ -837,6 +865,7 @@ export function adminGetAllUsers() {
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
export function adminDeleteUser(userId) {
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId);
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
@@ -931,3 +960,32 @@ export function destroyAllSessionsForUser(userId, exceptSid = null) {
const placeholders = staleSids.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
}
// ---------------------------------------------------------------------------
// Passkeys, sign-in alerts and the list of signed-in devices
// ---------------------------------------------------------------------------
export function setLoginAlerts(userId, enabled) {
db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
}
export function addPasskey({ id, userId, publicKey, counter, transports, name }) {
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now());
}
const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null);
const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt';
export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id));
export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow);
export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id);
export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0;
/** Every live session of one user: [{ sid, sess, expires }]. */
export function listUserSessions(userId) {
return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now())
.map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } })
.filter((r) => r?.sess?.userId === userId);
}