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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue