From 7006fbf544109308c99a9b4950161613462aaa4e Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 6 Jul 2026 13:07:54 -0400 Subject: [PATCH] Add admin panel restricted to a single designated account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full admin view (users + projects, with view/delete) gated server-side by ADMIN_EMAIL in server/auth.js (defaults to the site owner's account, overridable via env var for other deployments). The gate is enforced on every /api/admin/* route, not just hidden in the UI — verified a non-admin session gets 403 even when it hits the endpoints directly. Deleting a user leaves their projects in place (not cascade-deleted) so admin cleanup can't accidentally destroy someone's study data. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + SUGGESTIONS.md | 2 +- server/auth.js | 18 ++++ server/db.js | 47 +++++++++++ server/index.js | 71 ++++++++++++++-- src/App.jsx | 201 +++++++++++++++++++++++++++++++++++++++++++++ src/syncService.js | 24 ++++++ 7 files changed, 358 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 4511232..d35df42 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ .vite.log .api.pid .api.log +.DS_Store diff --git a/SUGGESTIONS.md b/SUGGESTIONS.md index 71c2787..25bf332 100644 --- a/SUGGESTIONS.md +++ b/SUGGESTIONS.md @@ -1,6 +1,6 @@ # Study App Improvement Suggestions -_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), and read-only share links._ +_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._ ## Features diff --git a/server/auth.js b/server/auth.js index 7e8d0c3..a445883 100644 --- a/server/auth.js +++ b/server/auth.js @@ -1,8 +1,13 @@ import bcrypt from 'bcryptjs'; import { authenticator } from 'otplib'; import { randomBytes } from 'crypto'; +import { getUserById } from './db.js'; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +// The single admin account for this deployment. Override via env var if you +// redeploy this app for someone else — don't hardcode your own email into a +// fork without changing this. +const ADMIN_EMAIL = (process.env.ADMIN_EMAIL || 'nmemmert@duck.com').toLowerCase(); export function isValidEmail(email) { return typeof email === 'string' && email.length <= 254 && EMAIL_RE.test(email); @@ -28,6 +33,19 @@ export function requireAuth(req, res, next) { next(); } +export function isAdminEmail(email) { + return typeof email === 'string' && email.toLowerCase() === ADMIN_EMAIL; +} + +/** Blocks the request unless the signed-in account is the designated admin. */ +export function requireAdmin(req, res, next) { + const user = req.session?.userId ? getUserById(req.session.userId) : null; + if (!user || !isAdminEmail(user.email)) { + return res.status(403).json({ error: 'Admin access only.' }); + } + next(); +} + // --------------------------------------------------------------------------- // Two-factor auth (TOTP, RFC 6238 — compatible with any authenticator app) // --------------------------------------------------------------------------- diff --git a/server/db.js b/server/db.js index adb4201..1c7511f 100644 --- a/server/db.js +++ b/server/db.js @@ -251,6 +251,53 @@ export function getProjectByShareToken(token) { } } +// --------------------------------------------------------------------------- +// Admin — unscoped views across every user/project. Callers must gate access +// themselves (see requireAdmin in server/auth.js); nothing here checks who's asking. +// --------------------------------------------------------------------------- + +/** Every account, with a project count, for the admin users list. */ +export function adminGetAllUsers() { + return db.prepare(` + SELECT u.id, u.email, u.created_at AS createdAt, u.totp_enabled AS totpEnabled, + (SELECT COUNT(*) FROM projects p WHERE p.user_id = u.id) AS projectCount + FROM users u + ORDER BY u.created_at ASC + `).all().map((r) => ({ ...r, totpEnabled: !!r.totpEnabled })); +} + +/** 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 users WHERE id = ?').run(userId); +} + +/** Every project across every user, with the owner's email, for the admin projects list. */ +export function adminGetAllProjects() { + return db.prepare(` + SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary, + p.share_token AS shareToken, u.email AS ownerEmail + FROM projects p + LEFT JOIN users u ON u.id = p.user_id + ORDER BY p.last_edited DESC + `).all(); +} + +/** Full project data by id, regardless of owner — for admin inspection. */ +export function adminGetProject(id) { + const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); + if (!row) return null; + try { + return JSON.parse(row.data); + } catch { + return null; + } +} + +/** Deletes any project by id, regardless of owner. */ +export function adminDeleteProject(id) { + db.prepare('DELETE FROM projects WHERE id = ?').run(id); +} + // --------------------------------------------------------------------------- // Session store backing (used by server/sessionStore.js) // --------------------------------------------------------------------------- diff --git a/server/index.js b/server/index.js index a3f6586..9063285 100644 --- a/server/index.js +++ b/server/index.js @@ -9,10 +9,11 @@ import { countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, + adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { - isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, + isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail, generateTotpSecret, totpKeyUri, verifyTotpToken, generateBackupCodes, hashBackupCodes, consumeBackupCode, } from './auth.js'; @@ -81,7 +82,7 @@ app.post('/api/auth/register', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null }); + res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) }); }); } catch (err) { console.error('POST /api/auth/register error:', err); @@ -109,7 +110,7 @@ app.post('/api/auth/login', async (req, res) => { return res.json({ mfaRequired: true }); } req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null }); + res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) }); }); } catch (err) { console.error('POST /api/auth/login error:', err); @@ -147,7 +148,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null }); + res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) }); }); } catch (err) { console.error('POST /api/auth/mfa/verify error:', err); @@ -165,7 +166,10 @@ app.post('/api/auth/logout', (req, res) => { app.get('/api/auth/me', (req, res) => { const user = req.session?.userId ? getUserById(req.session.userId) : null; if (!user) return res.status(401).json({ error: 'Not signed in.' }); - res.json({ id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null }); + res.json({ + id: user.id, email: user.email, totpEnabled: user.totpEnabled, + podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email), + }); }); // --------------------------------------------------------------------------- @@ -358,6 +362,63 @@ app.get('/api/share/:token', (req, res) => { } }); +// --------------------------------------------------------------------------- +// Admin — restricted to the single designated admin account (see ADMIN_EMAIL +// in server/auth.js). Full visibility/control over every user and project. +// --------------------------------------------------------------------------- + +app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => { + try { + res.json(adminGetAllUsers()); + } catch (err) { + console.error('GET /api/admin/users error:', err); + res.status(500).json({ error: 'Failed to list users.' }); + } +}); + +app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => { + try { + if (req.params.id === req.session.userId) { + return res.status(400).json({ error: "Can't delete your own admin account." }); + } + adminDeleteUser(req.params.id); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/admin/users/:id error:', err); + res.status(500).json({ error: 'Failed to delete user.' }); + } +}); + +app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => { + try { + res.json(adminGetAllProjects()); + } catch (err) { + console.error('GET /api/admin/projects error:', err); + res.status(500).json({ error: 'Failed to list projects.' }); + } +}); + +app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { + try { + const project = adminGetProject(req.params.id); + if (!project) return res.status(404).json({ error: 'Project not found.' }); + res.json(project); + } catch (err) { + console.error('GET /api/admin/projects/:id error:', err); + res.status(500).json({ error: 'Failed to load project.' }); + } +}); + +app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => { + try { + adminDeleteProject(req.params.id); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/admin/projects/:id error:', err); + res.status(500).json({ error: 'Failed to delete project.' }); + } +}); + // --------------------------------------------------------------------------- // Serve Vite production build (when NODE_ENV=production) // --------------------------------------------------------------------------- diff --git a/src/App.jsx b/src/App.jsx index f0a20f3..256f097 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -35,6 +35,11 @@ import { enableSharing, disableSharing, getSharedProject, + adminListUsers, + adminDeleteUser, + adminListProjects, + adminGetProject, + adminDeleteProject, } from './syncService.js'; const COMMENTARY_OPTIONS = [ @@ -1068,6 +1073,13 @@ const App = () => { const [sharedViewToken] = useState(() => new URLSearchParams(window.location.search).get('share')); const [sharedProject, setSharedProject] = useState(null); const [sharedError, setSharedError] = useState(''); + // Admin (only reachable/rendered when authUser.isAdmin) + const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' + const [adminUsers, setAdminUsers] = useState([]); + const [adminProjects, setAdminProjects] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminError, setAdminError] = useState(''); + const [adminViewProject, setAdminViewProject] = useState(null); // full project data being previewed const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' | 'settings' const [currentPage, setCurrentPage] = useState('home'); @@ -1570,6 +1582,24 @@ const App = () => { }); }, [project?.id, currentPage]); + const loadAdminData = () => { + setAdminLoading(true); + setAdminError(''); + Promise.all([adminListUsers(), adminListProjects()]).then(([usersResult, projectsResult]) => { + setAdminLoading(false); + if (!usersResult.ok || !projectsResult.ok) { + setAdminError(usersResult.error ?? projectsResult.error ?? 'Failed to load admin data.'); + return; + } + setAdminUsers(usersResult.data); + setAdminProjects(projectsResult.data); + }); + }; + + useEffect(() => { + if (currentPage === 'admin' && authUser?.isAdmin) loadAdminData(); + }, [currentPage, authUser?.isAdmin]); + // Once we know who's signed in, reconcile the local project index against the server. // Runs on every authUser change (including logout -> different login) so a previous // account's stale suggestions never linger after switching users. Projects that exist @@ -3366,6 +3396,15 @@ const deleteProject = (id) => { const authStatus = authUser && (
{authUser.email} + {authUser.isAdmin && ( + + )} + {authStatus} +
+ + + +
+
+ + +
+ + {adminLoading &&

Loading…

} + {adminError &&

{adminError}

} + + {!adminLoading && !adminError && adminTab === 'users' && ( +
+ + + + + + + + + + + + {adminUsers.map((u) => ( + + + + + + + + ))} + +
EmailJoined2FAProjects
+ {u.email}{u.id === authUser.id && (you)} + {new Date(u.createdAt).toLocaleDateString()}{u.totpEnabled ? '✓' : '—'}{u.projectCount} + {u.id !== authUser.id && ( + + )} +
+
+ )} + + {!adminLoading && !adminError && adminTab === 'projects' && ( +
+ + + + + + + + + + + + + {adminProjects.map((p) => ( + + + + + + + + + ))} + +
TitleOwnerPassageLast editedShared
{p.title} + {p.ownerEmail ?? orphaned} + {p.chapterSummary}{new Date(p.lastEdited).toLocaleDateString()}{p.shareToken ? '🔗' : '—'} +
+ + +
+
+
+ )} +
+ + {adminViewProject && ( +
setAdminViewProject(null)}> +
e.stopPropagation()}> +
+

{adminViewProject.title}

+ +
+