Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import NotFound from "./pages/NotFound";
import { Landing } from "./pages/Landing";
import ProtectedRoute from "./components/auth/ProtectedRoute";
import Account from "./pages/Account";
import { Privacy } from "./pages/Privacy";

const queryClient = new QueryClient({
defaultOptions: {
Expand Down Expand Up @@ -91,6 +92,14 @@ const App = () => (
</ProtectedRoute>
}
/>
<Route
path="privacy"
element={
<ProtectedRoute>
<Privacy />
</ProtectedRoute>
}
/>
</Route>
<Route path="/signin" element={<SignIn />} />
<Route path="/register" element={<Register />} />
Expand Down
39 changes: 39 additions & 0 deletions app/src/api/privacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { api, baseURL } from './client';
import { getToken } from '@/lib/auth';

export type AuditEntry = {
user_id: number;
action: string;
ip: string;
timestamp: string;
};

export async function requestExport(): Promise<{ job_id: string; status: string }> {
return api('/privacy/export', { method: 'POST' });
}

export async function downloadExport(jobId: string): Promise<void> {
const res = await fetch(`${baseURL}/privacy/export/${jobId}`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!res.ok) {
const data = await res.json().catch(() => ({})) as { status?: string };
if (data.status === 'pending') throw new Error('Export still processing');
throw new Error('Export not ready');
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'finmind-export.zip';
a.click();
URL.revokeObjectURL(url);
}

export async function deleteAccount(confirmation: string): Promise<{ message: string }> {
return api('/privacy/account', { method: 'DELETE', body: { confirmation } });
}

export async function getAuditLog(): Promise<AuditEntry[]> {
return api<AuditEntry[]>('/privacy/audit');
}
1 change: 1 addition & 0 deletions app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const navigation = [
{ name: 'Reminders', href: '/reminders' },
{ name: 'Expenses', href: '/expenses' },
{ name: 'Analytics', href: '/analytics' },
{ name: 'Privacy', href: '/privacy' },
];

export function Navbar() {
Expand Down
165 changes: 165 additions & 0 deletions app/src/pages/Privacy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
FinancialCard,
FinancialCardContent,
FinancialCardHeader,
FinancialCardTitle,
} from '@/components/ui/financial-card';
import { useToast } from '@/hooks/use-toast';
import {
requestExport,
downloadExport,
deleteAccount,
getAuditLog,
type AuditEntry,
} from '@/api/privacy';
import { Download, Trash2, Shield } from 'lucide-react';

export function Privacy() {
const { toast } = useToast();
const [exportJobId, setExportJobId] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
const [audit, setAudit] = useState<AuditEntry[]>([]);

async function loadAudit() {
try {
setAudit(await getAuditLog());
} catch { /* silent */ }
}

useEffect(() => { void loadAudit(); }, []);

async function handleExport() {
setExporting(true);
try {
const { job_id } = await requestExport();
setExportJobId(job_id);
toast({ title: 'Export started', description: 'Your data is being prepared.' });
// Poll until ready
const poll = setInterval(async () => {
try {
await downloadExport(job_id);
clearInterval(poll);
setExporting(false);
setExportJobId(null);
toast({ title: 'Export ready', description: 'Download started.' });
void loadAudit();
} catch {
// still processing
}
}, 2000);
// Timeout after 30s
setTimeout(() => { clearInterval(poll); setExporting(false); }, 30000);
} catch (err: unknown) {
setExporting(false);
toast({ title: 'Export failed', description: err instanceof Error ? err.message : 'Unknown error' });
}
}

async function handleDelete() {
if (deleteConfirm !== 'DELETE') return;
setDeleting(true);
try {
await deleteAccount(deleteConfirm);
toast({ title: 'Account deletion requested', description: 'Your account has been marked for deletion.' });
setDeleteConfirm('');
void loadAudit();
} catch (err: unknown) {
toast({ title: 'Failed', description: err instanceof Error ? err.message : 'Unknown error' });
} finally {
setDeleting(false);
}
}

return (
<div className="page-wrap space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">Privacy & Data</h1>
<p className="page-subtitle">Export your data or delete your account. GDPR-ready controls.</p>
</div>
</div>

<div className="grid gap-6 md:grid-cols-2">
<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle className="flex items-center gap-2">
<Download className="h-4 w-4" /> Export My Data
</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Download a ZIP containing your profile, expenses, bills, budgets, and categories as JSON files.
</p>
<Button onClick={() => void handleExport()} disabled={exporting}>
{exporting ? 'Preparing export…' : 'Export My Data'}
</Button>
{exportJobId && (
<p className="text-xs text-muted-foreground">Job ID: {exportJobId}</p>
)}
</FinancialCardContent>
</FinancialCard>

<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="h-4 w-4" /> Delete My Account
</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
This action is irreversible. Type <strong>DELETE</strong> to confirm.
</p>
<Input
value={deleteConfirm}
onChange={(e) => setDeleteConfirm(e.target.value)}
placeholder='Type "DELETE" to confirm'
aria-label="delete confirmation"
/>
<Button
variant="destructive"
onClick={() => void handleDelete()}
disabled={deleteConfirm !== 'DELETE' || deleting}
>
{deleting ? 'Deleting…' : 'Delete My Account'}
</Button>
</FinancialCardContent>
</FinancialCard>
</div>

<FinancialCard variant="financial">
<FinancialCardHeader>
<FinancialCardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" /> Audit Log
</FinancialCardTitle>
</FinancialCardHeader>
<FinancialCardContent>
{audit.length === 0 ? (
<p className="text-sm text-muted-foreground">No privacy requests yet.</p>
) : (
<div className="space-y-2">
{audit.map((entry, i) => (
<div key={i} className="flex items-center justify-between rounded-md border p-2 text-sm">
<div>
<Badge variant="secondary">{entry.action}</Badge>
<span className="ml-2 text-muted-foreground">from {entry.ip}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(entry.timestamp).toLocaleString()}
</span>
</div>
))}
</div>
)}
</FinancialCardContent>
</FinancialCard>
</div>
);
}

export default Privacy;
4 changes: 4 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .jobs import bp as jobs_bp
from .privacy import bp as privacy_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +20,5 @@ def register_routes(app: Flask):
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(jobs_bp, url_prefix="/jobs")
app.register_blueprint(privacy_bp, url_prefix="/privacy")
Loading