From 3228332e8c7705d6ec13878b3de5036fa7df4ba4 Mon Sep 17 00:00:00 2001 From: _null Date: Mon, 4 May 2026 23:34:24 -0500 Subject: [PATCH] push --- HISTORY.md | 12 +++- client/App.jsx | 4 ++ client/api.js | 1 + client/components/layout/Sidebar.jsx | 31 +++++---- client/lib/version.js | 4 +- client/pages/AdminPage.jsx | 98 ++++++++++++++++++---------- client/pages/DataPage.jsx | 41 +++++++++++- client/pages/LoginPage.jsx | 10 +-- client/pages/ProfilePage.jsx | 64 +++--------------- db/database.js | 21 ++++++ db/schema.sql | 2 + middleware/requireAuth.js | 5 +- package-lock.json | 4 +- package.json | 2 +- routes/admin.js | 41 +++++++++--- routes/profile.js | 27 +++++--- services/authService.js | 9 ++- services/notificationService.js | 2 +- services/oidcService.js | 4 ++ setup/firstRun.js | 6 +- 20 files changed, 251 insertions(+), 137 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 5e0e45c..236c498 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,8 +1,12 @@ # Bill Tracker β€” Changelog -## v0.18.3 +## v0.18.4 ### Added +- Added user active/inactive management in Admin. Inactive users cannot log in and active sessions are invalidated when they are disabled. +- Added a durable default-admin marker so the built-in default admin remains an admin-only operator account. +- Admin users created after first run can now sign in directly to Tracker while retaining Admin Panel access from the menu. +- Admins can delete any other user, including other admins, with a destructive 2026 warning that all user-owned bill data will be permanently removed. - Added an `Other` monthly starting amount alongside the 1st and 15th amounts. - New `monthly_starting_amounts` records store user-scoped, month-specific starting cash with `first_amount`, `fifteenth_amount`, and `other_amount`. - New `GET /api/monthly-starting-amounts` and `PUT /api/monthly-starting-amounts` endpoints manage monthly starting balances. @@ -20,7 +24,11 @@ - Previous month remaining is exposed to Summary as informational context only when available. - Navigation now groups Overview, Summary, Bills, and Categories under Tracker, and groups Profile, Settings, and Data in the user menu. - System Status is admin-only and appears in the Admin Panel navigation. -- No payment behavior, bill behavior, Calendar, Analytics, auth, or admin behavior was changed. +- Profile now focuses on account details, display name, password, and notification preferences in a narrower modern layout. +- Data is restored as a dedicated import/export/history page instead of redirecting into Profile. +- Fixed Admin Panel availability when all managed users have been promoted to admin. +- The default admin account is blocked from Tracker/user-data routes; non-default admins keep regular Tracker access. +- No payment behavior, bill behavior, Calendar, or Analytics behavior was changed. ## v0.18.1 diff --git a/client/App.jsx b/client/App.jsx index cdb7dc1..b5a45d8 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -42,6 +42,10 @@ function RequireAuth({ children, role }) { const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin'); + if (role === 'user' && user.is_default_admin) { + return ; + } + // Role mismatch if (!roleAllowed) { return ; diff --git a/client/api.js b/client/api.js index 7d2d658..26594e5 100644 --- a/client/api.js +++ b/client/api.js @@ -41,6 +41,7 @@ export const api = { createUser: (data) => post('/admin/users', data), resetPassword: (id, data) => put(`/admin/users/${id}/password`, data), updateUserRole: (id, data) => put(`/admin/users/${id}/role`, data), + updateUserActive: (id, data) => put(`/admin/users/${id}/active`, data), deleteUser: (id) => del(`/admin/users/${id}`), authModeConfig: () => get('/admin/auth-mode'), setAuthMode: (data) => put('/admin/auth-mode', data), diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index c4d9112..eefdaa6 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -119,6 +119,7 @@ function UserMenu({ adminMode = false }) { const { user, logout } = useAuth(); const navigate = useNavigate(); const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile'); + const accountToolsAllowed = !user?.is_default_admin; const handleLogout = async () => { try { await logout(); } catch {} @@ -155,19 +156,23 @@ function UserMenu({ adminMode = false }) { )} - navigate('/profile')}> - - Profile - - navigate('/settings')}> - - Settings - - navigate('/data')}> - - Data - - + {accountToolsAllowed && ( + <> + navigate('/profile')}> + + Profile + + navigate('/settings')}> + + Settings + + navigate('/data')}> + + Data + + + + )} navigate('/about')}> About diff --git a/client/lib/version.js b/client/lib/version.js index 3fe0c8f..065cbd2 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,8 +1,8 @@ -export const APP_VERSION = '0.18.1'; +export const APP_VERSION = '0.18.4'; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.18.1', + version: '0.18.4', date: '2026-05-04', highlights: [ { icon: 'πŸ“±', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' }, diff --git a/client/pages/AdminPage.jsx b/client/pages/AdminPage.jsx index b5c541d..f3cec59 100644 --- a/client/pages/AdminPage.jsx +++ b/client/pages/AdminPage.jsx @@ -961,6 +961,7 @@ function UsersTable({ users, onRefresh, currentUser }) { const [deleting, setDeleting] = useState(null); const [resetting, setResetting] = useState(null); const [roleUpdating, setRoleUpdating] = useState(null); + const [activeUpdating, setActiveUpdating] = useState(null); // Delete confirmation dialog const [deleteTarget, setDeleteTarget] = useState(null); // user object @@ -1012,6 +1013,19 @@ function UsersTable({ users, onRefresh, currentUser }) { } }; + const handleActiveChange = async (user, active) => { + setActiveUpdating(user.id); + try { + await api.updateUserActive(user.id, { active }); + toast.success(`${user.username} is now ${active ? 'active' : 'inactive'}.`); + onRefresh(); + } catch (err) { + toast.error(err.message || 'Failed to update user status.'); + } finally { + setActiveUpdating(null); + } + }; + return ( <> @@ -1025,6 +1039,7 @@ function UsersTable({ users, onRefresh, currentUser }) { Username Role + Status Password Reset Password @@ -1036,7 +1051,12 @@ function UsersTable({ users, onRefresh, currentUser }) { const isSelf = currentUser?.id === user.id; return ( - {user.username} + +
+ {user.username} + {user.is_default_admin && default admin} +
+
@@ -1054,6 +1074,18 @@ function UsersTable({ users, onRefresh, currentUser }) {
+ + + {user.must_change_password ? Temporary @@ -1061,40 +1093,38 @@ function UsersTable({ users, onRefresh, currentUser }) { } - {user.role !== 'admin' && ( - form.open ? ( -
- setReset(user.id, { pw: e.target.value })} - className="h-8 text-sm w-36" - /> - - -
- ) : ( - - ) + + + ) : ( + )} - {user.role !== 'admin' && ( + {!isSelf && ( @@ -249,7 +243,6 @@ function ProfileNav() { ['#account', 'Account'], ['#security', 'Security'], ['#notifications', 'Notifications'], - ['#data', 'My Data'], ]; return (
@@ -266,42 +259,6 @@ function ProfileNav() { ); } -function DataManagement() { - const [history, setHistory] = useState(null); - const [historyLoading, setHistoryLoading] = useState(true); - - const loadHistory = async () => { - setHistoryLoading(true); - try { - const { history } = await api.importHistory(); - setHistory(history); - } catch { - setHistory([]); - } finally { - setHistoryLoading(false); - } - }; - - useEffect(() => { loadHistory(); }, []); - - return ( -
-
-

My Data

-

- Import spreadsheet history, import your SQLite data export, and export your user-owned records. -

-
-
- - -
- - -
- ); -} - export default function ProfilePage() { const { setUser, refresh } = useAuth(); const [profile, setProfile] = useState({}); @@ -332,11 +289,11 @@ export default function ProfilePage() { }; return ( -
-
+
+

Profile

-

Manage your account, notifications, password, exports, and import history.

+

Manage your account, notification preferences, and password.

@@ -346,7 +303,7 @@ export default function ProfilePage() { -
+
{!loading && } @@ -357,7 +314,6 @@ export default function ProfilePage() {
{!loading && }
-
); diff --git a/db/database.js b/db/database.js index 55483f8..f859747 100644 --- a/db/database.js +++ b/db/database.js @@ -110,6 +110,8 @@ function runMigrations() { // ── users: notification columns ─────────────────────────────────────────── const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const newUserCols = [ + ['active', 'INTEGER NOT NULL DEFAULT 1'], + ['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'], ['notification_email', 'TEXT'], ['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['notify_3d', 'INTEGER NOT NULL DEFAULT 1'], @@ -120,6 +122,25 @@ function runMigrations() { for (const [col, def] of newUserCols) { if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); } + const defaultAdminName = process.env.INIT_ADMIN_USER || 'admin'; + db.prepare(` + UPDATE users + SET is_default_admin = 1 + WHERE role = 'admin' + AND username = ? + AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1) + `).run(defaultAdminName); + db.exec(` + UPDATE users + SET is_default_admin = 1 + WHERE id = ( + SELECT id FROM users + WHERE role = 'admin' + ORDER BY id + LIMIT 1 + ) + AND NOT EXISTS (SELECT 1 FROM users WHERE is_default_admin = 1) + `); // ── payments: soft-delete column (v0.2) ────────────────────────────────── const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); diff --git a/db/schema.sql b/db/schema.sql index 6dd9b2b..66f4795 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS users ( username TEXT NOT NULL UNIQUE COLLATE NOCASE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')), + active INTEGER NOT NULL DEFAULT 1, + is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0, first_login INTEGER NOT NULL DEFAULT 1, created_at TEXT DEFAULT (datetime('now')), diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js index 6e571d3..2a0736c 100644 --- a/middleware/requireAuth.js +++ b/middleware/requireAuth.js @@ -6,7 +6,7 @@ function getSingleModeUser() { const userId = getSetting('default_user_id'); if (!userId) return null; const row = getDb().prepare( - "SELECT id, username, display_name, role, must_change_password, first_login FROM users WHERE id = ? AND role = 'user'" + "SELECT id, username, display_name, role, must_change_password, first_login, active, is_default_admin FROM users WHERE id = ? AND role = 'user' AND active = 1" ).get(userId); return row ? publicUser(row) : null; } @@ -27,6 +27,9 @@ function requireAuth(req, res, next) { } function requireUser(req, res, next) { + if (req.user?.is_default_admin) { + return res.status(403).json({ error: 'Default admin account does not have tracker access' }); + } if (!['user', 'admin'].includes(req.user?.role)) { return res.status(403).json({ error: 'Access denied: user account required' }); } diff --git a/package-lock.json b/package-lock.json index b0dea5d..cb7d101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bill-tracker", - "version": "0.18.3", + "version": "0.18.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bill-tracker", - "version": "0.18.3", + "version": "0.18.4", "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", diff --git a/package.json b/package.json index 95c1148..c356031 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.18.3", + "version": "0.18.4", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/admin.js b/routes/admin.js index 647934d..abb4207 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -30,7 +30,7 @@ function sendError(res, err) { // GET /api/admin/has-users router.get('/has-users', (req, res) => { - const count = getDb().prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'user'").get().n; + const count = getDb().prepare('SELECT COUNT(*) AS n FROM users WHERE id != ?').get(req.user.id).n; res.json({ has_users: count > 0 }); }); @@ -38,7 +38,7 @@ router.get('/has-users', (req, res) => { router.get('/users', (req, res) => { res.json( getDb().prepare( - 'SELECT id, username, role, must_change_password, first_login, created_at FROM users ORDER BY role DESC, username ASC' + 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users ORDER BY is_default_admin DESC, role DESC, username ASC' ).all() ); }); @@ -156,7 +156,7 @@ router.post('/users', async (req, res) => { ).run(username, hash); res.status(201).json( - db.prepare('SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?') + db.prepare('SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?') .get(result.lastInsertRowid) ); }); @@ -170,7 +170,6 @@ router.put('/users/:id/password', async (req, res) => { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); - if (user.role === 'admin') return res.status(403).json({ error: 'Cannot reset admin password this way' }); const hash = await hashPassword(password); db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?") @@ -208,20 +207,46 @@ router.put('/users/:id/role', (req, res) => { .run(role, targetId); const updated = db.prepare( - 'SELECT id, username, role, must_change_password, first_login, created_at FROM users WHERE id = ?' + 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' ).get(targetId); res.json(updated); }); +// PUT /api/admin/users/:id/active +router.put('/users/:id/active', (req, res) => { + const active = req.body?.active ? 1 : 0; + const targetId = Number(req.params.id); + const db = getDb(); + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(targetId); + if (!user) return res.status(404).json({ error: 'User not found' }); + if (req.user?.id === targetId) { + return res.status(400).json({ error: 'You cannot deactivate your own account.' }); + } + + db.prepare("UPDATE users SET active = ?, updated_at = datetime('now') WHERE id = ?").run(active, targetId); + if (!active) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(targetId); + + res.json(db.prepare( + 'SELECT id, username, role, active, is_default_admin, must_change_password, first_login, created_at FROM users WHERE id = ?' + ).get(targetId)); +}); + // DELETE /api/admin/users/:id router.delete('/users/:id', (req, res) => { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); - if (user.role === 'admin') return res.status(403).json({ error: 'Cannot delete the admin account' }); - db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); - res.json({ success: true }); + if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' }); + + const deleteUser = db.transaction(() => { + db.prepare('DELETE FROM import_sessions WHERE user_id = ?').run(user.id); + db.prepare('DELETE FROM import_history WHERE user_id = ?').run(user.id); + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id); + db.prepare('DELETE FROM users WHERE id = ?').run(user.id); + }); + deleteUser(); + res.json({ success: true, deleted_user_id: user.id }); }); // ── Cleanup endpoints ───────────────────────────────────────────────────────── diff --git a/routes/profile.js b/routes/profile.js index cd9201f..01d5f06 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -18,7 +18,7 @@ const { passwordLimiter } = require('../middleware/rateLimiter'); router.get('/', (req, res) => { const db = getDb(); const user = db.prepare(` - SELECT id, username, display_name, role, + SELECT id, username, display_name, role, active, is_default_admin, first_login, created_at, updated_at, last_password_change_at, notification_email, notifications_enabled, @@ -33,6 +33,8 @@ router.get('/', (req, res) => { 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, @@ -73,7 +75,14 @@ router.patch('/', (req, res) => { ).run(trimmed || null, req.user.id); } - res.json({ success: true }); + 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 ───────────────────────────────────────────────── @@ -105,15 +114,17 @@ router.get('/settings', (req, res) => { router.patch('/settings', (req, res) => { const db = getDb(); const { - notification_email, notifications_enabled, + notification_email, 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') { + 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 (notification_email.trim().length > 255) { + if (nextEmail.trim().length > 255) { return res.status(400).json({ error: 'notification_email is too long' }); } } @@ -124,8 +135,8 @@ router.patch('/settings', (req, res) => { FROM users WHERE id = ? `).get(req.user.id); - const emailVal = notification_email !== undefined - ? (notification_email ? notification_email.trim() || null : null) + const emailVal = nextEmail !== undefined + ? (nextEmail ? nextEmail.trim() || null : null) : current.notification_email; const boolVal = (incoming, fallback) => diff --git a/services/authService.js b/services/authService.js index c1bea79..44e3cd1 100644 --- a/services/authService.js +++ b/services/authService.js @@ -46,6 +46,7 @@ async function login(username, password) { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) return null; + if (user.active === 0) return null; // Reject OIDC-only accounts from local login if (user.auth_provider && user.auth_provider !== 'local') { @@ -79,6 +80,7 @@ async function createSession(userId) { const db = getDb(); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) return null; + if (user.active === 0) return null; const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000) @@ -98,10 +100,11 @@ function logout(sessionId) { function getSessionUser(sessionId) { if (!sessionId) return null; const row = getDb().prepare(` - SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login + SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login, + u.active, u.is_default_admin FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.id = ? AND s.expires_at > datetime('now') + WHERE s.id = ? AND s.expires_at > datetime('now') AND u.active = 1 `).get(sessionId); return row || null; } @@ -116,6 +119,8 @@ function publicUser(u) { username: u.username, display_name: u.display_name || null, role: u.role, + active: u.active !== 0, + is_default_admin: !!u.is_default_admin, must_change_password: !!u.must_change_password, first_login: !!u.first_login, }; diff --git a/services/notificationService.js b/services/notificationService.js index 9a5e826..0cee7f4 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -186,7 +186,7 @@ async function runNotifications() { if (allowUserConfig) { const users = db.prepare( - "SELECT * FROM users WHERE role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''" + "SELECT * FROM users WHERE active = 1 AND role='user' AND notifications_enabled=1 AND notification_email IS NOT NULL AND notification_email != ''" ).all(); recipients.push(...users); } else if (globalRecipient) { diff --git a/services/oidcService.js b/services/oidcService.js index 52922db..1d528ef 100644 --- a/services/oidcService.js +++ b/services/oidcService.js @@ -475,6 +475,10 @@ async function findOrProvisionUser(claims, config) { console.log(`[oidc] Provisioned new ${role} user from OIDC login`); } + if (user.active === 0) { + throw Object.assign(new Error('This account is inactive.'), { status: 403 }); + } + // Refresh login timestamp and display name from provider claims db.prepare(` UPDATE users diff --git a/setup/firstRun.js b/setup/firstRun.js index c3f3995..95e0b8c 100644 --- a/setup/firstRun.js +++ b/setup/firstRun.js @@ -54,9 +54,9 @@ function promptPassword(label) { async function createUser(db, username, password, role) { const hash = await bcrypt.hash(password, 12); db.prepare(` - INSERT INTO users (username, password_hash, role, first_login, must_change_password) - VALUES (?, ?, ?, 0, 0) - `).run(username, hash, role); + INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin) + VALUES (?, ?, ?, 0, 0, ?) + `).run(username, hash, role, role === 'admin' ? 1 : 0); } async function runFromEnv(db) {