This commit is contained in:
null 2026-05-15 22:45:38 -05:00
parent 74603ff2d5
commit 0ba315bd32
43 changed files with 917 additions and 200 deletions

View File

@ -135,7 +135,7 @@ SESSION_CLEANUP_INTERVAL_MS=86400000
HTTPS=true
COOKIE_SECURE=true
CORS_ORIGIN=https://bills.example.com
CSRF_HTTP_ONLY=true
CSRF_HTTP_ONLY=false
CSRF_SAME_SITE=strict
CSRF_SECURE=true
CSRF_COOKIE_NAME=bt_csrf_token
@ -235,7 +235,7 @@ Backups and exports contain sensitive financial data. The code writes SQLite bac
- Admin routes require an admin session.
- The default admin account cannot access user tracker routes.
- User-owned bill, category, payment, import, and export routes derive ownership from the authenticated session (`req.user.id` in SQL).
- CSRF protection uses a double-submit cookie pattern: a `bt_csrf_token` cookie is set on responses, and mutating requests must include a matching `x-csrf-token` header. Defaults are `httpOnly`, `sameSite=strict`, and `secure` (overridable via env vars).
- CSRF protection uses a double-submit cookie pattern: a `bt_csrf_token` cookie is set on responses, and mutating requests must include a matching `x-csrf-token` header. The SPA reads this cookie with `document.cookie`, so `CSRF_HTTP_ONLY` defaults to `false`; do not set it to `true` unless token delivery changes. `sameSite=strict` and secure cookies remain the default posture.
- Local login, password change, import, export, admin actions, and OIDC routes have per-IP in-memory rate limits.
- CORS is disabled unless `CORS_ORIGIN` is set.
- Security headers include Content-Security-Policy with per-request nonces, plus standard hardening headers. HSTS is sent only when `HTTPS=true`.

View File

@ -35,6 +35,7 @@ const StatusPage = lazy(() => import('@/pages/StatusPage'));
const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
const AboutPage = lazy(() => import('@/pages/AboutPage'));
const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
@ -107,6 +108,7 @@ export default function App() {
<Routes>
<Route path="/login" element={<ErrorBoundary><LoginPage /></ErrorBoundary>} />
<Route path="/about" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AboutPage /></Suspense></ErrorBoundary>} />
<Route path="/privacy" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PrivacyPage /></Suspense></ErrorBoundary>} />
<Route path="/release-notes" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ReleaseNotesPage /></Suspense></ErrorBoundary>} />
<Route

View File

