MyMidas/frontend/src/pages/settings/SettingsPage.tsx
megaproxy fe4e69b9ad Complete Phase 3, Phase 5 polish and hardening
Phase 3 — Investments:
- Multi-currency support: holdings track purchase currency, FX rates convert to base for totals
- Capital gains report using UK Section 104 pool method, grouped by tax year
- Capital Gains tab added to Reports page

Phase 5 — Polish & Hardening:
- Mobile-responsive layout: bottom nav, sidebar hidden on mobile, logo in TopBar, compact header buttons, hover-only actions now always visible on touch
- Backup system: encrypted GPG backups via backup.sh, nightly scheduler job, admin API (list/trigger/download/restore), Settings UI with drag-to-restore confirmation
- Docker entrypoint with gosu privilege drop to fix bind-mount ownership on fresh deployments
- OWASP fixes: refresh token now bound to its session (new refresh_token_hash column + migration), CSRF secure flag tied to environment, IP-level rate limiting on login, TOTPEnableRequest Pydantic schema replaces raw dict
- AES-256-GCM key rotation script (rotate_keys.py) with dry-run mode and atomic DB transaction
- CLAUDE.md added for AI-assisted development context
- README updated: correct reverse proxy port, accurate backup/restore commands, key rotation instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:59:11 +00:00

707 lines
30 KiB
TypeScript

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 { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
import type { BackupFile } from "@/api/admin";
import { cn } from "@/utils/cn";
import { format } from "date-fns";
import {
User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
} 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 },
{ id: "backups", label: "Backups", icon: HardDrive },
] 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 />}
{section === "backups" && <BackupsSection />}
</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>
);
}
// ─── Backups ──────────────────────────────────────────────────────────────────
function BackupsSection() {
const qc = useQueryClient();
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreSuccess, setRestoreSuccess] = useState("");
const [restoreError, setRestoreError] = useState("");
const { data: backups = [], isLoading } = useQuery({
queryKey: ["backups"],
queryFn: listBackups,
});
const triggerMutation = useMutation({
mutationFn: triggerBackup,
onSuccess: () => qc.invalidateQueries({ queryKey: ["backups"] }),
});
const restoreMutation = useMutation({
mutationFn: (filename: string) => restoreBackup(filename),
onSuccess: (_, filename) => {
setRestoreTarget(null);
setRestoreSuccess(`Restored from ${filename}. Reload the page to see updated data.`);
setRestoreError("");
setTimeout(() => setRestoreSuccess(""), 8000);
},
onError: (e: any) => {
setRestoreError(e?.response?.data?.detail ?? "Restore failed");
setRestoreTarget(null);
},
});
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return (
<div className="space-y-4">
{/* Trigger */}
<div className={cardCls}>
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<SectionTitle>Database Backups</SectionTitle>
</div>
<p className="text-sm text-muted-foreground">
Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at <code className="text-xs bg-secondary px-1 py-0.5 rounded">/app/backups</code>.
</p>
{triggerMutation.isSuccess && <SuccessBanner message="Backup created successfully" />}
{triggerMutation.isError && <ErrorBanner message={(triggerMutation.error as any)?.response?.data?.detail ?? "Backup failed"} />}
<button
onClick={() => triggerMutation.mutate()}
disabled={triggerMutation.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"
>
{triggerMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{triggerMutation.isPending ? "Creating backup…" : "Backup now"}
</button>
</div>
{/* Backup list */}
<div className={cardCls}>
<div className="flex items-center justify-between">
<SectionTitle>Stored Backups</SectionTitle>
<button
onClick={() => qc.invalidateQueries({ queryKey: ["backups"] })}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Refresh list"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{restoreSuccess && <SuccessBanner message={restoreSuccess} />}
{restoreError && <ErrorBanner message={restoreError} />}
{isLoading ? (
<div className="space-y-2">
{[1,2,3].map(i => <div key={i} className="h-12 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : backups.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No backups yet click "Backup now" to create one.</p>
) : (
<div className="space-y-2">
{backups.map((b: BackupFile) => (
<div key={b.filename} className="flex items-center gap-3 p-3 rounded-lg border border-border bg-secondary/20">
<HardDrive className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium font-mono truncate">{b.filename}</p>
<p className="text-xs text-muted-foreground">
{formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => downloadBackup(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-secondary transition-colors text-muted-foreground hover:text-foreground"
title="Download"
>
<Download className="w-3 h-3" />
Download
</button>
<button
onClick={() => setRestoreTarget(b.filename)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-destructive/40 text-destructive hover:bg-destructive/10 transition-colors"
title="Restore"
>
<RotateCcw className="w-3 h-3" />
Restore
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Restore confirmation dialog */}
{restoreTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6">
<div className="flex items-start gap-4 mb-5">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Restore this backup?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will <strong>overwrite all current data</strong> with the contents of:
</p>
<p className="text-xs font-mono bg-secondary px-2 py-1 rounded mt-2 break-all">{restoreTarget}</p>
<p className="text-sm text-muted-foreground mt-2">This cannot be undone. Consider downloading your current backup first.</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setRestoreTarget(null)}
disabled={restoreMutation.isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => restoreMutation.mutate(restoreTarget)}
disabled={restoreMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{restoreMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{restoreMutation.isPending ? "Restoring…" : "Yes, restore"}
</button>
</div>
</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>
);
}