Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/(dashboard)/investor/portfolio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useStellarWallet } from "@/hooks/useStellarWallet";
import { usePageTitle } from "@/hooks/usePageTitle";
import { usePageLoadPerformanceLog } from "@/hooks/usePageLoadPerformanceLog";
import { TimelockPendingBanner } from "@/components/governance";
import { InvestorPortfolioPage } from "@/components/dashboard/InvestorPortfolioPage";

export default function PortfolioPage() {
Expand All @@ -25,6 +26,9 @@ export default function PortfolioPage() {

return (
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<TimelockPendingBanner />
</div>
<h1 className="text-2xl font-bold mb-6">My Portfolio</h1>
<InvestorPortfolioPage />
</main>
Expand Down
2 changes: 2 additions & 0 deletions app/marketplace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchInvoices, resolveInvoiceCategory, type Invoice, type InvoiceCategory } from "@/lib/api";
import { usePageTitle } from "@/hooks/usePageTitle";
import { TimelockPendingBanner } from "@/components/governance";
import { FundingProgressBar } from "@/components/invoices";
import {
ActiveTaxonomyChips,
Expand Down Expand Up @@ -740,6 +741,7 @@ function MarketplacePageContent() {
return (
<ComparisonProvider>
<main className="container mx-auto px-4 py-8 pb-24 space-y-8">
<TimelockPendingBanner />
<div className="space-y-3">
<h1 className="text-2xl font-bold">Invoice Marketplace</h1>
<MarketplaceViewTabs active="invoices" />
Expand Down
13 changes: 12 additions & 1 deletion components/admin/TimelockProposalsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
useExecuteTimelockProposalMutation,
useTimelockProposals,
} from "@/hooks/useTimelockProposals";
import type { TimelockProposal } from "@/lib/api";
import { AccessDenied } from "@/components/common";
import { ApiForbiddenError, type TimelockProposal } from "@/lib/api";

function formatDateTime(value: string): string {
if (!value) return "—";
Expand Down Expand Up @@ -185,6 +186,16 @@ export function TimelockProposalsPanel() {
);
}

if (proposalsQuery.error instanceof ApiForbiddenError) {
return (
<Card data-testid="timelock-proposals-panel">
<CardContent>
<AccessDenied restriction="SignerOnly" compact />
</CardContent>
</Card>
);
}

const confirmingProposal =
pending.find((proposal) => proposal.id === confirmingId) ?? null;

Expand Down
60 changes: 60 additions & 0 deletions components/common/AccessDenied.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import Link from "next/link";
import { Globe, KeyRound, ShieldAlert, ShieldCheck, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
ACCESS_RESTRICTIONS,
type AccessRestrictionType,
} from "@/lib/restrictions";

const ICONS: Record<AccessRestrictionType, LucideIcon> = {
KYCRequired: ShieldAlert,
JurisdictionBlocked: Globe,
AccreditationRequired: ShieldCheck,
SignerOnly: KeyRound,
};

interface AccessDeniedProps {
restriction: AccessRestrictionType;
/** Fill the surrounding card/panel instead of a full-page min height. */
compact?: boolean;
className?: string;
}

export function AccessDenied({ restriction, compact = false, className }: AccessDeniedProps) {
const config = ACCESS_RESTRICTIONS[restriction];
const Icon = ICONS[restriction];

return (
<div
role="alert"
data-testid="access-denied"
data-restriction={restriction}
className={cn(
"mx-auto flex w-full max-w-md flex-col items-center gap-3 px-4 text-center",
compact ? "py-8" : "min-h-[50vh] justify-center py-12",
className,
)}
>
<span
className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10 text-destructive"
data-testid="access-denied-icon"
>
<Icon className="h-7 w-7" aria-hidden="true" />
</span>
<h2 className="text-xl font-semibold" data-testid="access-denied-heading">
{config.heading}
</h2>
<p className="text-sm text-muted-foreground" data-testid="access-denied-explanation">
{config.explanation}
</p>
{config.cta && (
<Button asChild className="mt-2 w-full sm:w-auto" data-testid="access-denied-cta">
<Link href={config.cta.href}>{config.cta.label}</Link>
</Button>
)}
</div>
);
}
32 changes: 32 additions & 0 deletions components/common/__tests__/AccessDenied.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { AccessDenied } from "../AccessDenied";
import { ACCESS_RESTRICTIONS, type AccessRestrictionType } from "@/lib/restrictions";

const CASES: [AccessRestrictionType, string | null, string | null][] = [
["KYCRequired", "Upgrade KYC", "/kyc/upgrade"],
["JurisdictionBlocked", "Contact Support", "/help"],
["AccreditationRequired", "Apply for Accreditation", "/kyc/accreditation"],
["SignerOnly", null, null],
];

describe("AccessDenied", () => {
it.each(CASES)("renders %s content and CTA", (type, label, href) => {
render(<AccessDenied restriction={type} />);
expect(screen.getByTestId("access-denied-heading")).toHaveTextContent(
ACCESS_RESTRICTIONS[type].heading,
);
expect(screen.getByTestId("access-denied-explanation")).toHaveTextContent(
ACCESS_RESTRICTIONS[type].explanation,
);
expect(screen.getByTestId("access-denied-icon")).toBeInTheDocument();

if (label && href) {
const cta = screen.getByRole("link", { name: label });
expect(cta).toHaveAttribute("href", href);
} else {
expect(screen.queryByTestId("access-denied-cta")).not.toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
}
});
});
1 change: 1 addition & 0 deletions components/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AccessDenied } from "./AccessDenied";
153 changes: 153 additions & 0 deletions components/governance/TimelockPendingBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use client";

import { useEffect, useState } from "react";
import { ChevronDown, Clock, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { usePendingTimelockChanges } from "@/hooks/usePendingTimelockChanges";
import type { PendingTimelockChange } from "@/lib/api";

export const TIMELOCK_BANNER_DISMISS_KEY = "timelock-banner-dismissed";

function readDismissed(): boolean {
try {
return window.sessionStorage.getItem(TIMELOCK_BANNER_DISMISS_KEY) === "1";
} catch {
return false;
}
}

export function formatEtaCountdown(etaMs: number, nowMs: number): string {
const total = Math.max(Math.floor((etaMs - nowMs) / 1000), 0);
if (total === 0) return "Ready to execute";
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (d > 0) return `${d}d ${h}h ${m}m ${s}s`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}

function ChangeRow({ change, now }: { change: PendingTimelockChange; now: number }) {
const etaMs = new Date(change.eta).getTime();
return (
<li
className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between"
data-testid="timelock-change-item"
>
<div className="min-w-0">
<p className="text-sm font-medium break-words">{change.parameter}</p>
<p className="text-xs">
{change.current_value} <span aria-label="changing to">→</span>{" "}
<span className="font-medium">{change.proposed_value}</span>
</p>
</div>
<p className="text-xs tabular-nums sm:text-right" data-testid="timelock-change-eta">
{Number.isNaN(etaMs) ? "ETA unknown" : formatEtaCountdown(etaMs, now)}
</p>
</li>
);
}

export function TimelockPendingBanner() {
const { data } = usePendingTimelockChanges();
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [now, setNow] = useState(() => Date.now());

useEffect(() => {
setDismissed(readDismissed());
}, []);

const hasChanges = Boolean(data && data.length > 0);

useEffect(() => {
if (!hasChanges || dismissed) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [hasChanges, dismissed]);

if (!data || data.length === 0 || dismissed) return null;

const dismiss = () => {
setDismissed(true);
try {
window.sessionStorage.setItem(TIMELOCK_BANNER_DISMISS_KEY, "1");
} catch {
// Dismissal still applies for this page view.
}
};

const soonest = [...data].sort(
(a, b) => new Date(a.eta).getTime() - new Date(b.eta).getTime(),
)[0];
const multiple = data.length > 1;

return (
<section
role="status"
aria-label="Pending protocol changes"
data-testid="timelock-banner"
className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-100"
>
<div className="flex items-start gap-3">
<Clock className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" data-testid="timelock-banner-title">
{multiple
? `${data.length} protocol changes are scheduled`
: "A protocol change is scheduled"}
</p>
{!multiple && (
<ul data-testid="timelock-banner-list">
<ChangeRow change={soonest} now={now} />
</ul>
)}
{multiple && (
<>
<Button
type="button"
variant="ghost"
size="sm"
className="-ml-2 mt-1 h-7 px-2 text-amber-900 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900"
aria-expanded={expanded}
aria-controls="timelock-banner-list"
onClick={() => setExpanded((v) => !v)}
data-testid="timelock-banner-toggle"
>
{expanded ? "Hide details" : "Show details"}
<ChevronDown
className={`h-4 w-4 transition-transform ${expanded ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</Button>
{expanded && (
<ul
id="timelock-banner-list"
className="divide-y divide-amber-200 dark:divide-amber-800"
data-testid="timelock-banner-list"
>
{data.map((change) => (
<ChangeRow key={change.id} change={change} now={now} />
))}
</ul>
)}
</>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-amber-900 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900"
onClick={dismiss}
aria-label="Dismiss pending changes banner"
data-testid="timelock-banner-dismiss"
>
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</section>
);
}
Loading