@ -153,7 +153,6 @@ export const api = {
return get(`/bills/${id}/amortization${qs ? `?${qs}` : ''}`);
},
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data),
deleteBill: (id) => del(`/bills/${id}`),
togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data),
billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`),
@ -204,6 +203,7 @@ export const api = {
// Version (public)
about: () => get('/about'),
privacy: () => get('/privacy'),
aboutAdmin: () => get('/about-admin'),
roadmap: () => get('/about-admin/roadmap'),
updateStatus: () => get('/version/update-status'),

View File

@ -6,10 +6,6 @@ import { Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { api } from '@/api';
// Written on close so the dialog doesn't flash during the brief window before
// /me resolves on repeat visits. The backend is authoritative; this is a cache.
const LS_KEY = `bt-release-seen-${APP_VERSION}`;
export function ReleaseNotesDialog() {
const { hasNewVersion, setHasNewVersion } = useAuth();
const [open, setOpen] = useState(false);
@ -20,17 +16,16 @@ export function ReleaseNotesDialog() {
}, [hasNewVersion]);
const handleClose = () => {
localStorage.setItem(LS_KEY, '1');
setOpen(false);
setHasNewVersion(false); // optimistic don't wait for the server
api.acknowledgeVersion().catch(() => {}); // fire-and-forget
api.acknowledgeVersion().catch(() => {}); // backend stores the seen release version
const prev = document.activeElement;
if (prev?.focus) setTimeout(() => prev.focus(), 0);
};
return (
<Dialog open={open} onOpenChange={v => { if (!v) handleClose(); }}>
<DialogContent className="max-w-md">
<DialogContent className="max-h-[92dvh] max-w-md overflow-y-auto sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2 mb-1">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
@ -58,6 +53,19 @@ export function ReleaseNotesDialog() {
))}
</div>
{RELEASE_NOTES.image && (
<figure className="mt-5 flex justify-center">
<div className="w-full max-w-sm overflow-hidden rounded-xl border border-border bg-muted/20 shadow-sm sm:max-w-md">
<img
src={RELEASE_NOTES.image.src}
alt={RELEASE_NOTES.image.alt}
loading="lazy"
className="aspect-[16/10] w-full object-contain"
/>
</div>
</figure>
)}
<div className="mt-4 pt-4 border-t border-border flex items-center justify-end">
<Button size="sm" onClick={handleClose}>
Got it

View File

@ -3,7 +3,7 @@ import AppNavigation from './Sidebar';
export default function Layout({ mainContentId }) {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground"
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] text-foreground"
role="main"
aria-labelledby={mainContentId}
>

View File

@ -98,7 +98,7 @@ function UserMenu({ adminMode = false }) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="inline-flex h-9 items-center gap-2 rounded-full border border-border/70 bg-card/90 px-2.5 text-sm font-medium text-foreground shadow-sm transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-md focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="inline-flex h-9 items-center gap-2 rounded-full border border-border/80 bg-muted px-2.5 text-sm font-medium text-foreground shadow-sm transition-all hover:bg-accent hover:text-accent-foreground hover:shadow focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label="Open user menu"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary">
@ -167,7 +167,7 @@ export default function Sidebar({ adminMode = false }) {
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
return (
<header className="sticky top-0 z-40 border-b border-border/70 bg-background/85 shadow-sm shadow-foreground/5 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70">
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
<div className="mx-auto flex h-16 w-full max-w-[1500px] items-center gap-4 px-4 sm:px-6 lg:px-8">
<BrandBlock adminMode={adminMode} />
@ -179,13 +179,13 @@ export default function Sidebar({ adminMode = false }) {
</nav>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle className="rounded-full border border-border/70 bg-card/90 shadow-sm" />
<ThemeToggle className="rounded-full border border-border/80 bg-muted shadow-sm" />
<UserMenu adminMode={adminMode} />
<Button
type="button"
variant="outline"
size="icon"
className="lg:hidden rounded-full bg-card/90"
className="lg:hidden rounded-full bg-muted"
aria-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)}
@ -196,7 +196,7 @@ export default function Sidebar({ adminMode = false }) {
</div>
{mobileOpen && (
<div className="border-t border-border/60 bg-background/95 px-4 py-3 shadow-lg shadow-foreground/5 lg:hidden max-h-[70vh] overflow-y-auto">
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1">
{!adminMode && trackerItems.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />

View File

@ -4,7 +4,7 @@ import { cn } from '@/lib/utils';
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-2xl border border-border/70 bg-card/95 text-card-foreground shadow-sm transition-shadow hover:shadow-md', className)}
className={cn('rounded-2xl border border-border/70 bg-card text-card-foreground shadow-sm transition-shadow hover:shadow', className)}
{...props}
/>
));

View File

@ -13,7 +13,7 @@
:root {
--background: 0.98 0.01 335.69;
--foreground: 0.22 0 0;
--card: 0.96 0.01 335.69;
--card: 0.93 0.01 335.69;
--card-foreground: 0.14 0 0;
--popover: 0.95 0.01 316.67;
--popover-foreground: 0.40 0.04 309.35;
@ -21,7 +21,7 @@
--primary-foreground: 1.00 0 0;
--secondary: 0.49 0.04 300.23;
--secondary-foreground: 1.00 0 0;
--muted: 0.96 0.01 335.69;
--muted: 0.92 0.01 335.69;
--muted-foreground: 0.43 0.02 309.68;
--accent: 0.92 0.04 303.47;
--accent-foreground: 0.14 0 0;
@ -52,7 +52,7 @@
.dark {
--background: 0.15 0.01 317.69;
--foreground: 0.95 0.01 321.50;
--card: 0.22 0.02 322.13;
--card: 0.19 0.02 322.13;
--card-foreground: 0.95 0.01 321.50;
--popover: 0.22 0.02 322.13;
--popover-foreground: 0.95 0.01 321.50;
@ -60,7 +60,7 @@
--primary-foreground: 0.98 0.01 321.51;
--secondary: 0.45 0.03 294.79;
--secondary-foreground: 0.95 0.01 321.50;
--muted: 0.22 0.01 319.50;
--muted: 0.18 0.01 319.50;
--muted-foreground: 0.70 0.01 320.70;
--accent: 0.35 0.06 299.57;
--accent-foreground: 0.95 0.01 321.50;
@ -104,7 +104,7 @@
/* Generic surface */
.surface {
@apply rounded-2xl border border-border/70 bg-card/95 shadow-sm backdrop-blur-sm;
@apply rounded-2xl border border-border/70 bg-card shadow-sm;
}
/* Elevated surface */
@ -112,13 +112,13 @@
@apply surface;
box-shadow:
0 1px 2px rgb(0 0 0 / 0.05),
0 8px 32px rgb(0 0 0 / 0.08);
0 6px 18px rgb(0 0 0 / 0.06);
}
.dark .surface-elevated {
box-shadow:
0 0 0 1px oklch(var(--border) / 0.7),
0 8px 32px rgb(0 0 0 / 0.7);
0 8px 22px rgb(0 0 0 / 0.55);
}
/* Stat cards */

View File

@ -9,29 +9,33 @@ export const RELEASE_NOTES = {
date: '2026-05-15',
highlights: [
{
icon: '📋',
title: 'Bills page redesigned',
desc: 'The old table is gone. Bills now show as clean cards with icon actions, inline debt details (APR colour-coded, current balance), and a Columns button to choose exactly which fields are displayed — remembered across sessions.',
icon: '🛡️',
title: 'Safer payment and settings data',
desc: 'Payment amounts and dates are now validated consistently, and regular-user settings are stored per user instead of globally.',
},
{
icon: '📈',
title: 'Snowball projection is now live',
desc: 'The payoff sidebar updates instantly as you type your extra monthly budget — no save required. The projection now includes a minimum-only baseline so you can see exactly how many months and dollars the snowball saves you.',
icon: '🔐',
title: 'Security checks tightened',
desc: 'Logout-all now uses the normal CSRF header, password length rules match the backend, and CSRF SPA documentation now matches the actual cookie/header flow.',
},
{
icon: '🔑',
title: 'Login history',
desc: 'Your last 3 sign-ins are recorded with timestamp, IP address, and browser. Click the Last Login field on your Profile page to see the full history.',
title: 'Private login details',
desc: 'Your Profile now shows recent login date, IP, browser, OS, device type, and a short device ID. These details are shown only to you in the app UI.',
},
{
icon: '📥',
title: 'Import by bill',
desc: 'The XLSX import page has a new Bills tab. Select any existing bill and import its entire history from the spreadsheet in one click — no row-by-row review needed.',
icon: '📄',
title: 'Privacy and release notes',
desc: 'A public Privacy page is available from About, release notes can render images, and this update card now resets from the backend whenever the app version changes.',
},
{
icon: '📐',
title: 'APR calculation engine',
desc: 'New backend math service: monthly interest, months to payoff, total interest, and full amortization schedules. Available via GET /api/bills/:id/amortization.',
icon: '🎛️',
title: 'Cleaner tracker and interface polish',
desc: 'The Tracker remaining card now shows the active 1st or 15th balance, Roadmap columns breathe on desktop and mobile, and app surfaces have a calmer darker treatment.',
},
],
image: {
src: '/img/doingmypart.jpg',
alt: 'Doing my part',
},
};

View File

@ -82,7 +82,7 @@ export default function AboutPage() {
const displayVersion = about?.version ?? APP_VERSION;
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-3xl space-y-5">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to={user ? '/' : '/login'}>
@ -91,7 +91,7 @@ export default function AboutPage() {
</Link>
</Button>
<Card className="border-border/70 bg-card/95 shadow-sm" id="about-card">
<Card className="border-border/70 bg-card shadow-sm" id="about-card">
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<Info className="h-5 w-5" />
@ -139,6 +139,9 @@ export default function AboutPage() {
<Button asChild>
<Link to="/release-notes">Release Notes</Link>
</Button>
<Button asChild variant="outline">
<Link to="/privacy">Privacy</Link>
</Button>
{user == null && (
<Button asChild variant="outline">
<Link to="/login">Sign In</Link>

View File

@ -99,8 +99,8 @@ function OnboardingWizard({ onComplete }) {
let validationError = '';
if (password !== confirm) {
validationError = 'Passwords do not match.';
} else if (password.length < 6) {
validationError = 'Password must be at least 6 characters.';
} else if (password.length < 8) {
validationError = 'Password must be at least 8 characters.';
}
if (validationError) {
@ -992,7 +992,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
const handleReset = async (user) => {
const form = getForm(user.id);
if (!form.pw || form.pw.length < 6) { toast.error('Password must be at least 6 characters.'); return; }
if (!form.pw || form.pw.length < 8) { toast.error('Password must be at least 8 characters.'); return; }
setResetting(user.id);
try {
await api.resetPassword(user.id, { password: form.pw });
@ -1209,8 +1209,8 @@ function AddUserCard({ onCreated }) {
e.preventDefault();
setError('');
if (password.length < 6) {
const msg = 'Password must be at least 6 characters.';
if (password.length < 8) {
const msg = 'Password must be at least 8 characters.';
setError(msg);
toast.error(msg);
return;
@ -1890,7 +1890,7 @@ export default function AdminPage() {
}
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] text-foreground">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] text-foreground">
<AppNavigation adminMode />
{/* Content */}

View File

@ -90,8 +90,8 @@ export default function LoginPage() {
return;
}
if (newPw.length < 6) {
toast.error('Password must be at least 6 characters.');
if (newPw.length < 8) {
toast.error('Password must be at least 8 characters.');
return;
}

View File

@ -0,0 +1,121 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowLeft, BadgeCheck, Database, EyeOff, FileText, Fingerprint,
LockKeyhole, RadioTower, ShieldCheck, UserRoundCog,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
const iconByTitle = {
Overview: ShieldCheck,
'Data Collection': Database,
'Version Checking': RadioTower,
'Administrative Access': UserRoundCog,
Logging: FileText,
'Third-Party Services': EyeOff,
Security: LockKeyhole,
'Changes to This Policy': BadgeCheck,
Contact: Fingerprint,
};
export default function PrivacyPage() {
const { user } = useAuth();
const [privacy, setPrivacy] = useState(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setPrivacy(await api.privacy());
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const sections = privacy?.sections || [];
const updated = privacy?.last_updated
? new Date(privacy.last_updated).toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })
: null;
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-3xl space-y-5">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to={user ? '/about' : '/about'}>
<ArrowLeft className="h-3.5 w-3.5" />
Back to About
</Link>
</Button>
<Card className="border-border/70 bg-card shadow-sm">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<ShieldCheck className="h-5 w-5" />
</div>
{updated && (
<div className="rounded-full border border-border/70 bg-background/70 px-3 py-1 text-xs text-muted-foreground">
Updated {updated}
</div>
)}
</div>
<CardTitle className="text-3xl tracking-tight">{privacy?.name || 'Privacy'}</CardTitle>
<CardDescription className="max-w-2xl text-base leading-7">
{loading ? 'Loading privacy details...' : privacy?.summary}
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid gap-3 sm:grid-cols-3">
{[
['Local-first', 'Data stays in your installation'],
['No telemetry', 'No analytics or ad tracking'],
['User-owned', 'Login details stay in user view'],
].map(([label, text]) => (
<div key={label} className="rounded-xl border border-border/70 bg-background/65 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">{label}</p>
<p className="mt-1 text-sm text-muted-foreground">{text}</p>
</div>
))}
</div>
{sections.map((section, index) => {
const Icon = iconByTitle[section.title] || ShieldCheck;
return (
<section key={section.title} className="rounded-xl border border-border/70 bg-background/65 p-5">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/35 text-primary">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground/70">{String(index + 1).padStart(2, '0')}</span>
<h2 className="text-base font-semibold">{section.title}</h2>
</div>
<ul className="mt-3 space-y-2.5 text-sm leading-6 text-muted-foreground">
{section.items.map(item => (
<li key={item} className="flex gap-2">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-primary/70" />
<span>{item}</span>
</li>
))}
</ul>
</div>
</div>
</section>
);
})}
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 text-sm leading-6 text-emerald-700 dark:text-emerald-300">
Login history shown in your Profile modal is for your account view. It is not shown on the Admin page.
</div>
</CardContent>
</Card>
</main>
</div>
);
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone,
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
@ -84,18 +84,33 @@ function parseUserAgent(ua) {
return { browser, os, mobile };
}
function LoginHistoryModal({ lastLoginAt, open, onClose }) {
function deviceLabel(type) {
if (type === 'mobile') return 'Mobile';
if (type === 'tablet') return 'Tablet';
if (type === 'api') return 'API client';
return 'Desktop';
}
function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }) {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
setLoading(true);
api.loginHistory()
.then(d => setHistory(d.history ?? []))
.then(d => {
const rows = d.history ?? [];
setHistory(rows);
onLoaded?.(rows);
})
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [open]);
}, [open, providedHistory, onLoaded]);
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
@ -118,8 +133,11 @@ function LoginHistoryModal({ lastLoginAt, open, onClose }) {
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No login history recorded.</p>
) : history.map((entry, i) => {
const { browser, os, mobile } = parseUserAgent(entry.user_agent);
const DeviceIcon = mobile ? Smartphone : Monitor;
const parsed = parseUserAgent(entry.user_agent);
const browser = entry.browser || parsed.browser;
const os = entry.os || parsed.os;
const deviceType = entry.device_type || (parsed.mobile ? 'mobile' : 'desktop');
const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor;
return (
<div key={entry.id}
className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-4 py-3">
@ -134,11 +152,16 @@ function LoginHistoryModal({ lastLoginAt, open, onClose }) {
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{browser} on {os}
{deviceLabel(deviceType)} · {browser} on {os}
{entry.ip_address && (
<span className="ml-2 font-mono">{entry.ip_address}</span>
)}
</p>
{entry.device_fingerprint && (
<p className="text-[10px] text-muted-foreground/70 mt-1 font-mono">
Device ID {entry.device_fingerprint}
</p>
)}
</div>
</div>
);
@ -146,15 +169,81 @@ function LoginHistoryModal({ lastLoginAt, open, onClose }) {
</div>
<p className="text-[10px] text-muted-foreground/60 text-center pt-1">
Showing up to 3 most recent sign-ins
Showing up to 3 most recent sign-ins. Device ID is a short privacy-preserving identifier.
</p>
<p className="text-[10px] text-muted-foreground/60 text-center">
This information is shown only to you here. It is not shared with admins in the app UI.
</p>
</DialogContent>
</Dialog>
);
}
function LoginSummaryCard({ latestLogin, loading, onOpen }) {
const parsed = parseUserAgent(latestLogin?.user_agent);
const browser = latestLogin?.browser || parsed.browser;
const os = latestLogin?.os || parsed.os;
const deviceType = latestLogin?.device_type || (parsed.mobile ? 'mobile' : 'desktop');
const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor;
return (
<button
type="button"
onClick={onOpen}
className="group rounded-lg border border-border/60 bg-muted/25 px-4 py-3 text-left transition-colors hover:border-primary/35 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background/60 text-muted-foreground group-hover:text-primary">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <DeviceIcon className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
</div>
{latestLogin ? (
<>
<p className="mt-1 truncate text-sm font-semibold text-foreground">
{formatDateTime(latestLogin.logged_in_at)}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{deviceLabel(deviceType)} · {browser} on {os}
</p>
{latestLogin.ip_address && (
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{latestLogin.ip_address}
</p>
)}
</>
) : (
<>
<p className="mt-1 text-sm font-semibold text-foreground">
{loading ? 'Checking login history...' : 'No login recorded yet'}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Open history to view device and IP details.
</p>
</>
)}
</div>
</div>
</button>
);
}
function ProfileSummary({ profile, loading }) {
const [historyOpen, setHistoryOpen] = useState(false);
const [loginHistory, setLoginHistory] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (loading) return;
setHistoryLoading(true);
api.loginHistory()
.then(d => setLoginHistory(d.history ?? []))
.catch(() => setLoginHistory([]))
.finally(() => setHistoryLoading(false));
}, [loading]);
if (loading) {
return (
@ -164,7 +253,7 @@ function ProfileSummary({ profile, loading }) {
);
}
const lastLoginAt = profile.last_login_at || profile.last_login;
const latestLogin = loginHistory[0] || null;
return (
<>
@ -174,26 +263,21 @@ function ProfileSummary({ profile, loading }) {
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
{/* Last Login — clickable, opens history modal */}
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p>
<button
type="button"
onClick={() => setHistoryOpen(true)}
className="mt-1 text-sm font-medium text-foreground hover:text-primary hover:underline underline-offset-2 transition-colors text-left"
>
{lastLoginAt ? formatDateTime(lastLoginAt) : 'Not recorded'}
</button>
</div>
<LoginSummaryCard
latestLogin={latestLogin}
loading={historyLoading}
onOpen={() => setHistoryOpen(true)}
/>
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
</div>
</SectionCard>
<LoginHistoryModal
lastLoginAt={lastLoginAt}
history={loginHistory}
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onLoaded={setLoginHistory}
/>
</>
);

View File

@ -14,11 +14,32 @@ function formatDateTime(value) {
return date.toLocaleString();
}
function parseImageLine(line) {
const match = line.match(/^!\[([^\]\n]*)\]\(([^)\s]+)\)$/);
if (!match) return null;
return { alt: match[1], src: match[2] };
}
function HistoryLine({ line, index }) {
const trimmed = line.trim();
const image = parseImageLine(trimmed);
if (!trimmed) return <div key={index} className="h-3" />;
if (trimmed === '---') return <div key={index} className="my-5 border-t border-border" />;
if (image) {
return (
<figure key={index} className="my-6 flex justify-center">
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-muted/20 shadow-sm">
<img
src={image.src}
alt={image.alt}
loading="lazy"
className="aspect-[16/10] w-full object-contain"
/>
</div>
</figure>
);
}
if (trimmed.startsWith('# ')) {
return (
@ -83,7 +104,7 @@ export default function ReleaseNotesPage() {
const history = data?.history || '';
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.10),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.28))] px-4 py-8 text-foreground sm:px-6">
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,oklch(var(--primary)/0.06),transparent_34rem),linear-gradient(180deg,oklch(var(--background)),oklch(var(--muted)/0.18))] px-4 py-8 text-foreground sm:px-6">
<main className="mx-auto w-full max-w-4xl space-y-5">
<div className="flex items-center justify-between gap-3">
<div>

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@ -57,7 +56,7 @@ function RoadmapItemCard({ item, defaultOpen }) {
>
{lane.emoji} {lane.label}
</Badge>
<p className="font-semibold text-sm leading-snug">{item.title}</p>
<p className="break-words text-sm font-semibold leading-snug">{item.title}</p>
</div>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -97,19 +96,19 @@ function RoadmapItemCard({ item, defaultOpen }) {
{item.description && (
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Description</p>
<p className="text-sm leading-relaxed">{item.description}</p>
<p className="break-words text-sm leading-relaxed">{item.description}</p>
</div>
)}
{item.rationale && (
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Rationale</p>
<p className="text-sm leading-relaxed text-muted-foreground">{item.rationale}</p>
<p className="break-words text-sm leading-relaxed text-muted-foreground">{item.rationale}</p>
</div>
)}
{item.implementationNotes && (
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Implementation Notes</p>
<div className="rounded-lg bg-muted/50 border border-border/50 p-3 text-sm font-mono leading-relaxed whitespace-pre-wrap">
<div className="overflow-x-auto rounded-lg border border-border/50 bg-muted/50 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap sm:text-sm">
{item.implementationNotes}
</div>
</div>
@ -127,10 +126,10 @@ function PriorityLane({ lane, items, defaultOpenCards, forceKey }) {
if (items.length === 0) return null;
return (
<section aria-label={`${lane.label} priority`} className={`rounded-xl border border-border/60 border-t-4 ${lane.borderColor}`}>
<div className="px-4 py-2.5 flex items-center gap-2 border-b border-border/50">
<section aria-label={`${lane.label} priority`} className={`min-w-0 rounded-xl border border-border/60 border-t-4 ${lane.borderColor}`}>
<div className="flex items-center gap-2 border-b border-border/50 px-4 py-2.5">
<span aria-hidden="true">{lane.emoji}</span>
<h3 className={`font-bold text-xs uppercase tracking-wider ${lane.textColor}`}>{lane.label}</h3>
<h3 className={`min-w-0 break-words text-xs font-bold uppercase tracking-wider ${lane.textColor}`}>{lane.label}</h3>
<span className="ml-auto text-[11px] font-semibold text-muted-foreground tabular-nums">{items.length}</span>
</div>
<div className="p-3 space-y-2">
@ -206,7 +205,7 @@ function DevLogEntry({ entry }) {
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1.5">Files Modified</p>
<div className="flex flex-wrap gap-1">
{entry.filesModified.map((file, idx) => (
<code key={idx} className="text-[11px] bg-muted/50 px-1.5 py-0.5 rounded border border-border/50 text-muted-foreground">
<code key={idx} className="max-w-full break-all rounded border border-border/50 bg-muted/50 px-1.5 py-0.5 text-[11px] text-muted-foreground">
{file}
</code>
))}
@ -301,11 +300,11 @@ export default function RoadmapPage() {
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border/70 bg-primary/10 text-primary">
<Map className="h-5 w-5" />
</div>
<div>
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Roadmap</h1>
<p className="text-sm text-muted-foreground">Current and upcoming features by priority</p>
</div>
@ -317,7 +316,7 @@ export default function RoadmapPage() {
{/* Tabs */}
<Tabs defaultValue="roadmap" onValueChange={v => { if (v === 'activity') fetchDevLog(); }}>
<TabsList>
<TabsList className="grid w-full grid-cols-2 sm:inline-flex sm:w-auto">
<TabsTrigger value="roadmap" className="gap-1.5">
<Map className="h-3.5 w-3.5" />
Roadmap
@ -353,19 +352,38 @@ export default function RoadmapPage() {
</Button>
</div>
{/* Desktop: 5 columns */}
<div className="hidden lg:grid lg:grid-cols-5 gap-3">
{/* Wide desktop: full five-lane view */}
<div className="hidden 2xl:grid 2xl:grid-cols-5 gap-4">
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)}
</div>
{/* Tablet: 2 columns */}
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-3">
<div className="space-y-3">
{/* Desktop: balanced three-column view for admin shell widths */}
<div className="hidden lg:grid lg:grid-cols-3 2xl:hidden gap-4">
<div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-3">
<div className="space-y-4">
{grouped.filter(l => l.key === 'medium' || l.key === 'low').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-4">
{grouped.filter(l => l.key === 'niceToHave').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
</div>
{/* Tablet: 2 columns */}
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-4">
<div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-4">
{grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}

View File

@ -260,7 +260,7 @@ export default function SettingsPage() {
{/* Page header — flat on background */}
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage app-level display and billing preferences</p>
<p className="text-sm text-muted-foreground mt-0.5">Manage your display and billing preferences</p>
</div>
{/* Appearance */}

View File

@ -236,7 +236,7 @@ export default function SummaryPage() {
</div>
</div>
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card/95 p-1.5 shadow-sm">
<div className="summary-controls mx-auto flex w-full max-w-md items-center justify-between gap-2 rounded-full border border-border/70 bg-card p-1.5 shadow-sm">
<Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" />
</Button>

View File

@ -133,10 +133,11 @@ function TrendIndicator({ trend }) {
);
}
function SummaryCard({ type, value, onEdit, hint }) {
function SummaryCard({ type, value, onEdit, hint, label }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
const Icon = def.icon;
const displayLabel = label || def.label;
return (
<div className={cn(
@ -153,7 +154,7 @@ function SummaryCard({ type, value, onEdit, hint }) {
<div className="flex items-center gap-2 mb-3">
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} />
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{def.label}
{displayLabel}
</p>
{type === 'starting' && onEdit && (
<button
@ -1474,7 +1475,12 @@ export default function TrackerPage() {
onEdit={() => setEditStartingOpen(true)}
/>
<SummaryCard type="paid" value={summary.total_paid} />
<SummaryCard type="remaining" value={summary.remaining} />
<SummaryCard
type="remaining"
value={summary.remaining}
label={summary.remaining_label || 'Remaining'}
hint={summary.remaining_hint}
/>
<SummaryCard type="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@ -761,7 +761,11 @@ function reconcileLegacyMigrations() {
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT
user_agent TEXT,
browser TEXT,
os TEXT,
device_type TEXT,
device_fingerprint TEXT
)
`);
console.log('[migration] user_login_history table created');
@ -1327,6 +1331,72 @@ function runMigrations() {
`);
console.log('[migration] user_login_history table created');
}
},
{
version: 'v0.54',
description: 'user_settings: per-user display and billing preferences',
dependsOn: ['v0.53'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
)
`);
const userSettingKeys = ['currency', 'date_format', 'grace_period_days', 'notify_days_before'];
const users = db.prepare('SELECT id FROM users').all();
const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
const insert = db.prepare(`
INSERT OR IGNORE INTO user_settings (user_id, key, value)
VALUES (?, ?, ?)
`);
for (const user of users) {
for (const key of userSettingKeys) {
const row = getCurrent.get(key);
if (row) insert.run(user.id, key, row.value);
}
}
console.log('[migration] user_settings table created and seeded from current global defaults');
}
},
{
version: 'v0.55',
description: 'user_login_history: parsed device metadata and fingerprint',
dependsOn: ['v0.54'],
run: function() {
db.exec(`
CREATE TABLE IF NOT EXISTS user_login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT,
browser TEXT,
os TEXT,
device_type TEXT,
device_fingerprint TEXT
)
`);
const cols = db.prepare('PRAGMA table_info(user_login_history)').all().map(c => c.name);
const newCols = [
['browser', 'TEXT'],
['os', 'TEXT'],
['device_type', 'TEXT'],
['device_fingerprint', 'TEXT'],
];
for (const [col, def] of newCols) {
if (!cols.includes(col)) {
db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} ${def}`);
}
}
console.log('[migration] user_login_history device metadata columns ensured');
}
}
];
@ -1725,6 +1795,19 @@ const ROLLBACK_SQL_MAP = {
'v0.53': {
description: 'user_login_history table',
sql: ['DROP TABLE IF EXISTS user_login_history']
},
'v0.54': {
description: 'user_settings table',
sql: ['DROP TABLE IF EXISTS user_settings']
},
'v0.55': {
description: 'user_login_history device metadata columns',
sql: [
'ALTER TABLE user_login_history DROP COLUMN device_fingerprint',
'ALTER TABLE user_login_history DROP COLUMN device_type',
'ALTER TABLE user_login_history DROP COLUMN os',
'ALTER TABLE user_login_history DROP COLUMN browser',
]
}
};

View File

@ -69,6 +69,14 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@ -76,6 +84,18 @@ CREATE TABLE IF NOT EXISTS sessions (
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS user_login_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT,
user_agent TEXT,
browser TEXT,
os TEXT,
device_type TEXT,
device_fingerprint TEXT
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,

View File

@ -135,8 +135,8 @@ The implementation uses the **double-submit cookie pattern**:
### HttpOnly Setting
- **Default (Secure):** `CSRF_HTTP_ONLY=true` — Cookie is NOT accessible via JavaScript, only sent automatically with requests
- **SPA Mode:** `CSRF_HTTP_ONLY=false` — Cookie IS accessible via JavaScript, enabling the double-submit pattern
- **BillTracker SPA default:** `CSRF_HTTP_ONLY=false` — Cookie IS accessible via JavaScript so `client/api.js` can send the matching `x-csrf-token` header.
- **Do not set `CSRF_HTTP_ONLY=true` for this SPA** unless token delivery changes away from `document.cookie`; otherwise mutating requests will fail CSRF validation.
### SameSite Attribute

View File

@ -86,7 +86,8 @@ Global middleware order:
- Cookie name: `CSRF_COOKIE_NAME || bt_csrf_token`.
- Header name: `x-csrf-token`.
- Defaults: `CSRF_HTTP_ONLY !== false`, `CSRF_SAME_SITE || strict`, `CSRF_SECURE !== false`.
- Defaults: `CSRF_HTTP_ONLY === true` only when explicitly configured, `CSRF_SAME_SITE || strict`, `CSRF_SECURE !== false`.
- The SPA expects `CSRF_HTTP_ONLY=false` so `client/api.js` can read the token cookie and send `x-csrf-token`; do not enable httpOnly CSRF cookies unless token delivery changes.
- `csrfTokenProvider` sets a token cookie on responses.
- `csrfMiddleware` validates mutating requests unless `req.csrfSkip` is set. Token may come from header, query, or body and must match cookie.
- Failures return 403 and audit `csrf.failure`.

View File

@ -138,7 +138,7 @@ Bill Tracker uses a **double-submit cookie pattern** for CSRF protection:
**CSRF-exempt routes (via `req.csrfSkip`):**
- `POST /api/auth/login` — no session exists yet, nothing to hijack
- `POST /api/auth/logout-all` — uses session cookie directly
- `POST /api/auth/logout-all` is not exempt; it requires the SPA's normal `x-csrf-token` header because it changes authenticated session state.
**All other state-changing routes have CSRF enforced**, including:
- `POST /api/auth/change-password` — covered by `csrfMiddleware` on `/api/auth` mount

View File

@ -13,7 +13,9 @@ const CSRF_HEADER_NAME = 'x-csrf-token';
// Default: false — the SPA uses a double-submit pattern (reads token from
// document.cookie and sends it in the x-csrf-token header), which requires
// JavaScript access to the cookie. Setting httpOnly=true would break this flow.
// For server-rendered apps, set CSRF_HTTP_ONLY=true for additional protection.
// Do not enable CSRF_HTTP_ONLY for this SPA unless token delivery changes away
// from document.cookie. Server-rendered apps can use httpOnly CSRF cookies when
// they deliver the token through another trusted channel.
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA
// CSRF cookie sameSite setting - configurable via environment variable
@ -39,7 +41,8 @@ function generateCsrfToken() {
/**
* Get or create CSRF token for the current session.
* Tokens are stored in HTTP-only cookies for automatic validation.
* In the SPA's double-submit flow, tokens are stored in a readable cookie so
* client/api.js can copy the value into the x-csrf-token header.
*/
function getCsrfToken(req, res) {
let token = req.cookies?.[CSRF_COOKIE_NAME];

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "bill-tracker",
"version": "0.24.6",
"version": "0.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.24.6",
"version": "0.28.0",
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.27.04",
"version": "0.28.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
@ -8,6 +8,8 @@
"dev:ui": "vite",
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
"build": "vite build",
"check:server": "find server.js db middleware routes services utils -name '*.js' -print0 | xargs -0 -n1 node --check",
"check": "npm run check:server && npm run build",
"start": "node server.js"
},
"dependencies": {

View File

@ -17,6 +17,7 @@ router.get('/', (req, res) => {
ai_assisted: true,
links: {
release_notes: '/release-notes',
privacy: '/privacy',
},
});
});

View File

@ -86,6 +86,7 @@ router.get('/me', requireAuth, (req, res) => {
user: req.user,
single_user_mode: !!req.singleUserMode,
current_version: currentVersion,
release_notes_version: currentVersion,
has_new_version: req.user.last_seen_version !== currentVersion,
});
});
@ -94,7 +95,8 @@ router.get('/me', requireAuth, (req, res) => {
router.get('/login-history', requireAuth, (req, res) => {
const db = getDb();
const history = db.prepare(`
SELECT id, logged_in_at, ip_address, user_agent
SELECT id, logged_in_at, ip_address, user_agent,
browser, os, device_type, device_fingerprint
FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC
@ -109,7 +111,7 @@ router.post('/acknowledge-version', requireAuth, (req, res) => {
getDb()
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
.run(currentVersion, req.user.id);
res.json({ success: true, last_seen_version: currentVersion });
res.json({ success: true, last_seen_version: currentVersion, release_notes_version: currentVersion });
});
// GET /api/auth/mode

View File

@ -4,6 +4,7 @@ const { getDb, ensureUserDefaultCategories } = require('../db/database');
const { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation');
// ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => {
@ -306,6 +307,15 @@ router.post('/:id/toggle-paid', (req, res) => {
// Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 10) : null;
if ((year === null) !== (month === null)) {
return res.status(400).json(standardizeError('year and month must both be provided or both omitted', 'VALIDATION_ERROR', 'year'));
}
if (year !== null && (Number.isNaN(year) || year < 2000 || year > 2100)) {
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
}
if (month !== null && (Number.isNaN(month) || month < 1 || month > 12)) {
return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'));
}
let currentPayment;
if (year !== null && month !== null) {
@ -340,7 +350,7 @@ router.post('/:id/toggle-paid', (req, res) => {
// If unpaid, create payment → Paid
// Use expected_amount if no amount provided
const amount = req.body.amount !== undefined ? parseFloat(req.body.amount) : bill.expected_amount;
const amount = req.body.amount !== undefined ? req.body.amount : bill.expected_amount;
// Determine paid_date
let paidDate = req.body.paid_date;
@ -356,16 +366,21 @@ router.post('/:id/toggle-paid', (req, res) => {
const method = req.body.method || null;
const notes = req.body.notes || null;
if (isNaN(amount) || amount <= 0) {
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
const paymentValidation = validatePaymentInput(
{ amount, paid_date: paidDate },
{ requireBillId: false },
);
if (paymentValidation.error) {
return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payment = paymentValidation.normalized;
// Compute balance delta for debt bills before inserting
const balCalc = computeBalanceDelta(bill, amount);
const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(billId, amount, paidDate, method, notes, balCalc?.balance_delta ?? null);
).run(billId, payment.amount, payment.paid_date, method, notes, balCalc?.balance_delta ?? null);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")

View File

@ -3,6 +3,7 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = express.Router();
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate();
@ -45,6 +46,8 @@ router.get('/', (req, res) => {
}
const today = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const daysInMonth = new Date(year, month, 0).getDate();
const { start, end } = getCycleRange(year, month);
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1));
@ -107,7 +110,7 @@ router.get('/', (req, res) => {
const calendarBills = bills.map(bill => {
const billPayments = paymentsByBillStmt.all(bill.id, start, end);
const row = buildTrackerRow(bill, billPayments, year, month, today);
const row = buildTrackerRow(bill, billPayments, year, month, today, rowOptions);
const monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = monthlyState?.actual_amount ?? null;
const isSkipped = !!monthlyState?.is_skipped;

View File

@ -3,6 +3,7 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router();
const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
@ -59,19 +60,18 @@ router.post('/', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
if (!bill_id || amount == null || !paid_date)
return res.status(400).json(standardizeError('bill_id, amount, and paid_date are required', 'VALIDATION_ERROR', 'bill_id'));
const validation = validatePaymentInput({ bill_id, amount, paid_date });
if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized;
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0)
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id))
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(payment.bill_id, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)'
).run(bill_id, parsedAmount, paid_date, method || null, notes || null);
).run(payment.bill_id, payment.amount, payment.paid_date, method || null, notes || null);
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
});
@ -81,30 +81,40 @@ router.post('/quick', (req, res) => {
const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body;
if (!bill_id) return res.status(400).json(standardizeError('bill_id is required', 'VALIDATION_ERROR', 'bill_id'));
const billValidation = validatePaymentInput({ bill_id }, { requireAmount: false, requirePaidDate: false });
if (billValidation.error) {
return res.status(400).json(standardizeError(billValidation.error, 'VALIDATION_ERROR', billValidation.field));
}
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(bill_id, req.user.id);
const bill = db.prepare('SELECT * FROM bills WHERE id = ? AND user_id = ?').get(billValidation.normalized.bill_id, req.user.id);
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const payAmount = amount != null ? parseFloat(amount) : bill.expected_amount;
if (isNaN(payAmount) || payAmount <= 0)
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount'));
const payDate = paid_date || new Date().toISOString().slice(0, 10);
const paymentValidation = validatePaymentInput(
{
amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || new Date().toISOString().slice(0, 10),
},
{ requireBillId: false },
);
if (paymentValidation.error) {
return res.status(400).json(standardizeError(paymentValidation.error, 'VALIDATION_ERROR', paymentValidation.field));
}
const payAmount = paymentValidation.normalized.amount;
const payDate = paymentValidation.normalized.paid_date;
const balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)'
).run(bill_id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null);
).run(bill.id, payAmount, payDate, method || null, notes || null, balCalc?.balance_delta ?? null);
if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?")
.run(balCalc.new_balance, bill_id);
.run(balCalc.new_balance, bill.id);
}
if (bill.autopay_enabled) {
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill_id);
db.prepare("UPDATE bills SET autodraft_status='confirmed', updated_at=datetime('now') WHERE id=?").run(bill.id);
}
res.status(201).json(db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid));
@ -115,7 +125,7 @@ router.post('/quick', (req, res) => {
// Validation rules:
// - Request body must contain a `payments` array
// - Maximum 50 items per request
// - Each item requires: bill_id (integer), paid_date (valid date), amount (number >= 0)
// - Each item requires: bill_id (integer), paid_date (valid date), amount (positive number)
// - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] }
router.post('/bulk', (req, res) => {
@ -133,27 +143,9 @@ router.post('/bulk', (req, res) => {
// Validate each payment item
for (let i = 0; i < payments.length; i++) {
const item = payments[i];
if (!item.bill_id || item.amount == null || !item.paid_date) {
return res.status(400).json(standardizeError(`Payment at index ${i}: bill_id, amount, and paid_date are required`, 'VALIDATION_ERROR', `payments[${i}]`));
}
// Validate bill_id is an integer (check original input to prevent parseInt coercion)
const billIdStr = String(item.bill_id).trim();
const billIdInt = parseInt(billIdStr, 10);
if (!/^\d+$/.test(billIdStr) || !Number.isInteger(billIdInt)) {
return res.status(400).json(standardizeError(`Payment at index ${i}: bill_id must be an integer`, 'VALIDATION_ERROR', `payments[${i}].bill_id`));
}
// Validate paid_date is a valid date string
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(item.paid_date)) {
return res.status(400).json(standardizeError(`Payment at index ${i}: paid_date must be a valid date in YYYY-MM-DD format`, 'VALIDATION_ERROR', `payments[${i}].paid_date`));
}
// Validate amount is a finite number >= 0 (reject Infinity/NaN)
const parsedAmt = parseFloat(item.amount);
if (isNaN(parsedAmt) || parsedAmt < 0 || !isFinite(parsedAmt)) {
return res.status(400).json(standardizeError(`Payment at index ${i}: amount must be a number >= 0`, 'VALIDATION_ERROR', `payments[${i}].amount`));
const validation = validatePaymentInput(item, { fieldPrefix: `payments[${i}].` });
if (validation.error) {
return res.status(400).json(standardizeError(`Payment at index ${i}: ${validation.error}`, 'VALIDATION_ERROR', validation.field));
}
}
@ -180,9 +172,9 @@ router.post('/bulk', (req, res) => {
const runBulk = db.transaction(() => {
for (const item of payments) {
const bill_id = parseInt(String(item.bill_id).trim(), 10);
const parsedAmt = parseFloat(item.amount);
const { paid_date, method, notes } = item;
const payment = validatePaymentInput(item).normalized;
const { bill_id, amount: parsedAmt, paid_date } = payment;
const { method, notes } = item;
// Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt);
@ -216,6 +208,13 @@ router.put('/:id', (req, res) => {
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
const { amount, paid_date, method, notes } = req.body;
const validation = validatePaymentInput(
{ amount, paid_date },
{ requireBillId: false, requireAmount: false, requirePaidDate: false },
);
if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
db.prepare(`
UPDATE payments SET
@ -223,8 +222,8 @@ router.put('/:id', (req, res) => {
updated_at = datetime('now')
WHERE id = ?
`).run(
amount != null ? parseFloat(amount) : existing.amount,
paid_date ?? existing.paid_date,
validation.normalized.amount ?? existing.amount,
validation.normalized.paid_date ?? existing.paid_date,
method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes,
req.params.id,

82
routes/privacy.js Normal file
View File

@ -0,0 +1,82 @@
const express = require('express');
const router = express.Router();
let pkg;
try { pkg = require('../package.json'); } catch { pkg = { version: '0.1.0' }; }
router.get('/', (req, res) => {
res.json({
name: 'Privacy',
version: pkg.version,
last_updated: '2026-05-16',
summary: 'This application is designed to operate privately and locally. We do not collect, sell, analyze, or remotely store your bill tracking data.',
sections: [
{
title: 'Overview',
items: [
'This application is designed to operate privately and locally.',
'We do not collect, sell, analyze, or remotely store your bill tracking data.',
],
},
{
title: 'Data Collection',
items: [
'This application does not collect or transmit personal bill information to any server controlled by the administrator.',
'The administrator cannot view your bills, bill details, payment information, notes or attachments, or account activity in the app UI.',
'All user data remains local to your installation or environment.',
],
},
{
title: 'Version Checking',
items: [
'The only external communication performed by the application is an optional version check to determine whether the latest software release is installed.',
'This communication does not include your bill data or personal information.',
'Version check information is not tracked or stored server-side and is used solely to determine software update availability.',
],
},
{
title: 'Administrative Access',
items: [
'The administrator has extremely limited capabilities in the application.',
'The administrator cannot edit bill information, view bill information, or access your stored financial data in the app UI.',
'The administrator may only reset user passwords when requested.',
],
},
{
title: 'Logging',
items: [
'No application logs are transmitted externally.',
'All logs, if enabled, remain local to the device or server where the application is installed.',
'We do not maintain centralized logging, analytics, telemetry, or activity tracking systems.',
],
},
{
title: 'Third-Party Services',
items: [
'This application does not use third-party analytics, advertising, or tracking services.',
],
},
{
title: 'Security',
items: [
'Because data remains local and is not centrally stored, users retain direct control over their information and environment security.',
],
},
{
title: 'Changes to This Policy',
items: [
'This privacy policy may be updated to reflect changes in the application functionality.',
'Continued use of the software constitutes acceptance of any updated policy.',
],
},
{
title: 'Contact',
items: [
'If you have questions about this privacy policy, contact the repository administrator.',
],
},
],
});
});
module.exports = router;

View File

@ -2,39 +2,16 @@
const express = require('express');
const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database');
// Keys a regular user is allowed to read and write.
// Admin/SMTP/backup/auth settings are excluded — they are only readable through
// their respective admin endpoints and never exposed here.
const USER_SETTING_KEYS = [
'currency', 'date_format', 'grace_period_days', 'notify_days_before',
];
const { getUserSettings, setUserSettings } = require('../services/userSettings');
// GET /api/settings — returns only user-facing app preferences
router.get('/', (req, res) => {
const db = getDb();
const settings = {};
for (const key of USER_SETTING_KEYS) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
if (row) settings[key] = row.value;
}
res.json(settings);
res.json(getUserSettings(req.user.id));
});
// PUT /api/settings — updates only allowed user-facing keys; silently ignores others
// PUT /api/settings — updates only allowed user-facing keys for this user
router.put('/', (req, res) => {
for (const [key, value] of Object.entries(req.body)) {
if (USER_SETTING_KEYS.includes(key)) setSetting(key, value);
}
const db = getDb();
const settings = {};
for (const key of USER_SETTING_KEYS) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
if (row) settings[key] = row.value;
}
res.json(settings);
res.json(setUserSettings(req.user.id, req.body));
});
module.exports = router;

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {
@ -16,6 +17,8 @@ router.get('/', (req, res) => {
return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const { start, end } = getCycleRange(year, month);
@ -94,7 +97,7 @@ router.get('/', (req, res) => {
// Get payments for this bill
const payments = allPayments[bill.id] || [];
const row = buildTrackerRow(bill, payments, year, month, todayStr);
const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
// Overlay monthly state overrides
const mbs = monthlyStates[bill.id];
@ -116,11 +119,24 @@ router.get('/', (req, res) => {
// Get starting amounts for this month
const startingAmounts = db.prepare(`
SELECT COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
SELECT COALESCE(first_amount, 0) AS first_amount,
COALESCE(fifteenth_amount, 0) AS fifteenth_amount,
COALESCE(other_amount, 0) AS other_amount,
COALESCE(first_amount, 0) + COALESCE(fifteenth_amount, 0) + COALESCE(other_amount, 0) AS combined_amount
FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ?
`).get(req.user.id, year, month);
const dayOfMonth = now.getDate();
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaid = periodRows.reduce((s, r) => s + r.total_paid, 0);
const periodOutstandingBalance = periodRows.reduce((s, r) => s + Math.max(r.balance || 0, 0), 0);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
const periodLabel = activeRemainingPeriod === '1st' ? '1st balance' : '15th balance';
const totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
@ -198,7 +214,13 @@ router.get('/', (req, res) => {
total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid,
remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
remaining: hasStartingAmounts ? periodStartingAmount - periodPaid : periodOutstandingBalance,
total_remaining: hasStartingAmounts ? totalStarting - activeTotalPaid : activeOutstandingBalance,
remaining_period: activeRemainingPeriod,
remaining_label: periodLabel,
remaining_hint: hasStartingAmounts
? `${periodLabel}: ${periodStartingAmount.toFixed(2)} starting minus ${periodPaid.toFixed(2)} paid`
: `${periodLabel}: unpaid bills due in this period`,
overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').length,
@ -223,6 +245,8 @@ router.get('/upcoming', (req, res) => {
const days = Math.max(1, Math.min(parseInt(req.query.days || '30', 10) || 30, 365));
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const userSettings = getUserSettings(req.user.id);
const rowOptions = { gracePeriodDays: userSettings.grace_period_days };
const year = now.getFullYear();
const month = now.getMonth() + 1;
@ -274,7 +298,7 @@ router.get('/upcoming', (req, res) => {
// Get payments for this bill from the batched results
const payments = allPayments[bill.id] || [];
const row = buildTrackerRow(bill, payments, year, month, todayStr);
const row = buildTrackerRow(bill, payments, year, month, todayStr, rowOptions);
if (row.status === 'paid') continue; // skip already paid
upcoming.push({

View File

@ -64,10 +64,9 @@ function skipRateLimitIfNoUsers(limiter) {
// Mount login router with conditional rate limiting
// If no users exist, rate limit is bypassed; otherwise it applies
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter));
// Password change routes are exempt from CSRF - session-based auth is primary protection
// CSRF skip for login (no session exists yet to protect) and logout-all
// (uses session cookie directly). Password change routes MUST have CSRF protection.
app.use('/api/auth/logout-all', (req, res, next) => { req.csrfSkip = true; next(); });
// Login skips CSRF inside routes/auth because no authenticated session exists yet.
// Authenticated state-changing auth routes, including logout-all and password
// changes, require the SPA's x-csrf-token header like other mutating requests.
// All other auth routes require CSRF (loginLimiter applied via /api/auth/login above)
// Note: passwordLimiter is applied individually on routes that actually change passwords
app.use('/api/auth', csrfMiddleware, require('./routes/auth'));
@ -96,6 +95,7 @@ app.use('/api/notifications', csrfMiddleware, requireAuth, require(
app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status'));
app.use('/api/about', require('./routes/about')); // public
app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only
app.use('/api/privacy', require('./routes/privacy')); // public
app.use('/api/version', require('./routes/version')); // public
// Profile — rate limit only on password-change, not all profile reads

View File

@ -1,6 +1,7 @@
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database');
const { buildDeviceFingerprint } = require('./loginFingerprint');
const COOKIE_NAME = 'bt_session';
const SESSION_DAYS = 7;
@ -177,11 +178,23 @@ function publicUser(u) {
*/
function recordLogin(userId, ipAddress, userAgent) {
const db = getDb();
const device = buildDeviceFingerprint({ userAgent, ipAddress });
db.transaction(() => {
db.prepare(`
INSERT INTO user_login_history (user_id, logged_in_at, ip_address, user_agent)
VALUES (?, datetime('now'), ?, ?)
`).run(userId, ipAddress ?? null, userAgent ? userAgent.slice(0, 500) : null);
INSERT INTO user_login_history (
user_id, logged_in_at, ip_address, user_agent,
browser, os, device_type, device_fingerprint
)
VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?)
`).run(
userId,
ipAddress ?? null,
userAgent ? userAgent.slice(0, 500) : null,
device.browser,
device.os,
device.device_type,
device.device_fingerprint,
);
// Keep only the 3 most recent rows for this user
db.prepare(`

View File

@ -0,0 +1,72 @@
'use strict';
const crypto = require('crypto');
function parseBrowser(userAgent) {
const ua = String(userAgent || '');
if (/Edg\//i.test(ua)) return 'Edge';
if (/OPR\//i.test(ua) || /Opera/i.test(ua)) return 'Opera';
if (/Firefox\//i.test(ua)) return 'Firefox';
if (/Chrome\//i.test(ua) || /CriOS\//i.test(ua)) return 'Chrome';
if (/Safari\//i.test(ua)) return 'Safari';
if (/curl\//i.test(ua)) return 'curl';
if (/PostmanRuntime\//i.test(ua)) return 'Postman';
return 'Unknown';
}
function parseOs(userAgent) {
const ua = String(userAgent || '');
if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS';
if (/Android/i.test(ua)) return 'Android';
if (/Windows NT/i.test(ua)) return 'Windows';
if (/Mac OS X|Macintosh/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
if (/CrOS/i.test(ua)) return 'ChromeOS';
return 'Unknown';
}
function parseDeviceType(userAgent) {
const ua = String(userAgent || '');
if (/iPad|Tablet/i.test(ua)) return 'tablet';
if (/Mobi|iPhone|Android/i.test(ua)) return 'mobile';
if (/curl|PostmanRuntime/i.test(ua)) return 'api';
return 'desktop';
}
function coarseIpPrefix(ipAddress) {
const ip = String(ipAddress || '').trim();
if (!ip) return '';
const v4 = ip.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.\d{1,3}$/);
if (v4) return `${v4[1]}.${v4[2]}.${v4[3]}.0/24`;
if (ip.includes(':')) {
return `${ip.split(':').slice(0, 4).join(':')}::/64`;
}
return ip;
}
function buildDeviceFingerprint({ userAgent, ipAddress }) {
const browser = parseBrowser(userAgent);
const os = parseOs(userAgent);
const deviceType = parseDeviceType(userAgent);
const ipPrefix = coarseIpPrefix(ipAddress);
const source = [browser, os, deviceType, String(userAgent || '').slice(0, 500), ipPrefix].join('|');
const fingerprint = crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
return {
browser,
os,
device_type: deviceType,
device_fingerprint: fingerprint,
};
}
module.exports = {
buildDeviceFingerprint,
coarseIpPrefix,
parseBrowser,
parseDeviceType,
parseOs,
};

View File

@ -0,0 +1,86 @@
'use strict';
function isPositiveIntegerString(value) {
return /^\d+$/.test(String(value).trim());
}
function validateIsoDate(value, field = 'paid_date') {
if (typeof value !== 'string') {
return { error: `${field} must be a valid date in YYYY-MM-DD format` };
}
const trimmed = value.trim();
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
if (!match) {
return { error: `${field} must be a valid date in YYYY-MM-DD format` };
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return { error: `${field} must be a real calendar date in YYYY-MM-DD format` };
}
return { value: trimmed };
}
function validatePositiveAmount(value, field = 'amount') {
const amount = Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
return { error: `${field} must be a positive number` };
}
return { value: amount };
}
function validatePaymentInput(data, options = {}) {
const {
requireBillId = true,
requireAmount = true,
requirePaidDate = true,
fieldPrefix = '',
} = options;
const normalized = {};
if (requireBillId || data.bill_id !== undefined) {
if (data.bill_id === undefined || data.bill_id === null || data.bill_id === '') {
return { error: 'bill_id is required', field: `${fieldPrefix}bill_id` };
}
if (!isPositiveIntegerString(data.bill_id)) {
return { error: 'bill_id must be a positive integer', field: `${fieldPrefix}bill_id` };
}
normalized.bill_id = Number(String(data.bill_id).trim());
}
if (requireAmount || data.amount !== undefined) {
if (data.amount === undefined || data.amount === null || data.amount === '') {
return { error: 'amount is required', field: `${fieldPrefix}amount` };
}
const amount = validatePositiveAmount(data.amount, `${fieldPrefix}amount`);
if (amount.error) return { error: amount.error, field: `${fieldPrefix}amount` };
normalized.amount = amount.value;
}
if (requirePaidDate || data.paid_date !== undefined) {
if (data.paid_date === undefined || data.paid_date === null || data.paid_date === '') {
return { error: 'paid_date is required', field: `${fieldPrefix}paid_date` };
}
const paidDate = validateIsoDate(data.paid_date, `${fieldPrefix}paid_date`);
if (paidDate.error) return { error: paidDate.error, field: `${fieldPrefix}paid_date` };
normalized.paid_date = paidDate.value;
}
return { normalized };
}
module.exports = {
validateIsoDate,
validatePaymentInput,
validatePositiveAmount,
};

View File

@ -1,5 +1,10 @@
const { getSetting } = require('../db/database');
function resolveGracePeriodDays(value) {
const parsed = parseInt(value ?? getSetting('grace_period_days') ?? '5', 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 5;
}
/**
* Resolves the due date for a bill in a given year/month.
* Bills use a recurring day-of-month template field. Legacy override_due_date
@ -42,8 +47,8 @@ function getCycleRange(year, month) {
* late past due, within grace period
* missed past grace period, unpaid
*/
function calculateStatus(bill, payments, dueDate, today) {
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10);
function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
@ -69,11 +74,11 @@ function calculateStatus(bill, payments, dueDate, today) {
/**
* Builds a full tracker row for a bill in a given month.
*/
function buildTrackerRow(bill, payments, year, month, todayStr) {
function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const dueDate = resolveDueDate(bill, year, month);
const bucket = resolveBucket(bill);
const safePayments = Array.isArray(payments) ? payments : [];
const status = calculateStatus(bill, safePayments, dueDate, todayStr);
const status = calculateStatus(bill, safePayments, dueDate, todayStr, options);
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
@ -107,4 +112,4 @@ function buildTrackerRow(bill, payments, year, month, todayStr) {
};
}
module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow };
module.exports = { resolveDueDate, resolveBucket, getCycleRange, calculateStatus, buildTrackerRow, resolveGracePeriodDays };

62
services/userSettings.js Normal file
View File

@ -0,0 +1,62 @@
'use strict';
const { getDb, getSetting } = require('../db/database');
const USER_SETTING_KEYS = [
'currency',
'date_format',
'grace_period_days',
'notify_days_before',
];
function defaultUserSettings() {
const defaults = {};
for (const key of USER_SETTING_KEYS) {
defaults[key] = getSetting(key);
}
return defaults;
}
function getUserSettings(userId) {
const db = getDb();
const settings = defaultUserSettings();
const rows = db.prepare(`
SELECT key, value
FROM user_settings
WHERE user_id = ?
`).all(userId);
for (const row of rows) {
if (USER_SETTING_KEYS.includes(row.key)) settings[row.key] = row.value;
}
return settings;
}
function setUserSettings(userId, data) {
const db = getDb();
const upsert = db.prepare(`
INSERT INTO user_settings (user_id, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(user_id, key) DO UPDATE SET
value = excluded.value,
updated_at = datetime('now')
`);
const save = db.transaction(() => {
for (const [key, value] of Object.entries(data || {})) {
if (USER_SETTING_KEYS.includes(key)) {
upsert.run(userId, key, String(value));
}
}
});
save();
return getUserSettings(userId);
}
module.exports = {
USER_SETTING_KEYS,
getUserSettings,
setUserSettings,
};