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
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:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
+60
-2
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user