Build and Push Docker Image / build-and-push (push) Successful in 5m37s
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>
154 lines
6.8 KiB
JavaScript
154 lines
6.8 KiB
JavaScript
// 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.' });
|
||
}
|
||
});
|
||
}
|