v0.28.0
This commit is contained in:
parent
74603ff2d5
commit
0ba315bd32
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
|
@ -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',
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ?")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(`
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue