From ac3d27bfd674ce02d91fb7a104d03e140e7cb996 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 19:59:23 +0100 Subject: [PATCH 01/10] fix: import resolveApiBase and CurlBlock in docs page --- src/app/docs/page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 6769a87..c3903b9 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -1,5 +1,7 @@ import { PageShell } from "@/components/PageShell"; import { messages } from "@/lib/messages"; +import { resolveApiBase } from "@/lib/resolveApiBase"; +import { CurlBlock } from "@/components/CurlBlock"; export const metadata = { title: "Docs — AgentPay" }; From b8260cd6eb9baa140c6126d5f8647f9e5c4d9049 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 19:59:35 +0100 Subject: [PATCH 02/10] feat: add settings connection message keys --- src/lib/messages.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 8335cd9..6a04570 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -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; From 6b66247384efea824cd204e7642c7c2c230c7b09 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:00:15 +0100 Subject: [PATCH 03/10] feat: render connection section with resolved api base url --- src/app/settings/page.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 9f6f742..5e0a6f5 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,13 +1,19 @@ 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 (

{messages.settings.heading}

+

{messages.settings.appearance.heading}

@@ -15,6 +21,28 @@ export default function SettingsPage() {

+ +
+

{messages.settings.connection.heading}

+

+ {messages.settings.connection.description} +

+
+ + {apiBase} + +
+ ), + }, + ]} + /> + +
); } From 0529600d33d8f9b4ea660770ae5e7c5d00c3b838 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:00:52 +0100 Subject: [PATCH 04/10] feat: add settings page tests and update README --- README.md | 2 +- src/app/settings/page.test.tsx | 71 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/app/settings/page.test.tsx diff --git a/README.md b/README.md index 2eca963..3788fe3 100644 --- a/README.md +++ b/README.md @@ -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)_ | diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx new file mode 100644 index 0000000..3d636c5 --- /dev/null +++ b/src/app/settings/page.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, act, fireEvent, cleanup } from "@testing-library/react"; +import SettingsPage from "./page"; + +function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + return writeText; +} + +beforeEach(() => { + jest.useFakeTimers(); + mockClipboard(); +}); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); From 3c1fd38b848490303013f0bdd9b255b2b961cc55 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:08:56 +0100 Subject: [PATCH 05/10] test: fix header navigation tests and update messages snapshot --- src/components/__tests__/Header.test.tsx | 18 +++++++++--------- .../__snapshots__/messages.test.ts.snap | 5 +++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 54d7962..e3ba573 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -53,10 +53,11 @@ describe("Header", () => { mockPathname.mockReturnValue("/"); render(
); + // 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"); @@ -67,9 +68,7 @@ describe("Header", () => { mockPathname.mockReturnValue("/unknown-route-123"); render(
); - 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); }); @@ -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"); diff --git a/src/lib/__tests__/__snapshots__/messages.test.ts.snap b/src/lib/__tests__/__snapshots__/messages.test.ts.snap index d214e31..c7a3ff8 100644 --- a/src/lib/__tests__/__snapshots__/messages.test.ts.snap +++ b/src/lib/__tests__/__snapshots__/messages.test.ts.snap @@ -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", }, } From fc91c48bf7d586c093cfb32dbee06846a195139c Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:29:15 +0100 Subject: [PATCH 06/10] test: mock matchMedia in settings page tests --- src/app/settings/page.test.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/app/settings/page.test.tsx b/src/app/settings/page.test.tsx index 3d636c5..7380837 100644 --- a/src/app/settings/page.test.tsx +++ b/src/app/settings/page.test.tsx @@ -1,6 +1,23 @@ 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 }, @@ -12,6 +29,7 @@ function mockClipboard(writeText = jest.fn().mockResolvedValue(undefined)) { beforeEach(() => { jest.useFakeTimers(); mockClipboard(); + mockMatchMedia(false); }); afterEach(() => { From 12e006872e96564636a9cb42f3c9259bdaef8dae Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:37:49 +0100 Subject: [PATCH 07/10] feat(docs): route relative and external links through safeHref and secure external link --- src/app/docs/page.tsx | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index c3903b9..562b2f7 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -2,6 +2,7 @@ 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" }; @@ -41,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 (

{messages.docs.heading}

{messages.docs.introCompanionPrefix} - - {messages.docs.introOpenApi} - + {openApiLink.ok ? ( + + {messages.docs.introOpenApi} + + ) : ( + messages.docs.introOpenApi + )} {messages.docs.introCompanionSuffix}

{messages.docs.referencePrefix} - - {messages.docs.referenceLink} - + {referenceLink.ok ? ( + + {messages.docs.referenceLink} + + ) : ( + messages.docs.referenceLink + )} {messages.docs.referenceSuffix}

From 04a491327840c24208f831031c3c4cf07c6b1f31 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:38:02 +0100 Subject: [PATCH 08/10] test(docs): assert target and rel attributes for docs links --- src/app/docs/page.test.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/app/docs/page.test.tsx b/src/app/docs/page.test.tsx index 1cfa03e..9e7c837 100644 --- a/src/app/docs/page.test.tsx +++ b/src/app/docs/page.test.tsx @@ -193,18 +193,22 @@ describe("DocsPage", () => { it("renders the OpenAPI JSON link", () => { render(); - 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(); - 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"); }); }); From 90a1e061815278c569be6b8842318bc5048fcfb0 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:39:42 +0100 Subject: [PATCH 09/10] test(docs): add test coverage for docs link safety validation fallback --- src/app/docs/page.test.tsx | 55 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/app/docs/page.test.tsx b/src/app/docs/page.test.tsx index 9e7c837..334372f 100644 --- a/src/app/docs/page.test.tsx +++ b/src/app/docs/page.test.tsx @@ -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", { @@ -211,4 +212,58 @@ describe("DocsPage", () => { 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(); + + // 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(); + + // 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(); + }); + }); }); From 23cb2da960b956f8f703b059a8f62024dddfd663 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 28 Jun 2026 20:39:54 +0100 Subject: [PATCH 10/10] docs(README): document link safety validation on the docs page --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 3788fe3..f43cb63 100644 --- a/README.md +++ b/README.md @@ -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 |