This commit is contained in:
null 2026-05-15 01:36:56 -05:00
parent 576163e85b
commit 263f1c5e6e
14 changed files with 868 additions and 371 deletions

View File

@ -1,3 +1,4 @@
{
"MD013": false,
"MD024": { "siblings_only": true }
}

View File

@ -50,6 +50,7 @@ export const api = {
changePassword: (data) => post('/auth/change-password', data),
acknowledgePrivacy: () => post('/auth/acknowledge-privacy'),
acknowledgeVersion: () => post('/auth/acknowledge-version'),
loginHistory: () => get('/auth/login-history'),
// Admin
hasUsers: () => get('/admin/has-users'),

View File

@ -1,266 +1,190 @@
import {
Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { PenLine, EyeOff, Eye, Clock, Trash2, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { History } from 'lucide-react';
function hasHistoricalVisibility(bill) {
const visibility = bill.history_visibility;
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
function ordinal(n) {
const d = Number(n);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
switch (d % 10) {
case 1: return `${d}st`;
case 2: return `${d}nd`;
case 3: return `${d}rd`;
default: return `${d}th`;
}
}
function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory }) {
function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
}
function AprColor({ rate }) {
const cls =
rate >= 25 ? 'text-rose-400' :
rate >= 15 ? 'text-amber-400' :
'text-muted-foreground/60';
return <span className={cn('text-[10px] tabular-nums', cls)}>{rate}% APR</span>;
}
const ALL_ON = {
showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
};
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) {
const isDebt = bill.current_balance != null || bill.minimum_payment != null;
const hasHistory = hasHistoricalVisibility(bill);
return (
<div className="rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="min-w-0 truncate text-left text-sm font-semibold leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
onClick={() => onEdit?.(bill.id)}
title={`Edit ${bill.name}`}
>
{bill.name}
</button>
{hasHistory && (
<span
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
title="Historical visibility configured"
aria-label="Historical visibility configured"
>
<History className="h-3 w-3" />
</span>
)}
</div>
<div className={cn(
'flex items-center gap-3 px-5 py-3.5 transition-colors',
'hover:bg-accent/20',
!bill.active && 'opacity-60',
)}>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
bill.active
? 'bg-emerald-500/15 text-emerald-500'
: 'bg-muted text-muted-foreground',
)}>
{bill.active ? 'Active' : 'Inactive'}
</span>
{!!bill.autopay_enabled && (
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-500">AP</span>
)}
{!!bill.has_2fa && (
<span className="rounded bg-violet-500/15 px-1.5 py-0.5 text-[10px] text-violet-400">2FA</span>
)}
</div>
</div>
{/* Main info */}
<div className="flex-1 min-w-0 space-y-1.5">
<span className="shrink-0 font-mono text-sm font-semibold tabular-nums text-foreground">
${Number(bill.expected_amount).toFixed(2)}
</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Due</p>
<p className="mt-0.5 text-sm text-foreground">Day {bill.due_day}</p>
</div>
<div className="min-w-0">
<p className="uppercase tracking-wide text-muted-foreground/60">Category</p>
<p className="mt-0.5 truncate text-sm text-foreground">{bill.category_name || '—'}</p>
</div>
<div>
<p className="uppercase tracking-wide text-muted-foreground/60">Cycle</p>
<p className="mt-0.5 text-sm capitalize text-foreground">{bill.billing_cycle || 'monthly'}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2.5 text-xs',
bill.active
? 'text-muted-foreground hover:text-destructive'
: 'text-emerald-500 hover:text-emerald-400',
)}
onClick={() => onToggle?.(bill)}
>
{bill.active ? 'Deactivate' : 'Activate'}
</Button>
{!bill.active && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
onClick={() => onHistory?.(bill)}
{/* Name + badges */}
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={() => onEdit?.(bill.id)}
className="text-sm font-semibold text-foreground hover:text-primary transition-colors text-left truncate max-w-[240px]"
>
History
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete?.(bill)}
>
Delete
</Button>
{bill.name}
</button>
{prefs.showCategory && bill.category_name && (
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
{bill.category_name}
</span>
)}
{prefs.showAutopay && !!bill.autopay_enabled && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/15 text-emerald-500 shrink-0">
Autopay
</span>
)}
{prefs.show2fa && !!bill.has_2fa && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 shrink-0">
2FA
</span>
)}
{hasHistory && (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
title="Historical visibility configured"
>
<Clock className="h-2.5 w-2.5" />
</span>
)}
</div>
{/* Meta row */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{prefs.showCycle && <span className="capitalize">{bill.billing_cycle || 'monthly'}</span>}
{prefs.showCycle && prefs.showDueDay && <span className="text-border">·</span>}
{prefs.showDueDay && <span>Due {ordinal(bill.due_day)}</span>}
{prefs.showApr && isDebt && bill.interest_rate != null && (
<>
{(prefs.showCycle || prefs.showDueDay) && <span className="text-border">·</span>}
<AprColor rate={bill.interest_rate} />
</>
)}
{prefs.showBalance && isDebt && bill.current_balance != null && (
<>
{(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && <span className="text-border">·</span>}
<span className="text-[10px] text-muted-foreground/70 tabular-nums">
${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance
</span>
</>
)}
</div>
</div>
{/* Amount */}
{prefs.showAmount && (
<div className="text-right shrink-0 hidden sm:block">
<p className="font-mono text-sm font-semibold tabular-nums">
${Number(bill.expected_amount).toFixed(2)}
</p>
{prefs.showMinPayment && bill.minimum_payment != null && (
<p className="text-[10px] text-muted-foreground">
${Number(bill.minimum_payment).toFixed(0)} min
</p>
)}
</div>
)}
{/* Action icons */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => onEdit?.(bill.id)}
title="Edit"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PenLine className="h-3.5 w-3.5" />
</button>
{!bill.active && onHistory && (
<button
type="button"
onClick={() => onHistory?.(bill)}
title="History visibility"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-sky-400 hover:bg-sky-500/10 transition-colors"
>
<Clock className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
onClick={() => onToggle?.(bill)}
title={bill.active ? 'Deactivate' : 'Activate'}
className={cn(
'p-1.5 rounded-md transition-colors',
bill.active
? 'text-muted-foreground/40 hover:text-amber-400 hover:bg-amber-500/10'
: 'text-muted-foreground/40 hover:text-emerald-400 hover:bg-emerald-500/10',
)}
>
{bill.active ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => onDelete?.(bill)}
title="Delete"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
// Accepts row action handlers from BillsPage
export default function BillsTableInner({ bills, onEdit, onToggle, onDelete, onHistory }) {
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory }) {
return (
<>
<div className="grid gap-3 p-3 lg:hidden">
{bills.map((bill) => (
<MobileBillRow
key={bill.id}
bill={bill}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
/>
))}
</div>
<div className="hidden lg:block">
<Table className="min-w-[900px]">
<TableHeader className="bg-muted border-b border-border/70">
<TableRow className="hover:bg-transparent border-0">
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Bill</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground">Category</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Due</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28 text-right">Expected</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-28">Cycle</TableHead>
<TableHead className="px-6 py-3 text-xs uppercase text-muted-foreground w-24">Flags</TableHead>
<TableHead className="px-6 py-3 w-72" />
</TableRow>
</TableHeader>
<TableBody>
{bills.map((bill) => (
<TableRow
key={bill.id}
className="group border-b border-border/50 last:border-0 hover:bg-accent/60 transition-colors"
>
{/* Bill name */}
<TableCell className="px-6 py-4">
<div className="flex items-center gap-2">
<button
type="button"
className="text-left text-sm font-medium leading-tight text-foreground underline-offset-4 transition-colors hover:text-primary hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 rounded-sm"
onClick={() => onEdit?.(bill.id)}
title={`Edit ${bill.name}`}
>
{bill.name}
</button>
{hasHistoricalVisibility(bill) && (
<span
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"
title="Historical visibility configured"
aria-label="Historical visibility configured"
>
<History className="h-3 w-3" />
</span>
)}
</div>
</TableCell>
{/* Category */}
<TableCell className="px-6 py-4">
{bill.category_name ? (
<span className="text-xs text-muted-foreground">{bill.category_name}</span>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Due day */}
<TableCell className="px-6 py-4 w-24">
<span className="text-sm text-muted-foreground">Day {bill.due_day}</span>
</TableCell>
{/* Expected amount */}
<TableCell className="px-6 py-4 w-28 text-right">
<span className="font-mono text-sm tabular-nums text-muted-foreground">
${Number(bill.expected_amount).toFixed(2)}
</span>
</TableCell>
{/* Billing cycle — field is billing_cycle, not cycle */}
<TableCell className="px-6 py-4 w-28">
<span className="text-xs text-muted-foreground capitalize">
{bill.billing_cycle || 'monthly'}
</span>
</TableCell>
{/* Flags */}
<TableCell className="px-6 py-4 w-24">
{(!!bill.autopay_enabled || !!bill.has_2fa) ? (
<div className="flex items-center gap-1.5">
{!!bill.autopay_enabled && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-400">AP</span>
)}
{!!bill.has_2fa && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400">2FA</span>
)}
</div>
) : (
<span className="text-muted-foreground/40 text-xs"></span>
)}
</TableCell>
{/* Actions — visible on row hover */}
<TableCell className="px-6 py-4 w-72 text-right">
<div className="flex items-center justify-end gap-1.5 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100">
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 px-2.5 text-xs',
bill.active
? 'text-muted-foreground hover:text-destructive'
: 'text-emerald-500 hover:text-emerald-400',
)}
onClick={() => onToggle?.(bill)}
>
{bill.active ? 'Deactivate' : 'Activate'}
</Button>
{!bill.active && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-sky-500 hover:text-sky-400 hover:bg-sky-500/10"
onClick={() => onHistory?.(bill)}
>
History
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete?.(bill)}
>
Delete
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
<div className="divide-y divide-border/30">
{bills.map(bill => (
<BillCard
key={bill.id}
bill={bill}
prefs={prefs}
onEdit={onEdit}
onToggle={onToggle}
onDelete={onDelete}
onHistory={onHistory}
/>
))}
</div>
);
}

View File

@ -6,32 +6,32 @@ export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: APP_VERSION,
date: '2026-05-14',
date: '2026-05-15',
highlights: [
{
icon: '❄️',
title: 'Debt Snowball',
desc: 'New Snowball page built around Dave Ramsey\'s method: drag-and-drop ordering, attack-target highlight, auto-arrange by balance, and per-bill payoff date that updates live as you type your extra monthly budget.',
icon: '📋',
title: 'Bills page redesigned',
desc: 'The old table is gone. Bills now show as clean cards with icon actions, inline debt details (APR colour-coded, current balance), and a Columns button to choose exactly which fields are displayed — remembered across sessions.',
},
{
icon: '📉',
title: 'Payment → Balance sync',
desc: 'Recording a payment on any debt bill now automatically reduces its current balance (payment minus one month of accrued interest = principal paid). Un-marking a payment reverses the change exactly.',
icon: '📈',
title: 'Snowball projection is now live',
desc: 'The payoff sidebar updates instantly as you type your extra monthly budget — no save required. The projection now includes a minimum-only baseline so you can see exactly how many months and dollars the snowball saves you.',
},
{
icon: '💳',
title: 'Debt Details on Bills',
desc: 'Edit Bill now has a collapsible Debt / Credit Details section: current balance (inline-editable on the Snowball page), minimum payment, and APR. Bills in Credit Cards, Loans, or Mortgage categories are auto-detected.',
icon: '🔑',
title: 'Login history',
desc: 'Your last 3 sign-ins are recorded with timestamp, IP address, and browser. Click the Last Login field on your Profile page to see the full history.',
},
{
icon: '📊',
title: 'Avalanche comparison',
desc: 'The Snowball page sidebar shows your full payoff projection alongside an Avalanche method comparison — see how much interest you\'d save by attacking highest-rate debts first.',
icon: '📥',
title: 'Import by bill',
desc: 'The XLSX import page has a new Bills tab. Select any existing bill and import its entire history from the spreadsheet in one click — no row-by-row review needed.',
},
{
icon: '🔔',
title: 'Update notifications',
desc: 'The app now tracks which version you last saw. On your first login after an update you\'ll see this "What\'s new" panel. Admins can also check for newer releases from the Forgejo repo on the Status page.',
icon: '📐',
title: 'APR calculation engine',
desc: 'New backend math service: monthly interest, months to payoff, total interest, and full amortization schedules. Available via GET /api/bills/:id/amortization.',
},
],
};

View File

@ -1,10 +1,10 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Plus, ChevronRight, Trash2 } from 'lucide-react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Plus, ChevronRight, SlidersHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/Skeleton';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
@ -92,6 +92,115 @@ function validateRange(range) {
return null;
}
// Display preferences
const PREFS_KEY = 'bills-display-prefs-v1';
const PREFS_DEFAULTS = {
showCategory: true,
showDueDay: true,
showAmount: true,
showCycle: true,
showApr: true,
showBalance: true,
showMinPayment: true,
showAutopay: true,
show2fa: true,
};
const PREFS_LABELS = [
['showCategory', 'Category'],
['showDueDay', 'Due day'],
['showAmount', 'Amount'],
['showCycle', 'Billing cycle'],
['showApr', 'APR'],
['showBalance', 'Balance'],
['showMinPayment', 'Min payment'],
['showAutopay', 'Autopay badge'],
['show2fa', '2FA badge'],
];
function useDisplayPrefs() {
const [prefs, setPrefs] = useState(() => {
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? { ...PREFS_DEFAULTS, ...JSON.parse(raw) } : PREFS_DEFAULTS;
} catch {
return PREFS_DEFAULTS;
}
});
const toggle = (key) => {
setPrefs(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(PREFS_KEY, JSON.stringify(next)); } catch {}
return next;
});
};
return { prefs, toggle };
}
function DisplayPrefsPanel({ prefs, onToggle }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(v => !v)}
className={cn(
'h-9 px-3 rounded-md border border-border/70 bg-card/80 text-xs font-medium',
'flex items-center gap-2 transition-colors',
open
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
aria-label="Display options"
>
<SlidersHorizontal className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Columns</span>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-48 rounded-xl border border-border/60 bg-card/95 backdrop-blur-xl shadow-xl p-3 space-y-0.5">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground px-1 pb-1.5">
Display options
</p>
{PREFS_LABELS.map(([key, label]) => (
<label
key={key}
className="flex items-center gap-2.5 rounded-md px-1.5 py-1.5 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
>
<input
type="checkbox"
checked={!!prefs[key]}
onChange={() => onToggle(key)}
className="h-3.5 w-3.5 rounded border-border accent-primary"
/>
<span className={cn('text-xs', prefs[key] ? 'text-foreground' : 'text-muted-foreground/60')}>
{label}
</span>
</label>
))}
</div>
)}
</div>
);
}
//
function HistoryVisibilityDialog({ bill, onClose, onSaved }) {
const [visibility, setVisibility] = useState(bill?.history_visibility || 'default');
const [ranges, setRanges] = useState([]);
@ -335,6 +444,8 @@ export default function BillsPage() {
const [deleteBusy, setDeleteBusy] = useState(false);
const [historyTarget, setHistoryTarget] = useState(null);
const { prefs, toggle: togglePref } = useDisplayPrefs();
const load = useCallback(async () => {
try {
const [billsRes, catRes] = await Promise.all([
@ -433,30 +544,32 @@ export default function BillsPage() {
</p>
</div>
<Button
onClick={() => setModal({ bill: null })}
className="h-9 px-4 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
Add Bill
</Button>
<div className="flex items-center gap-2">
<DisplayPrefsPanel prefs={prefs} onToggle={togglePref} />
<Button
onClick={() => setModal({ bill: null })}
className="h-9 px-4 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
Add Bill
</Button>
</div>
</div>
{/* ── Active Bills ── */}
<div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Active Bills
<div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Active
</span>
<span className="text-xs font-mono text-muted-foreground">{active.length}</span>
<span className="text-xs tabular-nums text-muted-foreground">{active.length}</span>
</div>
{loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="mx-auto mb-3 h-12 w-3/4 rounded-lg bg-muted animate-pulse" />
<div className="space-y-px p-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-16 rounded-lg bg-muted/40 animate-pulse mb-2" />
))}
<span className="animate-pulse">Loading bills</span>
</div>
) : active.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
@ -469,14 +582,13 @@ export default function BillsPage() {
</button>
</div>
) : (
<div className="overflow-x-auto">
<BillsTableInner
bills={active}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
/>
</div>
<BillsTableInner
bills={active}
prefs={prefs}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
/>
)}
</div>
@ -498,22 +610,21 @@ export default function BillsPage() {
</button>
{showInactive && (
<div className="rounded-xl border border-border overflow-hidden bg-card">
<div className="flex flex-col gap-3 px-6 py-3 bg-muted/30 border-b border-border sm:flex-row sm:items-center sm:justify-between">
<span className="text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
Inactive Bills
<div className="surface-elevated rounded-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-5 py-3 border-b border-border/40">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Inactive
</span>
<span className="text-xs font-mono text-muted-foreground">{inactive.length}</span>
</div>
<div className="overflow-x-auto">
<BillsTableInner
bills={inactive}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
onHistory={setHistoryTarget}
/>
<span className="text-xs tabular-nums text-muted-foreground">{inactive.length}</span>
</div>
<BillsTableInner
bills={inactive}
prefs={prefs}
onEdit={handleEdit}
onToggle={handleToggle}
onDelete={handleDeleteRequest}
onHistory={setHistoryTarget}
/>
</div>
)}

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { toast } from 'sonner';
import {
Upload, FileSpreadsheet, Database, Download, CheckCircle2, XCircle,
AlertTriangle, Loader2, RefreshCw, Clock, ChevronDown,
ChevronUp, SkipForward, Plus, CheckCheck, Sparkles,
List, Building2, ChevronLeft,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
@ -1039,6 +1040,202 @@ const INITIAL_OPTIONS = {
defaultMonth: '',
};
// Bill History Import helpers
function ConfidenceDot({ level }) {
const cls = level === 'high' ? 'bg-emerald-500'
: level === 'medium' ? 'bg-amber-500'
: 'bg-muted-foreground/30';
return <span className={cn('h-2 w-2 rounded-full shrink-0 inline-block', cls)} />;
}
function useBillGroups(previewRows, allBills) {
return useMemo(() => {
const billMap = new Map(allBills.map(b => [b.id, b]));
const groups = new Map();
for (const row of previewRows) {
for (const match of (row.possible_bill_matches ?? [])) {
if (!billMap.has(match.bill_id)) continue;
if (!groups.has(match.bill_id)) {
groups.set(match.bill_id, {
bill: billMap.get(match.bill_id),
rows: [],
counts: { high: 0, medium: 0, low: 0 },
});
}
const g = groups.get(match.bill_id);
if (!g.rows.find(r => r.row_id === row.row_id)) {
g.rows.push({ ...row, _match: match });
g.counts[match.match_confidence] = (g.counts[match.match_confidence] || 0) + 1;
}
}
}
return [...groups.values()].sort((a, b) =>
b.rows.length !== a.rows.length ? b.rows.length - a.rows.length : b.counts.high - a.counts.high
);
}, [previewRows, allBills]);
}
function rowDateLabel(row) {
if (row.detected_year && row.detected_month)
return `${row.detected_year}-${String(row.detected_month).padStart(2, '0')}`;
return row.detected_paid_date ?? '—';
}
function BillDetailView({ group, onBack, onImport, isImporting, importResult }) {
const { bill, rows } = group;
const sorted = [...rows].sort((a, b) => {
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
return da - db;
});
return (
<div>
<div className="px-4 py-2.5 border-b border-border/50 flex items-center justify-between gap-3">
<button type="button" onClick={onBack}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-3.5 w-3.5" /> All bills
</button>
<span className="text-sm font-medium truncate">{bill.name}</span>
{importResult ? (
<span className="text-xs text-emerald-500 font-medium shrink-0">
{importResult.created + importResult.updated} imported
</span>
) : (
<Button size="sm" onClick={onImport} disabled={isImporting} className="h-7 text-xs px-3 shrink-0 gap-1.5">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
Import all {rows.length}
</Button>
)}
</div>
<div className="divide-y divide-border/30 max-h-80 overflow-y-auto">
{sorted.map(row => (
<div key={row.row_id} className="px-4 py-2 flex items-center gap-3">
<ConfidenceDot level={row._match.match_confidence} />
<span className="text-xs tabular-nums text-muted-foreground w-16 shrink-0">{rowDateLabel(row)}</span>
<span className="text-xs font-mono tabular-nums w-16 shrink-0">
{row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'}
</span>
<span className="text-xs text-muted-foreground truncate flex-1">{row.detected_name ?? '—'}</span>
<span className={cn('text-[10px] shrink-0',
row._match.match_confidence === 'high' ? 'text-emerald-500' :
row._match.match_confidence === 'medium' ? 'text-amber-500' : 'text-muted-foreground/40')}>
{row._match.match_confidence}
</span>
</div>
))}
</div>
</div>
);
}
function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) {
const [selectedBillId, setSelectedBillId] = useState(null);
const billGroups = useBillGroups(previewRows, allBills);
if (billGroups.length === 0) {
return (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No existing bills matched rows in this file.
</div>
);
}
if (selectedBillId) {
const group = billGroups.find(g => g.bill.id === selectedBillId);
return group
? <BillDetailView group={group}
isImporting={importingBillId === group.bill.id}
importResult={billImportResults.get(group.bill.id) ?? null}
onBack={() => setSelectedBillId(null)}
onImport={() => onImportBill(group)} />
: null;
}
return (
<div className="divide-y divide-border/50">
{billGroups.map(g => {
const { bill, rows, counts } = g;
const isImporting = importingBillId === bill.id;
const importResult = billImportResults.get(bill.id) ?? null;
const sorted3 = [...rows]
.sort((a, b) => {
const da = (a.detected_year ?? 0) * 100 + (a.detected_month ?? 0);
const db = (b.detected_year ?? 0) * 100 + (b.detected_month ?? 0);
return da - db;
})
.slice(0, 3);
return (
<div key={bill.id} className="px-4 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{bill.name}</span>
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
{rows.length} row{rows.length !== 1 ? 's' : ''}
</span>
{counts.high > 0 && <span className="text-[10px] text-emerald-500">{counts.high} high</span>}
{counts.medium > 0 && <span className="text-[10px] text-amber-500">{counts.medium} med</span>}
{counts.low > 0 && <span className="text-[10px] text-muted-foreground/50">{counts.low} low</span>}
{importResult && (
<span className="text-[10px] text-emerald-500 font-medium">
{importResult.created + importResult.updated} imported
{importResult.errored > 0 && ` · ${importResult.errored} errors`}
</span>
)}
</div>
<div className="mt-1.5 space-y-0.5">
{sorted3.map(row => (
<div key={row.row_id} className="flex items-center gap-2 text-xs text-muted-foreground">
<ConfidenceDot level={row._match.match_confidence} />
<span className="tabular-nums w-16 shrink-0">{rowDateLabel(row)}</span>
{row.detected_amount != null && (
<span className="tabular-nums">${Number(row.detected_amount).toFixed(2)}</span>
)}
{row.detected_name &&
row.detected_name.toLowerCase() !== bill.name.toLowerCase() && (
<span className="truncate italic opacity-60">"{row.detected_name}"</span>
)}
</div>
))}
{rows.length > 3 && (
<button type="button" onClick={() => setSelectedBillId(bill.id)}
className="text-[10px] text-primary/70 hover:text-primary transition-colors">
+{rows.length - 3} more view all
</button>
)}
</div>
</div>
<div className="flex flex-col gap-1 shrink-0 pt-0.5">
{importResult ? (
<Button size="sm" variant="outline" onClick={() => onImportBill(g)}
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
Re-import
</Button>
) : (
<Button size="sm" onClick={() => onImportBill(g)}
disabled={!!importingBillId} className="h-7 text-xs px-3 gap-1.5">
{isImporting && <Loader2 className="h-3 w-3 animate-spin" />}
Import {rows.length}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setSelectedBillId(bill.id)}
disabled={!!importingBillId} className="h-7 text-xs px-3">
Review
</Button>
</div>
</div>
);
})}
</div>
);
}
//
export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const fileRef = useRef(null);
const [file, setFile] = useState(null);
@ -1049,6 +1246,9 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const [allBills, setAllBills] = useState([]);
const [categories, setCategories] = useState([]);
const [selectedRows, setSelectedRows] = useState(new Set());
const [viewMode, setViewMode] = useState('rows'); // 'rows' | 'bills'
const [importingBillId, setImportingBillId] = useState(null);
const [billImportResults, setBillImportResults] = useState(new Map()); // bill_id { created, updated, errored }
// Load bills/categories for the decision controls
useEffect(() => {
@ -1065,6 +1265,9 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
setDecisions({});
setSelectedRows(new Set());
setApplyState({ status: 'idle', result: null, error: null });
setViewMode('rows');
setImportingBillId(null);
setBillImportResults(new Map());
try {
const data = await api.previewSpreadsheetImport(file, {
parseAllSheets: options.parseAllSheets,
@ -1094,6 +1297,43 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
const clearSelection = () => setSelectedRows(new Set());
// Bill-history direct import
// Applies all matching rows for a bill immediately no queue, no review step.
const handleDirectImportBill = async (group) => {
const sessionId = preview.data?.import_session_id;
if (!sessionId || importingBillId) return;
setImportingBillId(group.bill.id);
try {
const decisionsList = group.rows.map(row => ({
row_id: row.row_id,
action: 'match_existing_bill',
bill_id: group.bill.id,
actual_amount: row.detected_amount ?? null,
payment_amount: row.detected_payment_amount ?? row.detected_amount ?? null,
payment_date: row.detected_paid_date ?? null,
}));
const result = await api.applySpreadsheetImport({
import_session_id: sessionId,
decisions: decisionsList,
options: {},
});
const created = result.rows_created ?? 0;
const updated = result.rows_updated ?? 0;
const errored = result.rows_errored ?? 0;
setBillImportResults(prev => new Map(prev).set(group.bill.id, { created, updated, errored }));
toast.success(`Imported ${created + updated} entr${created + updated === 1 ? 'y' : 'ies'} for "${group.bill.name}"`);
onHistoryRefresh?.();
} catch (err) {
toast.error(err.message || `Import failed for "${group.bill.name}"`);
} finally {
setImportingBillId(null);
}
};
const selectAllVisibleRows = () => {
setSelectedRows(new Set((preview.data?.rows || []).map(r => r.row_id)));
};
@ -1333,31 +1573,67 @@ export function ImportSpreadsheetSection({ onHistoryRefresh }) {
{/* Row decision table */}
{previewRows.length > 0 ? (
<div className="rounded-lg border border-border overflow-hidden bg-background">
{/* Tab header */}
<div className="px-4 py-3 border-b border-border bg-muted/40 flex items-center justify-between gap-3 flex-wrap">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">XLSX Review Table</p>
<p className="text-xs text-muted-foreground mt-0.5">Select preview rows, then apply bulk review decisions before importing.</p>
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
<button type="button" onClick={() => setViewMode('rows')}
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'rows'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground')}>
<List className="h-3.5 w-3.5" />
Rows ({previewRows.length})
</button>
<button type="button" onClick={() => setViewMode('bills')}
className={cn('inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'bills'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground')}>
<Building2 className="h-3.5 w-3.5" />
Bills
</button>
</div>
<span className="text-xs text-muted-foreground tabular-nums">{previewRows.length} preview row{previewRows.length === 1 ? '' : 's'}</span>
<span className="text-xs text-muted-foreground">
{viewMode === 'rows'
? 'Select rows, apply bulk decisions, then import.'
: 'Click a bill to queue its entire history from this file.'}
</span>
</div>
<BulkActionBar
rows={previewRows}
selectedRows={selectedRows}
onSelectAll={selectAllVisibleRows}
onClearSelection={clearSelection}
onBulkSkip={handleBulkSkip}
onBulkCreateNew={handleBulkCreateNew}
onBulkReset={handleBulkReset}
/>
<PreviewTable
rows={previewRows}
decisions={decisions}
onDecisionChange={handleDecisionChange}
allBills={allBills}
categories={categories}
selectedRows={selectedRows}
onSelectedChange={handleSelectedChange}
/>
{/* Rows view */}
{viewMode === 'rows' && (
<>
<BulkActionBar
rows={previewRows}
selectedRows={selectedRows}
onSelectAll={selectAllVisibleRows}
onClearSelection={clearSelection}
onBulkSkip={handleBulkSkip}
onBulkCreateNew={handleBulkCreateNew}
onBulkReset={handleBulkReset}
/>
<PreviewTable
rows={previewRows}
decisions={decisions}
onDecisionChange={handleDecisionChange}
allBills={allBills}
categories={categories}
selectedRows={selectedRows}
onSelectedChange={handleSelectedChange}
/>
</>
)}
{/* Bills view */}
{viewMode === 'bills' && (
<BillHistoryView
previewRows={previewRows}
allBills={allBills}
importingBillId={importingBillId}
billImportResults={billImportResults}
onImportBill={handleDirectImportBill}
/>
)}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No data rows found in this file.</p>
@ -1588,7 +1864,7 @@ export default function DataPage() {
</div>
</div>
<div className="grid gap-5 xl:grid-cols-2 xl:items-start">
<div className="space-y-5">
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
<ImportMyDataSection onHistoryRefresh={loadHistory} />
</div>

View File

@ -1,13 +1,16 @@
import React, { useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
User, Mail, KeyRound, ShieldCheck, Loader2,
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
function asProfile(data) {
return data?.profile || data?.user || data || {};
@ -61,7 +64,98 @@ function CheckRow({ id, label, checked, onChange, disabled }) {
);
}
function parseUserAgent(ua) {
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
const s = ua;
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
const browser =
/Edg\//i.test(s) ? 'Edge' :
/OPR\//i.test(s) ? 'Opera' :
/Chrome\//i.test(s) ? 'Chrome' :
/Firefox\//i.test(s) ? 'Firefox' :
/Safari\//i.test(s) ? 'Safari' :
/curl\//i.test(s) ? 'curl' : 'Unknown';
const os =
/iPhone|iPad/i.test(s) ? 'iOS' :
/Android/i.test(s) ? 'Android' :
/Windows/i.test(s) ? 'Windows' :
/Macintosh/i.test(s) ? 'macOS' :
/Linux/i.test(s) ? 'Linux' : 'Unknown';
return { browser, os, mobile };
}
function LoginHistoryModal({ lastLoginAt, open, onClose }) {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
api.loginHistory()
.then(d => setHistory(d.history ?? []))
.catch(() => setHistory([]))
.finally(() => setLoading(false));
}, [open]);
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<History className="h-4 w-4 text-primary" />
Login History
</DialogTitle>
<DialogDescription className="sr-only">
Your last 3 sign-in events
</DialogDescription>
</DialogHeader>
<div className="mt-1 space-y-1">
{loading ? (
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No login history recorded.</p>
) : history.map((entry, i) => {
const { browser, os, mobile } = parseUserAgent(entry.user_agent);
const DeviceIcon = mobile ? Smartphone : Monitor;
return (
<div key={entry.id}
className="flex items-start gap-3 rounded-lg border border-border/50 bg-muted/20 px-4 py-3">
<DeviceIcon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">
{formatDateTime(entry.logged_in_at)}
{i === 0 && (
<span className="ml-2 text-[10px] font-semibold uppercase tracking-wide text-emerald-500">
most recent
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{browser} on {os}
{entry.ip_address && (
<span className="ml-2 font-mono">{entry.ip_address}</span>
)}
</p>
</div>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground/60 text-center pt-1">
Showing up to 3 most recent sign-ins
</p>
</DialogContent>
</Dialog>
);
}
function ProfileSummary({ profile, loading }) {
const [historyOpen, setHistoryOpen] = useState(false);
if (loading) {
return (
<SectionCard title="Profile Summary" icon={User}>
@ -70,16 +164,38 @@ function ProfileSummary({ profile, loading }) {
);
}
const lastLoginAt = profile.last_login_at || profile.last_login;
return (
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FieldRow label="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
<FieldRow label="Last Login" value={formatDateTime(profile.last_login_at || profile.last_login)} />
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
</div>
</SectionCard>
<>
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<FieldRow label="Username" value={profile.username} />
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
<FieldRow label="Role" value={profile.role} />
{/* Last Login — clickable, opens history modal */}
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p>
<button
type="button"
onClick={() => setHistoryOpen(true)}
className="mt-1 text-sm font-medium text-foreground hover:text-primary hover:underline underline-offset-2 transition-colors text-left"
>
{lastLoginAt ? formatDateTime(lastLoginAt) : 'Not recorded'}
</button>
</div>
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
</div>
</SectionCard>
<LoginHistoryModal
lastLoginAt={lastLoginAt}
open={historyOpen}
onClose={() => setHistoryOpen(false)}
/>
</>
);
}

View File

@ -245,7 +245,8 @@ function ProjectionPanel({ projection, projectionLoading, billCount }) {
// Pointer-based drag-and-drop hook (works on touch + mouse)
function useSortable(items, setItems, setDirty) {
const [draggingIdx, setDraggingIdx] = useState(null);
const [draggingIdx, setDraggingIdx] = useState(null);
const [draggingFromIdx, setDraggingFromIdx] = useState(null);
// Refs that live through the entire drag gesture
const state = useRef({
@ -300,6 +301,7 @@ function useSortable(items, setItems, setDirty) {
containerEl: list ?? null,
};
setDraggingIdx(index);
setDraggingFromIdx(index);
}, []);
const onPointerMove = useCallback((e) => {
@ -320,6 +322,7 @@ function useSortable(items, setItems, setDirty) {
state.current.fromIdx = null;
state.current.currentIdx = null;
setDraggingIdx(null);
setDraggingFromIdx(null);
if (fromIdx === null || currentIdx === null || fromIdx === currentIdx) return;
setItems(prev => {
@ -331,7 +334,7 @@ function useSortable(items, setItems, setDirty) {
setDirty(true);
}, [setItems, setDirty]);
return { draggingIdx, onPointerDown, onPointerMove, onPointerUp };
return { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp };
}
// Page
@ -352,7 +355,7 @@ export default function SnowballPage() {
const [editingBalance, setEditingBalance] = useState({ billId: null, value: '' });
const { draggingIdx, onPointerDown, onPointerMove, onPointerUp } =
const { draggingIdx, draggingFromIdx, onPointerDown, onPointerMove, onPointerUp } =
useSortable(bills, setBills, setDirty);
// loading
@ -573,10 +576,11 @@ export default function SnowballPage() {
onPointerCancel={onPointerUp}
>
{bills.map((bill, index) => {
const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id;
const isDragging = draggingIdx !== null;
const isTarget = draggingIdx === index;
const isAttack = index === 0;
const isEditingBal = editingBalance.billId === bill.id;
const isDragging = draggingFromIdx !== null;
const isDragSource = draggingFromIdx === index;
const isLandTarget = isDragging && !isDragSource && draggingIdx === index;
// Pull this debt's payoff info from the live projection (attack card only)
const attackProjection = isAttack
@ -589,9 +593,12 @@ export default function SnowballPage() {
data-card
data-card-index={index}
className={cn(
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
'surface-elevated rounded-xl border transition-all duration-150 select-none touch-none',
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
// Card being actively dragged lifted look
isDragSource && 'scale-[1.03] shadow-2xl ring-2 ring-primary/40 opacity-80 relative z-10',
// Where the card will land slot highlight
isLandTarget && 'ring-2 ring-primary/60 scale-[0.98] opacity-60',
)}
>
<div className="flex items-stretch">

View File

@ -747,6 +747,25 @@ function reconcileLegacyMigrations() {
}
console.log('[migration] users: last_seen_version column added');
}
},
{
version: 'v0.53',
description: 'user_login_history: track last 3 logins per user',
check: function() {
return !!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_login_history'").get();
},
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
)
`);
console.log('[migration] user_login_history table created');
}
}
];
@ -1291,6 +1310,23 @@ function runMigrations() {
}
console.log('[migration] users: last_seen_version column added');
}
},
{
version: 'v0.53',
description: 'user_login_history: track last 3 logins per user',
dependsOn: ['v0.52'],
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
)
`);
console.log('[migration] user_login_history table created');
}
}
];
@ -1685,6 +1721,10 @@ const ROLLBACK_SQL_MAP = {
'v0.52': {
description: 'users: last_seen_version column',
sql: ['ALTER TABLE users DROP COLUMN last_seen_version']
},
'v0.53': {
description: 'user_login_history table',
sql: ['DROP TABLE IF EXISTS user_login_history']
}
};

View File

@ -1,6 +1,7 @@
{
"compilerOptions": {
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["./client/*"]
},

View File

@ -10,7 +10,7 @@ function getAppVersion() {
}
const { getDb, getSetting, setSetting } = require('../db/database');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions } = require('../services/authService');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin } = require('../services/authService');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService');
const { ValidationError, formatError } = require('../utils/apiError');
@ -47,6 +47,7 @@ router.post('/login', (req, res, next) => {
}
logAudit({ user_id: result.user.id, action: 'login.success', ip_address: req.ip, user_agent: req.get('user-agent') });
recordLogin(result.user.id, req.ip, req.get('user-agent'));
res.cookie(COOKIE_NAME, result.sessionId, cookieOpts(req));
res.json({ user: result.user });
@ -89,6 +90,19 @@ router.get('/me', requireAuth, (req, res) => {
});
});
// GET /api/auth/login-history — last 3 logins for the authenticated user
router.get('/login-history', requireAuth, (req, res) => {
const db = getDb();
const history = db.prepare(`
SELECT id, logged_in_at, ip_address, user_agent
FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC
LIMIT 3
`).all(req.user.id);
res.json({ history });
});
// POST /api/auth/acknowledge-version — user has seen the release notes
router.post('/acknowledge-version', requireAuth, (req, res) => {
const currentVersion = getAppVersion();

View File

@ -13,7 +13,7 @@
const express = require('express');
const router = express.Router();
const { createSession, cookieOpts, COOKIE_NAME } = require('../services/authService');
const { createSession, cookieOpts, COOKIE_NAME, recordLogin } = require('../services/authService');
const {
getOidcConfig,
isOidcLoginActive,
@ -93,6 +93,7 @@ router.get('/callback', async (req, res) => {
const session = await createSession(user.id);
if (!session) throw new Error('Failed to create local session after OIDC login');
recordLogin(user.id, req.ip, req.get('user-agent'));
res.cookie(COOKIE_NAME, session.sessionId, cookieOpts(req));
res.redirect(savedState.redirect_to || '/');
} catch (err) {

View File

@ -593,24 +593,4 @@ router.patch('/:id/balance', (req, res) => {
res.json({ id: billId, current_balance: val });
});
// ── PATCH /api/bills/:id/snowball — lightweight snowball visibility update ───
router.patch('/:id/snowball', (req, res) => {
const db = getDb();
const billId = parseInt(req.params.id, 10);
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) {
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
}
const include = req.body.snowball_include ? 1 : 0;
const exempt = req.body.snowball_exempt ? 1 : 0;
db.prepare(`
UPDATE bills
SET snowball_include = ?, snowball_exempt = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?
`).run(include, exempt, billId, req.user.id);
res.json({ id: billId, snowball_include: include, snowball_exempt: exempt });
});
module.exports = router;

View File

@ -171,6 +171,31 @@ function publicUser(u) {
};
}
/**
* Records a successful login and prunes older entries so each user
* keeps at most 3 login history rows.
*/
function recordLogin(userId, ipAddress, userAgent) {
const db = getDb();
db.transaction(() => {
db.prepare(`
INSERT INTO user_login_history (user_id, logged_in_at, ip_address, user_agent)
VALUES (?, datetime('now'), ?, ?)
`).run(userId, ipAddress ?? null, userAgent ? userAgent.slice(0, 500) : null);
// Keep only the 3 most recent rows for this user
db.prepare(`
DELETE FROM user_login_history
WHERE user_id = ? AND id NOT IN (
SELECT id FROM user_login_history
WHERE user_id = ?
ORDER BY logged_in_at DESC, id DESC
LIMIT 3
)
`).run(userId, userId);
})();
}
// Prune expired sessions — called by daily worker
function pruneExpiredSessions() {
const result = getDb().prepare("DELETE FROM sessions WHERE expires_at <= datetime('now')").run();
@ -203,4 +228,4 @@ function invalidateOtherSessions(userId, keepSessionId) {
return result;
}
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions };
module.exports = { login, logout, createSession, getSessionUser, hashPassword, publicUser, pruneExpiredSessions, cookieOpts, COOKIE_NAME, SESSION_DAYS, rotateSessionId, invalidateOtherSessions, recordLogin };