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
("profile"); return (

Settings

Manage your account and preferences

{/* Side nav */} {/* Content */}
{section === "profile" && } {section === "security" && } {section === "sessions" && } {section === "data" && } {section === "backups" && }
); } // ─── 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

{children}

; } function SuccessBanner({ message }: { message: string }) { return (
{message}
); } function ErrorBanner({ message }: { message: string }) { return (
{message}
); } // ─── 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 (
Profile {success && } {mutation.isError && }
setName(e.target.value)} className={inputCls} placeholder="Your name" />
setCurrency(e.target.value.toUpperCase())} className={inputCls} placeholder="GBP" maxLength={10} />

Used for net worth and report totals

); } // ─── Security ───────────────────────────────────────────────────────────────── function SecuritySection() { return (
); } 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 (
Change Password
{success && } {mutation.isError && }
setCurrent(e.target.value)} className={cn(inputCls, "pr-10")} />
setNext(e.target.value)} className={cn(inputCls, "pr-10", tooShort && "border-destructive")} />
{tooShort &&

Minimum 10 characters

} {/* Strength bar */} {next.length > 0 && (
{[1,2,3,4].map(i => { const score = Math.min(4, Math.floor(next.length / 3)); return
; })}
)}
setConfirm(e.target.value)} className={cn(inputCls, mismatch && "border-destructive")} /> {mismatch &&

Passwords don't match

}
); } 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(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 (
Two-Factor Authentication {totpEnabled ? "Enabled" : "Disabled"}
{success && } {(enableMutation.isError || disableMutation.isError) && ( )} {/* Backup codes shown after enabling */} {backupCodes && (

2FA enabled — save your backup codes

Store these somewhere safe. Each can only be used once.

{backupCodes.map(c => ( {c} ))}
)} {step === "idle" && !totpEnabled && (

Add an extra layer of security with an authenticator app.

)} {step === "idle" && totpEnabled && (

2FA is active. Your account is protected.

)} {step === "setup" && setupData && (

Scan this QR code with your authenticator app, then enter the 6-digit code to confirm.

TOTP QR Code
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} className={inputCls} placeholder="000000" maxLength={6} />
)} {step === "disable" && (

Enter your password to confirm disabling 2FA.

setPassword(e.target.value)} className={inputCls} />
)}
); } // ─── 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 (
Active Sessions

All devices currently signed into your account.

{isLoading ? (
{[1,2,3].map(i =>
)}
) : (
{(sessions as any[]).map((s: any) => (

{s.user_agent?.split(" ")[0] ?? "Unknown device"}

{s.is_current && This device}

{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")}`}

{!s.is_current && ( )}
))}
)}
); } // ─── Backups ────────────────────────────────────────────────────────────────── function BackupsSection() { const qc = useQueryClient(); const [restoreTarget, setRestoreTarget] = useState(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 (
{/* Trigger */}
Database Backups

Backups run automatically at 3am daily. Each is GPG-encrypted with your backup passphrase and stored at /app/backups.

{triggerMutation.isSuccess && } {triggerMutation.isError && }
{/* Backup list */}
Stored Backups
{restoreSuccess && } {restoreError && } {isLoading ? (
{[1,2,3].map(i =>
)}
) : backups.length === 0 ? (

No backups yet — click "Backup now" to create one.

) : (
{backups.map((b: BackupFile) => (

{b.filename}

{formatSize(b.size_bytes)} · {format(new Date(b.created_at), "dd MMM yyyy HH:mm")}

))}
)}
{/* Restore confirmation dialog */} {restoreTarget && (

Restore this backup?

This will overwrite all current data with the contents of:

{restoreTarget}

This cannot be undone. Consider downloading your current backup first.

)}
); } // ─── 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 (
Export Data
{exported && }

Download all your transactions as a CSV file. Includes date, description, amount, category, and account for every transaction.

Danger Zone

These actions are permanent. Export your data first if needed.

Delete account

Permanently removes all your data. Cannot be undone.

); }