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 # Bill Tracker — Changelog
## v0.18.3 ## v0.18.4
### Added ### 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. - 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 `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. - 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. - 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. - 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. - 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 ## v0.18.1

View File

@ -42,6 +42,10 @@ function RequireAuth({ children, role }) {
const roleAllowed = !role || user.role === role || (role === 'user' && user.role === 'admin'); 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 // Role mismatch
if (!roleAllowed) { if (!roleAllowed) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />; return <Navigate to={user.role === 'admin' ? '/admin' : '/'} replace />;

View File

@ -41,6 +41,7 @@ export const api = {
createUser: (data) => post('/admin/users', data), createUser: (data) => post('/admin/users', data),
resetPassword: (id, data) => put(`/admin/users/${id}/password`, data), resetPassword: (id, data) => put(`/admin/users/${id}/password`, data),
updateUserRole: (id, data) => put(`/admin/users/${id}/role`, 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}`), deleteUser: (id) => del(`/admin/users/${id}`),
authModeConfig: () => get('/admin/auth-mode'), authModeConfig: () => get('/admin/auth-mode'),
setAuthMode: (data) => put('/admin/auth-mode', data), setAuthMode: (data) => put('/admin/auth-mode', data),

View File

@ -119,6 +119,7 @@ function UserMenu({ adminMode = false }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile'); const name = user?.display_name || user?.username || (adminMode ? 'Admin' : 'Profile');
const accountToolsAllowed = !user?.is_default_admin;
const handleLogout = async () => { const handleLogout = async () => {
try { await logout(); } catch {} try { await logout(); } catch {}
@ -155,19 +156,23 @@ function UserMenu({ adminMode = false }) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
)} )}
<DropdownMenuItem onSelect={() => navigate('/profile')}> {accountToolsAllowed && (
<User className="h-4 w-4" /> <>
Profile <DropdownMenuItem onSelect={() => navigate('/profile')}>
</DropdownMenuItem> <User className="h-4 w-4" />
<DropdownMenuItem onSelect={() => navigate('/settings')}> Profile
<Settings className="h-4 w-4" /> </DropdownMenuItem>
Settings <DropdownMenuItem onSelect={() => navigate('/settings')}>
</DropdownMenuItem> <Settings className="h-4 w-4" />
<DropdownMenuItem onSelect={() => navigate('/data')}> Settings
<Database className="h-4 w-4" /> </DropdownMenuItem>
Data <DropdownMenuItem onSelect={() => navigate('/data')}>
</DropdownMenuItem> <Database className="h-4 w-4" />
<DropdownMenuSeparator /> Data
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={() => navigate('/about')}> <DropdownMenuItem onSelect={() => navigate('/about')}>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
About 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 APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.18.1', version: '0.18.4',
date: '2026-05-04', date: '2026-05-04',
highlights: [ highlights: [
{ icon: '📱', title: 'Mobile and tablet layouts', desc: 'Navigation, page headers, dialogs, and dense tables now adapt better below desktop widths.' }, { 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 [deleting, setDeleting] = useState(null);
const [resetting, setResetting] = useState(null); const [resetting, setResetting] = useState(null);
const [roleUpdating, setRoleUpdating] = useState(null); const [roleUpdating, setRoleUpdating] = useState(null);
const [activeUpdating, setActiveUpdating] = useState(null);
// Delete confirmation dialog // Delete confirmation dialog
const [deleteTarget, setDeleteTarget] = useState(null); // user object 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 ( return (
<> <>
<Card> <Card>
@ -1025,6 +1039,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
<tr className="border-b border-border"> <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">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">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">Password</th>
<th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th> <th className="text-left px-6 py-3 text-muted-foreground font-medium">Reset Password</th>
<th className="px-6 py-3" /> <th className="px-6 py-3" />
@ -1036,7 +1051,12 @@ function UsersTable({ users, onRefresh, currentUser }) {
const isSelf = currentUser?.id === user.id; const isSelf = currentUser?.id === user.id;
return ( return (
<tr key={user.id} className="group border-b border-border last:border-0 hover:bg-muted/30 transition-colors"> <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"> <td className="px-6 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}> <Badge variant={user.role === 'admin' ? 'autodraft' : 'secondary'}>
@ -1054,6 +1074,18 @@ function UsersTable({ users, onRefresh, currentUser }) {
</select> </select>
</div> </div>
</td> </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"> <td className="px-6 py-3">
{user.must_change_password {user.must_change_password
? <Badge variant="due_soon">Temporary</Badge> ? <Badge variant="due_soon">Temporary</Badge>
@ -1061,40 +1093,38 @@ function UsersTable({ users, onRefresh, currentUser }) {
} }
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
{user.role !== 'admin' && ( {form.open ? (
form.open ? ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Input
<Input type="password"
type="password" placeholder="New password"
placeholder="New password" value={form.pw || ''}
value={form.pw || ''} onChange={e => setReset(user.id, { pw: e.target.value })}
onChange={e => setReset(user.id, { pw: e.target.value })} className="h-8 text-sm w-36"
className="h-8 text-sm w-36" />
/> <Button
<Button size="sm"
size="sm" onClick={() => handleReset(user)}
onClick={() => handleReset(user)} disabled={resetting === user.id}
disabled={resetting === user.id} >
> {resetting === user.id ? '…' : 'Save'}
{resetting === user.id ? '…' : 'Save'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setReset(user.id, { open: false, pw: '' })}
>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
</Button> </Button>
) <Button
size="sm"
variant="ghost"
onClick={() => setReset(user.id, { open: false, pw: '' })}
>
Cancel
</Button>
</div>
) : (
<Button size="sm" variant="outline" onClick={() => setReset(user.id, { open: true })}>
Reset
</Button>
)} )}
</td> </td>
<td className="px-6 py-3 text-right"> <td className="px-6 py-3 text-right">
{user.role !== 'admin' && ( {!isSelf && (
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
@ -1110,7 +1140,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
})} })}
{!users?.length && ( {!users?.length && (
<tr> <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> </tr>
)} )}
</tbody> </tbody>
@ -1125,7 +1155,9 @@ function UsersTable({ users, onRefresh, currentUser }) {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle> <AlertDialogTitle>Delete {deleteTarget?.username}?</AlertDialogTitle>
<AlertDialogDescription> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Navigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle, Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
@ -1411,5 +1410,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
// DataPage // DataPage
export default function 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 [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false); const [pwLoading, setPwLoading] = useState(false);
const destFor = (role) => (role === 'admin' ? '/admin' : '/'); const destFor = (user) => (user?.is_default_admin ? '/admin' : '/');
useEffect(() => { useEffect(() => {
api.authMode() api.authMode()
@ -45,7 +45,7 @@ export default function LoginPage() {
api.me() api.me()
.then(d => { .then(d => {
if (d.user) navigate(destFor(d.user.role), { replace: true }); if (d.user) navigate(destFor(d.user), { replace: true });
}) })
.catch(() => {}); .catch(() => {});
}, []); // eslint-disable-line }, []); // eslint-disable-line
@ -59,7 +59,7 @@ export default function LoginPage() {
setPendingUser(user); setPendingUser(user);
setShowPrivacy(true); setShowPrivacy(true);
} else { } else {
navigate(destFor(user.role), { replace: true }); navigate(destFor(user), { replace: true });
} }
}; };
@ -104,7 +104,7 @@ export default function LoginPage() {
if (pendingUser?.first_login) { if (pendingUser?.first_login) {
setShowPrivacy(true); setShowPrivacy(true);
} else { } else {
navigate(destFor(pendingUser.role), { replace: true }); navigate(destFor(pendingUser), { replace: true });
} }
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to change password.'); toast.error(err.message || 'Failed to change password.');
@ -120,7 +120,7 @@ export default function LoginPage() {
refresh(); refresh();
setShowPrivacy(false); setShowPrivacy(false);
navigate(destFor(pendingUser?.role), { replace: true }); navigate(destFor(pendingUser), { replace: true });
}; };
return ( return (

View File

@ -8,12 +8,6 @@ import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import {
ImportSpreadsheetSection,
DownloadMyDataSection,
ImportMyDataSection,
ImportHistorySection as DataImportHistorySection,
} from '@/pages/DataPage';
function asProfile(data) { function asProfile(data) {
return data?.profile || data?.user || data || {}; return data?.profile || data?.user || data || {};
@ -34,9 +28,9 @@ function formatDateTime(value) {
function SectionCard({ title, icon: Icon, subtitle, children }) { function SectionCard({ title, icon: Icon, subtitle, children }) {
return ( 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="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" /> <Icon className="h-4 w-4 text-primary" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@ -78,7 +72,7 @@ function ProfileSummary({ profile, loading }) {
return ( return (
<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 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="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} /> <FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} /> <FieldRow label="Role" value={profile.role} />
@ -133,7 +127,7 @@ function NotificationPreferences({ settings, onSaved }) {
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v })); const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
const payload = { const payload = {
email: form.email || '', email: form.email || form.notification_email || '',
notifications_enabled: !!(form.notifications_enabled ?? form.enabled), notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
notify_3_day: !!(form.notify_3_day ?? form.notify_3d), notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
notify_1_day: !!(form.notify_1_day ?? form.notify_1d), notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
@ -223,7 +217,7 @@ function ChangePassword() {
return ( return (
<SectionCard title="Change Password" icon={KeyRound} subtitle="Update your password without exposing it in logs or page state beyond this form."> <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"> <div className="space-y-1.5">
<label htmlFor="current-password" className="text-xs font-medium text-muted-foreground">Current password</label> <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)} /> <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> <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)} /> <Input id="confirm-password" type="password" autoComplete="new-password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} />
</div> </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'} {saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Change Password'}
</Button> </Button>
</form> </form>
@ -249,7 +243,6 @@ function ProfileNav() {
['#account', 'Account'], ['#account', 'Account'],
['#security', 'Security'], ['#security', 'Security'],
['#notifications', 'Notifications'], ['#notifications', 'Notifications'],
['#data', 'My Data'],
]; ];
return ( return (
<div className="mb-6 flex flex-wrap gap-2"> <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() { export default function ProfilePage() {
const { setUser, refresh } = useAuth(); const { setUser, refresh } = useAuth();
const [profile, setProfile] = useState({}); const [profile, setProfile] = useState({});
@ -332,11 +289,11 @@ export default function ProfilePage() {
}; };
return ( return (
<div> <div className="mx-auto w-full max-w-5xl">
<div className="mb-8 flex items-start justify-between gap-4"> <div className="mb-6 flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1> <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>
<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"> <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" /> <ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
@ -346,7 +303,7 @@ export default function ProfilePage() {
<ProfileNav /> <ProfileNav />
<div className="space-y-6"> <div className="space-y-5">
<div id="account" className="scroll-mt-6 space-y-6"> <div id="account" className="scroll-mt-6 space-y-6">
<ProfileSummary profile={profile} loading={loading} /> <ProfileSummary profile={profile} loading={loading} />
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />} {!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
@ -357,7 +314,6 @@ export default function ProfilePage() {
<div id="notifications" className="scroll-mt-6"> <div id="notifications" className="scroll-mt-6">
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />} {!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
</div> </div>
<DataManagement />
</div> </div>
</div> </div>
); );

View File

@ -110,6 +110,8 @@ function runMigrations() {
// ── users: notification columns ─────────────────────────────────────────── // ── users: notification columns ───────────────────────────────────────────
const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name); const userCols = db.prepare('PRAGMA table_info(users)').all().map(c => c.name);
const newUserCols = [ const newUserCols = [
['active', 'INTEGER NOT NULL DEFAULT 1'],
['is_default_admin', 'INTEGER NOT NULL DEFAULT 0'],
['notification_email', 'TEXT'], ['notification_email', 'TEXT'],
['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['notifications_enabled', 'INTEGER NOT NULL DEFAULT 0'],
['notify_3d', 'INTEGER NOT NULL DEFAULT 1'], ['notify_3d', 'INTEGER NOT NULL DEFAULT 1'],
@ -120,6 +122,25 @@ function runMigrations() {
for (const [col, def] of newUserCols) { for (const [col, def] of newUserCols) {
if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`); 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) ────────────────────────────────── // ── payments: soft-delete column (v0.2) ──────────────────────────────────
const paymentCols = db.prepare('PRAGMA table_info(payments)').all().map(c => c.name); 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, username TEXT NOT NULL UNIQUE COLLATE NOCASE,
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,
is_default_admin INTEGER NOT NULL DEFAULT 0,
must_change_password INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
first_login INTEGER NOT NULL DEFAULT 1, first_login INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),

