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: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npx vitest *)"
"Bash(npx vitest *)",
"Bash(ls /Users/ik/Documents/fluid/admin-dashboard/*.config.*)",
"Bash(ls /Users/ik/Documents/fluid/admin-dashboard/*.config.js)"
]
}
}
26 changes: 18 additions & 8 deletions admin-dashboard/app/admin/api-keys/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { AdminPageSkeleton } from "@/components/skeletons";
import { ApiKeysTableSkeleton } from "@/components/skeletons";

export default function Loading() {
return (
<AdminPageSkeleton
titleWidthClass="w-60"
rows={8}
columns={5}
tableLabel="Loading API keys"
toolbars={2}
/>
<main className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="h-3 w-24 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-8 w-60 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-3 w-80 max-w-full rounded-md bg-muted/60 motion-safe:animate-pulse" />
</div>
<div className="h-12 w-48 rounded-2xl bg-muted/60 motion-safe:animate-pulse" />
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<ApiKeysTableSkeleton rows={8} />
</div>
</main>
);
}
26 changes: 18 additions & 8 deletions admin-dashboard/app/admin/signers/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { AdminPageSkeleton } from "@/components/skeletons";
import { SignerPoolSkeleton } from "@/components/skeletons";

export default function Loading() {
return (
<AdminPageSkeleton
titleWidthClass="w-64"
rows={6}
columns={5}
tableLabel="Loading signer pool"
toolbars={2}
/>
<main className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="h-3 w-24 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-8 w-64 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-3 w-80 max-w-full rounded-md bg-muted/60 motion-safe:animate-pulse" />
</div>
<div className="h-12 w-48 rounded-2xl bg-muted/60 motion-safe:animate-pulse" />
</div>
</div>
</div>
<div className="mx-auto max-w-7xl space-y-6 px-4 py-6 sm:px-6 lg:px-8">
<SignerPoolSkeleton rows={6} />
</div>
</main>
);
}
26 changes: 18 additions & 8 deletions admin-dashboard/app/admin/webhooks/loading.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { AdminPageSkeleton } from "@/components/skeletons";
import { WebhookSkeleton } from "@/components/skeletons";

export default function Loading() {
return (
<AdminPageSkeleton
titleWidthClass="w-64"
rows={6}
columns={4}
tableLabel="Loading webhooks"
toolbars={1}
/>
<main className="min-h-screen bg-slate-100">
<div className="border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<div className="h-3 w-24 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-8 w-64 rounded-md bg-muted/60 motion-safe:animate-pulse" />
<div className="h-3 w-80 max-w-full rounded-md bg-muted/60 motion-safe:animate-pulse" />
</div>
<div className="h-12 w-48 rounded-2xl bg-muted/60 motion-safe:animate-pulse" />
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<WebhookSkeleton cards={3} eventRows={3} />
</div>
</main>
);
}
105 changes: 74 additions & 31 deletions admin-dashboard/components/dashboard/ApiKeysTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { useState } from "react";
import { ShieldOff } from "lucide-react";
import type { ApiKey, ChainId } from "@/components/dashboard/types";
import { RevokeKeyDialog } from "@/components/dashboard/RevokeKeyDialog";
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
import { CopyButton } from "@/components/dashboard/CopyButton";

