From d53a64b6047a632eaa931f9394a53ffba81f93d8 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 15:21:07 -0500 Subject: [PATCH] feat(data): "Erase my data" danger zone (Batch 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New services/userDataService.js eraseUserData() permanently wipes a user's financial + imported data in one transaction (child → parent order for FK safety): bills (+ cascading payments/monthly_bill_state/bill_history_ranges), transactions/accounts/data_sources, categories/groups, templates, snowball, spending rules/budgets, merchant rules, imports, and per-user hint tables. It PRESERVES the account, sessions, 2FA/WebAuthn, login history and preferences — this resets your data, not your account — then re-seeds default categories and writes an audit row to import_history. - POST /api/user/erase-data — rate-limited (demoDataLimiter), requires a type-to-confirm token ("ERASE"), structured errors. - UI: EraseDataSection danger-zone card (Export & backups pane) — red-accented, "download a backup first" nudge, type-to-confirm AlertDialog, toasts; on success DataPage reloads all state. Tests: tests/eraseUserData.test.js — wipes user A only, preserves user B + account + session, re-seeds categories, audited. Server 139 pass. Co-Authored-By: Claude Opus 4.8 --- client/api.js | 1 + client/components/data/EraseDataSection.jsx | 97 +++++++++++++++++++++ client/pages/DataPage.jsx | 13 ++- routes/user.js | 18 ++++ services/userDataService.js | 59 +++++++++++++ tests/eraseUserData.test.js | 85 ++++++++++++++++++ 6 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 client/components/data/EraseDataSection.jsx create mode 100644 services/userDataService.js create mode 100644 tests/eraseUserData.test.js diff --git a/client/api.js b/client/api.js index 239b99c..ef4322f 100644 --- a/client/api.js +++ b/client/api.js @@ -136,6 +136,7 @@ export const api = { seedDemoData: () => post('/user/seed-demo-data'), clearDemoData: () => post('/user/clear-demo-data'), seededStatus: () => get('/user/seeded-status'), + eraseMyData: (confirm) => post('/user/erase-data', { confirm }), downloadAdminBackup: async (id) => { const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { credentials: 'include', diff --git a/client/components/data/EraseDataSection.jsx b/client/components/data/EraseDataSection.jsx new file mode 100644 index 0000000..bd6328d --- /dev/null +++ b/client/components/data/EraseDataSection.jsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { toast } from 'sonner'; +import { ShieldAlert, Loader2, Trash2 } from 'lucide-react'; +import { api } from '@/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { SectionCard } from './dataShared'; + +/** + * Danger zone: permanently erase the user's financial data. Type-to-confirm, and + * makes clear that the account/login/2FA are preserved. Reuses POST + * /api/user/erase-data (transactional, audited, re-seeds default categories). + */ +export default function EraseDataSection({ onErased, cardProps = {} }) { + const [open, setOpen] = useState(false); + const [confirm, setConfirm] = useState(''); + const [busy, setBusy] = useState(false); + const canErase = confirm.trim().toUpperCase() === 'ERASE'; + + async function handleErase() { + if (!canErase) return; + setBusy(true); + try { + const r = await api.eraseMyData(confirm.trim()); + toast.success(`Your data was erased — ${r.erased} record${r.erased === 1 ? '' : 's'} removed.`); + setOpen(false); + setConfirm(''); + onErased?.(); + } catch (err) { + toast.error(err.message || 'Erase failed.'); + } finally { + setBusy(false); + } + } + + return ( + +
+
+
+ +
+

This permanently deletes your financial data.

+

+ Bills, payments, transactions, categories, bank connections, imports and rules will be erased and + can’t be recovered. Your account, login and 2FA are kept. + Consider downloading a backup first. +

+
+
+
+ + { setOpen(v); if (!v) setConfirm(''); }}> + + + + + + Erase all your data? + + This permanently deletes your bills, payments, transactions, categories, bank connections and imports. + It cannot be undone. Type ERASE to confirm. + + + setConfirm(e.target.value)} + placeholder="Type ERASE" + className="font-mono" + aria-label="Type ERASE to confirm" + /> + + Cancel + { e.preventDefault(); handleErase(); }} + className="bg-rose-600 text-white hover:bg-rose-700" + > + {busy ? <>Erasing… : 'Permanently erase'} + + + + +
+
+ ); +} diff --git a/client/pages/DataPage.jsx b/client/pages/DataPage.jsx index b244759..74c60c1 100644 --- a/client/pages/DataPage.jsx +++ b/client/pages/DataPage.jsx @@ -4,7 +4,7 @@ import { toast } from 'sonner'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; import { Landmark, ArrowRightLeft, Upload, DatabaseBackup, - FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, + FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, ShieldAlert, } from 'lucide-react'; import { api } from '@/api'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -18,6 +18,7 @@ import TransactionMatchingSection from '@/components/data/TransactionMatchingSec import SeedDemoDataSection from '@/components/data/SeedDemoDataSection'; import DownloadMyDataSection from '@/components/data/DownloadMyDataSection'; import ImportHistorySection from '@/components/data/ImportHistorySection'; +import EraseDataSection from '@/components/data/EraseDataSection'; // Heavy panes (XLSX parsing / SQLite restore) — code-split, loaded on demand. const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection')); @@ -165,6 +166,12 @@ export default function DataPage() { setTransactionRefreshKey(k => k + 1); }, [loadSimplefinSummary]); + const handleErased = useCallback(() => { + loadHistory(); + loadSimplefinSummary(); + setTransactionRefreshKey(k => k + 1); + }, [loadHistory, loadSimplefinSummary]); + const renderPane = () => { switch (activeSection) { case 'bank-sync': @@ -226,6 +233,10 @@ export default function DataPage() { onRefresh={loadHistory} cardProps={{ title: 'Recent activity', subtitle: 'A log of your past imports.', icon: History, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.history', summary: historyLoading ? 'Loading…' : `${history?.length || 0} import record${(history?.length || 0) === 1 ? '' : 's'}.` }} /> + ); default: diff --git a/routes/user.js b/routes/user.js index 058f7b8..5253653 100644 --- a/routes/user.js +++ b/routes/user.js @@ -5,6 +5,8 @@ const router = express.Router(); const { getDb } = require('../db/database'); const { seedDemoData } = require('../scripts/seedDemoData'); const { demoDataLimiter } = require('../middleware/rateLimiter'); +const { eraseUserData } = require('../services/userDataService'); +const { standardizeError } = require('../middleware/errorFormatter'); // GET /api/user/seeded-status — returns whether the current user has any seeded data router.get('/seeded-status', (req, res) => { @@ -80,4 +82,20 @@ router.post('/seed-demo-data', (req, res) => { } }); +// POST /api/user/erase-data — permanently wipe the requesting user's financial + +// imported data (bills, payments, transactions, connections, categories, imports). +// Preserves the account, login, 2FA, and preferences. Requires a type-to-confirm token. +router.post('/erase-data', demoDataLimiter, (req, res) => { + const confirm = String(req.body?.confirm ?? '').trim().toUpperCase(); + if (confirm !== 'ERASE') { + return res.status(400).json(standardizeError('Type ERASE to confirm.', 'ERASE_CONFIRM_REQUIRED', 'confirm')); + } + try { + const result = eraseUserData(getDb(), req.user.id); + res.json({ success: true, ...result }); + } catch (err) { + res.status(err.status || 500).json(standardizeError(err.message || 'Failed to erase data', err.code || 'ERASE_ERROR')); + } +}); + module.exports = router; diff --git a/services/userDataService.js b/services/userDataService.js new file mode 100644 index 0000000..2683dcb --- /dev/null +++ b/services/userDataService.js @@ -0,0 +1,59 @@ +'use strict'; + +const { ensureUserDefaultCategories } = require('../db/database'); + +// Independent user-owned tables (each has a user_id) wiped wholesale. +const USER_TABLES = [ + 'bill_merchant_rules', 'match_suggestion_rejections', 'autopay_suggestion_dismissals', + 'declined_subscription_hints', 'subscription_recommendation_feedback', 'user_catalog_descriptors', + 'spending_category_rules', 'spending_budgets', 'bill_templates', 'snowball_plans', + 'monthly_starting_amounts', 'monthly_income', 'calendar_tokens', 'import_sessions', 'import_history', +]; + +/** + * Permanently erase a user's financial + imported data, resetting them to a clean + * slate — WITHOUT touching the account, sessions, 2FA/WebAuthn, login history, or + * preferences. Runs in one transaction (child → parent order so it holds with + * foreign_keys ON), then re-seeds default categories so the app stays usable and + * writes an audit row to import_history. + * + * @returns {{ erased: number, counts: Record }} + */ +function eraseUserData(db, userId) { + const counts = {}; + const del = (label, sql) => { counts[label] = db.prepare(sql).run(userId).changes; }; + + const run = db.transaction(() => { + // Bill-children (no user_id of their own) — remove before bills. + del('payments', 'DELETE FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)'); + del('monthly_bill_state', 'DELETE FROM monthly_bill_state WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)'); + del('bill_history_ranges', 'DELETE FROM bill_history_ranges WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?)'); + + // Bank-data chain: transactions → accounts → sources. + del('transactions', 'DELETE FROM transactions WHERE user_id = ?'); + del('financial_accounts', 'DELETE FROM financial_accounts WHERE user_id = ?'); + del('data_sources', 'DELETE FROM data_sources WHERE user_id = ?'); + + for (const t of USER_TABLES) del(t, `DELETE FROM ${t} WHERE user_id = ?`); + + // bills → categories → category_groups (bills.category_id, categories.group_id FKs). + del('bills', 'DELETE FROM bills WHERE user_id = ?'); + del('categories', 'DELETE FROM categories WHERE user_id = ?'); + del('category_groups', 'DELETE FROM category_groups WHERE user_id = ?'); + }); + run(); + + // Re-seed default categories so the app is immediately usable again. + ensureUserDefaultCategories(userId); + + const erased = Object.values(counts).reduce((sum, n) => sum + n, 0); + db.prepare(` + INSERT INTO import_history (user_id, imported_at, source_filename, file_type, + rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) + VALUES (?, ?, 'erase-my-data', 'erase-data', ?, 0, 0, 0, 0, 0, ?, ?) + `).run(userId, new Date().toISOString(), erased, JSON.stringify({ action: 'erase-my-data' }), JSON.stringify(counts)); + + return { erased, counts }; +} + +module.exports = { eraseUserData }; diff --git a/tests/eraseUserData.test.js b/tests/eraseUserData.test.js new file mode 100644 index 0000000..b0e1ea5 --- /dev/null +++ b/tests/eraseUserData.test.js @@ -0,0 +1,85 @@ +'use strict'; + +// Batch 5: "erase my data" wipes the requesting user's financial data only — never +// another user's, never the account/auth — and re-seeds default categories. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-erase-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; + +const { getDb, closeDb } = require('../db/database'); +const { eraseUserData } = require('../services/userDataService'); + +function makeUser(db, name) { + return db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, 'hash', 'user', 1)").run(name).lastInsertRowid; +} +function seedFinancialData(db, userId, label) { + const catId = db.prepare("INSERT INTO categories (user_id, name) VALUES (?, ?)").run(userId, `${label}-cat`).lastInsertRowid; + const billId = db.prepare("INSERT INTO bills (user_id, name, category_id, due_day, expected_amount, active) VALUES (?, ?, ?, 1, 5000, 1)").run(userId, `${label}-bill`, catId).lastInsertRowid; + db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, 5000, '2026-06-01', 'manual')").run(billId); + const dsId = db.prepare("INSERT INTO data_sources (user_id, type, provider, name, status) VALUES (?, 'file_import', 'csv', 'CSV Import', 'active')").run(userId).lastInsertRowid; + db.prepare("INSERT INTO transactions (user_id, data_source_id, source_type, provider_transaction_id, amount, match_status, ignored) VALUES (?, ?, 'file_import', ?, -5000, 'unmatched', 0)").run(userId, dsId, `${label}-tx`); + return { billId, catId, dsId }; +} +const countFinancial = (db, userId) => ({ + bills: db.prepare('SELECT COUNT(*) n FROM bills WHERE user_id=?').get(userId).n, + payments: db.prepare('SELECT COUNT(*) n FROM payments p JOIN bills b ON b.id=p.bill_id WHERE b.user_id=?').get(userId).n, + transactions: db.prepare('SELECT COUNT(*) n FROM transactions WHERE user_id=?').get(userId).n, + data_sources: db.prepare('SELECT COUNT(*) n FROM data_sources WHERE user_id=?').get(userId).n, + categories: db.prepare('SELECT COUNT(*) n FROM categories WHERE user_id=?').get(userId).n, +}); + +let db, userA, userB; +test.before(() => { + db = getDb(); + db.pragma('foreign_keys = ON'); + userA = makeUser(db, 'erase-a'); + userB = makeUser(db, 'erase-b'); + seedFinancialData(db, userA, 'a'); + seedFinancialData(db, userB, 'b'); + // a session + a webauthn credential for user A to prove auth is preserved + db.prepare("INSERT INTO sessions (id, user_id, expires_at) VALUES ('sess-a', ?, datetime('now','+1 day'))").run(userA); +}); +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} } +}); + +test('erase wipes the requesting user\'s financial data', () => { + const before = countFinancial(db, userA); + assert.ok(before.bills > 0 && before.payments > 0 && before.transactions > 0 && before.data_sources > 0); + + const result = eraseUserData(db, userA); + assert.ok(result.erased > 0); + + const after = countFinancial(db, userA); + assert.equal(after.bills, 0); + assert.equal(after.payments, 0, 'bill-child payments cascade/removed'); + assert.equal(after.transactions, 0); + assert.equal(after.data_sources, 0); +}); + +test('erase re-seeds default categories (app stays usable)', () => { + const cats = db.prepare('SELECT COUNT(*) n FROM categories WHERE user_id=?').get(userA).n; + assert.ok(cats > 0, 'default categories re-seeded after wipe'); +}); + +test('erase never touches another user\'s data', () => { + const b = countFinancial(db, userB); + assert.ok(b.bills > 0 && b.payments > 0 && b.transactions > 0 && b.data_sources > 0, 'user B untouched'); +}); + +test('erase preserves the account and auth (session, user row)', () => { + assert.ok(db.prepare('SELECT 1 FROM users WHERE id=?').get(userA), 'account preserved'); + assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id='sess-a'").get(), 'session preserved'); +}); + +test('erase is audited to import_history', () => { + const row = db.prepare("SELECT file_type, source_filename FROM import_history WHERE user_id=? ORDER BY id DESC LIMIT 1").get(userA); + assert.equal(row.file_type, 'erase-data'); + assert.equal(row.source_filename, 'erase-my-data'); +});