feat: profile settings UI, auth service refactor, schema migration, route tests
This commit is contained in:
parent
6811eb8be5
commit
ab5e3fbf1f
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue