Skip to content
Open
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
32 changes: 27 additions & 5 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
## Summary

Fixes #<!-- issue -->
<!-- Briefly describe the change and why it is needed. -->

## Test plan
## Related Issue

<!-- Example: Closes #71 -->

## Type Of Change

- [ ] Bug fix
- [ ] Feature
- [ ] Documentation
- [ ] Refactor or maintenance

## Testing

<!-- Paste the commands you ran and the result. -->

- [ ] `npm test`
- [ ] `npm run lint`
- [ ] `npm test`
- [ ] `npm run build`

## Contributor Checklist

- [ ] Branch name follows `type/area-slug` where possible.
- [ ] Tests were added or updated for new UI or behaviour.
- [ ] Documentation was updated for visible or API-facing changes.
- [ ] Accessibility was considered for keyboard, screen-reader, color contrast, and responsive states.
- [ ] No unrelated CI workflow changes are included.

## GrantFox
## Notes For Reviewers

Stellar payout wallet: `GBVHELLD2JE235Y2NGTDT3MWI3T65ON6SY4N6FBHYVDAQ5FZC2CP5QXH`
<!-- Include screenshots, rendered template previews, or follow-up notes if useful. -->
90 changes: 67 additions & 23 deletions src/app/api-keys/Client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { IconButton } from "@/components/IconButton";
import { TextField } from "@/components/TextField";
Expand All @@ -20,7 +20,19 @@ export default function ApiKeysClient() {
const [label, setLabel] = useState("");
const [created, setCreated] = useState<string | null>(null);
const [recentPrefix, setRecentPrefix] = useState<string | null>(null);
const RECENT_MS = 5 * 60 * 1000; // 5 minutes
const [submitting, setSubmitting] = useState(false);

const recentItems = useMemo(() => {
if (!items) return [];
const now = Date.now();
return items.filter((item) => item.prefix === recentPrefix || now - item.createdAt < RECENT_MS);
}, [items, recentPrefix]);

const existingItems = useMemo(() => {
if (!items) return [];
return items.filter((item) => !recentItems.some((r) => r.prefix === item.prefix));
}, [items, recentItems]);
const [pendingRevoke, setPendingRevoke] = useState<string | null>(null);

const onCreate = async (event: React.FormEvent) => {
Expand Down Expand Up @@ -92,30 +104,62 @@ export default function ApiKeysClient() {
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
{loading && !items && <p>Loading…</p>}
{items && items.length === 0 && <p className="text-sm text-neutral-600">No API keys yet.</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{items.map((key) => (
<li key={key.prefix} className="flex items-center justify-between py-3">
<div>
<div className="flex items-center gap-2">
{recentItems.length > 0 && (
<section aria-labelledby="recent-keys-heading">
<h2 id="recent-keys-heading" className="mb-2 text-sm font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Recently created
</h2>
<ul className="divide-y divide-neutral-200 rounded border border-emerald-200 bg-emerald-50/50 dark:divide-neutral-800 dark:border-emerald-900 dark:bg-emerald-950/30">
{recentItems.map((key) => (
<li key={key.prefix} className="flex items-center justify-between px-3 py-3">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{key.label}</p>
{key.prefix === recentPrefix && <Badge variant="ok">New</Badge>}
</div>
<p className="font-mono text-xs text-neutral-500">{key.prefix}…</p>
<p className="text-xs text-neutral-500">
Created <TimeAgo ts={key.createdAt} />
</p>
</div>
<button
type="button"
onClick={() => setPendingRevoke(key.prefix)}
className="rounded border border-neutral-300 px-3 py-1 text-xs dark:border-neutral-700"
>
Revoke
</button>
</li>
))}
</ul>
</section>
)}
{existingItems.length > 0 && (
<section aria-labelledby="existing-keys-heading">
<h2 id="existing-keys-heading" className="mb-2 text-sm font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Existing keys
</h2>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{existingItems.map((key) => (
<li key={key.prefix} className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium">{key.label}</p>
{key.prefix === recentPrefix && <Badge variant="ok">New</Badge>}
<p className="font-mono text-xs text-neutral-500">{key.prefix}…</p>
<p className="text-xs text-neutral-500">
Created <TimeAgo ts={key.createdAt} />
</p>
</div>
<p className="font-mono text-xs text-neutral-500">{key.prefix}…</p>
<p className="text-xs text-neutral-500">
Created <TimeAgo ts={key.createdAt} />
</p>
</div>
<button
type="button"
onClick={() => setPendingRevoke(key.prefix)}
className="rounded border border-neutral-300 px-3 py-1 text-xs dark:border-neutral-700"
>
Revoke
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => setPendingRevoke(key.prefix)}
className="rounded border border-neutral-300 px-3 py-1 text-xs dark:border-neutral-700"
>
Revoke
</button>
</li>
))}
</ul>
</section>
)}
<ConfirmDialog
open={pendingRevoke !== null}
Expand Down
41 changes: 41 additions & 0 deletions src/app/api-keys/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,45 @@ describe("ApiKeysPage", () => {
});
expect(document.querySelectorAll("[aria-live=polite]")).toHaveLength(1);
});

it("separates recently created keys into a distinct section", async () => {
const now = Date.now();
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
text: async () =>
JSON.stringify({
items: [
{ prefix: "sk_old", label: "Old key", createdAt: now - 10 * 60 * 1000 },
{ prefix: "sk_new", label: "New key", createdAt: now - 60 * 1000 },
],
}),
} as unknown as Response);

render(<ApiKeysPage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /Recently created/i })).toBeInTheDocument();
});
expect(screen.getByRole("heading", { name: /Existing keys/i })).toBeInTheDocument();
expect(screen.getByText("New key")).toBeInTheDocument();
expect(screen.getByText("Old key")).toBeInTheDocument();
});

it("shows only existing section when no recent keys", async () => {
const now = Date.now();
global.fetch = jest.fn().mockResolvedValueOnce({
ok: true,
text: async () =>
JSON.stringify({
items: [
{ prefix: "sk_old", label: "Old key", createdAt: now - 10 * 60 * 1000 },
],
}),
} as unknown as Response);

render(<ApiKeysPage />);
await waitFor(() => {
expect(screen.getByText("Old key")).toBeInTheDocument();
});
expect(screen.queryByRole("heading", { name: /Recently created/i })).not.toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion src/app/pairs/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function PairsClient() {
const [query, setQuery] = useState("");
const [pendingDelete, setPendingDelete] = useState<Pair | null>(null);

const pairs = status === "ok" ? data.pairs : null;
const pairs = status === "ok" && data ? data.pairs : null;
const filtered = useMemo(() => {
if (!pairs) return null;
const needle = query.trim().toLowerCase();
Expand Down
2 changes: 1 addition & 1 deletion src/app/settings/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function RouterStatusRow() {
{status.error}
</p>
)}
{status.status === "ok" && (
{status.status === "ok" && status.data && (
<p className="text-sm">
Router is <strong>{status.data.paused ? "Paused" : "Live"}</strong>
</p>
Expand Down
4 changes: 2 additions & 2 deletions src/app/stats/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default function StatsClient() {
Loading…
</div>
)}
{status === "ok" && (
{status === "ok" && data && (
<section aria-labelledby="stats-metrics-heading">
<h2 id="stats-metrics-heading" className="sr-only">
Router metrics
Expand All @@ -40,7 +40,7 @@ export default function StatsClient() {
</dl>
</section>
)}
{status === "ok" && data.totalPairs === 0 && (
{status === "ok" && data && data.totalPairs === 0 && (
<EmptyState title="No pairs yet" description="Register a pair to see metrics." />
)}
</main>
Expand Down
21 changes: 19 additions & 2 deletions src/lib/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@ type State<T> =
| { status: "error"; error: string }
| { status: "ok"; data: T };

export type UseApiResult<T> = State<T> & { refetch: () => void };
// Flatten the discriminated union so consumers can destructure
// `status`, `data`, and `error` from the result without TypeScript
// losing track of which fields are present on the current variant.
// Callers still narrow on `status` before reading the data-specific
// fields, but the type now exposes all three so a single-line
// `const { status, data, error } = useApi(...)` type-checks.
export type UseApiResult<T> = {
status: State<T>["status"];
data: T | null;
error: string | null;
refetch: () => void;
};

export function useApi<T>(path: string | null): UseApiResult<T> {
const [state, setState] = useState<State<T>>({ status: "loading" });
Expand Down Expand Up @@ -37,5 +48,11 @@ export function useApi<T>(path: string | null): UseApiResult<T> {
};
}, [path, reloadKey]);

return { ...state, refetch };
if (state.status === "ok") {
return { status: "ok", data: state.data, error: null, refetch };
}
if (state.status === "error") {
return { status: "error", data: null, error: state.error, refetch };
}
return { status: "loading", data: null, error: null, refetch };
}
2 changes: 1 addition & 1 deletion tsconfig.tsbuildinfo

Large diffs are not rendered by default.

Loading