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

@ -1,8 +1,11 @@
import { useState, useEffect } from "react";
import { X, Search, Loader2 } from "lucide-react";
import { searchAssets, createHolding, addInvestmentTransaction, AssetSearchResult } from "@/api/investments";
import { useUiStore } from "@/store/uiStore";
import { format } from "date-fns";
const COMMON_CURRENCIES = ["GBP", "USD", "EUR", "JPY", "CAD", "AUD", "CHF"];
interface Account { id: string; name: string; type: string; }
interface Props {
@ -12,6 +15,7 @@ interface Props {
}
export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props) {
const baseCurrency = useUiStore(s => s.currency);
const [query, setQuery] = useState("");
const [results, setResults] = useState<AssetSearchResult[]>([]);
const [searching, setSearching] = useState(false);
@ -27,6 +31,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
account_id: investAccounts[0]?.id ?? "",
quantity: "",
price: "",
currency: baseCurrency,
fees: "0",
date: format(new Date(), "yyyy-MM-dd"),
});
@ -58,7 +63,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
asset_id: selected.id,
quantity: qty,
avg_cost_basis: price,
currency: selected.currency,
currency: form.currency,
});
await addInvestmentTransaction({
holding_id: holding.id,
@ -66,7 +71,7 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
quantity: qty,
price: price,
fees: parseFloat(form.fees) || 0,
currency: selected.currency,
currency: form.currency,
date: form.date,
});
onSuccess();
@ -156,13 +161,22 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</div>
<div>
<label className="text-xs font-medium block mb-1">Price paid *</label>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="150.00"
/>
<div className="flex gap-1">
<select
value={form.currency}
onChange={(e) => setForm(f => ({ ...f, currency: e.target.value }))}
className="rounded-md border border-input bg-background px-1.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring"
>
{COMMON_CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<input
type="number" min="0" step="any"
value={form.price}
onChange={(e) => setForm(f => ({ ...f, price: e.target.value }))}
className="w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="150.00"
/>
</div>
</div>
<div>
<label className="text-xs font-medium block mb-1">Fees</label>
@ -176,6 +190,12 @@ export default function AddHoldingModal({ accounts, onClose, onSuccess }: Props)
</div>
</div>
{selected && form.currency !== selected.currency && (
<p className="text-xs text-muted-foreground bg-secondary/50 rounded-md px-3 py-2">
Price in <strong>{form.currency}</strong> asset trades in <strong>{selected.currency}</strong>. Current values will be converted using live exchange rates.
</p>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Purchase date *</label>
<input

View file

@ -2,12 +2,14 @@ import { useParams, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getPriceHistory, getPortfolio } from "@/api/investments";
import { formatCurrency } from "@/utils/currency";
import { useUiStore } from "@/store/uiStore";
import { cn } from "@/utils/cn";
import { ArrowLeft, TrendingUp, TrendingDown } from "lucide-react";
import Plot from "react-plotly.js";
export default function AssetDetail() {
const { assetId } = useParams<{ assetId: string }>();
const baseCurrency = useUiStore(s => s.currency);
const { data: portfolio } = useQuery({ queryKey: ["portfolio"], queryFn: getPortfolio });
const holding = portfolio?.holdings.find(h => h.asset_id === assetId);
@ -46,10 +48,10 @@ export default function AssetDetail() {
{/* Price header */}
{latestPrice != null && (
<div className="flex items-end gap-4">
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? "GBP")}</p>
<p className="text-4xl font-bold tabular-nums">{formatCurrency(latestPrice, holding?.currency ?? baseCurrency)}</p>
<div className={cn("flex items-center gap-1 pb-1 text-sm font-medium", isUp ? "text-success" : "text-destructive")}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? "GBP")} ({changePct.toFixed(2)}%)
{isUp ? "+" : ""}{formatCurrency(change, holding?.currency ?? baseCurrency)} ({changePct.toFixed(2)}%)
</div>
</div>
)}

View file

@ -1,10 +1,11 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getPortfolio, deleteHolding } from "@/api/investments";
import { getPortfolio, deleteHolding, updateHolding } from "@/api/investments";
import type { HoldingResponse } from "@/api/investments";
import { getAccounts } from "@/api/accounts";
import { formatCurrency } from "@/utils/currency";
import { cn } from "@/utils/cn";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight } from "lucide-react";
import { Plus, Trash2, TrendingUp, TrendingDown, ChevronRight, Pencil, AlertTriangle, X, Loader2 } from "lucide-react";
import AddHoldingModal from "./AddHoldingModal";
import { Link } from "react-router-dom";
@ -13,9 +14,127 @@ const COLORS = [
"#f59e0b","#8b5cf6","#06b6d4","#84cc16","#ef4444",
];
function ConfirmDeleteDialog({ holding, onConfirm, onCancel, isPending }: {
holding: HoldingResponse;
onConfirm: () => void;
onCancel: () => void;
isPending: boolean;
}) {
return (
<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">Delete holding?</h3>
<p className="text-sm text-muted-foreground mt-1">
This will permanently remove <span className="font-medium text-foreground">{holding.symbol}</span> ({holding.asset_name}) and all its transaction history. This cannot be undone.
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={onCancel}
disabled={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={onConfirm}
disabled={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 transition-colors disabled:opacity-50"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isPending ? "Deleting…" : "Delete holding"}
</button>
</div>
</div>
</div>
);
}
function EditHoldingModal({ holding, onClose, onSuccess }: {
holding: HoldingResponse;
onClose: () => void;
onSuccess: () => void;
}) {
const [quantity, setQuantity] = useState(String(holding.quantity));
const [avgCost, setAvgCost] = useState(String(holding.avg_cost_basis));
const [error, setError] = useState<string | null>(null);
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: () => updateHolding(holding.id, {
quantity: parseFloat(quantity),
avg_cost_basis: parseFloat(avgCost),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["portfolio"] });
onSuccess();
},
onError: (e: any) => setError(e?.response?.data?.detail ?? "Failed to update holding"),
});
return (
<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">
<div className="flex items-center justify-between p-5 border-b border-border">
<div>
<h2 className="font-semibold text-lg">Edit {holding.symbol}</h2>
<p className="text-xs text-muted-foreground">{holding.asset_name}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-5 space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Quantity</label>
<input
type="number" min="0" step="any"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Avg cost basis ({holding.currency})</label>
<input
type="number" min="0" step="any"
value={avgCost}
onChange={(e) => setAvgCost(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{error && <p className="text-destructive text-sm bg-destructive/10 rounded-md px-3 py-2">{error}</p>}
<div className="flex gap-3 pt-1">
<button onClick={onClose} className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || !quantity || !avgCost}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{mutation.isPending ? "Saving…" : "Save changes"}
</button>
</div>
</div>
</div>
</div>
);
}
export default function PortfolioPage() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [editHolding, setEditHolding] = useState<HoldingResponse | null>(null);
const [deleteTarget, setDeleteTarget] = useState<HoldingResponse | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: portfolio, isLoading } = useQuery({
queryKey: ["portfolio"],
@ -27,7 +146,14 @@ export default function PortfolioPage() {
const deleteMutation = useMutation({
mutationFn: deleteHolding,
onSuccess: () => qc.invalidateQueries({ queryKey: ["portfolio"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["portfolio"] });
setDeleteTarget(null);
setDeleteError(null);
},
onError: (e: any) => {
setDeleteError(e?.response?.data?.detail ?? "Failed to delete holding");
},
});
const treemapData = portfolio?.holdings
@ -75,7 +201,7 @@ export default function PortfolioPage() {
</div>
)}
{/* Treemap */}
{/* Allocation bar */}
{treemapData.length > 1 && (
<div className="bg-card border border-border rounded-xl p-4">
<p className="text-sm font-medium mb-3">Allocation</p>
@ -127,7 +253,7 @@ export default function PortfolioPage() {
<th className="text-right px-4 py-3">Value</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Gain / Loss</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">24h</th>
<th className="w-16"></th>
<th className="w-24"></th>
</tr>
</thead>
<tbody>
@ -173,16 +299,25 @@ export default function PortfolioPage() {
) : "—"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center justify-end gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Link
to={`/investments/${h.asset_id}`}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="View chart"
>
<ChevronRight className="w-4 h-4" />
</Link>
<button
onClick={() => deleteMutation.mutate(h.id)}
onClick={() => setEditHolding(h)}
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-secondary"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => { setDeleteTarget(h); setDeleteError(null); }}
className="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
@ -196,6 +331,13 @@ export default function PortfolioPage() {
</div>
)}
{deleteError && (
<div className="bg-destructive/10 border border-destructive/30 rounded-lg px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{deleteError}
</div>
)}
{showAdd && (
<AddHoldingModal
accounts={accounts}
@ -203,6 +345,23 @@ export default function PortfolioPage() {
onSuccess={() => { qc.invalidateQueries({ queryKey: ["portfolio"] }); setShowAdd(false); }}
/>
)}
{editHolding && (
<EditHoldingModal
holding={editHolding}
onClose={() => setEditHolding(null)}
onSuccess={() => setEditHolding(null)}
/>
)}
{deleteTarget && (
<ConfirmDeleteDialog
holding={deleteTarget}
onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
onCancel={() => { setDeleteTarget(null); setDeleteError(null); }}
isPending={deleteMutation.isPending}
/>
)}
</div>
);
}