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
22 changes: 10 additions & 12 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useCallback, useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { VaultPanel } from "./components/dashboard/VaultPanel";
import { WalletConnect } from "./components/onboarding/WalletConnect";
import { Toasts } from "./components/ui/Toasts";
import { ErrorBoundary } from "./components/ui/ErrorBoundary";
import { useWalletStore } from "./store/wallet";
import { VaultPanel } from "./components/dashboard/VaultPanel"; // FIX 1:./
import { WalletConnect } from "./components/onboarding/WalletConnect"; // FIX 1:./
import { Toasts } from "./components/ui/Toasts"; // FIX 1:./
import { ErrorBoundary } from "./components/ui/ErrorBoundary"; // FIX 1:./
import { useWalletStore } from "./store/wallet"; // FIX 1:./
import { useTranslation } from "react-i18next";
import { AdminLogin } from "./pages/AdminLogin";

const queryClient = new QueryClient();

Expand All @@ -28,14 +29,13 @@ function Dashboard() {
<WalletConnect />
<button
onClick={toggleLanguage}
className="text-sm border border-gray-700 rounded-lg px-3 py-1.5 text-gray-300 hover:border-gray-600 hover:text-white transition-colors duration-150"
className="text-sm border-gray-700 rounded-lg px-3 py-1.5 text-gray-300 hover:border-gray-600 hover:text-white transition-colors duration-150" // FIX 2: added border
>
{i18n.language === "en" ? "FR" : "EN"}
</button>
</div>
</div>
</header>

<main className="max-w-xl mx-auto px-6 py-10">
<ErrorBoundary>
<VaultPanel />
Expand All @@ -49,17 +49,15 @@ export default function App() {
useEffect(() => {
const revalidate = () => void useWalletStore.getState().revalidate();
revalidate();
// Re-check authorization when the window regains focus. Freighter opens
// as an extension popup (separate window), so the page loses focus while
// the popup is open and regains it when it closes — this catches revoked
// site access without requiring a page reload.
window.addEventListener("focus", revalidate);
return () => window.removeEventListener("focus", revalidate);
}, []);

const isAdminRoute = window.location.pathname === "/admin";

return (
<QueryClientProvider client={queryClient}>
<Dashboard />
{isAdminRoute ? <AdminLogin /> : <Dashboard />}
<Toasts />
</QueryClientProvider>
);
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/__tests__/pages/AdminLogin.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { AdminLogin } from "../../pages/AdminLogin";
import { useWalletStore } from "../../store/wallet";
import { useWalletConnect } from "../../hooks/useWalletConnect";
import { fetchVaultAdmin } from "@meridian/stellar-sdk-helpers";

const handleConnect = vi.fn();
const ADMIN = "GCKFBEIYTKP6RCZNVPH73XL7XFJVSFAKQR4E4XQD4PGGPCCQTVMWXW6D";
const OTHER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";

vi.mock("../../hooks/useWalletConnect", () => ({
useWalletConnect: vi.fn(),
}));
vi.mock("@meridian/stellar-sdk-helpers", () => ({
fetchVaultAdmin: vi.fn(),
}));

beforeEach(() => {
vi.clearAllMocks();
useWalletStore.setState({ publicKey: null, connected: false });
vi.mocked(useWalletConnect).mockReturnValue({
handleConnect,
status: "idle",
attemptedWalletId: "freighter",
} as ReturnType<typeof useWalletConnect>);
});

describe("AdminLogin", () => {
it("shows the connect prompt when no wallet is connected", () => {
render(<AdminLogin />);

const button = screen.getByText("Connect Wallet");
expect(button).toBeDefined();

fireEvent.click(button);
expect(handleConnect).toHaveBeenCalledTimes(1);
});

it("shows the blocked screen with only the connected address for a non-admin wallet", async () => {
vi.mocked(fetchVaultAdmin).mockResolvedValue(ADMIN);
useWalletStore.setState({ publicKey: OTHER, connected: true });

render(<AdminLogin />);

await waitFor(() => {
expect(screen.getByText(`Not authorized: ${OTHER}`)).toBeDefined();
});
expect(screen.queryByText(ADMIN)).toBeNull();
});

it("shows the dashboard shell when the connected wallet matches get_admin", async () => {
vi.mocked(fetchVaultAdmin).mockResolvedValue(ADMIN);
useWalletStore.setState({ publicKey: ADMIN, connected: true });

render(<AdminLogin />);

await waitFor(() => {
expect(screen.getByText("Admin Dashboard")).toBeDefined();
});
});

it("treats a failed admin lookup as blocked", async () => {
vi.mocked(fetchVaultAdmin).mockRejectedValue(new Error("rpc down"));
useWalletStore.setState({ publicKey: OTHER, connected: true });

render(<AdminLogin />);

await waitFor(() => {
expect(screen.getByText(`Not authorized: ${OTHER}`)).toBeDefined();
});
});
});
81 changes: 81 additions & 0 deletions apps/web/src/pages/AdminLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { fetchVaultAdmin } from "@meridian/stellar-sdk-helpers";
import { APP_ADDRESSES, APP_NETWORK } from "@meridian/shared";
import { useWalletStore } from "../store/wallet";
import { useWalletConnect } from "../hooks/useWalletConnect";

// Keyed by the public key it was resolved for, so a wallet switch is
// recognized as "not checked yet" (loading) during render rather than
// needing an effect to reset it back to loading first.
interface GateResult {
publicKey: string;
authorized: boolean;
}

export function AdminLogin() {
const { publicKey, connected } = useWalletStore();
const { handleConnect, status: connectStatus } = useWalletConnect();
const [result, setResult] = useState<GateResult | null>(null);

useEffect(() => {
if (!connected || !publicKey) return;
let cancelled = false;
fetchVaultAdmin({ contractId: APP_ADDRESSES.vault, network: APP_NETWORK })
.then((admin) => {
if (!cancelled) {
setResult({ publicKey, authorized: admin === publicKey });
}
})
.catch(() => {
if (!cancelled) setResult({ publicKey, authorized: false });
});
return () => {
cancelled = true;
};
}, [connected, publicKey]);

const status: "loading" | "blocked" | "allowed" =
result?.publicKey !== publicKey
? "loading"
: result.authorized
? "allowed"
: "blocked";

if (!connected) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0d1117]">
<button
onClick={() => void handleConnect()}
disabled={connectStatus === "connecting"}
className="px-6 py-3 bg-emerald-500 text-white rounded-lg disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed"
>
Connect Wallet
</button>
</div>
);
}

