Initial commit: MyMidas personal finance tracker

Full-stack self-hosted finance app with FastAPI backend and React frontend.

Features:
- Accounts, transactions, budgets, investments with GBP base currency
- CSV import with auto-detection for 10 UK bank formats
- ML predictions: spending forecast, net worth projection, Monte Carlo
- 7 selectable themes (Obsidian, Arctic, Midnight, Vault, Terminal, Synthwave, Ledger)
- Receipt/document attachments on transactions (JPEG, PNG, WebP, PDF)
- AES-256-GCM field encryption, RS256 JWT, TOTP 2FA, RLS, audit log
- Encrypted nightly backups + key rotation script
- Mobile-responsive layout with bottom nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-21 11:56:10 +00:00
commit 61a7884ee5
127 changed files with 13323 additions and 0 deletions

View file

@ -0,0 +1,542 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
import {
getSessions, revokeSession, revokeAllSessions,
getTotpSetup, enableTotp, disableTotp,
changePassword, updateProfile, exportData, getMe,
} from "@/api/auth";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle,
} from "lucide-react";
const SECTIONS = [
{ id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download },
] as const;
type Section = (typeof SECTIONS)[number]["id"];
export default function SettingsPage() {
const [section, setSection] = useState<Section>("profile");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-muted-foreground mt-1">Manage your account and preferences</p>
</div>
<div className="flex gap-6 flex-col lg:flex-row">
{/* Side nav */}
<nav className="flex lg:flex-col gap-1 lg:w-48 shrink-0 overflow-x-auto lg:overflow-visible">
{SECTIONS.map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setSection(id)}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors whitespace-nowrap",
section === id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{label}
</button>
))}
</nav>
{/* Content */}
<div className="flex-1 min-w-0 space-y-4">
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
</div>
</div>
</div>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const inputCls = "w-full rounded-lg border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
const cardCls = "bg-card border border-border rounded-xl p-5 space-y-4";
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="font-semibold text-base">{children}</h2>;
}
function SuccessBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-success/10 border border-success/30 text-success rounded-lg px-3 py-2 text-sm">
<CheckCircle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
function ErrorBanner({ message }: { message: string }) {
return (
<div className="flex items-center gap-2 bg-destructive/10 border border-destructive/30 text-destructive rounded-lg px-3 py-2 text-sm">
<AlertTriangle className="w-4 h-4 shrink-0" />
{message}
</div>
);
}
// ─── Profile ──────────────────────────────────────────────────────────────────
function ProfileSection() {
const qc = useQueryClient();
const { displayName, setToken, token, userId } = useAuthStore();
const [name, setName] = useState(displayName ?? "");
const [currency, setCurrency] = useState("GBP");
const [success, setSuccess] = useState(false);
useQuery({ queryKey: ["me"], queryFn: getMe, onSuccess: (d: any) => {
setName(d.display_name ?? "");
setCurrency(d.base_currency ?? "GBP");
}} as any);
const mutation = useMutation({
mutationFn: () => updateProfile({ display_name: name, base_currency: currency }),
onSuccess: () => {
setSuccess(true);
setToken(token!, userId!, name);
qc.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => setSuccess(false), 3000);
},
});
return (
<div className={cardCls}>
<SectionTitle>Profile</SectionTitle>
{success && <SuccessBanner message="Profile updated" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Update failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Display name</label>
<input value={name} onChange={e => setName(e.target.value)} className={inputCls} placeholder="Your name" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Base currency</label>
<input value={currency} onChange={e => setCurrency(e.target.value.toUpperCase())} className={inputCls} placeholder="GBP" maxLength={10} />
<p className="text-xs text-muted-foreground mt-1">Used for net worth and report totals</p>
</div>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
);
}
// ─── Security ─────────────────────────────────────────────────────────────────
function SecuritySection() {
return (
<div className="space-y-4">
<PasswordCard />
<TotpCard />
</div>
);
}
function PasswordCard() {
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [showCurrent, setShowCurrent] = useState(false);
const [showNext, setShowNext] = useState(false);
const [success, setSuccess] = useState(false);
const mutation = useMutation({
mutationFn: () => changePassword(current, next),
onSuccess: () => {
setSuccess(true);
setCurrent(""); setNext(""); setConfirm("");
setTimeout(() => setSuccess(false), 4000);
},
});
const mismatch = next.length > 0 && confirm.length > 0 && next !== confirm;
const tooShort = next.length > 0 && next.length < 10;
const canSubmit = current && next && confirm && next === confirm && next.length >= 10;
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Change Password</SectionTitle>
</div>
{success && <SuccessBanner message="Password changed successfully" />}
{mutation.isError && <ErrorBanner message={(mutation.error as any)?.response?.data?.detail ?? "Password change failed"} />}
<div>
<label className="text-sm font-medium block mb-1.5">Current password</label>
<div className="relative">
<input
type={showCurrent ? "text" : "password"}
value={current}
onChange={e => setCurrent(e.target.value)}
className={cn(inputCls, "pr-10")}
/>
<button type="button" onClick={() => setShowCurrent(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showCurrent ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">New password</label>
<div className="relative">
<input
type={showNext ? "text" : "password"}
value={next}
onChange={e => setNext(e.target.value)}
className={cn(inputCls, "pr-10", tooShort && "border-destructive")}
/>
<button type="button" onClick={() => setShowNext(v => !v)} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
{showNext ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{tooShort && <p className="text-xs text-destructive mt-1">Minimum 10 characters</p>}
{/* Strength bar */}
{next.length > 0 && (
<div className="mt-2 flex gap-1">
{[1,2,3,4].map(i => {
const score = Math.min(4, Math.floor(next.length / 3));
return <div key={i} className={cn("h-1 flex-1 rounded-full transition-colors", i <= score ? (score <= 1 ? "bg-destructive" : score <= 2 ? "bg-yellow-500" : score <= 3 ? "bg-primary" : "bg-success") : "bg-secondary")} />;
})}
</div>
)}
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
className={cn(inputCls, mismatch && "border-destructive")}
/>
{mismatch && <p className="text-xs text-destructive mt-1">Passwords don't match</p>}
</div>
<button
onClick={() => mutation.mutate()}
disabled={!canSubmit || mutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Update Password
</button>
</div>
);
}
function TotpCard() {
const qc = useQueryClient();
const totpEnabled = useAuthStore(s => s.totpEnabled);
const { setToken, token, userId, displayName } = useAuthStore();
const [step, setStep] = useState<"idle" | "setup" | "disable">("idle");
const [setupData, setSetupData] = useState<{ secret: string; qr_code_png_b64: string; backup_codes: string[] } | null>(null);
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [success, setSuccess] = useState("");
const setupMutation = useMutation({
mutationFn: getTotpSetup,
onSuccess: (data) => { setSetupData(data); setStep("setup"); },
});
const enableMutation = useMutation({
mutationFn: () => enableTotp(setupData!.secret, code),
onSuccess: () => {
setBackupCodes(setupData!.backup_codes);
setToken(token!, userId!, displayName ?? "");
useAuthStore.setState({ totpEnabled: true });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setCode("");
},
});
const disableMutation = useMutation({
mutationFn: () => disableTotp(password),
onSuccess: () => {
useAuthStore.setState({ totpEnabled: false });
qc.invalidateQueries({ queryKey: ["me"] });
setStep("idle");
setPassword("");
setSuccess("Two-factor authentication disabled");
setTimeout(() => setSuccess(""), 4000);
},
});
return (
<div className={cardCls}>
<div className="flex items-center gap-2">
<QrCode className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Two-Factor Authentication</SectionTitle>
<span className={cn("ml-auto text-xs px-2 py-0.5 rounded-full font-medium", totpEnabled ? "bg-success/15 text-success" : "bg-secondary text-muted-foreground")}>
{totpEnabled ? "Enabled" : "Disabled"}
</span>
</div>
{success && <SuccessBanner message={success} />}
{(enableMutation.isError || disableMutation.isError) && (
<ErrorBanner message={(enableMutation.error as any)?.response?.data?.detail ?? (disableMutation.error as any)?.response?.data?.detail ?? "Failed"} />
)}
{/* Backup codes shown after enabling */}
{backupCodes && (
<div className="bg-success/10 border border-success/30 rounded-lg p-4 space-y-2">
<p className="text-sm font-semibold text-success">2FA enabled save your backup codes</p>
<p className="text-xs text-muted-foreground">Store these somewhere safe. Each can only be used once.</p>
<div className="grid grid-cols-2 gap-1 mt-2">
{backupCodes.map(c => (
<code key={c} className="text-xs bg-background px-2 py-1 rounded font-mono">{c}</code>
))}
</div>
<button onClick={() => setBackupCodes(null)} className="text-xs text-muted-foreground hover:text-foreground underline mt-1">
I've saved these
</button>
</div>
)}
{step === "idle" && !totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Add an extra layer of security with an authenticator app.</p>
<button
onClick={() => setupMutation.mutate()}
disabled={setupMutation.isPending}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{setupMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Set up 2FA
</button>
</div>
)}
{step === "idle" && totpEnabled && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">2FA is active. Your account is protected.</p>
<button
onClick={() => setStep("disable")}
className="flex items-center gap-2 border border-destructive/40 text-destructive px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/10 transition-colors"
>
Disable 2FA
</button>
</div>
)}
{step === "setup" && setupData && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.</p>
<div className="flex justify-center">
<img src={`data:image/png;base64,${setupData.qr_code_png_b64}`} alt="TOTP QR Code" className="w-40 h-40 rounded-lg" />
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Verification code</label>
<input
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
className={inputCls}
placeholder="000000"
maxLength={6}
/>
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => enableMutation.mutate()}
disabled={code.length !== 6 || enableMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{enableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Verify & Enable
</button>
</div>
</div>
)}
{step === "disable" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Enter your password to confirm disabling 2FA.</p>
<div>
<label className="text-sm font-medium block mb-1.5">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={inputCls} />
</div>
<div className="flex gap-3">
<button onClick={() => setStep("idle")} className="flex-1 border border-border rounded-lg py-2 text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => disableMutation.mutate()}
disabled={!password || disableMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-destructive text-destructive-foreground rounded-lg py-2 text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{disableMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Disable 2FA
</button>
</div>
</div>
)}
</div>
);
}
// ─── Sessions ─────────────────────────────────────────────────────────────────
function SessionsSection() {
const qc = useQueryClient();
const navigate = useNavigate();
const { clearAuth } = useAuthStore();
const { data: sessions = [], isLoading } = useQuery({
queryKey: ["sessions"],
queryFn: getSessions,
});
const revokeMutation = useMutation({
mutationFn: revokeSession,
onSuccess: () => qc.invalidateQueries({ queryKey: ["sessions"] }),
});
const revokeAllMutation = useMutation({
mutationFn: revokeAllSessions,
onSuccess: () => { clearAuth(); navigate("/login"); },
});
return (
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Active Sessions</SectionTitle>
<button
onClick={() => revokeAllMutation.mutate()}
disabled={revokeAllMutation.isPending}
className="flex items-center gap-1.5 text-xs text-destructive hover:text-destructive/80 border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors"
>
{revokeAllMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <LogOut className="w-3 h-3" />}
Sign out all
</button>
</div>
<p className="text-sm text-muted-foreground">All devices currently signed into your account.</p>
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-14 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : (
<div className="space-y-2">
{(sessions as any[]).map((s: any) => (
<div key={s.id} className={cn(
"flex items-center gap-3 p-3 rounded-lg border",
s.is_current ? "border-primary/30 bg-primary/5" : "border-border bg-secondary/20"
)}>
<MonitorSmartphone className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{s.user_agent?.split(" ")[0] ?? "Unknown device"}</p>
{s.is_current && <span className="text-xs bg-primary/20 text-primary px-1.5 py-0.5 rounded font-medium shrink-0">This device</span>}
</div>
<p className="text-xs text-muted-foreground">
{s.ip_address} · {s.last_active_at ? `Active ${format(new Date(s.last_active_at), "dd MMM HH:mm")}` : `Created ${format(new Date(s.created_at), "dd MMM")}`}
</p>
</div>
{!s.is_current && (
<button
onClick={() => revokeMutation.mutate(s.id)}
disabled={revokeMutation.isPending}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
title="Revoke session"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() {
const [exporting, setExporting] = useState(false);
const [exported, setExported] = useState(false);
async function handleExport() {
setExporting(true);
try {
await exportData();
setExported(true);
setTimeout(() => setExported(false), 4000);
} finally {
setExporting(false);
}
}
return (
<div className="space-y-4">
<div className={cardCls}>
<div className="flex items-center gap-2">
<Download className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Export Data</SectionTitle>
</div>
{exported && <SuccessBanner message="Download started" />}
<p className="text-sm text-muted-foreground">
Download all your transactions as a CSV file. Includes date, description, amount, category, and account for every transaction.
</p>
<button
onClick={handleExport}
disabled={exporting}
className="flex items-center gap-2 bg-primary text-primary-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{exporting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
{exporting ? "Preparing export…" : "Download transactions CSV"}
</button>
</div>
<div className={cn(cardCls, "border-destructive/30")}>
<SectionTitle>Danger Zone</SectionTitle>
<p className="text-sm text-muted-foreground">
These actions are permanent. Export your data first if needed.
</p>
<div className="flex items-center gap-3 p-3 border border-destructive/20 rounded-lg bg-destructive/5">
<AlertTriangle className="w-4 h-4 text-destructive shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium">Delete account</p>
<p className="text-xs text-muted-foreground">Permanently removes all your data. Cannot be undone.</p>
</div>
<button className="text-xs text-destructive border border-destructive/30 px-3 py-1.5 rounded-lg hover:bg-destructive/10 transition-colors" disabled>
Contact admin
</button>
</div>
</div>
</div>
);
}