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:
parent
74e57a35c0
commit
fe4e69b9ad
40 changed files with 2079 additions and 127 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue