BillTracker/routes/admin.js

516 lines
19 KiB
JavaScript
Raw 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.

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;