From ab5e3fbf1f8331cf1058284a6a9d97f32a3be72f Mon Sep 17 00:00:00 2001 From: null Date: Sun, 7 Jun 2026 01:17:49 -0500 Subject: [PATCH] feat: profile settings UI, auth service refactor, schema migration, route tests --- client/components/layout/Sidebar.jsx | 2 +- client/pages/ProfilePage.jsx | 12 ++- db/schema.sql | 1 + routes/profile.js | 44 +++++++---- services/authService.js | 5 +- tests/profileRoute.test.js | 111 +++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 tests/profileRoute.test.js diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index f20ccbc..34d58ee 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -111,7 +111,7 @@ function UserMenu({ adminMode = false }) { const { user, logout } = useAuth(); const navigate = useNavigate(); const name = useMemo(() => - user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile'), + user?.display_name || user?.displayName || user?.name || user?.username || (adminMode ? 'Admin' : 'Profile'), [user, adminMode] ); const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]); diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index edfe554..3275d3d 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -17,6 +17,10 @@ function asProfile(data) { return data?.profile || data?.user || data || {}; } +function displayNameOf(profile) { + return profile.display_name || profile.displayName || profile.name || ''; +} + function asSettings(data) { return data?.settings || data?.notifications || data || {}; } @@ -297,7 +301,7 @@ function ProfileSummary({ profile, loading }) {
- + { - setDisplayName(profile.display_name || profile.displayName || ''); - }, [profile.display_name, profile.displayName]); + setDisplayName(displayNameOf(profile)); + }, [profile.display_name, profile.displayName, profile.name]); const save = async () => { setSaving(true); diff --git a/db/schema.sql b/db/schema.sql index 5ff26dc..bbedec7 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -78,6 +78,7 @@ CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')), active INTEGER NOT NULL DEFAULT 1, diff --git a/routes/profile.js b/routes/profile.js index 9a060c8..677b8e7 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -26,6 +26,24 @@ function requireDataImportEnabled(req, res, next) { next(); } +function profileResponse(user) { + const displayName = user.display_name || null; + return { + id: user.id, + username: user.username, + display_name: displayName, + displayName, + name: displayName || user.username, + 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, + }; +} + // ── GET /api/profile ────────────────────────────────────────────────────────── // Returns safe profile data for the signed-in user. // Never returns password_hash, session tokens, or secrets. @@ -43,16 +61,7 @@ router.get('/', (req, res) => { 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, + ...profileResponse(user), notifications: { email: user.notification_email || null, enabled: !!user.notifications_enabled, @@ -73,7 +82,12 @@ router.get('/', (req, res) => { // 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 { username } = req.body; + const displayNameInput = req.body.display_name !== undefined + ? req.body.display_name + : req.body.displayName !== undefined + ? req.body.displayName + : req.body.name; const db = getDb(); if (username !== undefined) { @@ -99,11 +113,11 @@ router.patch('/', (req, res) => { 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') { + if (displayNameInput !== undefined) { + if (typeof displayNameInput !== 'string') { return res.status(400).json({ error: 'display_name must be a string' }); } - const trimmed = display_name.trim(); + const trimmed = displayNameInput.trim(); if (trimmed.length > 100) { return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); } @@ -122,7 +136,7 @@ router.patch('/', (req, res) => { FROM users WHERE id = ? `).get(req.user.id); - res.json({ success: true, profile: updated }); + res.json({ success: true, profile: profileResponse(updated) }); }); // ── GET /api/profile/settings ───────────────────────────────────────────────── diff --git a/services/authService.js b/services/authService.js index 5b0cacb..b9cb402 100644 --- a/services/authService.js +++ b/services/authService.js @@ -182,10 +182,13 @@ async function hashPassword(password) { } function publicUser(u) { + const displayName = u.display_name || null; return { id: u.id, username: u.username, - display_name: u.display_name || null, + display_name: displayName, + displayName, + name: displayName || u.username, role: u.role, active: u.active !== 0, is_default_admin: !!u.is_default_admin, diff --git a/tests/profileRoute.test.js b/tests/profileRoute.test.js new file mode 100644 index 0000000..224eb4e --- /dev/null +++ b/tests/profileRoute.test.js @@ -0,0 +1,111 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-profile-route-test-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { publicUser } = require('../services/authService'); + +function createUser(db, suffix) { + return db.prepare(` + INSERT INTO users (username, password_hash, role, active, created_at, updated_at) + VALUES (?, 'x', 'user', 1, datetime('now'), datetime('now')) + `).run(`profile-user-${suffix}`).lastInsertRowid; +} + +function callProfileRoute(method, { userId, body = {} }) { + const profileRouter = require('../routes/profile'); + const layer = profileRouter.stack.find(item => item.route?.path === '/' && item.route.methods[method]); + assert.ok(layer, `route ${method.toUpperCase()} / should exist`); + const handler = layer.route.stack[0].handle; + + return new Promise((resolve, reject) => { + const req = { + body, + ip: '127.0.0.1', + user: { id: userId, role: 'user' }, + get(header) { + return header === 'user-agent' ? 'node:test' : undefined; + }, + }; + const res = { + statusCode: 200, + status(code) { + this.statusCode = code; + return this; + }, + json(data) { + resolve({ status: this.statusCode, data }); + }, + }; + try { + handler(req, res); + } catch (err) { + reject(err); + } + }); +} + +test.after(() => { + closeDb(); + for (const suffix of ['', '-wal', '-shm']) { + fs.rmSync(`${dbPath}${suffix}`, { force: true }); + } +}); + +test('profile name is persisted and returned consistently', async () => { + const db = getDb(); + const userId = createUser(db, 'name'); + + const saved = await callProfileRoute('patch', { + userId, + body: { name: 'Kasper Example' }, + }); + + assert.equal(saved.status, 200); + assert.equal(saved.data.profile.display_name, 'Kasper Example'); + assert.equal(saved.data.profile.displayName, 'Kasper Example'); + assert.equal(saved.data.profile.name, 'Kasper Example'); + + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); + assert.equal(row.display_name, 'Kasper Example'); + assert.deepEqual( + { + display_name: publicUser(row).display_name, + displayName: publicUser(row).displayName, + name: publicUser(row).name, + }, + { + display_name: 'Kasper Example', + displayName: 'Kasper Example', + name: 'Kasper Example', + }, + ); + + const loaded = await callProfileRoute('get', { userId }); + assert.equal(loaded.status, 200); + assert.equal(loaded.data.display_name, 'Kasper Example'); + assert.equal(loaded.data.displayName, 'Kasper Example'); + assert.equal(loaded.data.name, 'Kasper Example'); +}); + +test('clearing profile name falls back to username for display', async () => { + const db = getDb(); + const userId = createUser(db, 'clear'); + + const cleared = await callProfileRoute('patch', { + userId, + body: { displayName: ' ' }, + }); + + const username = db.prepare('SELECT username FROM users WHERE id = ?').get(userId).username; + + assert.equal(cleared.status, 200); + assert.equal(cleared.data.profile.display_name, null); + assert.equal(cleared.data.profile.displayName, null); + assert.equal(cleared.data.profile.name, username); +});