View File

@ -6,7 +6,7 @@ function getSingleModeUser() {
const userId = getSetting('default_user_id'); const userId = getSetting('default_user_id');
if (!userId) return null; if (!userId) return null;
const row = getDb().prepare( 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); ).get(userId);
return row ? publicUser(row) : null; return row ? publicUser(row) : null;
} }
@ -27,6 +27,9 @@ function requireAuth(req, res, next) {
} }
function requireUser(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)) { if (!['user', 'admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Access denied: user account required' }); 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", "name": "bill-tracker",
"version": "0.18.3", "version": "0.18.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.18.3", "version": "0.18.4",
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",

View File

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

View File

@ -30,7 +30,7 @@ function sendError(res, err) {
// GET /api/admin/has-users // GET /api/admin/has-users
router.get('/has-users', (req, res) => { 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 }); res.json({ has_users: count > 0 });
}); });
@ -38,7 +38,7 @@ router.get('/has-users', (req, res) => {
router.get('/users', (req, res) => { router.get('/users', (req, res) => {
res.json( res.json(
getDb().prepare( 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() ).all()
); );
}); });
@ -156,7 +156,7 @@ router.post('/users', async (req, res) => {
).run(username, hash); ).run(username, hash);
res.status(201).json( 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) .get(result.lastInsertRowid)
); );
}); });
@ -170,7 +170,6 @@ router.put('/users/:id/password', async (req, res) => {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); 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) 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); const hash = await hashPassword(password);
db.prepare("UPDATE users SET password_hash=?, must_change_password=1, updated_at=datetime('now') WHERE id=?") 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); .run(role, targetId);
const updated = db.prepare( 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); ).get(targetId);
res.json(updated); 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 // DELETE /api/admin/users/:id
router.delete('/users/:id', (req, res) => { router.delete('/users/:id', (req, res) => {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); 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) return res.status(404).json({ error: 'User not found' });
if (user.role === 'admin') return res.status(403).json({ error: 'Cannot delete the admin account' }); if (req.user?.id === user.id) return res.status(400).json({ error: 'You cannot delete your own account.' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
res.json({ success: true }); 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 ───────────────────────────────────────────────────────── // ── Cleanup endpoints ─────────────────────────────────────────────────────────

View File

@ -18,7 +18,7 @@ const { passwordLimiter } = require('../middleware/rateLimiter');
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
const user = db.prepare(` 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, first_login, created_at, updated_at,
last_password_change_at, last_password_change_at,
notification_email, notifications_enabled, notification_email, notifications_enabled,
@ -33,6 +33,8 @@ router.get('/', (req, res) => {
username: user.username, username: user.username,
display_name: user.display_name || null, display_name: user.display_name || null,
role: user.role, role: user.role,
active: !!user.active,
is_default_admin: !!user.is_default_admin,
created_at: user.created_at, created_at: user.created_at,
updated_at: user.updated_at, updated_at: user.updated_at,
last_password_change_at: user.last_password_change_at || null, last_password_change_at: user.last_password_change_at || null,
@ -73,7 +75,14 @@ router.patch('/', (req, res) => {
).run(trimmed || null, req.user.id); ).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 ───────────────────────────────────────────────── // ── GET /api/profile/settings ─────────────────────────────────────────────────
@ -105,15 +114,17 @@ router.get('/settings', (req, res) => {
router.patch('/settings', (req, res) => { router.patch('/settings', (req, res) => {
const db = getDb(); const db = getDb();
const { const {
notification_email, notifications_enabled, notification_email, email, notifications_enabled,
notify_3d, notify_1d, notify_due, notify_overdue, notify_3d, notify_1d, notify_due, notify_overdue,
} = req.body; } = req.body;
if (notification_email !== undefined && notification_email !== null) { const nextEmail = notification_email !== undefined ? notification_email : email;
if (typeof notification_email !== 'string') {
if (nextEmail !== undefined && nextEmail !== null) {
if (typeof nextEmail !== 'string') {
return res.status(400).json({ error: 'notification_email must be a 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' }); return res.status(400).json({ error: 'notification_email is too long' });
} }
} }
@ -124,8 +135,8 @@ router.patch('/settings', (req, res) => {
FROM users WHERE id = ? FROM users WHERE id = ?
`).get(req.user.id); `).get(req.user.id);
const emailVal = notification_email !== undefined const emailVal = nextEmail !== undefined
? (notification_email ? notification_email.trim() || null : null) ? (nextEmail ? nextEmail.trim() || null : null)
: current.notification_email; : current.notification_email;
const boolVal = (incoming, fallback) => const boolVal = (incoming, fallback) =>

View File

@ -46,6 +46,7 @@ async function login(username, password) {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username); const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) return null; if (!user) return null;
if (user.active === 0) return null;
// Reject OIDC-only accounts from local login // Reject OIDC-only accounts from local login
if (user.auth_provider && user.auth_provider !== 'local') { if (user.auth_provider && user.auth_provider !== 'local') {
@ -79,6 +80,7 @@ async function createSession(userId) {
const db = getDb(); const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) return null; if (!user) return null;
if (user.active === 0) return null;
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000) const expiresAt = new Date(Date.now() + SESSION_DAYS * 86400000)
@ -98,10 +100,11 @@ function logout(sessionId) {
function getSessionUser(sessionId) { function getSessionUser(sessionId) {
if (!sessionId) return null; if (!sessionId) return null;
const row = getDb().prepare(` 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 FROM sessions s
JOIN users u ON u.id = s.user_id 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); `).get(sessionId);
return row || null; return row || null;
} }
@ -116,6 +119,8 @@ function publicUser(u) {
username: u.username, username: u.username,
display_name: u.display_name || null, display_name: u.display_name || null,
role: u.role, role: u.role,
active: u.active !== 0,
is_default_admin: !!u.is_default_admin,
must_change_password: !!u.must_change_password, must_change_password: !!u.must_change_password,
first_login: !!u.first_login, first_login: !!u.first_login,
}; };

View File

@ -186,7 +186,7 @@ async function runNotifications() {
if (allowUserConfig) { if (allowUserConfig) {
const users = db.prepare( 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(); ).all();
recipients.push(...users); recipients.push(...users);
} else if (globalRecipient) { } else if (globalRecipient) {

View File

@ -475,6 +475,10 @@ async function findOrProvisionUser(claims, config) {
console.log(`[oidc] Provisioned new ${role} user from OIDC login`); 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 // Refresh login timestamp and display name from provider claims
db.prepare(` db.prepare(`
UPDATE users UPDATE users

View File

@ -54,9 +54,9 @@ function promptPassword(label) {
async function createUser(db, username, password, role) { async function createUser(db, username, password, role) {
const hash = await bcrypt.hash(password, 12); const hash = await bcrypt.hash(password, 12);
db.prepare(` db.prepare(`
INSERT INTO users (username, password_hash, role, first_login, must_change_password) INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
VALUES (?, ?, ?, 0, 0) VALUES (?, ?, ?, 0, 0, ?)
`).run(username, hash, role); `).run(username, hash, role, role === 'admin' ? 1 : 0);
} }
async function runFromEnv(db) { async function runFromEnv(db) {