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 1–72 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 30–3650 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;