BillTracker/routes/admin.js

516 lines
19 KiB
JavaScript
Raw Normal View History

2026-05-03 19:51:57 -05:00
const express = require('express');
const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database');
const { hashPassword } = require('../services/authService');
const {
createBackup,
deleteBackup,
getBackupFile,
importBackupBuffer,
listBackups,
restoreBackup,
} = require('../services/backupService');
const {
getScheduleStatus,
runScheduledBackupNow,
saveSettings: saveBackupScheduleSettings,
} = require('../services/backupScheduler');
const {
getCleanupStatus,
runAllCleanup,
validateAndApplySettings: applyCleanupSettings,
} = require('../services/cleanupService');
// All routes mounted at /api/admin (requireAuth + requireAdmin applied at server level)
function sendError(res, err) {
const status = err.status || 500;
res.status(status).json({ error: status === 500 ? 'Backup operation failed' : err.message });
}
// GET /api/admin/has-users
router.get('/has-users', (req, res) => {
const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n;
res.json({ has_users: count > 0 });
});
// GET /api/admin/users
router.get('/users', (req, res) => {
res.json(
getDb().prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC'
).all()
);
});
// POST /api/admin/backups
router.post('/backups', async (req, res) => {
try {
const backup = await createBackup();
res.status(201).json(backup);
} catch (err) {
sendError(res, err);
}
});
// GET /api/admin/backups
router.get('/backups', (req, res) => {
try {
res.json({ backups: listBackups() });
} catch (err) {
sendError(res, err);
}
});
// GET /api/admin/backups/settings
router.get('/backups/settings', (req, res) => {
try {
res.json(getScheduleStatus());
} catch (err) {
sendError(res, err);
}
});
// PUT /api/admin/backups/settings
router.put('/backups/settings', (req, res) => {
try {
res.json(saveBackupScheduleSettings(req.body));
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/backups/run-scheduled-now
router.post('/backups/run-scheduled-now', async (req, res) => {
try {
res.status(201).json(await runScheduledBackupNow());
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/backups/import
router.post(
'/backups/import',
express.raw({
type: ['application/octet-stream', 'application/x-sqlite3', 'application/vnd.sqlite3'],
limit: '100mb',
}),
async (req, res) => {
try {
const backup = await importBackupBuffer(req.body);
res.status(201).json(backup);
} catch (err) {
sendError(res, err);
}
},
);
// GET /api/admin/backups/:id/download
router.get('/backups/:id/download', (req, res) => {
try {
const backup = getBackupFile(req.params.id);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.download(backup.path, backup.metadata.id, (err) => {
if (err && !res.headersSent) sendError(res, err);
});
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/backups/:id/restore
router.post('/backups/:id/restore', async (req, res) => {
try {
res.json(await restoreBackup(req.params.id));
} catch (err) {
sendError(res, err);
}
});
// DELETE /api/admin/backups/:id
router.delete('/backups/:id', (req, res) => {
try {
res.json(deleteBackup(req.params.id));
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/users
router.post('/users', async (req, res) => {
const { username, password } = req.body;
if (!username || username.length < 3)
return res.status(400).json({ error: 'Username must be at least 3 characters' });
if (!password || password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' });
const db = getDb();
if (db.prepare('SELECT id FROM users WHERE username = ?').get(username))
return res.status(409).json({ error: 'Username already taken' });
const hash = await hashPassword(password);
const result = db.prepare(
"INSERT INTO users (username, password_hash, role, first_login) VALUES (?, ?, 'user', 1)"
).run(username, hash);
res.status(201).json(
db.prepare('SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?')
.get(result.lastInsertRowid)
);
});
// PUT /api/admin/users/:id/password
router.put('/users/:id/password', async (req, res) => {
const { password } = req.body;
if (!password || password.length < 8)
return res.status(400).json({ error: 'Password must be at least 8 characters' });
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot reset admin password this way' });
const hash = await hashPassword(password);
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?")
.run(hash, req.params.id);
db.prepare('DELETE FROM sessions WHERE user_id = ?').run(req.params.id);
res.json({ success: true });
});
// PUT /api/admin/users/:id/role
// Promote/demote an existing user. Prevents removing the last admin or
// changing your own role mid-session.
router.put('/users/:id/role', (req, res) => {
const { role } = req.body;
if (!['admin', 'user'].includes(role)) {
return res.status(400).json({ error: 'role must be "admin" or "user"' });
}
const targetId = Number(req.params.id);
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId);
if (!user) return res.status(404).json({ error: 'User not found' });
if (req.user?.id === targetId) {
return res.status(400).json({ error: 'You cannot change your own admin role.' });
}
if (user.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get().n;
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot remove the last admin account.' });
}
}
db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?")
.run(role, targetId);
const updated = db.prepare(
'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?'
).get(targetId);
res.json(updated);
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => {
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot delete the admin account' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true });
});
// ── Cleanup endpoints ─────────────────────────────────────────────────────────
// GET /api/admin/cleanup
// Returns current cleanup settings and the result of the last cleanup run.
router.get('/cleanup', (req, res) => {
try {
res.json(getCleanupStatus());
} catch (err) {
sendError(res, err);
}
});
// PUT /api/admin/cleanup
// Updates one or more cleanup settings. Accepts partial objects.
// import_sessions_enabled boolean prune expired import preview sessions
// temp_exports_enabled boolean prune stale SQLite export temp files
// temp_export_max_age_hours 172 hours before an orphaned export file is removed
// backup_partials_enabled boolean prune orphaned .partial/.upload backup files
// import_history_enabled boolean prune old import history rows (disabled by default)
// import_history_max_age_days 303650 age threshold for import history rows
router.put('/cleanup', (req, res) => {
try {
res.json(applyCleanupSettings(req.body));
} catch (err) {
sendError(res, err);
}
});
// POST /api/admin/cleanup/run
// Runs all enabled cleanup tasks immediately and returns the result.
router.post('/cleanup/run', async (req, res) => {
try {
const result = await runAllCleanup();
res.json(result);
} catch (err) {
sendError(res, err);
}
});
// ── Auth-mode helpers ─────────────────────────────────────────────────────────
const {
getAdminOidcSettings,
getOidcConfigStatus,
invalidateClientCache,
testOidcConfiguration,
} = require('../services/oidcService');
function trimOrEmpty(value) {
if (value === undefined || value === null) return '';
return String(value).trim();
}
function boolSetting(value, fallback) {
if (value === undefined) return fallback;
if (typeof value === 'string') return value === 'true';
return !!value;
}
function computeSubmittedOidcConfigured(body) {
const current = getAdminOidcSettings();
const next = {
issuer: body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url,
clientId: body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id,
redirectUri: body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri,
clientSecret: current.oidc_client_secret_set ? 'set' : '',
};
if (body.oidc_client_secret_clear === true) {
next.clientSecret = process.env.OIDC_CLIENT_SECRET ? 'set' : '';
}
if (trimOrEmpty(body.oidc_client_secret)) {
next.clientSecret = 'set';
}
return !!(next.issuer && next.clientId && next.clientSecret && next.redirectUri);
}
function buildSubmittedOidcConfig(body) {
const current = getAdminOidcSettings();
const status = getOidcConfigStatus();
const issuerUrl = body.oidc_issuer_url !== undefined
? trimOrEmpty(body.oidc_issuer_url)
: current.oidc_issuer_url;
const clientId = body.oidc_client_id !== undefined
? trimOrEmpty(body.oidc_client_id)
: current.oidc_client_id;
const redirectUri = body.oidc_redirect_uri !== undefined
? trimOrEmpty(body.oidc_redirect_uri)
: current.oidc_redirect_uri;
const tokenAuthMethod = body.oidc_token_auth_method !== undefined
? trimOrEmpty(body.oidc_token_auth_method)
: current.oidc_token_auth_method;
const scopes = body.oidc_scopes !== undefined
? trimOrEmpty(body.oidc_scopes)
: current.oidc_scopes;
const providerName = body.oidc_provider_name !== undefined
? trimOrEmpty(body.oidc_provider_name)
: current.oidc_provider_name;
let clientSecret = status.oidc_client_secret_set ? '__saved__' : '';
if (body.oidc_client_secret_clear === true) clientSecret = process.env.OIDC_CLIENT_SECRET || '';
if (trimOrEmpty(body.oidc_client_secret)) clientSecret = trimOrEmpty(body.oidc_client_secret);
if (!issuerUrl || !clientId || !clientSecret || !redirectUri) return null;
return {
enabled: true,
issuerUrl,
clientId,
clientSecret: clientSecret === '__saved__'
? (getSetting('oidc_client_secret') || process.env.OIDC_CLIENT_SECRET || '')
: clientSecret,
tokenEndpointAuthMethod: tokenAuthMethod === 'client_secret_post'
? 'client_secret_post'
: 'client_secret_basic',
redirectUri,
scopes: (scopes || 'openid email profile groups').split(/\s+/).filter(Boolean),
adminGroup: body.oidc_admin_group !== undefined ? trimOrEmpty(body.oidc_admin_group) : current.oidc_admin_group,
defaultRole: 'user',
autoProvision: body.oidc_auto_provision !== undefined ? !!body.oidc_auto_provision : current.oidc_auto_provision,
providerName: providerName || 'authentik',
};
}
function buildAuthModeStatus() {
const oidcConfigured = getOidcConfigStatus().oidc_configured;
const localEnabled = getSetting('local_login_enabled') !== 'false';
const oidcEnabled = getSetting('oidc_login_enabled') === 'true';
const oidcAdminGroup = getAdminOidcSettings().oidc_admin_group;
// Disabling local is only safe if OIDC is configured, enabled, and has an admin path.
const canDisableLocal = oidcConfigured && oidcEnabled && !!oidcAdminGroup;
const warnings = [];
if (!localEnabled && !oidcConfigured) {
warnings.push('Local login is disabled but OIDC is not configured; users may be locked out.');
}
if (!localEnabled && !oidcEnabled) {
warnings.push('No login method is enabled. Re-enable local login or configure OIDC.');
}
if (oidcEnabled && !oidcConfigured) {
warnings.push('authentik/OIDC login is enabled but configuration is incomplete, so the login button will stay hidden.');
}
if (!localEnabled && !oidcAdminGroup) {
warnings.push('Local login is disabled but no OIDC admin group is configured.');
}
return {
auth_mode: getSetting('auth_mode') || 'multi',
default_user_id: getSetting('default_user_id') || null,
local_login_enabled: localEnabled,
oidc_login_enabled: oidcEnabled,
oidc_configured: oidcConfigured,
...getOidcConfigStatus(),
...getAdminOidcSettings(),
can_disable_local: canDisableLocal,
warnings,
};
}
// GET /api/admin/auth-mode
router.get('/auth-mode', (req, res) => {
res.json(buildAuthModeStatus());
});
// POST /api/admin/auth-mode/oidc-test
// Tests submitted or saved OIDC provider settings with OIDC discovery.
// Never returns client secret or token material.
router.post('/auth-mode/oidc-test', async (req, res) => {
const config = buildSubmittedOidcConfig(req.body || {});
const result = await testOidcConfiguration(config);
res.status(result.ok ? 200 : 400).json(result);
});
// PUT /api/admin/auth-mode
// Accepts legacy auth_mode/default_user_id fields plus new auth method settings.
// Validates lockout protection before saving.
router.put('/auth-mode', (req, res) => {
const {
auth_mode, default_user_id,
local_login_enabled, oidc_login_enabled, oidc_enabled,
oidc_provider_name, oidc_issuer_url, oidc_client_id, oidc_client_secret,
oidc_client_secret_clear, oidc_token_auth_method, oidc_redirect_uri, oidc_scopes,
oidc_auto_provision, oidc_admin_group, oidc_default_role,
} = req.body;
// ── Legacy single/multi mode (unchanged behavior) ─────────────────────────
if (auth_mode !== undefined) {
if (!['multi', 'single'].includes(auth_mode))
return res.status(400).json({ error: 'auth_mode must be "multi" or "single"' });
if (auth_mode === 'single') {
if (!default_user_id) return res.status(400).json({ error: 'default_user_id is required for single mode' });
const u = getDb().prepare("SELECT id FROM users WHERE id=? AND role='user'").get(default_user_id);
if (!u) return res.status(404).json({ error: 'User not found or not a regular user' });
setSetting('default_user_id', default_user_id);
}
setSetting('auth_mode', auth_mode);
}
// ── Auth method toggles ───────────────────────────────────────────────────
const oidcConfigured = computeSubmittedOidcConfigured(req.body || {});
const nextLocal = boolSetting(local_login_enabled, getSetting('local_login_enabled') !== 'false');
const requestedOidc = oidc_login_enabled !== undefined ? oidc_login_enabled : oidc_enabled;
const nextOidc = boolSetting(requestedOidc, getSetting('oidc_login_enabled') === 'true');
const nextAdminGroup = oidc_admin_group !== undefined
? trimOrEmpty(oidc_admin_group)
: getAdminOidcSettings().oidc_admin_group;
// Lockout protection: cannot disable both login methods
if (!nextLocal && !nextOidc) {
return res.status(400).json({ error: 'Cannot disable all login methods. At least one must remain enabled.' });
}
// Lockout protection: cannot disable local login unless OIDC has a working admin path.
if (!nextLocal && !oidcConfigured) {
return res.status(400).json({
error: 'Cannot disable local login until authentik/OIDC is fully configured.',
});
}
if (!nextLocal && !nextOidc) {
return res.status(400).json({
error: 'Cannot disable local login without OIDC login enabled.',
});
}
if (!nextLocal && !nextAdminGroup) {
return res.status(400).json({
error: 'Cannot disable local login until an OIDC admin group is configured.',
});
}
// Cannot enable OIDC login if required provider settings are incomplete
if (nextOidc && !oidcConfigured) {
return res.status(400).json({
error: 'Cannot enable OIDC login until issuer URL, client ID, client secret, and redirect URI are configured.',
});
}
if (local_login_enabled !== undefined) setSetting('local_login_enabled', nextLocal ? 'true' : 'false');
if (oidc_login_enabled !== undefined) setSetting('oidc_login_enabled', nextOidc ? 'true' : 'false');
// OIDC provider settings. Client secret is write-only from the Admin API.
if (oidc_provider_name !== undefined) {
const name = String(oidc_provider_name).slice(0, 100).trim();
if (name) setSetting('oidc_provider_name', name);
}
if (oidc_issuer_url !== undefined) setSetting('oidc_issuer_url', trimOrEmpty(oidc_issuer_url).slice(0, 500));
if (oidc_client_id !== undefined) setSetting('oidc_client_id', trimOrEmpty(oidc_client_id).slice(0, 500));
if (oidc_token_auth_method !== undefined) {
const method = oidc_token_auth_method === 'client_secret_post' ? 'client_secret_post' : 'client_secret_basic';
setSetting('oidc_token_auth_method', method);
}
if (oidc_redirect_uri !== undefined) setSetting('oidc_redirect_uri', trimOrEmpty(oidc_redirect_uri).slice(0, 500));
if (oidc_scopes !== undefined) {
const scopes = trimOrEmpty(oidc_scopes).split(/\s+/).filter(Boolean).join(' ') || 'openid email profile groups';
setSetting('oidc_scopes', scopes.slice(0, 500));
}
if (oidc_client_secret_clear === true) setSetting('oidc_client_secret', '');
if (trimOrEmpty(oidc_client_secret)) {
setSetting('oidc_client_secret', trimOrEmpty(oidc_client_secret).slice(0, 1000));
}
if (oidc_auto_provision !== undefined) setSetting('oidc_auto_provision', !!oidc_auto_provision ? 'true' : 'false');
if (oidc_admin_group !== undefined) setSetting('oidc_admin_group', String(oidc_admin_group).slice(0, 200).trim());
if (oidc_default_role !== undefined) {
setSetting('oidc_default_role', 'user');
}
if (
oidc_issuer_url !== undefined ||
oidc_client_id !== undefined ||
oidc_client_secret !== undefined ||
oidc_client_secret_clear === true ||
oidc_token_auth_method !== undefined ||
oidc_redirect_uri !== undefined
) {
invalidateClientCache();
}
res.json({ success: true, ...buildAuthModeStatus() });
});
module.exports = router;