diff --git a/src/components/layout/HeaderComponent.tsx b/src/components/layout/HeaderComponent.tsx new file mode 100644 index 00000000..e62b1d0d --- /dev/null +++ b/src/components/layout/HeaderComponent.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useEffect, useState, useMemo, startTransition } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useWallet } from "@/hooks/useWallet"; +import { IGrantContext } from "@/types/grants"; + +export default function HeaderComponent() { + const { connected, publicKey, connect } = useWallet(); + + // Grant Management Context States + const [grantsState, setGrantsState] = useState<{ + grants: IGrantContext[]; + pendingCount: number; + loading: boolean; + }>({ + grants: [], + pendingCount: 0, + loading: false, + }); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Sync and fetch active delegation scopes from the network ledger state + useEffect(() => { + if (!connected || !publicKey) { + setGrantsState({ grants: [], pendingCount: 0, loading: false }); + return; + } + + let isMounted = true; + + async function fetchActiveGrants() { + try { + startTransition(() => { + setGrantsState((prev) => ({ ...prev, loading: true })); + }); + + // Query the core Soroban proxy database mapping for active delegation parameters + const res = await fetch(`/api/contracts/grants?authority=${publicKey}`); + if (!res.ok) throw new Error("Grant lookup network response failed."); + + const data = await res.json(); + + if (isMounted) { + setGrantsState({ + grants: data.activeGrants || [], + pendingCount: data.pendingApprovalsCount || 0, + loading: false, + }); + } + } catch (err) { + console.error("Failed to synchronize active authorization grants:", err); + if (isMounted) { + setGrantsState((prev) => ({ ...prev, loading: false })); + } + } + } + + void fetchActiveGrants(); + + return () => { + isMounted = false; + }; + }, [connected, publicKey]); + + // Compute total safe baseline allowance thresholds to optimize layout re-renders + const activeScopesFormatted = useMemo(() => { + if (grantsState.grants.length === 0) return "No Active Delegations"; + return `${grantsState.grants.length} Scope ${grantsState.grants.length === 1 ? "Grant" : "Grants"} Authorized`; + }, [grantsState.grants]); + + const handleRevokeGrant = async (grantId: string, e: React.MouseEvent) => { + e.stopPropagation(); + try { + const res = await fetch(`/api/contracts/grants/${grantId}/revoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + + if (!res.ok) throw new Error(); + + toast.success("Authorization grant revoked cleanly from contract ledger."); + + // Optimistic layout patch update + setGrantsState((prev) => ({ + ...prev, + grants: prev.grants.filter((g) => g.id !== grantId), + })); + } catch { + toast.error("Failed to broadcast revocation transaction payload."); + } + }; + + return ( +
+
+ + {/* BRAND IDENTITY LINK */} +
+ + V3 + StellarSplit + + +
+ + {/* GRANT MANAGEMENT & WALLET RUNTIME ACTIONS */} +
+ {connected && ( +
+ + + {/* INTERACTIVE OVERLAY PANEL */} + {isDropdownOpen && ( +
+
+ Active Ledger Authority + setIsDropdownOpen(false)} + className="text-xs text-blue-500 hover:underline font-medium" + > + Manage All + +
+ + {grantsState.loading ? ( +
Syncing delegation keys...
+ ) : grantsState.grants.length > 0 ? ( +
+ {grantsState.grants.map((grant) => ( +
+
+
+ {grant.scope.replace("_", " ")} +
+
+ Target: {grant.grantee} +
+ {grant.authorizedAmount && ( +
+ Limit: {grant.authorizedAmount} {grant.assetSymbol || "XLM"} +
+ )} +
+ +
+ ))} +
+ ) : ( +
+ No delegated allowances bound to this address. +
+ )} +
+ )} +
+ )} + + {/* STANDARD AUTH ENTRIES */} + {connected && publicKey ? ( +
+ {publicKey.slice(0, 4)}...{publicKey.slice(-4)} +
+ ) : ( + + )} +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/__tests__/HeaderComponent.test.tsx b/src/components/layout/__tests__/HeaderComponent.test.tsx new file mode 100644 index 00000000..c9c9be99 --- /dev/null +++ b/src/components/layout/__tests__/HeaderComponent.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import HeaderComponent from "../HeaderComponent"; +import { useWallet } from "@/hooks/useWallet"; + +vi.mock("@/hooks/useWallet", () => ({ + useWallet: vi.fn(), +})); + +global.fetch = vi.fn(); + +describe("HeaderComponent — Grant Management Pipeline Verification", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should show basic wallet action buttons when disconnected", () => { + (useWallet as any).mockReturnValue({ connected: false, publicKey: null }); + render(); + expect(screen.getByText("Connect Wallet")).toBeInTheDocument(); + }); + + it("should synchronize active ledger grants upon successful connection", async () => { + (useWallet as any).mockReturnValue({ connected: true, publicKey: "GABC...123" }); + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + activeGrants: [ + { id: "g1", grantee: "GXYZ...999", scope: "budget_management", authorizedAmount: "500" } + ], + pendingApprovalsCount: 1, + }), + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("1 Scope Grant Authorized")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); // Counter badge + }); + }); + + it("should allow a user to dispatch a revocation signal directly from the menu drop overlay", async () => { + (useWallet as any).mockReturnValue({ connected: true, publicKey: "GABC...123" }); + + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + activeGrants: [{ id: "g1", grantee: "GXYZ...999", scope: "budget_management" }], + pendingApprovalsCount: 0, + }), + }); + + render(); + + const dropdownButton = await screen.findByText("1 Scope Grant Authorized"); + fireEvent.click(dropdownButton); + + expect(screen.getByText("BUDGET MANAGEMENT")).toBeInTheDocument(); + + // Mock successful revocation endpoint cycle + (global.fetch as any).mockResolvedValueOnce({ ok: true }); + + const revokeButton = screen.getByText("Revoke"); + fireEvent.click(revokeButton); + + await waitFor(() => { + expect(screen.queryByText("BUDGET MANAGEMENT")).not.toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/src/types/grants.ts b/src/types/grants.ts new file mode 100644 index 00000000..29ba4a29 --- /dev/null +++ b/src/types/grants.ts @@ -0,0 +1,18 @@ +export type GrantScope = "all" | "budget_management" | "dispute_resolution" | "escrow_payout"; + +export interface IGrantContext { + id: string; + contractAddress: string; + grantee: string; + scope: GrantScope; + expirationLedger: number; + authorizedAmount?: string; + assetSymbol?: string; +} + +export interface IGrantManagementState { + activeGrants: IGrantContext[]; + pendingApprovalsCount: number; + loading: boolean; + error: string | null; +} \ No newline at end of file