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>
707 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|