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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/
| `/services/:serviceId/agents` | Agents for a given service | `GET /api/v1/services/:serviceId/agents` |
| `/services/:serviceId/edit` | Edit service | _(reads service + submits via service update endpoints in code)_ |
| `/services/new` | Create service | `POST /api/v1/services` |
| `/settings` | User/app settings | _(calls settings endpoints in code)_ |
| `/settings` | User/app settings (theme configuration and Connection section displaying the resolved API base URL) | _(static UI settings surface)_ |
| `/stats` | Statistics | _(calls stats endpoints in code)_ |
| `/usage` | Usage totals & settlement workflow | `POST /api/v1/usage`, `GET /api/v1/usage/:agent/:serviceId`, `POST /api/v1/settle` |
| `/webhooks` | Webhooks management | _(calls webhooks endpoints in code)_ |
Expand Down Expand Up @@ -294,6 +294,8 @@ When rendering links:

- Any external link rendered with `target="_blank"` must include `rel="noopener noreferrer"`.
- Any `href` derived from backend/user data must be validated with `safeHref()` from `src/lib/url.ts`. Unsafe schemes like `javascript:` and `data:` are rejected.
- Links on the `/docs` page (relative OpenAPI and external GitHub reference link) are validated through `safeHref()`, falling back to plain text if validation fails.

## Route map (frontend)

| Path | Notes |
Expand Down
71 changes: 65 additions & 6 deletions src/app/docs/page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { render, screen, act, fireEvent, cleanup } from "@testing-library/react";
import DocsPage from "./page";
import * as urlUtils from "@/lib/url";

