This commit is contained in:
_null 2026-05-04 23:34:24 -05:00
parent d1efeece04
commit 3228332e8c
20 changed files with 251 additions and 137 deletions

View File

@ -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

View File

@ -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 <Navigate to="/admin" replace />;
}
// Role mismatch
if (!roleAllowed) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;

View File

@ -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),

View File

@ -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,6 +156,8 @@ function UserMenu({ adminMode = false }) {
<DropdownMenuSeparator />
</>
)}
{accountToolsAllowed && (
<>
<DropdownMenuItem onSelect={() => navigate('/profile')}>
<User className="h-4 w-4" />
Profile
@ -168,6 +171,8 @@ function UserMenu({ adminMode = false }) {
Data
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={() => navigate('/about')}>
<Info className="h-4 w-4" />
About

View File

@ -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.' },

View File

@ -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 (
<>
<Card>
@ -1025,6 +1039,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
<tr className="border-b border-border">
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Username</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Role</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Status</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Password</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
<th className="px-6 py-3" />
@ -1036,7 +1051,12 @@ function UsersTable({ users, onRefresh, currentUser }) {
const isSelf = currentUser?.id === user.id;
return (
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-6 py-3 font-medium">{user.username}</td>
<td className="px-6 py-3 font-medium">
<div className="flex items-center gap-2">
<span>{user.username}</span>
{user.is_default_admin && <Badge variant="secondary">default admin</Badge>}
</div>
</td>
<td className="px-6 py-3">
<div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
@ -1054,6 +1074,18 @@ function UsersTable({ users, onRefresh, currentUser }) {
</select>
</div>
</td>
<td className="px-6 py-3">
<select
value={user.active === false || user.active === 0 ? 'inactive' : 'active'}
disabled={isSelf || activeUpdating === user.id}
onChange={e => handleActiveChange(user, e.target.value === 'active')}
className="h-8 rounded-md border border-input bg-background px-2 text-xs disabled:cursor-not-allowed disabled:opacity-50"
title={isSelf ? 'You cannot deactivate your own account' : 'Change user status'}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
</td>
<td className="px-6 py-3">
{user.must_change_password
? <Badge variant="due_soon">Temporary</Badge>
@ -1061,8 +1093,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
}
</td>
<td className="px-6 py-3">
{user.role !== 'admin' && (
form.open ? (
{form.open ? (
<div className="flex items-center gap-2">
<Input
type="password"
@ -1090,11 +1121,10 @@ function UsersTable({ users, onRefresh, currentUser }) {
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
</Button>
)
)}
</td>
<td className="px-6 py-3 text-right">
{user.role !== 'admin' && (
{!isSelf && (
<Button
size="sm"
variant="destructive"
@ -1110,7 +1140,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
})}
{!users?.length && (
<tr>
<td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">No users found.</td>
</tr>
)}
</tbody>
@ -1125,7 +1155,9 @@ function UsersTable({ users, onRefresh, currentUser }) {
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. All their sessions will be invalidated.
This is permanent in 2026. The user account and all user-owned data will be deleted, including bills,
payments, categories, monthly state, monthly starting amounts, imports, import history, and sessions.
This cannot be undone from BillTracker.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>

View File

@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
@ -1411,5 +1410,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
// DataPage
export default function DataPage() {
return <Navigate to="/profile" replace />;
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 (
<div className="mx-auto w-full max-w-6xl space-y-5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Data</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Import, export, and review your user-owned bill tracker records.
</p>
</div>
<div className="rounded-full border border-border/70 bg-card/80 px-3 py-1.5 text-xs font-medium text-muted-foreground">
User data only
</div>
</div>
<div className="grid gap-5 xl:grid-cols-2 xl:items-start">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>
<DownloadMyDataSection />
<ImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
</div>
);
}

View File

@ -33,7 +33,7 @@ export default function LoginPage() {
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const destFor = (role) => (role === 'admin' ? '/admin' : '/');
const destFor = (user) => (user?.is_default_admin ? '/admin' : '/');
useEffect(() => {
api.authMode()
@ -45,7 +45,7 @@ export default function LoginPage() {
api.me()
.then(d => {
if (d.user) navigate(destFor(d.user.role), { replace: true });
if (d.user) navigate(destFor(d.user), { replace: true });
})
.catch(() => {});
}, []); // eslint-disable-line
@ -59,7 +59,7 @@ export default function LoginPage() {
setPendingUser(user);
setShowPrivacy(true);
} else {
navigate(destFor(user.role), { replace: true });
navigate(destFor(user), { replace: true });
}
};
@ -104,7 +104,7 @@ export default function LoginPage() {
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
navigate(destFor(pendingUser.role), { replace: true });
navigate(destFor(pendingUser), { replace: true });
}
} catch (err) {
toast.error(err.message || 'Failed to change password.');
@ -120,7 +120,7 @@ export default function LoginPage() {
refresh();
setShowPrivacy(false);
navigate(destFor(pendingUser?.role), { replace: true });
navigate(destFor(pendingUser), { replace: true });
};
return (

View File

@ -8,12 +8,6 @@ import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
ImportSpreadsheetSection,
DownloadMyDataSection,
ImportMyDataSection,
ImportHistorySection as DataImportHistorySection,
} from '@/pages/DataPage';
function asProfile(data) {
return data?.profile || data?.user || data || {};
@ -34,9 +28,9 @@ function formatDateTime(value) {
function SectionCard({ title, icon: Icon, subtitle, children }) {
return (
<section className="table-surface">
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
@ -78,7 +72,7 @@ function ProfileSummary({ profile, loading }) {
return (
<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 xl:grid-cols-5">
<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="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
@ -133,7 +127,7 @@ function NotificationPreferences({ settings, onSaved }) {
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
const payload = {
email: form.email || '',
email: form.email || form.notification_email || '',
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
@ -223,7 +217,7 @@ function ChangePassword() {
return (
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form.">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-[1fr_1fr_1fr_auto] lg:items-end">
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-3">
<div className="space-y-1.5">
<label htmlFor="current-password" className="text-xs font-medium text-muted-foreground">Current password</label>
<Input id="current-password" type="password" autoComplete="current-password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} />
@ -236,7 +230,7 @@ function ChangePassword() {
<label htmlFor="confirm-password" className="text-xs font-medium text-muted-foreground">Confirm new password</label>
<Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div>
<Button type="submit" disabled={saving}>
<Button type="submit" disabled={saving} className="lg:col-start-3">
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Change Password'}
</Button>
</form>
@ -249,7 +243,6 @@ function ProfileNav() {
['#account', 'Account'],
['#security', 'Security'],
['#notifications', 'Notifications'],
['#data', 'My Data'],
];
return (
<div className="mb-6 flex flex-wrap gap-2">
@ -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 (
<div id="data" className="scroll-mt-6 space-y-6">
<div>
<h2 className="text-sm font-semibold tracking-tight">My Data</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Import spreadsheet history, import your SQLite data export, and export your user-owned records.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>
<DownloadMyDataSection />
<DataImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
</div>
);
}
export default function ProfilePage() {
const { setUser, refresh } = useAuth();
const [profile, setProfile] = useState({});
@ -332,11 +289,11 @@ export default function ProfilePage() {
};
return (
<div>
<div className="mb-8 flex items-start justify-between gap-4">
<div className="mx-auto w-full max-w-5xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notifications, password, exports, and import history.</p>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notification preferences, and password.</p>
</div>
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
@ -346,7 +303,7 @@ export default function ProfilePage() {
<ProfileNav />
<div className="space-y-6">
<div className="space-y-5">
<div id="account" className="scroll-mt-6 space-y-6">
<ProfileSummary profile={profile} loading={loading} />
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
@ -357,7 +314,6 @@ export default function ProfilePage() {
<div id="notifications" className="scroll-mt-6">
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
</div>
<DataManagement />
</div>
</div>
);

View File

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

View File

@ -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')),

View File

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

4
package-lock.json generated
View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.18.3",
"version": "0.18.4",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -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 ─────────────────────────────────────────────────────────

View File

@ -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) =>

View File

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

View File

@ -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) {

View File

@ -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

View File

@ -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) {