'use strict'; const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const { passwordLimiter } = require('../middleware/rateLimiter'); const { getDb, getSetting } = require('../db/database'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService'); const { getImportHistory } = require('../services/spreadsheetImportService'); const { logAudit } = require('../services/auditService'); // All profile routes require authentication — enforced in server.js. // req.user is always the signed-in user; user_id is never accepted from the body. // ── GET /api/profile ────────────────────────────────────────────────────────── // Returns safe profile data for the signed-in user. // Never returns password_hash, session tokens, or secrets. router.get('/', (req, res) => { const db = getDb(); const user = db.prepare(` SELECT id, username, display_name, role, active, is_default_admin, first_login, created_at, updated_at, last_password_change_at, notification_email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue FROM users WHERE id = ? `).get(req.user.id); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ id: user.id, username: user.username, display_name: user.display_name || null, role: user.role, active: !!user.active, is_default_admin: !!user.is_default_admin, created_at: user.created_at, updated_at: user.updated_at, last_password_change_at: user.last_password_change_at || null, first_login: !!user.first_login, notifications: { email: user.notification_email || null, enabled: !!user.notifications_enabled, notify_3d: !!user.notify_3d, notify_1d: !!user.notify_1d, notify_due: !!user.notify_due, notify_overdue: !!user.notify_overdue, }, exports: { user_db: '/api/export/user-db', user_excel: '/api/export/user-excel', }, import_history_url: '/api/profile/import-history', }); }); // ── PATCH /api/profile ──────────────────────────────────────────────────────── // Updates safe profile fields: username and display_name. // Ignores any unknown or restricted fields. router.patch('/', (req, res) => { const { username, display_name } = req.body; const db = getDb(); if (username !== undefined) { if (typeof username !== 'string') { return res.status(400).json({ error: 'username must be a string' }); } const trimmedUsername = username.trim(); if (trimmedUsername.length < 3) { return res.status(400).json({ error: 'username must be at least 3 characters' }); } if (trimmedUsername.length > 50) { return res.status(400).json({ error: 'username must be 50 characters or fewer' }); } const taken = db.prepare( 'SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id != ?' ).get(trimmedUsername, req.user.id); if (taken) { return res.status(409).json({ error: 'Username already taken' }); } db.prepare( "UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?" ).run(trimmedUsername, req.user.id); logAudit({ user_id: req.user.id, action: 'profile.username.change', ip_address: req.ip, user_agent: req.get('user-agent') }); } if (display_name !== undefined) { if (typeof display_name !== 'string') { return res.status(400).json({ error: 'display_name must be a string' }); } const trimmed = display_name.trim(); if (trimmed.length > 100) { return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); } db.prepare( "UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?" ).run(trimmed || null, req.user.id); logAudit({ user_id: req.user.id, action: 'profile.update', ip_address: req.ip, user_agent: req.get('user-agent') }); } const updated = getDb().prepare(` SELECT id, username, display_name, role, active, is_default_admin, first_login, created_at, updated_at, last_password_change_at FROM users WHERE id = ? `).get(req.user.id); res.json({ success: true, profile: updated }); }); // ── GET /api/profile/settings ───────────────────────────────────────────────── // Returns user-owned notification preferences from the users table. // Does not return admin/global/SMTP settings. router.get('/settings', (req, res) => { const db = getDb(); const user = db.prepare(` SELECT notification_email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue FROM users WHERE id = ? `).get(req.user.id); if (!user) return res.status(404).json({ error: 'User not found' }); res.json({ notification_email: user.notification_email || null, notifications_enabled: !!user.notifications_enabled, notify_3d: !!user.notify_3d, notify_1d: !!user.notify_1d, notify_due: !!user.notify_due, notify_overdue: !!user.notify_overdue, }); }); // ── PATCH /api/profile/settings ─────────────────────────────────────────────── // Updates user-owned notification preferences only. // Cannot modify global/admin/SMTP settings through this endpoint. router.patch('/settings', (req, res) => { const db = getDb(); const { notification_email, email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue, } = req.body; const nextEmail = notification_email !== undefined ? notification_email : email; if (nextEmail !== undefined && nextEmail !== null) { if (typeof nextEmail !== 'string') { return res.status(400).json({ error: 'notification_email must be a string' }); } if (nextEmail.trim().length > 255) { return res.status(400).json({ error: 'notification_email is too long' }); } } const current = db.prepare(` SELECT notification_email, notifications_enabled, notify_3d, notify_1d, notify_due, notify_overdue FROM users WHERE id = ? `).get(req.user.id); const emailVal = nextEmail !== undefined ? (nextEmail ? nextEmail.trim() || null : null) : current.notification_email; const boolVal = (incoming, fallback) => incoming !== undefined ? (incoming ? 1 : 0) : fallback; db.prepare(` UPDATE users SET notification_email = ?, notifications_enabled = ?, notify_3d = ?, notify_1d = ?, notify_due = ?, notify_overdue = ?, updated_at = datetime('now') WHERE id = ? `).run( emailVal, boolVal(notifications_enabled, current.notifications_enabled), boolVal(notify_3d, current.notify_3d), boolVal(notify_1d, current.notify_1d), boolVal(notify_due, current.notify_due), boolVal(notify_overdue, current.notify_overdue), req.user.id, ); logAudit({ user_id: req.user.id, action: 'profile.settings.update', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ success: true }); }); // ── POST /api/profile/change-password ───────────────────────────────────────── // Changes the signed-in user's password. // Always requires: current_password, new_password, confirm_new_password. // Never bypasses current_password verification regardless of must_change_password. // Never accepts user_id from the request body. router.post('/change-password', passwordLimiter, async (req, res) => { const { current_password, new_password, confirm_new_password } = req.body; if (!current_password) { return res.status(400).json({ error: 'current_password is required' }); } if (!new_password) { return res.status(400).json({ error: 'new_password is required' }); } if (!confirm_new_password) { return res.status(400).json({ error: 'confirm_new_password is required' }); } if (new_password !== confirm_new_password) { return res.status(400).json({ error: 'new passwords do not match' }); } if (new_password.length < 8) { return res.status(400).json({ error: 'new password must be at least 8 characters' }); } const db = getDb(); const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id); if (!user) return res.status(404).json({ error: 'User not found' }); const valid = await bcrypt.compare(current_password, user.password_hash); if (!valid) { return res.status(401).json({ error: 'current password is incorrect' }); } const hash = await hashPassword(new_password); db.prepare(` UPDATE users SET password_hash = ?, must_change_password = 0, last_password_change_at = datetime('now'), updated_at = datetime('now') WHERE id = ? `).run(hash, req.user.id); // Invalidate all other sessions for this user const currentSessionId = req.cookies?.[COOKIE_NAME]; if (currentSessionId) { invalidateOtherSessions(req.user.id, currentSessionId); // Rotate the current session ID for security const newSessionId = rotateSessionId(currentSessionId, req.user.id); if (newSessionId) { res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req)); } } logAudit({ user_id: req.user.id, action: 'password.change', ip_address: req.ip, user_agent: req.get('user-agent') }); res.json({ success: true }); }); // ── GET /api/profile/exports ────────────────────────────────────────────────── // Returns metadata about available user export capabilities. // Does not trigger any download; links point to the actual export endpoints. router.get('/exports', (req, res) => { res.json({ exports: [ { id: 'user_db', label: 'SQLite database export', description: "Your bills, categories, payments, and notes as a portable SQLite file", url: '/api/export/user-db', method: 'GET', }, { id: 'user_excel', label: 'Excel databook export', description: "Your data as an Excel workbook with multiple sheets", url: '/api/export/user-excel', method: 'GET', }, ], }); }); // ── GET /api/profile/import-history ────────────────────────────────────────── // Returns the signed-in user's import history. // Delegates to the same service as GET /api/import/history. router.get('/import-history', (req, res) => { try { const history = getImportHistory(req.user.id); res.json({ history }); } catch (err) { console.error('[profile] import-history error:', err.message); res.status(500).json({ error: 'Failed to load import history' }); } }); module.exports = router;