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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ 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 (GDPR): `/privacy/export`, `/privacy/export/{id}`, `/privacy/requests`, `/privacy/delete`, `/privacy/delete/confirm`

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down Expand Up @@ -183,6 +184,12 @@ finmind/
- Primary: schedule via APScheduler in-process with persistence in Postgres (job table) and a simple daily trigger. Alternatively, use Railway/Render cron to hit `/reminders/run`.
- Twilio WhatsApp free trial supports sandbox; email via SMTP (e.g., SendGrid free tier).

## GDPR / Privacy Compliance
- **Data Export**: Users can request a full JSON export of all personal data (profile, expenses, bills, reminders, categories, subscriptions, activity logs).
- **Account Deletion**: Two-step confirmation flow (request token, then confirm with token) permanently removes all user data with cascade deletion.
- **Audit Trail**: All GDPR actions are logged. Deletion anonymizes audit entries (hashed email, null user_id) for compliance record-keeping.
- **Frontend**: Privacy page accessible from navbar with export/download, deletion workflow, and request history.

## Security & Scalability
- JWT access/refresh, secure cookies OR Authorization header.
- RBAC-ready via roles on `users.role`.
Expand Down
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
88 changes: 88 additions & 0 deletions app/src/api/privacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { api, baseURL } from './client';
import { getToken } from '../lib/auth';

// --- Types ---

export type DataRequestType = 'EXPORT' | 'DELETE';
export type DataRequestStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';

export interface DataRequestItem {
id: number;
request_type: DataRequestType;
status: DataRequestStatus;
has_download: boolean;
expires_at: string | null;
created_at: string | null;
completed_at: string | null;
}

export interface ExportResponse {
request_id: number;
status: string;
message: string;
}

export interface DeleteRequestResponse {
request_id: number;
confirmation_token: string;
message: string;
}

export interface DeleteConfirmResponse {
request_id: number;
status: string;
message: string;
}

// --- API functions ---

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

export async function downloadExport(requestId: number): Promise<void> {
const token = getToken();
const res = await fetch(`${baseURL}/privacy/export/${requestId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) {
const text = await res.text();
let msg = text;
try {
const obj = JSON.parse(text) as { error?: string };
msg = obj?.error || text;
} catch {
// use raw text
}
throw new Error(msg || `HTTP ${res.status}`);
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `finmind-data-export.json`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}

export async function listDataRequests(): Promise<DataRequestItem[]> {
return api<DataRequestItem[]>('/privacy/requests');
}

export async function requestDeletion(): Promise<DeleteRequestResponse> {
return api<DeleteRequestResponse>('/privacy/delete', { method: 'POST' });
}

export async function confirmDeletion(
requestId: number,
confirmationToken: string,
): Promise<DeleteConfirmResponse> {
return api<DeleteConfirmResponse>('/privacy/delete/confirm', {
method: 'POST',
body: { request_id: requestId, confirmation_token: confirmationToken },
});
}
8 changes: 7 additions & 1 deletion app/src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export function Navbar() {
</div>
{isAuthed ? (
<>
<Button variant="outline" size="sm" asChild>
<Link to="/privacy">Privacy</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/account">Account</Link>
</Button>
Expand Down Expand Up @@ -135,10 +138,13 @@ export function Navbar() {
<div className="grid grid-cols-2 gap-2 pt-2">
{isAuthed ? (
<>
<Button variant="outline" size="sm" asChild onClick={() => setIsOpen(false)}>
<Link to="/privacy">Privacy</Link>
</Button>
<Button variant="outline" size="sm" asChild onClick={() => setIsOpen(false)}>
<Link to="/account">Account</Link>
</Button>
<Button variant="hero" size="sm" onClick={() => { setIsOpen(false); void handleLogout(); }}>
<Button variant="hero" size="sm" className="col-span-2" onClick={() => { setIsOpen(false); void handleLogout(); }}>
Logout
</Button>
</>
Expand Down
Loading