push
This commit is contained in:
parent
d1efeece04
commit
3228332e8c
12
HISTORY.md
12
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/data')}>
|
||||
<Database className="h-4 w-4" />
|
||||
Data
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{accountToolsAllowed && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/settings')}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate('/data')}>
|
||||
<Database className="h-4 w-4" />
|
||||
Data
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => navigate('/about')}>
|
||||
<Info className="h-4 w-4" />
|
||||
About
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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,40 +1093,38 @@ function UsersTable({ users, onRefresh, currentUser }) {
|
|||
}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
{user.role !== 'admin' && (
|
||||
form.open ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={form.pw || ''}
|
||||
onChange={e => setReset(user.id, { pw: e.target.value })}
|
||||
className="h-8 text-sm w-36"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReset(user)}
|
||||
disabled={resetting === user.id}
|
||||
>
|
||||
{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
|
||||
{form.open ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={form.pw || ''}
|
||||
onChange={e => setReset(user.id, { pw: e.target.value })}
|
||||
className="h-8 text-sm w-36"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleReset(user)}
|
||||
disabled={resetting === user.id}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.18.3",
|
||||
"version": "0.18.4",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue