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 HTTPS=true
COOKIE_SECURE=true COOKIE_SECURE=true
CORS_ORIGIN=https://bills.example.com CORS_ORIGIN=https://bills.example.com
CSRF_HTTP_ONLY=true CSRF_HTTP_ONLY=false
CSRF_SAME_SITE=strict CSRF_SAME_SITE=strict
CSRF_SECURE=true CSRF_SECURE=true
CSRF_COOKIE_NAME=bt_csrf_token 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. - Admin routes require an admin session.
- The default admin account cannot access user tracker routes. - 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). - 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. - 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. - 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`. - 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 AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage'));
const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage')); const ReleaseNotesPage = lazy(() => import('@/pages/ReleaseNotesPage'));
const AboutPage = lazy(() => import('@/pages/AboutPage')); const AboutPage = lazy(() => import('@/pages/AboutPage'));
const PrivacyPage = lazy(() => import('@/pages/PrivacyPage'));
const RoadmapPage = lazy(() => import('@/pages/RoadmapPage')); const RoadmapPage = lazy(() => import('@/pages/RoadmapPage'));
const DataPage = lazy(() => import('@/pages/DataPage')); const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage')); const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
@ -107,6 +108,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/login" element={<ErrorBoundary><LoginPage /></ErrorBoundary>} /> <Route path="/login" element={<ErrorBoundary><LoginPage /></ErrorBoundary>} />
<Route path="/about" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AboutPage /></Suspense></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 path="/release-notes" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ReleaseNotesPage /></Suspense></ErrorBoundary>} />
<Route <Route

View File

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

View File

@ -6,10 +6,6 @@ import { Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { api } from '@/api'; 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() { export function ReleaseNotesDialog() {
const { hasNewVersion, setHasNewVersion } = useAuth(); const { hasNewVersion, setHasNewVersion } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -20,17 +16,16 @@ export function ReleaseNotesDialog() {
}, [hasNewVersion]); }, [hasNewVersion]);
const handleClose = () => { const handleClose = () => {
localStorage.setItem(LS_KEY, '1');
setOpen(false); setOpen(false);
setHasNewVersion(false); // optimistic don't wait for the server 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; const prev = document.activeElement;
if (prev?.focus) setTimeout(() => prev.focus(), 0); if (prev?.focus) setTimeout(() => prev.focus(), 0);
}; };
return ( return (
<Dialog open={open} onOpenChange={v => { if (!v) handleClose(); }}> <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> <DialogHeader>
<div className="flex items-center gap-2 mb-1"> <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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10">
@ -58,6 +53,19 @@ export function ReleaseNotesDialog() {
))} ))}
</div> </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"> <div className="mt-4 pt-4 border-t border-border flex items-center justify-end">
<Button size="sm" onClick={handleClose}> <Button size="sm" onClick={handleClose}>
Got it Got it

View File

@ -3,7 +3,7 @@ import AppNavigation from './Sidebar';
export default function Layout({ mainContentId }) { export default function Layout({ mainContentId }) {
return ( 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" role="main"
aria-labelledby={mainContentId} aria-labelledby={mainContentId}
> >

View File

@ -98,7 +98,7 @@ function UserMenu({ adminMode = false }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <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" aria-label="Open user menu"
> >
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary"> <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]); const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
return ( 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"> <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} /> <BrandBlock adminMode={adminMode} />
@ -179,13 +179,13 @@ export default function Sidebar({ adminMode = false }) {
</nav> </nav>
<div className="ml-auto flex items-center gap-2"> <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} /> <UserMenu adminMode={adminMode} />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon" 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-label={mobileOpen ? 'Close navigation menu' : 'Open navigation menu'}
aria-expanded={mobileOpen} aria-expanded={mobileOpen}
onClick={() => setMobileOpen(v => !v)} onClick={() => setMobileOpen(v => !v)}
@ -196,7 +196,7 @@ export default function Sidebar({ adminMode = false }) {
</div> </div>
{mobileOpen && ( {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"> <nav className="mx-auto grid max-w-[1500px] gap-1">
{!adminMode && trackerItems.map(item => ( {!adminMode && trackerItems.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} /> <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) => ( const Card = React.forwardRef(({ className, ...props }, ref) => (
<div <div
ref={ref} 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} {...props}
/> />
)); ));

View File

@ -13,7 +13,7 @@
:root { :root {
--background: 0.98 0.01 335.69; --background: 0.98 0.01 335.69;
--foreground: 0.22 0 0; --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; --card-foreground: 0.14 0 0;
--popover: 0.95 0.01 316.67; --popover: 0.95 0.01 316.67;
--popover-foreground: 0.40 0.04 309.35; --popover-foreground: 0.40 0.04 309.35;
@ -21,7 +21,7 @@
--primary-foreground: 1.00 0 0; --primary-foreground: 1.00 0 0;
--secondary: 0.49 0.04 300.23; --secondary: 0.49 0.04 300.23;
--secondary-foreground: 1.00 0 0; --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; --muted-foreground: 0.43 0.02 309.68;
--accent: 0.92 0.04 303.47; --accent: 0.92 0.04 303.47;
--accent-foreground: 0.14 0 0; --accent-foreground: 0.14 0 0;
@ -52,7 +52,7 @@
.dark { .dark {
--background: 0.15 0.01 317.69; --background: 0.15 0.01 317.69;
--foreground: 0.95 0.01 321.50; --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; --card-foreground: 0.95 0.01 321.50;
--popover: 0.22 0.02 322.13; --popover: 0.22 0.02 322.13;
--popover-foreground: 0.95 0.01 321.50; --popover-foreground: 0.95 0.01 321.50;
@ -60,7 +60,7 @@
--primary-foreground: 0.98 0.01 321.51; --primary-foreground: 0.98 0.01 321.51;
--secondary: 0.45 0.03 294.79; --secondary: 0.45 0.03 294.79;
--secondary-foreground: 0.95 0.01 321.50; --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; --muted-foreground: 0.70 0.01 320.70;
--accent: 0.35 0.06 299.57; --accent: 0.35 0.06 299.57;
--accent-foreground: 0.95 0.01 321.50; --accent-foreground: 0.95 0.01 321.50;
@ -104,7 +104,7 @@
/* Generic surface */ /* Generic surface */
.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 */ /* Elevated surface */
@ -112,13 +112,13 @@
@apply surface; @apply surface;
box-shadow: box-shadow:
0 1px 2px rgb(0 0 0 / 0.05), 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 { .dark .surface-elevated {
box-shadow: box-shadow:
0 0 0 1px oklch(var(--border) / 0.7), 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 */ /* Stat cards */

View File

@ -9,29 +9,33 @@ export const RELEASE_NOTES = {
date: '2026-05-15', date: '2026-05-15',
highlights: [ highlights: [
{ {
icon: '📋', icon: '🛡️',
title: 'Bills page redesigned', title: 'Safer payment and settings data',
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.', desc: 'Payment amounts and dates are now validated consistently, and regular-user settings are stored per user instead of globally.',
}, },
{ {
icon: '📈', icon: '🔐',
title: 'Snowball projection is now live', title: 'Security checks tightened',
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.', 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: '🔑', icon: '🔑',
title: 'Login history', title: 'Private login details',
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.', 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: '📥', icon: '📄',
title: 'Import by bill', title: 'Privacy and release notes',
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.', 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: '📐', icon: '🎛️',
title: 'APR calculation engine', title: 'Cleaner tracker and interface polish',
desc: 'New backend math service: monthly interest, months to payoff, total interest, and full amortization schedules. Available via GET /api/bills/:id/amortization.', 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; const displayVersion = about?.version ?? APP_VERSION;
return ( 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"> <main className="mx-auto w-full max-w-3xl space-y-5">
<Button asChild variant="ghost" size="sm" className="-ml-2"> <Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to={user ? '/' : '/login'}> <Link to={user ? '/' : '/login'}>
@ -91,7 +91,7 @@ export default function AboutPage() {
</Link> </Link>
</Button> </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> <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"> <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" /> <Info className="h-5 w-5" />
@ -139,6 +139,9 @@ export default function AboutPage() {
<Button asChild> <Button asChild>
<Link to="/release-notes">Release Notes</Link> <Link to="/release-notes">Release Notes</Link>
</Button> </Button>
<Button asChild variant="outline">
<Link to="/privacy">Privacy</Link>
</Button>
{user == null && ( {user == null && (
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link to="/login">Sign In</Link> <Link to="/login">Sign In</Link>

View File

@ -99,8 +99,8 @@ function OnboardingWizard({ onComplete }) {
let validationError = ''; let validationError = '';
if (password !== confirm) { if (password !== confirm) {
validationError = 'Passwords do not match.'; validationError = 'Passwords do not match.';
} else if (password.length < 6) { } else if (password.length < 8) {
validationError = 'Password must be at least 6 characters.'; validationError = 'Password must be at least 8 characters.';
} }
if (validationError) { if (validationError) {
@ -992,7 +992,7 @@ function UsersTable({ users, onRefresh, currentUser }) {
const handleReset = async (user) => { const handleReset = async (user) => {
const form = getForm(user.id); 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); setResetting(user.id);
try { try {
await api.resetPassword(user.id, { password: form.pw }); await api.resetPassword(user.id, { password: form.pw });
@ -1209,8 +1209,8 @@ function AddUserCard({ onCreated }) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (password.length < 6) { if (password.length < 8) {
const msg = 'Password must be at least 6 characters.'; const msg = 'Password must be at least 8 characters.';
setError(msg); setError(msg);
toast.error(msg); toast.error(msg);
return; return;
@ -1890,7 +1890,7 @@ export default function AdminPage() {
} }
return ( 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 /> <AppNavigation adminMode />
{/* Content */} {/* Content */}

View File

@ -90,8 +90,8 @@ export default function LoginPage() {
return; return;
} }
if (newPw.length < 6) { if (newPw.length < 8) {
toast.error('Password must be at least 6 characters.'); toast.error('Password must be at least 8 characters.');
return; 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 React, { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
@ -84,18 +84,33 @@ function parseUserAgent(ua) {
return { browser, os, mobile }; 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 [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
setLoading(true); setLoading(true);
api.loginHistory() api.loginHistory()
.then(d => setHistory(d.history ?? [])) .then(d => {
const rows = d.history ?? [];
setHistory(rows);
onLoaded?.(rows);
})
.catch(() => setHistory([])) .catch(() => setHistory([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [open]); }, [open, providedHistory, onLoaded]);
return ( return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}> <Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
@ -118,8 +133,11 @@ function LoginHistoryModal({ lastLoginAt, open, onClose }) {
) : history.length === 0 ? ( ) : history.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No login history recorded.</p> <p className="text-sm text-muted-foreground text-center py-8">No login history recorded.</p>
) : history.map((entry, i) => { ) : history.map((entry, i) => {
const { browser, os, mobile } = parseUserAgent(entry.user_agent); const parsed = parseUserAgent(entry.user_agent);
const DeviceIcon = mobile ? Smartphone : Monitor; 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 ( return (
<div key={entry.id} <div key={entry.id}
className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-4 py-3"> 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>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
{browser} on {os} {deviceLabel(deviceType)} · {browser} on {os}
{entry.ip_address && ( {entry.ip_address && (
<span className="ml-2 font-mono">{entry.ip_address}</span> <span className="ml-2 font-mono">{entry.ip_address}</span>
)} )}
</p> </p>
{entry.device_fingerprint && (
<p className="text-[10px] text-muted-foreground/70 mt-1 font-mono">
Device ID {entry.device_fingerprint}
</p>
)}
</div> </div>
</div> </div>
); );
@ -146,15 +169,81 @@ function LoginHistoryModal({ lastLoginAt, open, onClose }) {
</div> </div>
<p className="text-[10px] text-muted-foreground/60 text-center pt-1"> <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> </p>
</DialogContent> </DialogContent>
</Dialog> </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 }) { function ProfileSummary({ profile, loading }) {
const [historyOpen, setHistoryOpen] = useState(false); 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) { if (loading) {
return ( return (
@ -164,7 +253,7 @@ function ProfileSummary({ profile, loading }) {
); );
} }
const lastLoginAt = profile.last_login_at || profile.last_login; const latestLogin = loginHistory[0] || null;
return ( return (
<> <>
@ -174,26 +263,21 @@ function ProfileSummary({ profile, loading }) {
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} /> <FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} /> <FieldRow label="Role" value={profile.role} />
{/* Last Login — clickable, opens history modal */} <LoginSummaryCard
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3"> latestLogin={latestLogin}
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p> loading={historyLoading}
<button onOpen={() => setHistoryOpen(true)}
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>
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} /> <FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
</div> </div>
</SectionCard> </SectionCard>
<LoginHistoryModal <LoginHistoryModal
lastLoginAt={lastLoginAt} history={loginHistory}
open={historyOpen} open={historyOpen}
onClose={() => setHistoryOpen(false)} onClose={() => setHistoryOpen(false)}
onLoaded={setLoginHistory}
/> />
</> </>
); );

View File

@ -14,11 +14,32 @@ function formatDateTime(value) {
return date.toLocaleString(); 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 }) { function HistoryLine({ line, index }) {
const trimmed = line.trim(); const trimmed = line.trim();
const image = parseImageLine(trimmed);
if (!trimmed) return <div key={index} className="h-3" />; if (!trimmed) return <div key={index} className="h-3" />;
if (trimmed === '---') return <div key={index} className="my-5 border-t border-border" />; 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('# ')) { if (trimmed.startsWith('# ')) {
return ( return (
@ -83,7 +104,7 @@ export default function ReleaseNotesPage() {
const history = data?.history || ''; const history = data?.history || '';
return ( 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"> <main className="mx-auto w-full max-w-4xl space-y-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>

View File

@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -57,7 +56,7 @@ function RoadmapItemCard({ item, defaultOpen }) {
> >
{lane.emoji} {lane.label} {lane.emoji} {lane.label}
</Badge> </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> </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" /> <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> </div>
@ -97,19 +96,19 @@ function RoadmapItemCard({ item, defaultOpen }) {
{item.description && ( {item.description && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Description</p> <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> </div>
)} )}
{item.rationale && ( {item.rationale && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Rationale</p> <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> </div>
)} )}
{item.implementationNotes && ( {item.implementationNotes && (
<div> <div>
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mb-1">Implementation Notes</p> <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} {item.implementationNotes}
</div> </div>
</div> </div>
@ -127,10 +126,10 @@ function PriorityLane({ lane, items, defaultOpenCards, forceKey }) {
if (items.length === 0) return null; if (items.length === 0) return null;
return ( return (
<section aria-label={`${lane.label} priority`} className={`rounded-xl border border-border/60 border-t-4 ${lane.borderColor}`}> <section aria-label={`${lane.label} priority`} className={`min-w-0 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"> <div className="flex items-center gap-2 border-b border-border/50 px-4 py-2.5">
<span aria-hidden="true">{lane.emoji}</span> <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> <span className="ml-auto text-[11px] font-semibold text-muted-foreground tabular-nums">{items.length}</span>
</div> </div>
<div className="p-3 space-y-2"> <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> <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"> <div className="flex flex-wrap gap-1">
{entry.filesModified.map((file, idx) => ( {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} {file}
</code> </code>
))} ))}
@ -301,11 +300,11 @@ export default function RoadmapPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <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"> <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" /> <Map className="h-5 w-5" />
</div> </div>
<div> <div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Roadmap</h1> <h1 className="text-2xl font-bold tracking-tight">Roadmap</h1>
<p className="text-sm text-muted-foreground">Current and upcoming features by priority</p> <p className="text-sm text-muted-foreground">Current and upcoming features by priority</p>
</div> </div>
@ -317,7 +316,7 @@ export default function RoadmapPage() {
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="roadmap" onValueChange={v => { if (v === 'activity') fetchDevLog(); }}> <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"> <TabsTrigger value="roadmap" className="gap-1.5">
<Map className="h-3.5 w-3.5" /> <Map className="h-3.5 w-3.5" />
Roadmap Roadmap
@ -353,19 +352,38 @@ export default function RoadmapPage() {
</Button> </Button>
</div> </div>
{/* Desktop: 5 columns */} {/* Wide desktop: full five-lane view */}
<div className="hidden lg:grid lg:grid-cols-5 gap-3"> <div className="hidden 2xl:grid 2xl:grid-cols-5 gap-4">
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)} {grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)}
</div> </div>
{/* Tablet: 2 columns */} {/* Desktop: balanced three-column view for admin shell widths */}
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-3"> <div className="hidden lg:grid lg:grid-cols-3 2xl:hidden gap-4">
<div className="space-y-3"> <div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => {grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} /> <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)} )}
</div> </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 => {grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} /> <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 */} {/* Page header — flat on background */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1> <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> </div>
{/* Appearance */} {/* Appearance */}

View File

@ -236,7 +236,7 @@ export default function SummaryPage() {
</div> </div>
</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"> <Button variant="ghost" size="icon" onClick={() => moveMonth(-1)} aria-label="Previous month">
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </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 def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0); const isActive = def.activateWhen(value || 0);
const Icon = def.icon; const Icon = def.icon;
const displayLabel = label || def.label;
return ( return (
<div className={cn( <div className={cn(
@ -153,7 +154,7 @@ function SummaryCard({ type, value, onEdit, hint }) {
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Icon className={cn('h-4 w-4', isActive ? def.valueClass : 'text-muted-foreground')} /> <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"> <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{def.label} {displayLabel}
</p> </p>
{type === 'starting' && onEdit && ( {type === 'starting' && onEdit && (
<button <button
@ -1474,7 +1475,12 @@ export default function TrackerPage() {
onEdit={() => setEditStartingOpen(true)} onEdit={() => setEditStartingOpen(true)}
/> />
<SummaryCard type="paid" value={summary.total_paid} /> <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="overdue" value={summary.overdue} />
<SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/> <SummaryCard type="paid" value={summary.previous_month_total} hint="Previous month"/>
{summary.trend && <TrendCard trend={summary.trend} />} {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, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
logged_in_at TEXT NOT NULL DEFAULT (datetime('now')), logged_in_at TEXT NOT NULL DEFAULT (datetime('now')),
ip_address TEXT, 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'); console.log('[migration] user_login_history table created');
@ -1327,6 +1331,72 @@ function runMigrations() {
`); `);
console.log('[migration] user_login_history table created'); 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': { 'v0.53': {
description: 'user_login_history table', description: 'user_login_history table',
sql: ['DROP TABLE IF EXISTS user_login_history'] 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')) 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 ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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')) 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 ( CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE, 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 ### HttpOnly Setting
- **Default (Secure):** `CSRF_HTTP_ONLY=true` — Cookie is NOT accessible via JavaScript, only sent automatically with requests - **BillTracker SPA default:** `CSRF_HTTP_ONLY=false` — Cookie IS accessible via JavaScript so `client/api.js` can send the matching `x-csrf-token` header.
- **SPA Mode:** `CSRF_HTTP_ONLY=false` — Cookie IS accessible via JavaScript, enabling the double-submit pattern - **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 ### SameSite Attribute

View File

@ -86,7 +86,8 @@ Global middleware order:
- Cookie name: `CSRF_COOKIE_NAME || bt_csrf_token`. - Cookie name: `CSRF_COOKIE_NAME || bt_csrf_token`.
- Header name: `x-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. - `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. - `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`. - 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`):** **CSRF-exempt routes (via `req.csrfSkip`):**
- `POST /api/auth/login` — no session exists yet, nothing to hijack - `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: **All other state-changing routes have CSRF enforced**, including:
- `POST /api/auth/change-password` — covered by `csrfMiddleware` on `/api/auth` mount - `POST /api/auth/change-password` — covered by `csrfMiddleware` on `/api/auth` mount
@ -238,4 +238,4 @@ If the redesign has issues in production:
- Revert `App.jsx` route to `<AboutPage admin />` - Revert `App.jsx` route to `<AboutPage admin />`
- Restore `AdminDashboard.jsx` from git - Restore `AdminDashboard.jsx` from git
- Roadmap page works again in the old format - Roadmap page works again in the old format
- New `/api/roadmap` and `/api/dev-log` endpoints are additive — no data loss - New `/api/roadmap` and `/api/dev-log` endpoints are additive — no data loss

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 // 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 // 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. // 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 const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA
// CSRF cookie sameSite setting - configurable via environment variable // CSRF cookie sameSite setting - configurable via environment variable
@ -39,7 +41,8 @@ function generateCsrfToken() {
/** /**
* Get or create CSRF token for the current session. * 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) { function getCsrfToken(req, res) {
let token = req.cookies?.[CSRF_COOKIE_NAME]; let token = req.cookies?.[CSRF_COOKIE_NAME];

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "bill-tracker", "name": "bill-tracker",
"version": "0.27.04", "version": "0.28.0",
"description": "Monthly bill tracking system", "description": "Monthly bill tracking system",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@ -8,6 +8,8 @@
"dev:ui": "vite", "dev:ui": "vite",
"dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"", "dev": "concurrently \"npm run dev:api\" \"npm run dev:ui\"",
"build": "vite build", "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" "start": "node server.js"
}, },
"dependencies": { "dependencies": {

View File

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

View File

@ -86,6 +86,7 @@ router.get('/me', requireAuth, (req, res) => {
user: req.user, user: req.user,
single_user_mode: !!req.singleUserMode, single_user_mode: !!req.singleUserMode,
current_version: currentVersion, current_version: currentVersion,
release_notes_version: currentVersion,
has_new_version: req.user.last_seen_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) => { router.get('/login-history', requireAuth, (req, res) => {
const db = getDb(); const db = getDb();
const history = db.prepare(` 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 FROM user_login_history
WHERE user_id = ? WHERE user_id = ?
ORDER BY logged_in_at DESC ORDER BY logged_in_at DESC
@ -109,7 +111,7 @@ router.post('/acknowledge-version', requireAuth, (req, res) => {
getDb() getDb()
.prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?") .prepare("UPDATE users SET last_seen_version = ?, updated_at = datetime('now') WHERE id = ?")
.run(currentVersion, req.user.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 // 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 { VALID_VISIBILITY, getValidCycleTypes, parseDueDay, parseInterestRate, validateCycleDay, validateBillData, computeBalanceDelta } = require('../services/billsService');
const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService'); const { amortizationSchedule, debtAprSnapshot } = require('../services/aprService');
const { standardizeError } = require('../middleware/errorFormatter'); const { standardizeError } = require('../middleware/errorFormatter');
const { validatePaymentInput } = require('../services/paymentValidation');
// ── GET /api/bills ──────────────────────────────────────────────────────────── // ── GET /api/bills ────────────────────────────────────────────────────────────
router.get('/', (req, res) => { router.get('/', (req, res) => {
@ -306,6 +307,15 @@ router.post('/:id/toggle-paid', (req, res) => {
// Scope to year/month if provided // Scope to year/month if provided
const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null; const year = req.body.year !== undefined ? parseInt(req.body.year, 10) : null;
const month = req.body.month !== undefined ? parseInt(req.body.month, 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; let currentPayment;
if (year !== null && month !== null) { if (year !== null && month !== null) {
@ -340,7 +350,7 @@ router.post('/:id/toggle-paid', (req, res) => {
// If unpaid, create payment → Paid // If unpaid, create payment → Paid
// Use expected_amount if no amount provided // 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 // Determine paid_date
let paidDate = req.body.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 method = req.body.method || null;
const notes = req.body.notes || null; const notes = req.body.notes || null;
if (isNaN(amount) || amount <= 0) { const paymentValidation = validatePaymentInput(
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount')); { 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 // Compute balance delta for debt bills before inserting
const balCalc = computeBalanceDelta(bill, amount); const balCalc = computeBalanceDelta(bill, payment.amount);
const result = db.prepare( const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' '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) { if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") 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 router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange } = require('../services/statusService'); const { buildTrackerRow, getCycleRange } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
function clampDay(year, month, day) { function clampDay(year, month, day) {
const daysInMonth = new Date(year, month, 0).getDate(); const daysInMonth = new Date(year, month, 0).getDate();
@ -45,6 +46,8 @@ router.get('/', (req, res) => {
} }
const today = now.toISOString().slice(0, 10); 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 daysInMonth = new Date(year, month, 0).getDate();
const { start, end } = getCycleRange(year, month); const { start, end } = getCycleRange(year, month);
const days = Array.from({ length: daysInMonth }, (_, index) => emptyDay(year, month, index + 1)); 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 calendarBills = bills.map(bill => {
const billPayments = paymentsByBillStmt.all(bill.id, start, end); 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 monthlyState = monthlyStateStmt.get(bill.id, year, month);
const actualAmount = monthlyState?.actual_amount ?? null; const actualAmount = monthlyState?.actual_amount ?? null;
const isSkipped = !!monthlyState?.is_skipped; const isSkipped = !!monthlyState?.is_skipped;

View File

@ -3,6 +3,7 @@ const { standardizeError } = require('../middleware/errorFormatter');
const router = require('express').Router(); const router = require('express').Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { computeBalanceDelta } = require('../services/billsService'); const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
@ -59,19 +60,18 @@ router.post('/', (req, res) => {
const db = getDb(); const db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body; const { bill_id, amount, paid_date, method, notes } = req.body;
if (!bill_id || amount == null || !paid_date) const validation = validatePaymentInput({ bill_id, amount, paid_date });
return res.status(400).json(standardizeError('bill_id, amount, and paid_date are required', 'VALIDATION_ERROR', 'bill_id')); if (validation.error) {
return res.status(400).json(standardizeError(validation.error, 'VALIDATION_ERROR', validation.field));
}
const payment = validation.normalized;
const parsedAmount = parseFloat(amount); if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(payment.bill_id, req.user.id))
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))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const result = db.prepare( const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes) VALUES (?, ?, ?, ?, ?)' '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)); 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 db = getDb();
const { bill_id, amount, paid_date, method, notes } = req.body; 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')); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const payAmount = amount != null ? parseFloat(amount) : bill.expected_amount; const paymentValidation = validatePaymentInput(
if (isNaN(payAmount) || payAmount <= 0) {
return res.status(400).json(standardizeError('amount must be a positive number', 'VALIDATION_ERROR', 'amount')); amount: amount != null ? amount : bill.expected_amount,
paid_date: paid_date || new Date().toISOString().slice(0, 10),
const payDate = 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 balCalc = computeBalanceDelta(bill, payAmount);
const result = db.prepare( const result = db.prepare(
'INSERT INTO payments (bill_id, amount, paid_date, method, notes, balance_delta) VALUES (?, ?, ?, ?, ?, ?)' '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) { if (balCalc) {
db.prepare("UPDATE bills SET current_balance = ?, updated_at = datetime('now') WHERE id = ?") 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) { 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)); 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: // Validation rules:
// - Request body must contain a `payments` array // - Request body must contain a `payments` array
// - Maximum 50 items per request // - 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 // - Duplicate payments (same bill_id + paid_date + amount) are skipped, not created
// - Returns { created: [...], skipped: [...], errors: [...] } // - Returns { created: [...], skipped: [...], errors: [...] }
router.post('/bulk', (req, res) => { router.post('/bulk', (req, res) => {
@ -133,27 +143,9 @@ router.post('/bulk', (req, res) => {
// Validate each payment item // Validate each payment item
for (let i = 0; i < payments.length; i++) { for (let i = 0; i < payments.length; i++) {
const item = payments[i]; const item = payments[i];
if (!item.bill_id || item.amount == null || !item.paid_date) { const validation = validatePaymentInput(item, { fieldPrefix: `payments[${i}].` });
return res.status(400).json(standardizeError(`Payment at index ${i}: bill_id, amount, and paid_date are required`, 'VALIDATION_ERROR', `payments[${i}]`)); if (validation.error) {
} return res.status(400).json(standardizeError(`Payment at index ${i}: ${validation.error}`, 'VALIDATION_ERROR', validation.field));
// 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`));
} }
} }
@ -180,9 +172,9 @@ router.post('/bulk', (req, res) => {
const runBulk = db.transaction(() => { const runBulk = db.transaction(() => {
for (const item of payments) { for (const item of payments) {
const bill_id = parseInt(String(item.bill_id).trim(), 10); const payment = validatePaymentInput(item).normalized;
const parsedAmt = parseFloat(item.amount); const { bill_id, amount: parsedAmt, paid_date } = payment;
const { paid_date, method, notes } = item; const { method, notes } = item;
// Check for duplicates using composite key (bill_id + paid_date + amount) // Check for duplicates using composite key (bill_id + paid_date + amount)
const isDuplicate = duplicateCheckStmt.get(req.user.id, bill_id, paid_date, parsedAmt); 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')); if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
const { amount, paid_date, method, notes } = req.body; 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(` db.prepare(`
UPDATE payments SET UPDATE payments SET
@ -223,8 +222,8 @@ router.put('/:id', (req, res) => {
updated_at = datetime('now') updated_at = datetime('now')
WHERE id = ? WHERE id = ?
`).run( `).run(
amount != null ? parseFloat(amount) : existing.amount, validation.normalized.amount ?? existing.amount,
paid_date ?? existing.paid_date, validation.normalized.paid_date ?? existing.paid_date,
method !== undefined ? (method || null) : existing.method, method !== undefined ? (method || null) : existing.method,
notes !== undefined ? (notes || null) : existing.notes, notes !== undefined ? (notes || null) : existing.notes,
req.params.id, 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 express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb, getSetting, setSetting } = require('../db/database'); const { getUserSettings, setUserSettings } = require('../services/userSettings');
// 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',
];
// GET /api/settings — returns only user-facing app preferences // GET /api/settings — returns only user-facing app preferences
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); res.json(getUserSettings(req.user.id));
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);
}); });
// 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) => { router.put('/', (req, res) => {
for (const [key, value] of Object.entries(req.body)) { res.json(setUserSettings(req.user.id, 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);
}); });
module.exports = router; module.exports = router;

View File

@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService'); const { buildTrackerRow, getCycleRange, resolveDueDate } = require('../services/statusService');
const { getUserSettings } = require('../services/userSettings');
// GET /api/tracker?year=2026&month=5 // GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => { 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' }); return res.status(400).json({ error: 'month must be an integer between 1 and 12' });
const todayStr = now.toISOString().slice(0, 10); 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); const { start, end } = getCycleRange(year, month);
@ -94,7 +97,7 @@ router.get('/', (req, res) => {
// Get payments for this bill // Get payments for this bill
const payments = allPayments[bill.id] || []; 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 // Overlay monthly state overrides
const mbs = monthlyStates[bill.id]; const mbs = monthlyStates[bill.id];
@ -116,11 +119,24 @@ router.get('/', (req, res) => {
// Get starting amounts for this month // Get starting amounts for this month
const startingAmounts = db.prepare(` 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 FROM monthly_starting_amounts
WHERE user_id = ? AND year = ? AND month = ? WHERE user_id = ? AND year = ? AND month = ?
`).get(req.user.id, year, 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 totalStarting = startingAmounts?.combined_amount || 0;
const hasStartingAmounts = !!startingAmounts; const hasStartingAmounts = !!startingAmounts;
const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0); const activeTotalPaid = activeRows.reduce((s, r) => s + r.total_paid, 0);
@ -198,7 +214,13 @@ router.get('/', (req, res) => {
total_starting: totalStarting, total_starting: totalStarting,
has_starting_amounts: hasStartingAmounts, has_starting_amounts: hasStartingAmounts,
total_paid: activeTotalPaid, 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, overdue: totalOverdue,
count_paid: activeRows.filter(r => r.status === 'paid').length, count_paid: activeRows.filter(r => r.status === 'paid').length,
count_upcoming: activeRows.filter(r => r.status === 'upcoming' || r.status === 'due_soon').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 days = Math.max(1, Math.min(parseInt(req.query.days || '30', 10) || 30, 365));
const now = new Date(); const now = new Date();
const todayStr = now.toISOString().slice(0, 10); 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 year = now.getFullYear();
const month = now.getMonth() + 1; const month = now.getMonth() + 1;
@ -274,7 +298,7 @@ router.get('/upcoming', (req, res) => {
// Get payments for this bill from the batched results // Get payments for this bill from the batched results
const payments = allPayments[bill.id] || []; 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 if (row.status === 'paid') continue; // skip already paid
upcoming.push({ upcoming.push({

View File

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

View File

@ -1,6 +1,7 @@
const crypto = require('crypto'); const crypto = require('crypto');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { buildDeviceFingerprint } = require('./loginFingerprint');
const COOKIE_NAME = 'bt_session'; const COOKIE_NAME = 'bt_session';
const SESSION_DAYS = 7; const SESSION_DAYS = 7;
@ -177,11 +178,23 @@ function publicUser(u) {
*/ */
function recordLogin(userId, ipAddress, userAgent) { function recordLogin(userId, ipAddress, userAgent) {
const db = getDb(); const db = getDb();
const device = buildDeviceFingerprint({ userAgent, ipAddress });
db.transaction(() => { db.transaction(() => {
db.prepare(` db.prepare(`
INSERT INTO user_login_history (user_id, logged_in_at, ip_address, user_agent) INSERT INTO user_login_history (
VALUES (?, datetime('now'), ?, ?) user_id, logged_in_at, ip_address, user_agent,
`).run(userId, ipAddress ?? null, userAgent ? userAgent.slice(0, 500) : null); 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 // Keep only the 3 most recent rows for this user
db.prepare(` 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'); 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. * 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 * 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 * late past due, within grace period
* missed past grace period, unpaid * missed past grace period, unpaid
*/ */
function calculateStatus(bill, payments, dueDate, today) { function calculateStatus(bill, payments, dueDate, today, options = {}) {
const gracePeriodDays = parseInt(getSetting('grace_period_days') || '5', 10); const gracePeriodDays = resolveGracePeriodDays(options.gracePeriodDays);
const safePayments = Array.isArray(payments) ? payments : []; const safePayments = Array.isArray(payments) ? payments : [];
const totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0); 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. * 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 dueDate = resolveDueDate(bill, year, month);
const bucket = resolveBucket(bill); const bucket = resolveBucket(bill);
const safePayments = Array.isArray(payments) ? payments : []; 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 totalPaid = safePayments.reduce((sum, p) => sum + p.amount, 0);
const hasPayment = safePayments.length > 0; const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft'; 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,
};