function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) {
Object.defineProperty(navigator, "clipboard", {
Expand Down Expand Up @@ -193,18 +194,76 @@ describe("DocsPage", () => {

it("renders the OpenAPI JSON link", () => {
render(<DocsPage />);
expect(
screen.getByRole("link", { name: /GET \/api\/v1\/openapi\.json/i }),
).toHaveAttribute("href", "/api/v1/openapi.json");
const link = screen.getByRole("link", { name: /GET \/api\/v1\/openapi\.json/i });
expect(link).toHaveAttribute("href", "/api/v1/openapi.json");
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
});

it("renders the dashboard API integration reference link", () => {
render(<DocsPage />);
expect(
screen.getByRole("link", { name: /dashboard API integration reference/i }),
).toHaveAttribute(
const link = screen.getByRole("link", {
name: /dashboard API integration reference/i,
});
expect(link).toHaveAttribute(
"href",
"https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});

describe("link safety validation fallbacks", () => {
let safeHrefSpy: jest.SpyInstance;
const originalSafeHref = urlUtils.safeHref;

beforeEach(() => {
safeHrefSpy = jest.spyOn(urlUtils, "safeHref");
});

afterEach(() => {
safeHrefSpy.mockRestore();
});

it("renders relative OpenAPI link as plain text if validation fails", () => {
safeHrefSpy.mockImplementation((href) => {
if (href === "/api/v1/openapi.json") {
return { ok: false };
}
return originalSafeHref(href);
});

render(<DocsPage />);

// The link should not be present
expect(
screen.queryByRole("link", { name: /GET \/api\/v1\/openapi\.json/i }),
).not.toBeInTheDocument();

// But the text should still be rendered as plain text
expect(screen.getByText(/GET \/api\/v1\/openapi\.json/i)).toBeInTheDocument();
});

it("renders external reference link as plain text if validation fails", () => {
safeHrefSpy.mockImplementation((href) => {
if (
href ===
"https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md"
) {
return { ok: false };
}
return originalSafeHref(href);
});

render(<DocsPage />);

// The link should not be present
expect(
screen.queryByRole("link", { name: /dashboard API integration reference/i }),
).not.toBeInTheDocument();

// But the text should still be rendered as plain text
expect(screen.getByText(/dashboard API integration reference/i)).toBeInTheDocument();
});
});
});
36 changes: 27 additions & 9 deletions src/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { PageShell } from "@/components/PageShell";
import { messages } from "@/lib/messages";
import { resolveApiBase } from "@/lib/resolveApiBase";
import { CurlBlock } from "@/components/CurlBlock";
import { safeHref } from "@/lib/url";

export const metadata = { title: "Docs — AgentPay" };

Expand Down Expand Up @@ -39,24 +42,39 @@ export default function DocsPage() {
},
];

const openApiLink = safeHref("/api/v1/openapi.json");
const referenceLink = safeHref(
"https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md",
);

return (
<PageShell maxWidth="3xl" gap="6">
<h1 className="text-3xl font-semibold tracking-tight">{messages.docs.heading}</h1>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{messages.docs.introCompanionPrefix}
<a className="underline" href="/api/v1/openapi.json">
{messages.docs.introOpenApi}
</a>
{openApiLink.ok ? (
<a className="underline" href={openApiLink.href}>
{messages.docs.introOpenApi}
</a>
) : (
messages.docs.introOpenApi
)}
{messages.docs.introCompanionSuffix}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{messages.docs.referencePrefix}
<a
className="underline"
href="https://github.com/Agentpay-Org/Agentpay-frontend/blob/main/docs/api-integration.md"
>
{messages.docs.referenceLink}
</a>
{referenceLink.ok ? (
<a
className="underline"
href={referenceLink.href}
target="_blank"
rel="noopener noreferrer"
>
{messages.docs.referenceLink}
</a>
) : (
messages.docs.referenceLink
)}
{messages.docs.referenceSuffix}
</p>
<dl className="space-y-4">
Expand Down
89 changes: 89 additions & 0 deletions src/app/settings/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen, act, fireEvent, cleanup } from "@testing-library/react";
import SettingsPage from "./page";

const mockMatchMedia = (matches: boolean) => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: jest.fn().mockImplementation((query: string) => ({
matches,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
};

function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) {
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
return writeText;
}

beforeEach(() => {
jest.useFakeTimers();
mockClipboard();
mockMatchMedia(false);
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
cleanup();
delete process.env.NEXT_PUBLIC_AGENTPAY_API_BASE;
});

describe("SettingsPage", () => {
it("renders the settings headings and description text", () => {
render(<SettingsPage />);

expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Connection" })).toBeInTheDocument();
expect(
screen.getByText("Resolved API base URL of the AgentPay backend.")
).toBeInTheDocument();
});

it("uses the default API base URL (http://localhost:3001) when env is not set", () => {
render(<SettingsPage />);

expect(screen.getByText("http://localhost:3001")).toBeInTheDocument();
});

it("uses an overridden API base URL when NEXT_PUBLIC_AGENTPAY_API_BASE is set", () => {
process.env.NEXT_PUBLIC_AGENTPAY_API_BASE = "https://api.custombackend.com";
render(<SettingsPage />);

expect(screen.getByText("https://api.custombackend.com")).toBeInTheDocument();
});

it("renders the copy button and handles the copy behavior with aria-live feedback", async () => {
const writeText = mockClipboard();
process.env.NEXT_PUBLIC_AGENTPAY_API_BASE = "https://api.custombackend.com";
render(<SettingsPage />);

const copyBtn = screen.getByRole("button", { name: "Copy" });
expect(copyBtn).toBeInTheDocument();
expect(copyBtn).toHaveAttribute("aria-live", "polite");

await act(async () => {
fireEvent.click(copyBtn);
});

expect(writeText).toHaveBeenCalledWith("https://api.custombackend.com");
expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();

act(() => {
jest.advanceTimersByTime(1500);
});

expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
});
28 changes: 28 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,48 @@
import { ThemeToggle } from "@/components/ThemeToggle";
import { PageShell } from "@/components/PageShell";
import { messages } from "@/lib/messages";
import { resolveApiBase } from "@/lib/resolveApiBase";
import { KeyValueGrid } from "@/components/KeyValueGrid";
import { CopyButton } from "@/components/CopyButton";

export const metadata = { title: "Settings — AgentPay" };

export default function SettingsPage() {
const apiBase = resolveApiBase();

return (
<PageShell maxWidth="2xl" gap="8">
<h1 className="text-3xl font-semibold tracking-tight">{messages.settings.heading}</h1>

<section className="flex flex-col gap-2">
<h2 className="text-lg font-medium">{messages.settings.appearance.heading}</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{messages.settings.appearance.description}
</p>
<ThemeToggle />
</section>

<section className="flex flex-col gap-2">
<h2 className="text-lg font-medium">{messages.settings.connection.heading}</h2>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{messages.settings.connection.description}
</p>
<div className="mt-2 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<KeyValueGrid
rows={[
{
label: messages.settings.connection.label,
value: (
<div className="flex items-center gap-2">
<span className="font-mono text-zinc-900 dark:text-zinc-100">{apiBase}</span>
<CopyButton value={apiBase} label="Copy" />
</div>
),
},
]}
/>
</div>
</section>
</PageShell>
);
}
18 changes: 9 additions & 9 deletions src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ describe("Header", () => {
mockPathname.mockReturnValue("/");
render(<Header />);

// Open mobile menu so mobile links are rendered
fireEvent.click(getMobileToggle());

// Exactly one link has aria-current="page"
const activeLinks = screen.getAllByRole("link").filter(
(link) => link.getAttribute("aria-current") === "page"
);
const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]'));
// Home link is active twice (desktop + mobile)
expect(activeLinks.length).toBe(2);
expect(activeLinks[0]).toHaveTextContent("Home");
Expand All @@ -67,9 +68,7 @@ describe("Header", () => {
mockPathname.mockReturnValue("/unknown-route-123");
render(<Header />);

const activeLinks = screen.getAllByRole("link").filter(
(link) => link.getAttribute("aria-current") === "page"
);
const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]'));
expect(activeLinks.length).toBe(0);
});

Expand All @@ -80,10 +79,11 @@ describe("Header", () => {
// Open the secondary menu to expose secondary links
fireEvent.click(screen.getByRole("button", { name: /more/i }));

// Open mobile menu so mobile links are rendered
fireEvent.click(getMobileToggle());

// The active links should strictly be the desktop "Services" and the mobile "Services"
const activeLinks = screen.getAllByRole("link", { hidden: true }).filter(
(link) => link.getAttribute("aria-current") === "page"
);
const activeLinks = Array.from(document.querySelectorAll('[aria-current="page"]'));

expect(activeLinks.length).toBe(2);
expect(activeLinks[0]).toHaveTextContent("Services");
Expand Down
5 changes: 5 additions & 0 deletions src/lib/__tests__/__snapshots__/messages.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ exports[`messages catalog matches the committed catalog snapshot 1`] = `
"description": "Choose a colour scheme. System follows your OS preference.",
"heading": "Appearance",
},
"connection": {
"description": "Resolved API base URL of the AgentPay backend.",
"heading": "Connection",
"label": "API Base URL",
},
"heading": "Settings",
},
}
Expand Down
5 changes: 5 additions & 0 deletions src/lib/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export const messages = {
heading: "Appearance",
description: "Choose a colour scheme. System follows your OS preference.",
},
connection: {
heading: "Connection",
description: "Resolved API base URL of the AgentPay backend.",
label: "API Base URL",
},
},
} as const;

Expand Down
Loading