feat(data): "Erase my data" danger zone (Batch 5)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 15:21:07 -05:00
parent 314e4ff45e
commit d53a64b604
6 changed files with 272 additions and 1 deletions

View File

@ -136,6 +136,7 @@ export const api = {
seedDemoData: () => post('/user/seed-demo-data'), seedDemoData: () => post('/user/seed-demo-data'),
clearDemoData: () => post('/user/clear-demo-data'), clearDemoData: () => post('/user/clear-demo-data'),
seededStatus: () => get('/user/seeded-status'), seededStatus: () => get('/user/seeded-status'),
eraseMyData: (confirm) => post('/user/erase-data', { confirm }),
downloadAdminBackup: async (id) => { downloadAdminBackup: async (id) => {
const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, { const res = await fetch(`/api/admin/backups/${encodeURIComponent(id)}/download`, {
credentials: 'include', credentials: 'include',

View File

@ -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 (
<SectionCard {...cardProps}>
<div className="space-y-4 px-6 py-5">
<div className="rounded-lg border border-rose-300/60 bg-rose-50 p-4 dark:border-rose-800/50 dark:bg-rose-950/20">
<div className="flex items-start gap-3">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<div className="min-w-0 text-sm">
<p className="font-medium text-rose-700 dark:text-rose-300">This permanently deletes your financial data.</p>
<p className="mt-1 text-muted-foreground">
Bills, payments, transactions, categories, bank connections, imports and rules will be erased and
cant be recovered. Your <span className="font-medium text-foreground">account, login and 2FA are kept</span>.
Consider downloading a backup first.
</p>
</div>
</div>
</div>
<AlertDialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) setConfirm(''); }}>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="gap-2 border-rose-300 text-rose-600 hover:bg-rose-50 dark:border-rose-800/60 dark:text-rose-400 dark:hover:bg-rose-950/30"
>
<Trash2 className="h-4 w-4" /> Erase my data
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Erase all your data?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes your bills, payments, transactions, categories, bank connections and imports.
It cannot be undone. Type <span className="font-mono font-semibold text-foreground">ERASE</span> to confirm.
</AlertDialogDescription>
</AlertDialogHeader>
<Input
autoFocus
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Type ERASE"
className="font-mono"
aria-label="Type ERASE to confirm"
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={!canErase || busy}
onClick={(e) => { e.preventDefault(); handleErase(); }}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{busy ? <><Loader2 className="mr-1.5 h-4 w-4 animate-spin" />Erasing</> : 'Permanently erase'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SectionCard>
);
}

View File

@ -4,7 +4,7 @@ import { toast } from 'sonner';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion'; import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { import {
Landmark, ArrowRightLeft, Upload, DatabaseBackup, Landmark, ArrowRightLeft, Upload, DatabaseBackup,
FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, FileSpreadsheet, FileText, FlaskConical, Download, RotateCcw, History, ShieldAlert,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; 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 SeedDemoDataSection from '@/components/data/SeedDemoDataSection';
import DownloadMyDataSection from '@/components/data/DownloadMyDataSection'; import DownloadMyDataSection from '@/components/data/DownloadMyDataSection';
import ImportHistorySection from '@/components/data/ImportHistorySection'; import ImportHistorySection from '@/components/data/ImportHistorySection';
import EraseDataSection from '@/components/data/EraseDataSection';
// Heavy panes (XLSX parsing / SQLite restore) code-split, loaded on demand. // Heavy panes (XLSX parsing / SQLite restore) code-split, loaded on demand.
const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection')); const ImportSpreadsheetSection = lazy(() => import('@/components/data/ImportSpreadsheetSection'));
@ -165,6 +166,12 @@ export default function DataPage() {
setTransactionRefreshKey(k => k + 1); setTransactionRefreshKey(k => k + 1);
}, [loadSimplefinSummary]); }, [loadSimplefinSummary]);
const handleErased = useCallback(() => {
loadHistory();
loadSimplefinSummary();
setTransactionRefreshKey(k => k + 1);
}, [loadHistory, loadSimplefinSummary]);
const renderPane = () => { const renderPane = () => {
switch (activeSection) { switch (activeSection) {
case 'bank-sync': case 'bank-sync':
@ -226,6 +233,10 @@ export default function DataPage() {
onRefresh={loadHistory} 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'}.` }} 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'}.` }}
/> />
<EraseDataSection
onErased={handleErased}
cardProps={{ title: 'Erase my data', subtitle: 'Permanently wipe your financial data and start fresh.', icon: ShieldAlert, collapsible: true, defaultOpen: false, storageKey: 'billtracker:data.card.erase', summary: 'Danger zone — permanently delete your data.' }}
/>
</div> </div>
); );
default: default:

View File

@ -5,6 +5,8 @@ const router = express.Router();
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { seedDemoData } = require('../scripts/seedDemoData'); const { seedDemoData } = require('../scripts/seedDemoData');
const { demoDataLimiter } = require('../middleware/rateLimiter'); 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 // GET /api/user/seeded-status — returns whether the current user has any seeded data
router.get('/seeded-status', (req, res) => { 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; module.exports = router;

View File

@ -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<string, number> }}
*/
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 };

View File

@ -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');
});