From 263f1c5e6e8f299c484ee9efef580d5998c6bc17 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 15 May 2026 01:36:56 -0500 Subject: [PATCH] v0.27.04 --- .markdownlint.json | 1 + client/api.js | 1 + client/components/BillsTableInner.jsx | 420 +++++++++++--------------- client/lib/version.js | 32 +- client/pages/BillsPage.jsx | 193 +++++++++--- client/pages/DataPage.jsx | 324 ++++++++++++++++++-- client/pages/ProfilePage.jsx | 136 ++++++++- client/pages/SnowballPage.jsx | 25 +- db/database.js | 40 +++ jsconfig.json | 1 + routes/auth.js | 16 +- routes/authOidc.js | 3 +- routes/bills.js | 20 -- services/authService.js | 27 +- 14 files changed, 868 insertions(+), 371 deletions(-) diff --git a/.markdownlint.json b/.markdownlint.json index 8ab4145..60cdd14 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,3 +1,4 @@ { + "MD013": false, "MD024": { "siblings_only": true } } diff --git a/client/api.js b/client/api.js index a4bface..0d27fa9 100644 --- a/client/api.js +++ b/client/api.js @@ -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'), diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index 04323d0..9139d0d 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -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 {rate}% APR; +} + +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 ( -
-
-
-
- - {hasHistory && ( - - - - )} -
+
-
- - {bill.active ? 'Active' : 'Inactive'} - - {!!bill.autopay_enabled && ( - AP - )} - {!!bill.has_2fa && ( - 2FA - )} -
-
+ {/* Main info */} +
- - ${Number(bill.expected_amount).toFixed(2)} - -
- -
-
-

Due

-

Day {bill.due_day}

-
-
-

Category

-

{bill.category_name || '—'}

-
-
-

Cycle

-

{bill.billing_cycle || 'monthly'}

