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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ verification checklist.
### Auth and API client behavior

* `src/lib/api.js` adds request header support with `x-request-id` and automatic session refresh on `401`
* `src/utils/fetchWithAuth.ts` (used by `useWallets` / `useWallet` / the Send flow) mirrors that
behaviour: on a `401` it calls `POST /api/auth/refresh` once and retries the original request with the
rotated token, only clearing the session and redirecting to `/login` if the refresh itself fails (#630)
* `src/lib/session.js` persists auth state and clears stale sessions gracefully
* `src/hooks/useSessionGuard.ts` is the documented client-side stale-session guard; `AuthGuard`
(wrapped around every real `/dashboard/*` route by `DashboardLayout`) delegates its redirect to it, so
a middleware-cookie pass with a missing in-memory session still bounces to `/login` (#624)
* `src/hooks/useWallets.ts` adds a wallet query hook that loads wallets from `/api/wallets`
* `src/app/api/auth/refresh/route.ts`, `/api/wallets/route.ts`, and `/api/wallets/[id]/route.ts` simulate auth-protected backend behavior for local testing
* `src/app/api/requests/today/route.ts` and `src/app/api/transactions/route.ts` (list via `GET`, the wallet
Expand Down Expand Up @@ -159,8 +165,10 @@ verification checklist.
on HTTPS.
* Any bearer-token block in the login response is persisted to
`sessionStorage` (never `localStorage`) via `src/lib/session.js` so
`src/lib/api.js` can attach `Authorization` headers and refresh on `401`
(#628).
`src/lib/api.js` **and** `src/utils/fetchWithAuth.ts` can attach
`Authorization` headers and refresh on `401` (#628, #630). Both read the
same `mux-auth-session` key, so `useWallets` sends the token `AuthContext`
actually stored (#629).
* `signOut()` calls `POST /api/auth/logout` to clear the HttpOnly cookie and
the stored bearer session.

Expand Down
84 changes: 84 additions & 0 deletions src/app/demo/dashboard/__tests__/route-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Route-tree drift guard for `/demo/dashboard/*` vs `/dashboard/*` (#631).
*
* The demo tree is a deliberate parallel of the production dashboard that
* renders mock data with no authenticated session (`DashboardLayout
* requireAuth={false}`, `useWallets({ demo: true })`, `/api/demo/*` routes).
* A *full* second copy of every route invites silent drift — a page fixed on
* one side and forgotten on the other.
*
* This test pins the split: the two trees must expose the same set of routes
* except for the explicitly-listed, reason-tagged exceptions below. Adding a
* route to only one tree fails here until it is either mirrored or added to an
* allowlist with a rationale.
*/
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

const APP_DIR = join(process.cwd(), "src", "app");
const PROD_DIR = join(APP_DIR, "dashboard");
const DEMO_DIR = join(APP_DIR, "demo", "dashboard");

const ROUTE_FILES = new Set(["page.tsx", "page.ts", "page.jsx", "page.js"]);

/** Collect every route segment (relative dir holding a `page.*`) under `dir`. */
function collectRoutes(dir: string, base = dir): string[] {
const routes: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
routes.push(...collectRoutes(join(dir, entry.name), base));
} else if (ROUTE_FILES.has(entry.name)) {
const rel = dir.slice(base.length).replace(/\\/g, "/");
routes.push(rel === "" ? "/" : rel);
}
}
return routes.sort();
}

/**
* Routes that intentionally exist on only one side. Every entry needs a
* reason; an un-listed asymmetry is treated as accidental drift.
*/
const PROD_ONLY: Record<string, string> = {
"/api-keys/[id]/usage":
"Per-key usage analytics reads real API-key activity; there is no mock fixture for it.",
"/settings/team":
"Team management is an authenticated-account feature with no demo equivalent.",
};

const DEMO_ONLY: Record<string, string> = {
"/users":
"Demo-only roster screen used for walkthroughs; no production counterpart is planned.",
};

describe("/demo/dashboard vs /dashboard route parity (#631)", () => {
const prodRoutes = collectRoutes(PROD_DIR);
const demoRoutes = collectRoutes(DEMO_DIR);

it("has no production route missing from the demo tree (beyond the allowlist)", () => {
const missingFromDemo = prodRoutes.filter(
(route) => !demoRoutes.includes(route) && !(route in PROD_ONLY),
);
expect(missingFromDemo).toEqual([]);
});

it("has no demo route missing from the production tree (beyond the allowlist)", () => {
const missingFromProd = demoRoutes.filter(
(route) => !prodRoutes.includes(route) && !(route in DEMO_ONLY),
);
expect(missingFromProd).toEqual([]);
});

it("keeps the prod-only / demo-only allowlists accurate", () => {
for (const route of Object.keys(PROD_ONLY)) {
expect(prodRoutes, `${route} listed in PROD_ONLY`).toContain(route);
expect(demoRoutes, `${route} listed in PROD_ONLY`).not.toContain(route);
}
for (const route of Object.keys(DEMO_ONLY)) {
expect(demoRoutes, `${route} listed in DEMO_ONLY`).toContain(route);
expect(prodRoutes, `${route} listed in DEMO_ONLY`).not.toContain(route);
}
});
});
44 changes: 10 additions & 34 deletions src/app/demo/dashboard/api-keys/page.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,10 @@
import { ApiKeysTable } from "@/components/dashboard/ApiKeysTable";
import { PageHeader } from "@/components/ui/PageHeader";

export default function ApiKeysPage() {
return (
<div className="space-y-8">
<PageHeader
title="Settings"
description="Manage your account settings, API keys, and developer preferences."
/>

<div className="grid gap-8">
<ApiKeysTable />

<div className="rounded-xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-950">
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50 mb-2">
Usage Policy
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed mb-4">
All API keys are subject to our developer usage policy. Please
ensure you keep your secret keys secure and never share them in
client-side code or public repositories.
</p>
<a
href="#"
className="text-sm font-medium text-zinc-900 dark:text-zinc-50 hover:underline inline-flex items-center gap-1"
>
Read documentation
</a>
</div>
</div>
</div>
);
}
/**
* `/demo/dashboard/api-keys` renders the exact production page (#631).
*
* The API-keys page has no auth-gated data path of its own — `ApiKeysTable`
* talks to `/api/api-keys`, which already does the backend-vs-mock split — so
* the demo route has nothing to diverge for. Re-exporting keeps the two trees
* from drifting (the demo copy had previously drifted to a stale "Settings"
* header).
*/
export { default } from "@/app/dashboard/api-keys/page";
16 changes: 7 additions & 9 deletions src/components/layouts/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"use client";

import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useSessionGuard } from "@/hooks/useSessionGuard";

// ---------------------------------------------------------------------------
// DashboardSkeleton — shown while auth state rehydrates on protected routes
Expand Down Expand Up @@ -87,17 +86,16 @@ interface AuthGuardProps {
* AuthGuard renders a loading skeleton while the auth state is rehydrating
* from sessionStorage, then redirects unauthenticated users to the login page.
* Authenticated users see the protected content immediately.
*
* The redirect itself is delegated to {@link useSessionGuard} (issue #624) so
* the documented client-side stale-session guard is actually on the production
* `/dashboard/*` path — `DashboardLayout` wraps every real dashboard route in
* this component — rather than being a second, unused implementation.
*/
export function AuthGuard({ children }: AuthGuardProps) {
const { isLoading, isAuthenticated } = useAuth();
const router = useRouter();

useEffect(() => {
if (!isLoading && !isAuthenticated) {
const callbackUrl = encodeURIComponent(window.location.pathname);
router.replace(`/login?callbackUrl=${callbackUrl}`);
}
}, [isLoading, isAuthenticated, router]);
useSessionGuard();

if (isLoading) {
return <DashboardSkeleton />;
Expand Down
6 changes: 6 additions & 0 deletions src/components/layouts/__tests__/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ describe("AuthGuard — auth loading skeleton on protected routes (#466)", () =>
});

it("shows skeleton and redirects when auth loaded but user is not authenticated", () => {
mockReplace.mockClear();
window.history.replaceState({}, "", "/dashboard/settings");
mockUseAuth.mockReturnValue({
isLoading: false,
isAuthenticated: false,
Expand All @@ -79,6 +81,10 @@ describe("AuthGuard — auth loading skeleton on protected routes (#466)", () =>

expect(screen.getByTestId("dashboard-auth-skeleton")).toBeDefined();
expect(screen.queryByTestId("protected-content")).toBeNull();
// Redirect is delegated to useSessionGuard (#624).
expect(mockReplace).toHaveBeenCalledWith(
"/login?callbackUrl=%2Fdashboard%2Fsettings",
);
});

it("DashboardSkeleton has correct aria attributes", () => {
Expand Down
29 changes: 25 additions & 4 deletions src/docs/API_Hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,31 @@

Client side: `signIn(user, ttlMs?, tokens?)` in `src/context/AuthContext.tsx`
persists any `tokens` block to tab-scoped `sessionStorage` via
`src/lib/session.js` (`createSession` → `saveSession`); `src/lib/api.js`
(`apiFetch`) then sends `Authorization: Bearer <accessToken>` and calls
`/api/auth/refresh` once on a `401`. `signOut()` clears it. No token is ever
written to `localStorage` or a `NEXT_PUBLIC_*` var (#628).
`src/lib/session.js` (`createSession` → `saveSession`, key `mux-auth-session`);
`src/lib/api.js` (`apiFetch`) then sends `Authorization: Bearer <accessToken>`
and calls `/api/auth/refresh` once on a `401`. `signOut()` clears it. No token
is ever written to `localStorage` or a `NEXT_PUBLIC_*` var (#628).

`src/utils/fetchWithAuth.ts` — the wrapper used by `useWallets`, `useWallet`
and the wallet Send flow — follows the same `401` contract as `apiFetch`
(#630): it reads the refresh token from the **same** `mux-auth-session` store
(`loadSession()`, not an ad-hoc `localStorage` key — #629), `POST`s to
`/api/auth/refresh`, persists any rotated `accessToken`, and retries the
original request once with the new bearer token. Only if the refresh call
fails — or the retried request is still a `401` — does it clear the session
(`sessionStorage` user record + bearer session + `mux_auth_session` cookie)
and `window.location.replace` to `/login?callbackUrl=…`.

### Stale-session guard (#624)

`src/hooks/useSessionGuard.ts` is the client-side complement to the middleware
route protection: once `useAuth()` has finished rehydrating, an unauthenticated
visitor is redirected to `/login?callbackUrl=<path>`. It is **not** a
standalone opt-in per page — `src/components/layouts/AuthGuard.tsx` calls it,
and `DashboardLayout` wraps every real `/dashboard/*` route in `AuthGuard`
(`requireAuth` defaults to `true`; the `/demo/dashboard/*` tree passes
`requireAuth={false}`). So the guard runs on the whole production dashboard
tree, and the `/demo` tree — which has no session — is the explicit opt-out.

## Spending limits

Expand Down
68 changes: 68 additions & 0 deletions src/hooks/__tests__/useSessionGuard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Tests for `useSessionGuard` (#624).
*
* The hook is the documented client-side complement to the middleware route
* protection: once auth state has finished rehydrating, an unauthenticated
* visitor to a protected page is bounced to `/login` with a `callbackUrl`.
* `AuthGuard` (and therefore every `/dashboard/*` route via `DashboardLayout`)
* delegates its redirect to this hook, so a regression here re-opens the
* "stale session renders the real dashboard" gap.
*/
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const replace = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace }),
}));

const mockUseAuth = vi.fn();
vi.mock("@/context/AuthContext", () => ({
useAuth: () => mockUseAuth(),
}));

import { useSessionGuard } from "../useSessionGuard";

beforeEach(() => {
replace.mockClear();
mockUseAuth.mockReset();
window.history.replaceState({}, "", "/dashboard/wallets");
});

describe("useSessionGuard", () => {
it("does not redirect while auth is still loading", () => {
mockUseAuth.mockReturnValue({ isLoading: true, isAuthenticated: false });

renderHook(() => useSessionGuard());

expect(replace).not.toHaveBeenCalled();
});

it("does not redirect an authenticated user", () => {
mockUseAuth.mockReturnValue({ isLoading: false, isAuthenticated: true });

renderHook(() => useSessionGuard());

expect(replace).not.toHaveBeenCalled();
});

it("redirects an unauthenticated user to /login with the current path as callbackUrl", () => {
mockUseAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });

renderHook(() => useSessionGuard());

expect(replace).toHaveBeenCalledWith(
"/login?callbackUrl=%2Fdashboard%2Fwallets",
);
});

it("honours a custom redirect target", () => {
mockUseAuth.mockReturnValue({ isLoading: false, isAuthenticated: false });

renderHook(() => useSessionGuard("/demo"));

expect(replace).toHaveBeenCalledWith(
"/demo?callbackUrl=%2Fdashboard%2Fwallets",
);
});
});
31 changes: 31 additions & 0 deletions src/hooks/useWallets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,37 @@ describe("useWallets", () => {
);
});

// #629: the bearer session AuthContext writes on sign-in (via
// `createSession` → `saveSession`, key `mux-auth-session` in sessionStorage)
// must be exactly what `useWallets` reads back — no second, drifting key.
it("sends the token AuthContext persisted on sign-in", async () => {
const { createSession, saveSession: persist } = await import(
"@/lib/session"
);
// Same call shape AuthContext.signIn makes with a login token block.
persist(
createSession({
accessToken: "authcontext-token",
refreshToken: "r",
expiresIn: 900,
}),
);

const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([mockWallet]),
});
vi.stubGlobal("fetch", fetchMock);

const { result } = renderHook(() => useWallets());
await waitFor(() => expect(result.current.loading).toBe(false));

const [, init] = fetchMock.mock.calls[0];
expect((init.headers as Record<string, string>).Authorization).toBe(
"Bearer authcontext-token",
);
});

it("omits the Authorization header when a token exists only under the legacy localStorage key", async () => {
window.localStorage.setItem(
"mux-auth-session",
Expand Down
Loading