diff --git a/client/src/components/portfolio/PortfolioDashboard.tsx b/client/src/components/portfolio/PortfolioDashboard.tsx index 63ac0a6e0..8c1fface2 100644 --- a/client/src/components/portfolio/PortfolioDashboard.tsx +++ b/client/src/components/portfolio/PortfolioDashboard.tsx @@ -12,6 +12,7 @@ import PortfolioVisualizer from "../visualizer/PortfolioVisualizer"; import { ExposureMap } from "../../portfolio/ExposureMap"; import PresetsPanel from "../../features/presets/PresetsPanel"; import UnifiedActivityTimeline from "./UnifiedActivityTimeline"; +import PortfolioExport from "./PortfolioExport"; // ── Types ─────────────────────────────────────────────────────────────── @@ -126,12 +127,15 @@ export default function PortfolioDashboard({ walletAddress }: PortfolioDashboard {walletAddress.slice(0, 8)}...{walletAddress.slice(-8)}

- +
+ + +
{/* Stats Cards */} diff --git a/client/src/components/portfolio/PortfolioExport.test.tsx b/client/src/components/portfolio/PortfolioExport.test.tsx new file mode 100644 index 000000000..f4188ba96 --- /dev/null +++ b/client/src/components/portfolio/PortfolioExport.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import PortfolioExport from "./PortfolioExport"; + +const STORAGE_KEY = "stellar_yield_portfolio_export_privacy_warning_dismissed"; + +describe("PortfolioExport", () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("shows a privacy warning before export and persists dismissal when requested", async () => { + const urlMock = vi.fn().mockReturnValue("blob:url"); + Object.defineProperty(window, "URL", { + configurable: true, + value: { + ...window.URL, + createObjectURL: urlMock, + revokeObjectURL: vi.fn(), + }, + }); + + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + + (globalThis.fetch as unknown) = vi.fn().mockResolvedValueOnce( + new Response(new Blob(["col1,col2\nvalue1,value2"], { type: "text/csv" }), { + status: 200, + headers: { "Content-Disposition": 'attachment; filename="portfolio-export.csv"' }, + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /export portfolio/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/don't show this warning again/i)); + fireEvent.click(screen.getByRole("button", { name: /confirm export/i })); + + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + expect(urlMock).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(localStorage.getItem(STORAGE_KEY)).toBe("true"); + }); + + it("skips the warning dialog when the warning has already been dismissed", async () => { + localStorage.setItem(STORAGE_KEY, "true"); + + Object.defineProperty(window, "URL", { + configurable: true, + value: { + ...window.URL, + createObjectURL: vi.fn().mockReturnValue("blob:url"), + revokeObjectURL: vi.fn(), + }, + }); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + + (globalThis.fetch as unknown) = vi.fn().mockResolvedValueOnce( + new Response(new Blob(["col1,col2\nvalue1,value2"], { type: "text/csv" }), { + status: 200, + headers: { "Content-Disposition": 'attachment; filename="portfolio-export.csv"' }, + }), + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /export portfolio/i })); + + await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/portfolio/PortfolioExport.tsx b/client/src/components/portfolio/PortfolioExport.tsx new file mode 100644 index 000000000..25fbca5b2 --- /dev/null +++ b/client/src/components/portfolio/PortfolioExport.tsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from "react"; +import { Download, AlertTriangle } from "lucide-react"; + +const STORAGE_KEY = "stellar_yield_portfolio_export_privacy_warning_dismissed"; + +function readWarningDismissed(): boolean { + try { + return window.localStorage.getItem(STORAGE_KEY) === "true"; + } catch { + return false; + } +} + +function saveWarningDismissed(): void { + try { + window.localStorage.setItem(STORAGE_KEY, "true"); + } catch { + // Ignore storage failures intentionally. + } +} + +interface PortfolioExportProps { + walletAddress: string; +} + +export default function PortfolioExport({ walletAddress }: PortfolioExportProps) { + const [showWarningModal, setShowWarningModal] = useState(false); + const [warningDismissed, setWarningDismissed] = useState(false); + const [rememberWarning, setRememberWarning] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setWarningDismissed(readWarningDismissed()); + }, []); + + const downloadFile = async () => { + setError(null); + setIsExporting(true); + + try { + const response = await fetch( + `/api/users/${encodeURIComponent(walletAddress)}/export`, + ); + + if (!response.ok) { + let message = "Failed to generate export."; + try { + const body = await response.json(); + if (body?.message) message = body.message; + } catch { + // ignore JSON parse errors + } + throw new Error(message); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const filename = + response.headers + .get("Content-Disposition") + ?.match(/filename="(.+)"/)?.[1] ?? "portfolio-export.csv"; + + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(anchor); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate export."); + } finally { + setIsExporting(false); + } + }; + + const handleExportClick = () => { + if (warningDismissed) { + void downloadFile(); + return; + } + + setShowWarningModal(true); + }; + + const handleConfirmExport = async () => { + if (rememberWarning) { + saveWarningDismissed(); + setWarningDismissed(true); + } + setShowWarningModal(false); + await downloadFile(); + }; + + return ( +
+ + + {error ? ( +

{error}

+ ) : null} + + {showWarningModal ? ( +
+
+
+
+ +
+
+
+

Portfolio export privacy warning

+

+ Your export may include wallet address, portfolio balances, asset details, and transaction history. +

+
+ +
+

What will be included

+
    +
  • Wallet address and account identifier
  • +
  • Asset holdings and current balances
  • +
  • Recent portfolio transaction history
  • +
+
+ + + +
+ + +
+
+
+
+
+ ) : null} +
+ ); +}