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>
This commit is contained in:
megaproxy 2026-04-22 14:59:11 +00:00
parent 74e57a35c0
commit fe4e69b9ad
40 changed files with 2079 additions and 127 deletions

View file

@ -7,12 +7,14 @@ import {
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,
User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2,
LogOut, QrCode, KeyRound, AlertTriangle,
LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw,
} from "lucide-react";
const SECTIONS = [
@ -20,6 +22,7 @@ const SECTIONS = [
{ 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"];
@ -60,6 +63,7 @@ export default function SettingsPage() {
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
</div>
</div>
</div>
@ -483,6 +487,167 @@ function SessionsSection() {
);
}
// ─── 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() {