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:
parent
314e4ff45e
commit
d53a64b604
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
can’t 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'}.` }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue