Skip to content

Commit d08d0e0

Browse files
committed
Merge branch 'pr-165'
2 parents 9b236a7 + 374d806 commit d08d0e0

6 files changed

Lines changed: 253 additions & 39 deletions

File tree

README.md

-73 Bytes

# stableroute-frontend Next.js frontend application for [StableRoute](https://github.com/StableRoute-Org/Stableroute) — the Stellar liquidity routing protocol. It provides user interfaces for obtaining path routing quotes, managing liquidity pairs, viewing stats, configuring API keys and webhooks, browsing audit logs, adjusting developer settings, and reading endpoint documentation. ## What this repo contains - **Next.js 15** (App Router) with **React 19** - **TailwindCSS** for styling - A comprehensive set of routing, management, and audit log pages integrated with the StableRoute backend. ## Routes Each route is defined under `src/app` and connects to its respective UI page: - **`/`** ([page.tsx](src/app/page.tsx)): Home landing page with navigation links and quick CTAs. - **`/pairs`** ([pairs/page.tsx](src/app/pairs/page.tsx)): Lists registered currency pairs on the router. - **`/pairs/new`** ([pairs/new/page.tsx](src/app/pairs/new/page.tsx)): Form interface to register a new currency pair. - **`/quote`** ([quote/page.tsx](src/app/quote/page.tsx)): Form interface to request currency routing path quotes. - **`/stats`** ([stats/page.tsx](src/app/stats/page.tsx)): Status dashboard showing system metrics and polling the backend. - **`/admin`** ([admin/page.tsx](src/app/admin/page.tsx)): Control center to pause or unpause router activity. - **`/api-keys`** ([api-keys/page.tsx](src/app/api-keys/page.tsx)): Dashboard to create, list, and revoke API keys. - **`/events`** ([events/page.tsx](src/app/events/page.tsx)): Audit log page rendering the system event log history. - **`/webhooks`** ([webhooks/page.tsx](src/app/webhooks/page.tsx)): Webhook manager for listing and adding event subscribers. - **`/settings`** ([settings/page.tsx](src/app/settings/page.tsx)): User settings interface hosting the light/dark appearance toggle. - **`/docs`** ([docs/page.tsx](src/app/docs/page.tsx)): Documentation page describing the API endpoints and usage. - **`/about`** ([about/page.tsx](src/app/about/page.tsx)): Static about page describing the protocol. ## Footer Navigation The shared footer keeps the StableRoute tagline visible on every page, renders the current copyright year dynamically, and links to `/docs`, `/about`, and the StableRoute Discord community. ## Configuration & API Integration The frontend communicates with the StableRoute API backend. ### Environment Variables - **`NEXT_PUBLIC_STABLEROUTE_API_BASE`**: Specifies the base URL of the StableRoute API backend (defaults to `http://localhost:3001` if unset). ### API Endpoints Consumed - **`/api/v1/pairs`**: Lists registered pairs (`GET`) and registers new pairs (`POST`). - **`/api/v1/quote`**: Requests path routing quotes for (source, destination, amount) triples (`GET`). - **`/api/v1/stats`**: Retrieves system performance and routing metrics (`GET`). - **`/api/v1/admin/status`**: Retrieves router paused status (`GET`). - **`/api/v1/admin/pause` / `/api/v1/admin/unpause`**: Pauses and unpauses routing activity (`POST`). - **`/api/v1/api-keys`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/api-keys/:prefix`) API keys. - **`/api/v1/events`**: Retrieves system event audit logs (`GET`). - **`/api/v1/webhooks`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/webhooks/:id`) webhook subscriptions. ## Prerequisites - Node.js 18+ - npm ## Setup (contributors) 1. Clone the repo and enter the directory: ```bash git clone && cd stableroute-frontend ``` 2. Install dependencies: ```bash npm install ``` 3. Build and test: ```bash npm run build npm test ``` 4. Run locally: ```bash npm run dev ``` App: `http://localhost:3000`. ## Scripts | Script | Description | |--------|-------------| | `npm run dev` | Start dev server (Next.js) | | `npm run build` | Production build | | `npm run start` | Run production server | | `npm test` | Run Jest tests | | `npm run lint` | Next.js ESLint | ## Accessibility ### ARIA Live Regions Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in `aria-live="polite"` regions so screen-reader users are notified when content arrives. Error messages continue to use `role="alert"` for assertive announcements. A single polite region per page prevents double announcements. ## Tested Components The following shared components in `src/components` have dedicated unit tests under `src/components/__tests__`: `Card`, `Spinner`, `Badge`, `Header`, `Footer`, `ConfirmDialog`. ## CI/CD On every push/PR to `main`, GitHub Actions runs: - `npm ci` - `npm run build` - `npm test` Ensure these pass locally before pushing. ## Contributing 1. Fork the repo and create a branch from `main`. 2. Add tests for new UI/behavior; keep `npm run build` and `npm test` passing. 3. Open a PR; CI must be green. ## License MIT # test

stableroute-frontend

Next.js frontend application for StableRoute — the Stellar liquidity routing protocol. It provides user interfaces for obtaining path routing quotes, managing liquidity pairs, viewing stats, configuring API keys and webhooks, browsing audit logs, adjusting developer settings, and reading endpoint documentation.

What this repo contains

  • Next.js 15 (App Router) with React 19
  • TailwindCSS for styling
  • A comprehensive set of routing, management, and audit log pages integrated with the StableRoute backend.

Routes

Each route is defined under src/app and connects to its respective UI page:

  • / (page.tsx): Home landing page with navigation links and quick CTAs.
  • /pairs (pairs/page.tsx): Lists registered currency pairs on the router.
  • /pairs/new (pairs/new/page.tsx): Form interface to register a new currency pair.
  • /quote (quote/page.tsx): Form interface to request currency routing path quotes.
  • /stats (stats/page.tsx): Status dashboard showing system metrics and polling the backend.
  • /admin (admin/page.tsx): Control center to pause or unpause router activity.
  • /api-keys (api-keys/page.tsx): Dashboard to create, list, and revoke API keys.
  • /events (events/page.tsx): Audit log page rendering the system event log history.
  • /webhooks (webhooks/page.tsx): Webhook manager for listing and adding event subscribers.
  • /settings (settings/page.tsx): User settings interface hosting the light/dark appearance toggle.
  • /docs (docs/page.tsx): Documentation page describing the API endpoints and usage.
  • /about (about/page.tsx): Static about page describing the protocol.

Footer Navigation

The shared footer keeps the StableRoute tagline visible on every page, renders the current copyright year dynamically, and links to /docs, /about, and the StableRoute Discord community.

Configuration & API Integration

The frontend communicates with the StableRoute API backend.

Environment Variables

  • NEXT_PUBLIC_STABLEROUTE_API_BASE: Specifies the base URL of the StableRoute API backend (defaults to http://localhost:3001 if unset).

API Endpoints Consumed

  • /api/v1/pairs: Lists registered pairs (GET) and registers new pairs (POST).
  • /api/v1/quote: Requests path routing quotes for (source, destination, amount) triples (GET).
  • /api/v1/stats: Retrieves system performance and routing metrics (GET).
  • /api/v1/admin/status: Retrieves router paused status (GET).
  • /api/v1/admin/pause / /api/v1/admin/unpause: Pauses and unpauses routing activity (POST).
  • /api/v1/api-keys: Creates (POST), lists (GET), and revokes (DELETE at /api/v1/api-keys/:prefix) API keys.
  • /api/v1/events: Retrieves system event audit logs (GET).
  • /api/v1/webhooks: Creates (POST), lists (GET), and revokes (DELETE at /api/v1/webhooks/:id) webhook subscriptions.

Asset Codes

Stellar asset codes entered through the new-pair form are trimmed, validated as 1-12 ASCII letters or numbers, uppercased before submission, and compared after normalization so duplicate pairs such as usdc and USDC cannot be registered.

Prerequisites

  • Node.js 18+
  • npm

Setup (contributors)

  1. Clone the repo and enter the directory:
    git clone <repo-url> && cd stableroute-frontend
  2. Install dependencies:
    npm install
  3. Build and test:
    npm run build
    npm test
  4. Run locally:
    npm run dev
    App: http://localhost:3000.

Scripts

Script Description
npm run dev Start dev server (Next.js)
npm run build Production build
npm run start Run production server
npm test Run Jest tests
npm run lint Next.js ESLint

Accessibility

ARIA Live Regions

Dynamic list updates (loading → loaded / loading → empty) on the pairs, events, api-keys, and webhooks pages are wrapped in aria-live="polite" regions so screen-reader users are notified when content arrives. Error messages continue to use role="alert" for assertive announcements. A single polite region per page prevents double announcements.

CI/CD

On every push/PR to main, GitHub Actions runs:

  • npm ci
  • npm run build
  • npm test

Ensure these pass locally before pushing.

Contributing

  1. Fork the repo and create a branch from main.
  2. Add tests for new UI/behavior; keep npm run build and npm test passing.
  3. Open a PR; CI must be green.

License

MIT

src/app/api-keys/page.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@ describe("ApiKeysPage", () => {
2121
it("renders api keys in a single polite live region", async () => {
2222
global.fetch = jest.fn().mockResolvedValueOnce({
2323
ok: true,
24-
text: async () => JSON.stringify({
25-
items: [{ prefix: "sk_abc", label: "Production", createdAt: Date.now() }],
26-
}),
24+
text: async () =>
25+
JSON.stringify({
26+
items: [{ prefix: "sk_abc", label: "Production", createdAt: Date.now() }],
27+
}),
2728
} as unknown as Response);
2829

2930
render(<ApiKeysPage />);

src/app/events/page.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ describe("EventsPage", () => {
2222
it("renders events in a single polite live region", async () => {
2323
global.fetch = jest.fn().mockResolvedValueOnce({
2424
ok: true,
25-
text: async () => JSON.stringify({
26-
items: [{ id: "evt1", ts: Date.now(), type: "pair.registered", payload: {} }],
27-
}),
25+
text: async () =>
26+
JSON.stringify({
27+
items: [{ id: "evt1", ts: Date.now(), type: "pair.registered", payload: {} }],
28+
}),
2829
} as unknown as Response);
2930

3031
render(<EventsPage />);

src/app/pairs/new/page.test.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import NewPairPage from "./page";
3+
4+
const mockPush = jest.fn();
5+
6+
jest.mock("next/navigation", () => ({
7+
useRouter: () => ({ push: mockPush }),
8+
}));
9+
10+
describe("NewPairPage", () => {
11+
let originalFetch: typeof globalThis.fetch;
12+
13+
beforeEach(() => {
14+
originalFetch = globalThis.fetch;
15+
mockPush.mockReset();
16+
});
17+
18+
afterEach(() => {
19+
globalThis.fetch = originalFetch;
20+
});
21+
22+
function submitPair(source: string, destination: string) {
23+
fireEvent.change(screen.getByLabelText("Source"), {
24+
target: { value: source },
25+
});
26+
fireEvent.change(screen.getByLabelText("Destination"), {
27+
target: { value: destination },
28+
});
29+
fireEvent.submit(screen.getByRole("button", { name: /Register pair/i }).closest("form")!);
30+
}
31+
32+
it("normalizes lowercase and surrounding whitespace before submit", async () => {
33+
const mockFetch = jest.fn().mockResolvedValueOnce({
34+
ok: true,
35+
text: async () => "{}",
36+
} as unknown as Response);
37+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
38+
39+
render(<NewPairPage />);
40+
submitPair(" usdc ", " eurc ");
41+
42+
await waitFor(() => {
43+
expect(mockPush).toHaveBeenCalledWith("/pairs");
44+
});
45+
const requestInit = mockFetch.mock.calls[0][1] as RequestInit;
46+
expect(requestInit.method).toBe("POST");
47+
expect(JSON.parse(requestInit.body as string)).toEqual({
48+
source: "USDC",
49+
destination: "EURC",
50+
});
51+
});
52+
53+
it.each(["USD-C", "USD C", "ABCDEFGHIJKLM"])(
54+
"rejects invalid source asset code %s with accessible field errors",
55+
async (code) => {
56+
const mockFetch = jest.fn();
57+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
58+
59+
render(<NewPairPage />);
60+
submitPair(code, "EURC");
61+
62+
const sourceInput = document.getElementById("source");
63+
await waitFor(() => {
64+
expect(screen.getByRole("alert")).toHaveTextContent(/ASCII letters or numbers/i);
65+
});
66+
expect(sourceInput).toHaveAttribute("aria-invalid", "true");
67+
expect(sourceInput).toHaveAttribute("aria-describedby", "source-err");
68+
expect(mockFetch).not.toHaveBeenCalled();
69+
}
70+
);
71+
72+
it("rejects pairs that are identical after trimming and uppercasing", async () => {
73+
const mockFetch = jest.fn();
74+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
75+
76+
render(<NewPairPage />);
77+
submitPair(" usdc ", "USDC");
78+
79+
const destinationInput = document.getElementById("destination");
80+
await waitFor(() => {
81+
expect(screen.getByRole("alert")).toHaveTextContent(/must differ/i);
82+
});
83+
expect(destinationInput).toHaveAttribute("aria-invalid", "true");
84+
expect(destinationInput).toHaveAttribute("aria-describedby", "destination-err");
85+
expect(mockFetch).not.toHaveBeenCalled();
86+
});
87+
88+
it("validates empty fields with inline errors instead of native browser validation", async () => {
89+
const mockFetch = jest.fn();
90+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
91+
92+
render(<NewPairPage />);
93+
fireEvent.submit(screen.getByRole("button", { name: /Register pair/i }).closest("form")!);
94+
95+
await waitFor(() => {
96+
expect(screen.getAllByRole("alert")).toHaveLength(2);
97+
});
98+
expect(document.getElementById("source")).toHaveAttribute("aria-invalid", "true");
99+
expect(document.getElementById("destination")).toHaveAttribute("aria-invalid", "true");
100+
expect(mockFetch).not.toHaveBeenCalled();
101+
});
102+
103+
it("clears an identical-pair error when source changes", async () => {
104+
const mockFetch = jest.fn();
105+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
106+
107+
render(<NewPairPage />);
108+
submitPair("USDC", "USDC");
109+
110+
await waitFor(() => {
111+
expect(screen.getByRole("alert")).toHaveTextContent(/must differ/i);
112+
});
113+
fireEvent.change(screen.getByLabelText("Source"), {
114+
target: { value: "XLM" },
115+
});
116+
117+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
118+
});
119+
120+
it("surfaces backend errors without losing the normalized request body", async () => {
121+
const mockFetch = jest.fn().mockResolvedValueOnce({
122+
ok: false,
123+
text: async () =>
124+
JSON.stringify({
125+
error: "invalid_request",
126+
message: "Pair already exists",
127+
}),
128+
} as unknown as Response);
129+
globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;
130+
131+
render(<NewPairPage />);
132+
submitPair("xlm", "usdc");
133+
134+
await waitFor(() => {
135+
expect(screen.getByRole("alert")).toHaveTextContent(/Pair already exists/i);
136+
});
137+
const requestInit = mockFetch.mock.calls[0][1] as RequestInit;
138+
expect(JSON.parse(requestInit.body as string)).toEqual({
139+
source: "XLM",
140+
destination: "USDC",
141+
});
142+
});
143+
});

src/app/pairs/new/page.tsx

Lines changed: 91 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,73 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { type FormEvent, useState } from "react";
44
import { useRouter } from "next/navigation";
5+
import { TextField } from "@/components/TextField";
56
import { apiPost } from "@/lib/apiClient";
67
import { assetsDiffer } from "@/lib/quote";
78

9+
const ASSET_CODE_RE = /^[A-Za-z0-9]{1,12}$/;
10+
const ASSET_CODE_ERROR = "Use 1-12 ASCII letters or numbers.";
11+
12+
type FormErrors = {
13+
source?: string;
14+
destination?: string;
15+
form?: string;
16+
};
17+
18+
/**
19+
* Trims and normalizes Stellar asset codes after validating the raw code
20+
* characters are ASCII alphanumeric only.
21+
*/
22+
function normalizeAssetCode(value: string): string | null {
23+
const trimmed = value.trim();
24+
return ASSET_CODE_RE.test(trimmed) ? trimmed.toUpperCase() : null;
25+
}
26+
827
export default function NewPairPage() {
928
const router = useRouter();
1029
const [source, setSource] = useState("");
1130
const [destination, setDestination] = useState("");
12-
const [error, setError] = useState<string | null>(null);
31+
const [errors, setErrors] = useState<FormErrors>({});
1332
const [loading, setLoading] = useState(false);
1433

15-
const onSubmit = async (e: React.FormEvent) => {
34+
const onSubmit = async (e: FormEvent) => {
1635
e.preventDefault();
17-
setError(null);
18-
if (!assetsDiffer(source, destination)) {
19-
setError("Source and destination must differ.");
36+
const normalizedSource = normalizeAssetCode(source);
37+
const normalizedDestination = normalizeAssetCode(destination);
38+
const nextErrors: FormErrors = {};
39+
40+
if (!normalizedSource) {
41+
nextErrors.source = ASSET_CODE_ERROR;
42+
}
43+
if (!normalizedDestination) {
44+
nextErrors.destination = ASSET_CODE_ERROR;
45+
}
46+
if (
47+
normalizedSource &&
48+
normalizedDestination &&
49+
normalizedSource === normalizedDestination
50+
) {
51+
nextErrors.destination = "Source and destination must differ.";
52+
}
53+
54+
if (Object.keys(nextErrors).length > 0 || !normalizedSource || !normalizedDestination) {
55+
setErrors(nextErrors);
2056
return;
2157
}
58+
59+
setErrors({});
60+
setSource(normalizedSource);
61+
setDestination(normalizedDestination);
2262
setLoading(true);
2363
try {
24-
await apiPost("/api/v1/pairs", { source, destination });
64+
await apiPost("/api/v1/pairs", {
65+
source: normalizedSource,
66+
destination: normalizedDestination,
67+
});
2568
router.push("/pairs");
2669
} catch (err) {
27-
setError((err as Error).message);
70+
setErrors({ form: (err as Error).message });
2871
} finally {
2972
setLoading(false);
3073
}
@@ -37,35 +80,53 @@ export default function NewPairPage() {
3780
className="mx-auto flex min-h-[60vh] max-w-xl flex-col gap-6 p-8 focus:outline-none"
3881
>
3982
<h1 className="text-3xl font-semibold tracking-tight">New pair</h1>
40-
<form onSubmit={onSubmit} className="flex flex-col gap-3">
41-
<label className="flex flex-col gap-1 text-sm">
42-
<span>Source</span>
43-
<input
44-
required
45-
maxLength={12}
46-
value={source}
47-
onChange={(e) => setSource(e.target.value)}
48-
className="rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
49-
/>
50-
</label>
51-
<label className="flex flex-col gap-1 text-sm">
52-
<span>Destination</span>
53-
<input
54-
required
55-
maxLength={12}
56-
value={destination}
57-
onChange={(e) => setDestination(e.target.value)}
58-
className="rounded-md border border-neutral-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
59-
/>
60-
</label>
83+
<form onSubmit={onSubmit} className="flex flex-col gap-3" noValidate>
84+
<TextField
85+
id="source"
86+
label="Source"
87+
required
88+
value={source}
89+
onChange={(e) => {
90+
setSource(e.target.value);
91+
setErrors((current) => ({
92+
...current,
93+
source: undefined,
94+
destination:
95+
current.destination === "Source and destination must differ."
96+
? undefined
97+
: current.destination,
98+
form: undefined,
99+
}));
100+
}}
101+
error={errors.source}
102+
/>
103+
<TextField
104+
id="destination"
105+
label="Destination"
106+
required
107+
value={destination}
108+
onChange={(e) => {
109+
setDestination(e.target.value);
110+
setErrors((current) => ({
111+
...current,
112+
destination: undefined,
113+
form: undefined,
114+
}));
115+
}}
116+
error={errors.destination}
117+
/>
61118
<button
62119
type="submit"
63120
disabled={loading}
64121
className="self-start rounded-full bg-black px-5 py-2 text-sm font-medium text-white disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
65122
>
66123
{loading ? "Saving…" : "Register pair"}
67124
</button>
68-
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
125+
{errors.form && (
126+
<p role="alert" className="text-sm text-rose-600">
127+
{errors.form}
128+
</p>
129+
)}
69130
</form>
70131
</main>
71132
);

src/app/webhooks/page.test.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,17 @@ describe("WebhooksPage", () => {
2121
it("renders webhooks in a single polite live region", async () => {
2222
global.fetch = jest.fn().mockResolvedValueOnce({
2323
ok: true,
24-
text: async () => JSON.stringify({
25-
items: [{ id: "wh1", url: "https://example.com/hook", events: ["pair.registered"], createdAt: Date.now() }],
26-
}),
24+
text: async () =>
25+
JSON.stringify({
26+
items: [
27+
{
28+
id: "wh1",
29+
url: "https://example.com/hook",
30+
events: ["pair.registered"],
31+
createdAt: Date.now(),
32+
},
33+
],
34+
}),
2735
} as unknown as Response);
2836

2937
render(<WebhooksPage />);

0 commit comments

Comments
 (0)