Files
nmemmertandClaude Sonnet 5.5 9893883b19
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Split server/index.js into one file per area
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:52 -04:00

154 lines
6.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Admin only: users, studies, sign-up rules and invites, server health and snapshots.
import { adminDeleteProject, adminDeleteUser, adminGetAllProjects, adminGetAllUsers, adminGetProject, createInvite, dataDir, deleteInvite, destroyAllSessionsForUser, getSetting, getUserById, listInvites, setSetting, setUserPassword } from '../db.js';
import { generateTemporaryPassword, hashPassword, requireAdmin, requireAuth } from '../auth.js';
import { SIGNUP_MODES, generateInviteCode, isSignupMode, normalizeInviteCode } from '../signup.js';
import { restoreSnapshot, runBackup, snapshotPath } from '../backups.js';
import { backupStatus, diskUse, logEvent, recentEvents, uptimeSeconds } from '../health.js';
export function registerAdminRoutes(app) {
// ---------------------------------------------------------------------------
// 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);
destroyAllSessionsForUser(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.' });
}
});
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
try {
const temporaryPassword = generateTemporaryPassword();
const passwordHash = await hashPassword(temporaryPassword);
setUserPassword(req.params.id, passwordHash);
destroyAllSessionsForUser(req.params.id);
res.json({ temporaryPassword });
} catch (err) {
console.error('POST /api/admin/users/:id/reset-password error:', err);
res.status(500).json({ error: 'Failed to reset password.' });
}
});
// Who may sign up, and the invite codes for it
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
try {
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
res.json({
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
disk: diskUse(), backups: backupStatus(),
iqConfigured: !!process.env.IQBIBLE_API_KEY,
signinFailures: recentEvents('signin', 30).map(withUser),
notifyFailures: recentEvents('notify', 30).map(withUser),
backupFailures: recentEvents('backup', 10),
});
} catch (err) {
console.error('GET /api/admin/health error:', err);
res.status(500).json({ error: 'Failed to read server health.' });
}
});
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
try {
await runBackup();
res.json(backupStatus());
} catch (err) {
console.error('POST /api/admin/backups error:', err);
logEvent('backup', err.message);
res.status(500).json({ error: 'The snapshot could not be made.' });
}
});
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
const file = snapshotPath(req.params.name);
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
res.download(file, req.params.name);
});
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
try {
const result = await restoreSnapshot(req.params.name);
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
} catch (err) {
console.error('POST /api/admin/backups/:name/restore error:', err);
res.status(500).json({ error: 'The snapshot could not be restored.' });
}
});
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
});
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
setSetting('signup', req.body.mode);
res.json({ mode: req.body.mode });
});
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
const code = generateInviteCode();
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
res.json({ code, expiresInDays: days });
});
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
deleteInvite(normalizeInviteCode(req.params.code));
res.json({ ok: true });
});
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.' });
}
});
}