241 lines
9.1 KiB
JavaScript
241 lines
9.1 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
const express = require('express');
|
||
|
|
const router = express.Router();
|
||
|
|
const bcrypt = require('bcryptjs');
|
||
|
|
|
||
|
|
const { getDb, getSetting } = require('../db/database');
|
||
|
|
const { hashPassword } = require('../services/authService');
|
||
|
|
const { getImportHistory } = require('../services/spreadsheetImportService');
|
||
|
|
const { passwordLimiter } = require('../middleware/rateLimiter');
|
||
|
|
|
||
|
|
// 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,
|
||
|
|
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,
|
||
|
|
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: display_name only.
|
||
|
|
// Ignores any unknown or restricted fields.
|
||
|
|
router.patch('/', (req, res) => {
|
||
|
|
const { display_name } = req.body;
|
||
|
|
|
||
|
|
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' });
|
||
|
|
}
|
||
|
|
|
||
|
|
getDb().prepare(
|
||
|
|
"UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?"
|
||
|
|
).run(trimmed || null, req.user.id);
|
||
|
|
}
|
||
|
|
|
||
|
|
res.json({ success: true });
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── 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, notifications_enabled,
|
||
|
|
notify_3d, notify_1d, notify_due, notify_overdue,
|
||
|
|
} = req.body;
|
||
|
|
|
||
|
|
if (notification_email !== undefined && notification_email !== null) {
|
||
|
|
if (typeof notification_email !== 'string') {
|
||
|
|
return res.status(400).json({ error: 'notification_email must be a string' });
|
||
|
|
}
|
||
|
|
if (notification_email.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 = notification_email !== undefined
|
||
|
|
? (notification_email ? notification_email.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,
|
||
|
|
);
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
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;
|