feat: profile settings UI, auth service refactor, schema migration, route tests

This commit is contained in:
null 2026-06-07 01:17:49 -05:00
parent 6811eb8be5
commit ab5e3fbf1f
6 changed files with 154 additions and 21 deletions

View File

@ -111,7 +111,7 @@ function UserMenu({ adminMode = false }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const name = useMemo(() => 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] [user, adminMode]
); );
const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]); const accountToolsAllowed = useMemo(() => !user?.is_default_admin, [user]);

View File

@ -17,6 +17,10 @@ function asProfile(data) {
return data?.profile || data?.user || data || {}; return data?.profile || data?.user || data || {};
} }
function displayNameOf(profile) {
return profile.display_name || profile.displayName || profile.name || '';
}
function asSettings(data) { function asSettings(data) {
return data?.settings || data?.notifications || data || {}; return data?.settings || data?.notifications || data || {};
} }
@ -297,7 +301,7 @@ function ProfileSummary({ profile, loading }) {
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details."> <SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FieldRow label="Username" value={profile.username} /> <FieldRow label="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} /> <FieldRow label="Display Name" value={displayNameOf(profile)} />
<FieldRow label="Role" value={profile.role} /> <FieldRow label="Role" value={profile.role} />
<LoginSummaryCard <LoginSummaryCard
@ -321,12 +325,12 @@ function ProfileSummary({ profile, loading }) {
} }
function EditProfile({ profile, onSaved }) { function EditProfile({ profile, onSaved }) {
const [displayName, setDisplayName] = useState(profile.display_name || profile.displayName || ''); const [displayName, setDisplayName] = useState(displayNameOf(profile));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
setDisplayName(profile.display_name || profile.displayName || ''); setDisplayName(displayNameOf(profile));
}, [profile.display_name, profile.displayName]); }, [profile.display_name, profile.displayName, profile.name]);
const save = async () => { const save = async () => {
setSaving(true); setSaving(true);

View File

@ -78,6 +78,7 @@ CREATE TABLE IF NOT EXISTS settings (
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')), role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
active INTEGER NOT NULL DEFAULT 1, active INTEGER NOT NULL DEFAULT 1,

View File

@ -26,6 +26,24 @@ function requireDataImportEnabled(req, res, next) {
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 ────────────────────────────────────────────────────────── // ── GET /api/profile ──────────────────────────────────────────────────────────
// Returns safe profile data for the signed-in user. // Returns safe profile data for the signed-in user.
// Never returns password_hash, session tokens, or secrets. // 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' }); if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ res.json({
id: user.id, ...profileResponse(user),
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: { notifications: {
email: user.notification_email || null, email: user.notification_email || null,
enabled: !!user.notifications_enabled, enabled: !!user.notifications_enabled,
@ -73,7 +82,12 @@ router.get('/', (req, res) => {
// Updates safe profile fields: username and display_name. // Updates safe profile fields: username and display_name.
// Ignores any unknown or restricted fields. // Ignores any unknown or restricted fields.
router.patch('/', (req, res) => { 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(); const db = getDb();
if (username !== undefined) { 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') }); 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 (displayNameInput !== undefined) {
if (typeof display_name !== 'string') { if (typeof displayNameInput !== 'string') {
return res.status(400).json({ error: 'display_name must be a 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) { if (trimmed.length > 100) {
return res.status(400).json({ error: 'display_name must be 100 characters or fewer' }); 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 = ? FROM users WHERE id = ?
`).get(req.user.id); `).get(req.user.id);
res.json({ success: true, profile: updated }); res.json({ success: true, profile: profileResponse(updated) });
}); });
// ── GET /api/profile/settings ───────────────────────────────────────────────── // ── GET /api/profile/settings ─────────────────────────────────────────────────

View File

@ -182,10 +182,13 @@ async function hashPassword(password) {
} }
function publicUser(u) { function publicUser(u) {
const displayName = u.display_name || null;
return { return {
id: u.id, id: u.id,
username: u.username, username: u.username,
display_name: u.display_name || null, display_name: displayName,
displayName,
name: displayName || u.username,
role: u.role, role: u.role,
active: u.active !== 0, active: u.active !== 0,
is_default_admin: !!u.is_default_admin, is_default_admin: !!u.is_default_admin,

111
tests/profileRoute.test.js Normal file
View File

@ -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);
});