interface ApiKeysTableProps {
initialKeys: ApiKey[];
Expand Down Expand Up @@ -120,24 +121,43 @@ export function ApiKeysTable({
}: ApiKeysTableProps) {
const [keys, setKeys] = useState<ApiKey[]>(initialKeys);
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
const [revoking, setRevoking] = useState(false);
const [revokeError, setRevokeError] = useState<string | null>(null);

async function handleRevoke(keyId: string) {
const res = await fetch(`${serverUrl}/admin/api-keys/${keyId}/revoke`, {
method: "PATCH",
headers: {
"x-admin-token": adminToken,
"Content-Type": "application/json",
},
});
async function handleRevoke() {
if (!pendingRevoke) return;
setRevoking(true);
setRevokeError(null);
try {
const res = await fetch(`${serverUrl}/admin/api-keys/${pendingRevoke.id}/revoke`, {
method: "PATCH",
headers: {
"x-admin-token": adminToken,
"Content-Type": "application/json",
},
});

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Request failed (${res.status})`);
}

if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error ?? `Request failed (${res.status})`);
setKeys((prev) =>
prev.map((k: ApiKey) => (k.id === pendingRevoke.id ? { ...k, active: false } : k)),
);
setPendingRevoke(null);
} catch (err: unknown) {
setRevokeError(err instanceof Error ? err.message : "Failed to revoke key. Please try again.");
} finally {
setRevoking(false);
}
}

setKeys((prev) =>
prev.map((k: ApiKey) => (k.id === keyId ? { ...k, active: false } : k)),
);
function closeRevokeDialog() {
if (!revoking) {
setPendingRevoke(null);
setRevokeError(null);
}
}

function handleChainUpdate(keyId: string, chains: ChainId[]) {
Expand Down Expand Up @@ -201,15 +221,25 @@ export function ApiKeysTable({
}
>
<td className="px-5 py-4">
<span
className={`font-mono text-sm ${
apiKey.active
? "text-slate-900"
: "text-slate-400 line-through"
}`}
>
{apiKey.key}
</span>
<div className="flex items-center gap-2">
<span
className={`font-mono text-sm ${
apiKey.active
? "text-slate-900"
: "text-slate-400 line-through"
}`}
>
{apiKey.key}
</span>
{apiKey.active && (
<CopyButton
value={apiKey.key}
label="Copy"
size="sm"
iconOnly
/>
)}
</div>
</td>

<td className="hidden px-5 py-4 text-sm text-slate-600 sm:table-cell">
Expand Down Expand Up @@ -263,13 +293,26 @@ export function ApiKeysTable({
</div>
</div>

{pendingRevoke && (
<RevokeKeyDialog
keyId={pendingRevoke.id}
keyDisplay={pendingRevoke.key}
onConfirm={handleRevoke}
onClose={() => setPendingRevoke(null)}
/>
<ConfirmDialog
open={pendingRevoke !== null}
onOpenChange={(open) => { if (!open) closeRevokeDialog(); }}
title="Revoke API Key"
description={
pendingRevoke
? `This will immediately deactivate ${pendingRevoke.key}. Any dApp or service using this key will lose access instantly. This action cannot be undone.`
: ""
}
confirmLabel={revoking ? "Revoking…" : "Revoke Key"}
cancelLabel="Cancel"
onConfirm={() => void handleRevoke()}
onCancel={closeRevokeDialog}
variant="destructive"
isLoading={revoking}
/>
{revokeError && (
<div className="mt-2 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{revokeError}
</div>
)}
</>
);
Expand Down
99 changes: 99 additions & 0 deletions admin-dashboard/components/dashboard/CopyButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { CopyButton } from "./CopyButton";

describe("CopyButton", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("navigator", {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

it("renders with default label", () => {
render(<CopyButton value="test-value" />);
expect(screen.getByText("Copy")).toBeInTheDocument();
});

it("renders with a custom label", () => {
render(<CopyButton value="test-value" label="Copy hash" />);
expect(screen.getByText("Copy hash")).toBeInTheDocument();
});

it("calls navigator.clipboard.writeText with the correct value on click", async () => {
render(<CopyButton value="abc123" />);
await act(async () => {
fireEvent.click(screen.getByRole("button"));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("abc123");
});

it("shows 'Copied' state immediately after click", async () => {
render(<CopyButton value="abc123" />);
await act(async () => {
fireEvent.click(screen.getByRole("button"));
});
expect(screen.getByText("Copied")).toBeInTheDocument();
});

it("reverts to copy label after 2 seconds", async () => {
render(<CopyButton value="abc123" label="Copy key" />);
await act(async () => {
fireEvent.click(screen.getByRole("button"));
});
expect(screen.getByText("Copied")).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(2000);
});
expect(screen.getByText("Copy key")).toBeInTheDocument();
});

it("renders in iconOnly mode without a text label", () => {
render(<CopyButton value="abc123" label="Copy" iconOnly />);
// The text label should not be in the document
expect(screen.queryByText("Copy")).not.toBeInTheDocument();
// But the button itself is still present
expect(screen.getByRole("button")).toBeInTheDocument();
});

it("shows no text label in iconOnly Copied state", async () => {
render(<CopyButton value="abc123" iconOnly />);
await act(async () => {
fireEvent.click(screen.getByRole("button"));
});
expect(screen.queryByText("Copied")).not.toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
});

it("handles clipboard failure gracefully without staying in copied state", async () => {
vi.stubGlobal("navigator", {
clipboard: {
writeText: vi.fn().mockRejectedValue(new Error("Permission denied")),
},
});
render(<CopyButton value="abc123" />);
await act(async () => {
fireEvent.click(screen.getByRole("button"));
});
expect(screen.queryByText("Copied")).not.toBeInTheDocument();
expect(screen.getByText("Copy")).toBeInTheDocument();
});

it("renders in sm size without errors", () => {
render(<CopyButton value="abc123" size="sm" />);
expect(screen.getByRole("button")).toBeInTheDocument();
});

it("renders in lg size without errors", () => {
render(<CopyButton value="abc123" size="lg" />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
});
Loading
Loading