if (status === "blocked") {
return (
<div className="flex items-center justify-center min-h-screen bg-[#0d1117]">
<div className="rounded-xl border border-gray-800 bg-[#161b22] px-6 py-4 text-red-400 text-sm">
Not authorized: {publicKey}
</div>
</div>
);
}

if (status === "allowed") {
return (
<div className="min-h-screen bg-[#0d1117] text-emerald-400 p-4">
Admin Dashboard
</div>
);
}

return (
<div className="flex items-center justify-center min-h-screen bg-[#0d1117] text-gray-400">
Loading...
</div>
);
}
26 changes: 26 additions & 0 deletions packages/stellar-sdk-helpers/src/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
buildCoordinatorDepositTx,
buildCoordinatorWithdrawTx,
fetchCoordinatorPosition,
fetchVaultAdmin,
} from "./coordinator";
import { prepareSorobanTx, simulateView } from "./tx";

Expand Down Expand Up @@ -269,3 +270,28 @@ describe("fetchCoordinatorPosition", () => {
expect(positions[0]!.earned).toBe(0);
});
});

describe("fetchVaultAdmin", () => {
it("returns the admin address from get_admin", async () => {
const ADMIN = "GCKFBEIYTKP6RCZNVPH73XL7XFJVSFAKQR4E4XQD4PGGPCCQTVMWXW6D";
vi.mocked(simulateView).mockResolvedValue(ADMIN as never);

const admin = await fetchVaultAdmin({ contractId: CONTRACT_ID, network });

expect(admin).toBe(ADMIN);
expect(simulateView).toHaveBeenCalledWith(
expect.anything(),
CONTRACT_ID,
network.passphrase,
"get_admin"
);
});

it("throws when get_admin doesn't resolve to a string", async () => {
vi.mocked(simulateView).mockResolvedValue(null as never);

await expect(
fetchVaultAdmin({ contractId: CONTRACT_ID, network })
).rejects.toThrow(/unexpected response shape/);
});
});
22 changes: 22 additions & 0 deletions packages/stellar-sdk-helpers/src/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,25 @@ export async function fetchCoordinatorPosition(
entryTime: toBigInt(entryTimeRaw),
});
}

/**
* Read the coordinator vault's current admin address via read-only
* simulation. Used to gate the admin dashboard: a connected wallet is
* authorized only if its public key matches this address.
*/
export async function fetchVaultAdmin(
config: CoordinatorConfig
): Promise<string> {
const { contractId, network } = config;
const server = getRpcServer(network.rpcUrl, 12_000);
const admin = await simulateView(
server,
contractId,
network.passphrase,
"get_admin"
);
if (typeof admin !== "string") {
throw new Error("get_admin: unexpected response shape");
}
return admin;
}
Loading