-
-
- -
- - {!bill.active && ( - - )} - + {bill.name} + + + {prefs.showCategory && bill.category_name && ( + + {bill.category_name} + + )} + + {prefs.showAutopay && !!bill.autopay_enabled && ( + + Autopay + + )} + {prefs.show2fa && !!bill.has_2fa && ( + + 2FA + + )} + {hasHistory && ( + + + + )} +
+ + {/* Meta row */} +
+ {prefs.showCycle && {bill.billing_cycle || 'monthly'}} + + {prefs.showCycle && prefs.showDueDay && ·} + + {prefs.showDueDay && Due {ordinal(bill.due_day)}} + + {prefs.showApr && isDebt && bill.interest_rate != null && ( + <> + {(prefs.showCycle || prefs.showDueDay) && ·} + + + )} + + {prefs.showBalance && isDebt && bill.current_balance != null && ( + <> + {(prefs.showCycle || prefs.showDueDay || (prefs.showApr && bill.interest_rate != null)) && ·} + + ${Number(bill.current_balance).toLocaleString(undefined, { maximumFractionDigits: 0 })} balance + + + )} +
+
+ + {/* Amount */} + {prefs.showAmount && ( +
+

+ ${Number(bill.expected_amount).toFixed(2)} +

+ {prefs.showMinPayment && bill.minimum_payment != null && ( +

+ ${Number(bill.minimum_payment).toFixed(0)} min +

+ )} +
+ )} + + {/* Action icons */} +
+ + + {!bill.active && onHistory && ( + + )} + + + + +
+
); } -// 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 ( - <> -
- {bills.map((bill) => ( - - ))} -
- -
- - - - - Bill - Category - Due - Expected - Cycle - Flags - - - - - - {bills.map((bill) => ( - - - {/* Bill name */} - -
- - {hasHistoricalVisibility(bill) && ( - - - - )} -
-
- - {/* Category */} - - {bill.category_name ? ( - {bill.category_name} - ) : ( - - )} - - - {/* Due day */} - - Day {bill.due_day} - - - {/* Expected amount */} - - - ${Number(bill.expected_amount).toFixed(2)} - - - - {/* Billing cycle — field is billing_cycle, not cycle */} - - - {bill.billing_cycle || 'monthly'} - - - - {/* Flags */} - - {(!!bill.autopay_enabled || !!bill.has_2fa) ? ( -
- {!!bill.autopay_enabled && ( - AP - )} - {!!bill.has_2fa && ( - 2FA - )} -
- ) : ( - - )} -
- - {/* Actions — visible on row hover */} - -
- - {!bill.active && ( - - )} - -
-
- -
- ))} -
- -
-
- +
+ {bills.map(bill => ( + + ))} +
); } diff --git a/client/lib/version.js b/client/lib/version.js index 44087cd..3dd033b 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -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.', }, ], }; diff --git a/client/pages/BillsPage.jsx b/client/pages/BillsPage.jsx index 362a179..686935f 100644 --- a/client/pages/BillsPage.jsx +++ b/client/pages/BillsPage.jsx @@ -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 ( +
+ + + {open && ( +
+

+ Display options +

+ {PREFS_LABELS.map(([key, label]) => ( + + ))} +
+ )} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── + 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() {

- +
+ + +
{/* ── Active Bills ── */} -
-
- - Active Bills +
+
+ + Active - {active.length} + {active.length}
{loading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
+
+ {[...Array(4)].map((_, i) => ( +
))} - Loading bills…
) : active.length === 0 ? (
@@ -469,14 +582,13 @@ export default function BillsPage() {
) : ( -
- -
+ )}
@@ -498,22 +610,21 @@ export default function BillsPage() { {showInactive && ( -
-
- - Inactive Bills +
+
+ + Inactive - {inactive.length} -
-
- + {inactive.length}
+
)} diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index bf0652f..c120d16 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -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 ; +} + +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 ( +
+
+ + {bill.name} + {importResult ? ( + + ✓ {importResult.created + importResult.updated} imported + + ) : ( + + )} +
+
+ {sorted.map(row => ( +
+ + {rowDateLabel(row)} + + {row.detected_amount != null ? `$${Number(row.detected_amount).toFixed(2)}` : '—'} + + {row.detected_name ?? '—'} + + {row._match.match_confidence} + +
+ ))} +
+
+ ); +} + +function BillHistoryView({ previewRows, allBills, importingBillId, billImportResults, onImportBill }) { + const [selectedBillId, setSelectedBillId] = useState(null); + const billGroups = useBillGroups(previewRows, allBills); + + if (billGroups.length === 0) { + return ( +
+ No existing bills matched rows in this file. +
+ ); + } + + if (selectedBillId) { + const group = billGroups.find(g => g.bill.id === selectedBillId); + return group + ? setSelectedBillId(null)} + onImport={() => onImportBill(group)} /> + : null; + } + + return ( +
+ {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 ( +
+
+
+ {bill.name} + + {rows.length} row{rows.length !== 1 ? 's' : ''} + + {counts.high > 0 && {counts.high} high} + {counts.medium > 0 && {counts.medium} med} + {counts.low > 0 && {counts.low} low} + {importResult && ( + + ✓ {importResult.created + importResult.updated} imported + {importResult.errored > 0 && ` · ${importResult.errored} errors`} + + )} +
+
+ {sorted3.map(row => ( +
+ + {rowDateLabel(row)} + {row.detected_amount != null && ( + ${Number(row.detected_amount).toFixed(2)} + )} + {row.detected_name && + row.detected_name.toLowerCase() !== bill.name.toLowerCase() && ( + "{row.detected_name}" + )} +
+ ))} + {rows.length > 3 && ( + + )} +
+
+
+ {importResult ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── + 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 ? (
+ {/* Tab header */}
-
-

XLSX Review Table

-

Select preview rows, then apply bulk review decisions before importing.

+
+ +
- {previewRows.length} preview row{previewRows.length === 1 ? '' : 's'} + + {viewMode === 'rows' + ? 'Select rows, apply bulk decisions, then import.' + : 'Click a bill to queue its entire history from this file.'} +
- - + + {/* Rows view */} + {viewMode === 'rows' && ( + <> + + + + )} + + {/* Bills view */} + {viewMode === 'bills' && ( + + )}
) : (

No data rows found in this file.

@@ -1588,7 +1864,7 @@ export default function DataPage() {
-
+
diff --git a/client/pages/ProfilePage.jsx b/client/pages/ProfilePage.jsx index a06ae74..6a773f2 100644 --- a/client/pages/ProfilePage.jsx +++ b/client/pages/ProfilePage.jsx @@ -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 ( + { if (!v) onClose(); }}> + + + + + Login History + + + Your last 3 sign-in events + + + +
+ {loading ? ( +
+ Loading… +
+ ) : history.length === 0 ? ( +

No login history recorded.

+ ) : history.map((entry, i) => { + const { browser, os, mobile } = parseUserAgent(entry.user_agent); + const DeviceIcon = mobile ? Smartphone : Monitor; + return ( +
+ +
+

+ {formatDateTime(entry.logged_in_at)} + {i === 0 && ( + + most recent + + )} +

+

+ {browser} on {os} + {entry.ip_address && ( + {entry.ip_address} + )} +

+
+
+ ); + })} +
+ +

+ Showing up to 3 most recent sign-ins +

+
+
+ ); +} + function ProfileSummary({ profile, loading }) { + const [historyOpen, setHistoryOpen] = useState(false); + if (loading) { return ( @@ -70,16 +164,38 @@ function ProfileSummary({ profile, loading }) { ); } + const lastLoginAt = profile.last_login_at || profile.last_login; + return ( - -
- - - - - -
-
+ <> + +
+ + + + + {/* Last Login — clickable, opens history modal */} +
+

Last Login

+ +
+ + +
+
+ + setHistoryOpen(false)} + /> + ); } diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 40792c1..54ac29e 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -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', )} >
diff --git a/db/database.js b/db/database.js index 9771ef9..4a0c699 100644 --- a/db/database.js +++ b/db/database.js @@ -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'] } }; diff --git a/jsconfig.json b/jsconfig.json index 63303a5..17ea4fb 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "baseUrl": ".", + "ignoreDeprecations": "6.0", "paths": { "@/*": ["./client/*"] }, diff --git a/routes/auth.js b/routes/auth.js index dea3d3a..03734a0 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -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(); diff --git a/routes/authOidc.js b/routes/authOidc.js index b0bd7f4..030234b 100644 --- a/routes/authOidc.js +++ b/routes/authOidc.js @@ -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) { diff --git a/routes/bills.js b/routes/bills.js index a401a77..dd9fc3c 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -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; diff --git a/services/authService.js b/services/authService.js index 2bc12b0..1641c40 100644 --- a/services/authService.js +++ b/services/authService.js @@ -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 };