+
{!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) {