Skip to content
This repository was archived by the owner on Jun 19, 2026. It is now read-only.
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ OpenAPI: `backend/app/openapi.yaml`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- Privacy: ZIP export `/privacy/export`, confirmed account deletion `/privacy/me`

## Privacy Workflow
- `GET /privacy/export` creates a ZIP archive for the authenticated user with `manifest.json`, `profile.json`, `data.json`, and per-table CSV files. The package includes profile, categories, expenses, recurring expenses, bills, reminders, ad impressions, subscriptions, and audit logs, but excludes password hashes and other users' records.
- `DELETE /privacy/me` requires `{"confirm":"DELETE_MY_DATA"}` and permanently removes the requesting user's owned records in dependency order. Existing audit logs are anonymized, a deletion completion audit entry is retained, user-scoped cache keys are removed, and matching refresh sessions are revoked from Redis.
- Backend coverage: `REDIS_URL=redis://localhost:6379/15 python -m pytest tests/test_privacy.py tests/test_auth.py` from `packages/backend`.

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down Expand Up @@ -186,6 +192,7 @@ finmind/
## Security & Scalability
- JWT access/refresh, secure cookies OR Authorization header.
- RBAC-ready via roles on `users.role`.
- GDPR-ready account export/delete endpoints with audit logs and confirmation guard.
- N+1 avoided via SQLAlchemy eager loading.
- Redis caching for hot paths to cut DB load.
- 12-factor app env config; stateless API.
Expand Down
36 changes: 35 additions & 1 deletion app/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { api } from './client';
import { api, baseURL } from './client';
import { getToken } from '../lib/auth';

export type LoginResponse = { access_token: string; refresh_token?: string };

Expand Down Expand Up @@ -35,3 +36,36 @@ export async function updateMe(payload: {
}): Promise<MeResponse> {
return api<MeResponse>('/auth/me', { method: 'PATCH', body: payload });
}

export async function exportPrivacyData(): Promise<Blob> {
const token = getToken();
const res = await fetch(`${baseURL}/privacy/export`, {
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : {},
credentials: 'include',
});
if (!res.ok) {
let message = `HTTP ${res.status}`;
try {
const payload = (await res.json()) as { error?: string; message?: string };
message = payload.error || payload.message || message;
} catch {
// Keep status-based fallback for non-JSON failures.
}
throw new Error(message);
}
return res.blob();
}

export async function deleteAccount(confirm: string): Promise<{
message: string;
deleted_records: Record<string, number>;
anonymized_audit_logs: number;
deleted_cache_keys: number;
revoked_refresh_sessions: number;
}> {
return api('/privacy/me', {
method: 'DELETE',
body: { confirm },
});
}
105 changes: 103 additions & 2 deletions app/src/pages/Account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { me, updateMe } from '@/api/auth';
import { setCurrency } from '@/lib/auth';
import { deleteAccount, exportPrivacyData, me, updateMe } from '@/api/auth';
import { clearRefreshToken, clearToken, setCurrency } from '@/lib/auth';

const SUPPORTED_CURRENCIES = [
{ code: 'INR', label: 'Indian Rupee (INR)' },
Expand All @@ -23,6 +23,9 @@ export default function Account() {
const [currency, setCurrencyState] = useState('INR');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteConfirmation, setDeleteConfirmation] = useState('');

useEffect(() => {
const load = async () => {
Expand Down Expand Up @@ -60,6 +63,58 @@ export default function Account() {
}
};

const onExport = async () => {
setExporting(true);
try {
const blob = await exportPrivacyData();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
link.href = url;
link.download = `finmind-data-export-${timestamp}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast({
title: 'Export ready',
description: 'Your personal data export has been downloaded.',
});
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Failed to export account data';
toast({ title: 'Export failed', description: message });
} finally {
setExporting(false);
}
};

const onDelete = async () => {
if (deleteConfirmation !== 'DELETE_MY_DATA') {
toast({
title: 'Confirmation required',
description: 'Enter DELETE_MY_DATA before deleting this account.',
});
return;
}
setDeleting(true);
try {
await deleteAccount(deleteConfirmation);
clearToken();
clearRefreshToken();
toast({
title: 'Account deleted',
description: 'Your FinMind account and personal data were deleted.',
});
window.location.assign('/');
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : 'Failed to delete account';
toast({ title: 'Delete failed', description: message });
setDeleting(false);
}
};

return (
<div className="page-wrap space-y-6">
<div className="page-header">
Expand Down Expand Up @@ -108,6 +163,52 @@ export default function Account() {
</>
)}
</div>

<div className="card card-interactive space-y-5 fade-in-up">
<div>
<h2 className="text-lg font-semibold">Privacy Controls</h2>
<p className="text-sm text-muted-foreground">
Download a ZIP export of your account data or permanently delete the
account.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<Label>Personal data export</Label>
<p className="text-sm text-muted-foreground">
Includes profile, expenses, bills, reminders, subscriptions, and
audit entries.
</p>
</div>
<Button variant="outline" onClick={onExport} disabled={exporting || loading}>
{exporting ? 'Exporting...' : 'Download Export'}
</Button>
</div>
<div className="space-y-3 border-t border-border pt-4">
<div>
<Label htmlFor="delete_confirmation">Delete account</Label>
<p className="text-sm text-muted-foreground">
Enter DELETE_MY_DATA to remove your profile and owned records.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<input
id="delete_confirmation"
className="input"
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.target.value)}
disabled={deleting || loading}
/>
<Button
variant="destructive"
onClick={onDelete}
disabled={deleting || loading}
>
{deleting ? 'Deleting...' : 'Delete Account'}
</Button>
</div>
</div>
</div>
</div>
);
}
74 changes: 74 additions & 0 deletions packages/backend/app/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ tags:
- name: Bills
- name: Reminders
- name: Insights
- name: Privacy
paths:
/auth/register:
post:
Expand Down Expand Up @@ -481,6 +482,79 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/privacy/export:
get:
summary: Export authenticated user's personal data
tags: [Privacy]
security: [{ bearerAuth: [] }]
responses:
'200':
description: ZIP archive containing manifest, JSON, and CSV files
content:
application/zip:
schema:
type: string
format: binary
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
description: User not found
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

/privacy/me:
delete:
summary: Permanently delete authenticated user's account and owned records
tags: [Privacy]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirm]
properties:
confirm:
type: string
enum: [DELETE_MY_DATA]
example:
confirm: DELETE_MY_DATA
responses:
'200':
description: Deletion result and removed record counts
content:
application/json:
schema:
type: object
properties:
message: { type: string }
deleted_records:
type: object
additionalProperties: { type: integer }
anonymized_audit_logs: { type: integer }
deleted_cache_keys: { type: integer }
revoked_refresh_sessions: { type: integer }
'400':
description: Missing or invalid confirmation
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'404':
description: User not found
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }

components:
securitySchemes:
bearerAuth:
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .privacy import bp as privacy_bp


def register_routes(app: Flask):
Expand All @@ -18,3 +19,4 @@ 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(privacy_bp, url_prefix="/privacy")
Loading