From 02ef376ee559d9ffe3f7ada6f4bd662a3c8493c5 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Wed, 27 May 2026 14:37:23 +0200 Subject: [PATCH] fix(admin): forward session cookie on server-side API fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Next.js Server Component under apps/admin/src/app/(authenticated) was issuing the GoNext API fetch without forwarding the operator's gonext_session cookie. The browser-oriented credentials: 'include' default does nothing on the Next.js server runtime — there is no document.cookie jar to attach. The API auth middleware therefore saw every list/detail request as anonymous and returned 401, leaving every authenticated admin page stuck on its "Couldn't load X (HTTP 401)" empty state even when the user was signed in. Adds apps/admin/src/lib/server-api.ts exposing two helpers that pull the inbound request's cookies via next/headers and stamp them onto the outbound fetch: serverApiGet(path) — JSON GET, throws on non-2xx serverApiFetch(path, init?) — escape hatch returning raw Response Both default to cache: 'no-store' because every page they back is operator-facing dynamic data, and a cached page would otherwise leak one operator's view to the next. Refactors the 13 server components and one shared themes helper that were hand-rolling the cookie-forwarding pattern (or omitting it entirely, as in users/page.tsx) to use the new helpers. The existing graceful empty/error shapes are preserved so the UI continues to render the same friendly state on non-2xx — only the cookie-less fetch is replaced. The (public)/setup page is migrated for code-path consistency even though it doesn't need a session. Signed-off-by: Tayeb Mokni --- .../(authenticated)/appearance/menus/page.tsx | 22 +--- .../(authenticated)/appearance/themes/api.ts | 20 ++-- .../appearance/themes/page.tsx | 13 +-- .../(authenticated)/comments/[id]/page.tsx | 32 +----- .../src/app/(authenticated)/comments/page.tsx | 31 +---- .../(authenticated)/jobs/dlq/[id]/page.tsx | 26 +---- .../src/app/(authenticated)/jobs/dlq/page.tsx | 26 +---- .../app/(authenticated)/media/[id]/page.tsx | 25 +--- .../media/collections/[...slug]/page.tsx | 46 ++------ .../src/app/(authenticated)/media/page.tsx | 22 +--- .../src/app/(authenticated)/posts/page.tsx | 45 ++------ .../(authenticated)/redirects/[id]/page.tsx | 25 +--- .../app/(authenticated)/redirects/page.tsx | 23 +--- .../src/app/(authenticated)/users/page.tsx | 16 +-- .../(authenticated)/webhooks/[id]/page.tsx | 25 +--- .../src/app/(authenticated)/webhooks/page.tsx | 23 +--- apps/admin/src/app/(public)/setup/page.tsx | 28 ++--- apps/admin/src/lib/server-api.ts | 108 ++++++++++++++++++ 18 files changed, 188 insertions(+), 368 deletions(-) create mode 100644 apps/admin/src/lib/server-api.ts diff --git a/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx b/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx index f4831458..b8dd62bb 100644 --- a/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx +++ b/apps/admin/src/app/(authenticated)/appearance/menus/page.tsx @@ -7,33 +7,15 @@ * owns create / select / item-level CRUD with drag-to-reorder. */ import type { ReactElement } from 'react'; -import { cookies } from 'next/headers'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { MenusClient } from './MenusClient'; import type { MenuListResponse } from './types'; export const dynamic = 'force-dynamic'; async function fetchInitial(): Promise { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - try { - const res = await fetch(`${apiBaseUrl}/api/v1/admin/menus`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/admin/menus'); if (!res.ok) return null; return (await res.json()) as MenuListResponse; } catch { diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/api.ts b/apps/admin/src/app/(authenticated)/appearance/themes/api.ts index 1b2ac5a4..58b980ef 100644 --- a/apps/admin/src/app/(authenticated)/appearance/themes/api.ts +++ b/apps/admin/src/app/(authenticated)/appearance/themes/api.ts @@ -1,12 +1,14 @@ /** * Themes admin API client — small fetch wrappers over the * /api/v1/admin/themes surface. Server-side calls forward the - * inbound cookie header so the API auth middleware sees the - * session; client-side calls rely on `credentials: 'include'` to - * carry the cookie cross-origin (admin runs on :3001, api on :8080). + * inbound cookie header via `serverApiFetch` so the API auth + * middleware sees the session; client-side calls (install/activate) + * rely on `credentials: 'include'` to carry the cookie cross-origin + * (admin runs on :3001, api on :8080). */ import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import type { InstallResponse, ThemesListResponse } from './types'; const LIST_URL = '/api/v1/admin/themes'; @@ -16,17 +18,11 @@ const ACTIVATE_URL = '/api/v1/admin/themes/activate'; /** * Server-side list fetch. Returns `null` on any non-2xx so the * caller can render an empty-state without short-circuiting the - * whole page render. + * whole page render. Cookie forwarding is handled by `serverApiFetch`. */ -export async function fetchThemesList(cookieHeader: string): Promise { +export async function fetchThemesList(): Promise { try { - const res = await fetch(`${apiBaseUrl}${LIST_URL}`, { - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch(LIST_URL); if (!res.ok) { return null; } diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx b/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx index be058ae8..67f66d87 100644 --- a/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx +++ b/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx @@ -16,24 +16,13 @@ */ import type { ReactElement } from 'react'; -import { cookies } from 'next/headers'; import { fetchThemesList } from './api'; import { ThemesGalleryClient } from './ThemesGalleryClient'; export const dynamic = 'force-dynamic'; export default async function ThemesPage(): Promise { - let cookieHeader = ''; - try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const data = await fetchThemesList(cookieHeader); + const data = await fetchThemesList(); return ( ; } -async function authHeaders(): Promise { - let cookieHeader = ''; - try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - return { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }; -} - async function fetchComment(id: string): Promise { // We fetch via the list endpoint filtered by a single post — the // detail endpoint isn't strictly required for the first cut. @@ -65,12 +47,8 @@ async function fetchComment(id: string): Promise { // (status-filtered out), we widen and re-fetch. The cost is // bounded and the code stays simple until a dedicated GET-by-id // lands. - const headers = await authHeaders(); try { - const res = await fetch( - `${apiBaseUrl}/api/v1/admin/comments?limit=100`, - { method: 'GET', headers, cache: 'no-store' }, - ); + const res = await serverApiFetch('/api/v1/admin/comments?limit=100'); if (!res.ok) return null; const wire = (await res.json()) as WireListResponse; const found = wire.data.find((c) => c.id === id); @@ -81,11 +59,9 @@ async function fetchComment(id: string): Promise { } async function fetchThread(postId: string): Promise { - const headers = await authHeaders(); try { - const res = await fetch( - `${apiBaseUrl}/api/v1/admin/comments?post_id=${encodeURIComponent(postId)}&limit=100`, - { method: 'GET', headers, cache: 'no-store' }, + const res = await serverApiFetch( + `/api/v1/admin/comments?post_id=${encodeURIComponent(postId)}&limit=100`, ); if (!res.ok) return null; const wire = (await res.json()) as WireListResponse; diff --git a/apps/admin/src/app/(authenticated)/comments/page.tsx b/apps/admin/src/app/(authenticated)/comments/page.tsx index bc06f41a..54343aba 100644 --- a/apps/admin/src/app/(authenticated)/comments/page.tsx +++ b/apps/admin/src/app/(authenticated)/comments/page.tsx @@ -16,11 +16,10 @@ * style table inside the CommentListClient island. The skeleton + * error states use the brand's paper-2 + danger-soft tokens. */ -import { cookies } from 'next/headers'; import { Suspense, type ReactElement } from 'react'; import { Headline } from '@/components/ui/headline'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { CommentListClient } from './CommentListClient'; import { @@ -76,40 +75,22 @@ function FetchFailureState({ reason }: { reason: string }): ReactElement { } /** - * Server-side fetch helper. Forwards the session cookie so the API - * sees the operator. Returns `null` on any failure so the caller can - * render a friendly state without crashing the layout. + * Server-side fetch helper. Forwards the session cookie via + * `serverApiFetch` so the API sees the operator. Returns `null` on + * any failure so the caller can render a friendly state without + * crashing the layout. */ async function fetchInitialComments( params: { status?: string; postId?: string; userId?: string }, ): Promise<{ data: CommentListResponse | null; error: string | null }> { - let cookieHeader = ''; - try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const qs = new URLSearchParams(); if (params.status) qs.set('status', params.status); if (params.postId) qs.set('post_id', params.postId); if (params.userId) qs.set('user_id', params.userId); qs.set('limit', '30'); - const url = `${apiBaseUrl}/api/v1/admin/comments?${qs.toString()}`; try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch(`/api/v1/admin/comments?${qs.toString()}`); if (!res.ok) { return { data: null, error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(authenticated)/jobs/dlq/[id]/page.tsx b/apps/admin/src/app/(authenticated)/jobs/dlq/[id]/page.tsx index 317c055d..83476834 100644 --- a/apps/admin/src/app/(authenticated)/jobs/dlq/[id]/page.tsx +++ b/apps/admin/src/app/(authenticated)/jobs/dlq/[id]/page.tsx @@ -11,11 +11,10 @@ * italic accent so an operator immediately sees what kind of failure * they're inspecting. */ -import { cookies } from 'next/headers'; import Link from 'next/link'; import { ChevronLeft } from 'lucide-react'; import { Suspense, type ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Card } from '@/components/ui/card'; import { DLQDetailClient } from './DLQDetailClient'; import type { ArchivedTask } from '../types'; @@ -26,27 +25,10 @@ async function fetchTask( id: string, queue: string, ): Promise<{ data: ArchivedTask | null; error: string | null }> { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - - const url = `${apiBaseUrl}/api/v1/admin/jobs/dlq/${encodeURIComponent(id)}?queue=${encodeURIComponent(queue)}`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch( + `/api/v1/admin/jobs/dlq/${encodeURIComponent(id)}?queue=${encodeURIComponent(queue)}`, + ); if (!res.ok) { return { data: null, error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(authenticated)/jobs/dlq/page.tsx b/apps/admin/src/app/(authenticated)/jobs/dlq/page.tsx index 5df111e4..01e40196 100644 --- a/apps/admin/src/app/(authenticated)/jobs/dlq/page.tsx +++ b/apps/admin/src/app/(authenticated)/jobs/dlq/page.tsx @@ -13,11 +13,10 @@ * * Issue #262. */ -import { cookies } from 'next/headers'; import { type ReactElement, Suspense } from 'react'; import Link from 'next/link'; import { ChevronLeft } from 'lucide-react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Card } from '@/components/ui/card'; import { DLQListClient } from './DLQListClient'; @@ -34,27 +33,10 @@ export const dynamic = 'force-dynamic'; async function fetchInitialDLQ( queue: string, ): Promise<{ data: DLQListResponse | null; error: string | null }> { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - - const url = `${apiBaseUrl}/api/v1/admin/jobs/dlq?queue=${encodeURIComponent(queue)}&limit=30`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch( + `/api/v1/admin/jobs/dlq?queue=${encodeURIComponent(queue)}&limit=30`, + ); if (!res.ok) { return { data: null, error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(authenticated)/media/[id]/page.tsx b/apps/admin/src/app/(authenticated)/media/[id]/page.tsx index 3d342ab6..33f9a458 100644 --- a/apps/admin/src/app/(authenticated)/media/[id]/page.tsx +++ b/apps/admin/src/app/(authenticated)/media/[id]/page.tsx @@ -5,37 +5,18 @@ * off to the client-side editor for alt-text + caption editing, * deletion, and storage-URL display. */ -import { cookies } from 'next/headers'; import { notFound } from 'next/navigation'; import type { ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { MediaDetailClient } from './MediaDetailClient'; import type { MediaAsset } from '../types'; export const dynamic = 'force-dynamic'; async function fetchAsset(id: string): Promise { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - try { - const res = await fetch( - `${apiBaseUrl}/api/v1/admin/media/${encodeURIComponent(id)}`, - { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }, + const res = await serverApiFetch( + `/api/v1/admin/media/${encodeURIComponent(id)}`, ); if (res.status === 404) return null; if (!res.ok) return null; diff --git a/apps/admin/src/app/(authenticated)/media/collections/[...slug]/page.tsx b/apps/admin/src/app/(authenticated)/media/collections/[...slug]/page.tsx index e0dd92e1..143e7c38 100644 --- a/apps/admin/src/app/(authenticated)/media/collections/[...slug]/page.tsx +++ b/apps/admin/src/app/(authenticated)/media/collections/[...slug]/page.tsx @@ -15,10 +15,9 @@ * * Issue #69. */ -import { cookies } from 'next/headers'; import { notFound } from 'next/navigation'; import type { ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { CollectionMediaClient } from './CollectionMediaClient'; import type { CollectionListResponse, @@ -34,30 +33,9 @@ interface PageProps { params: Promise<{ slug: string[] }>; } -async function buildCookieHeader(): Promise { +async function fetchCollections(): Promise { try { - const store = await cookies(); - return store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - return ''; - } -} - -async function fetchCollections( - cookieHeader: string, -): Promise { - try { - const res = await fetch(`${apiBaseUrl}/api/v1/admin/media/collections`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/admin/media/collections'); if (!res.ok) return null; return (await res.json()) as CollectionListResponse; } catch { @@ -66,19 +44,12 @@ async function fetchCollections( } async function fetchMediaInFolder( - cookieHeader: string, collectionId: string, ): Promise { try { - const url = `${apiBaseUrl}/api/v1/admin/media?limit=30&collection=${encodeURIComponent(collectionId)}`; - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch( + `/api/v1/admin/media?limit=30&collection=${encodeURIComponent(collectionId)}`, + ); if (!res.ok) return null; return (await res.json()) as MediaListResponse; } catch { @@ -108,9 +79,8 @@ export default async function MediaCollectionPage( props: PageProps, ): Promise { const { slug } = await props.params; - const cookieHeader = await buildCookieHeader(); - const collections = await fetchCollections(cookieHeader); + const collections = await fetchCollections(); if (!collections) { notFound(); } @@ -119,7 +89,7 @@ export default async function MediaCollectionPage( if (!match) { notFound(); } - const media = await fetchMediaInFolder(cookieHeader, match.id); + const media = await fetchMediaInFolder(match.id); return ( { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - try { - const res = await fetch(`${apiBaseUrl}/api/v1/admin/media?limit=30`, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/admin/media?limit=30'); if (!res.ok) return null; return (await res.json()) as MediaListResponse; } catch { diff --git a/apps/admin/src/app/(authenticated)/posts/page.tsx b/apps/admin/src/app/(authenticated)/posts/page.tsx index e30d6678..fa9cb178 100644 --- a/apps/admin/src/app/(authenticated)/posts/page.tsx +++ b/apps/admin/src/app/(authenticated)/posts/page.tsx @@ -25,16 +25,16 @@ * ==== * Admin pages are session-protected. The session cookie lives on the * admin origin (`:3001` in dev, the public admin host in prod) and is - * forwarded explicitly via `next/headers` `cookies()` — without this - * the server-side fetch would issue an anonymous request and the API - * would 401 every list screen. The auth middleware in front of the - * admin guarantees `cookies()` is populated by the time we get here. + * forwarded by `serverApiFetch` (see `lib/server-api.ts`) — without + * that the server-side fetch would issue an anonymous request and the + * API would 401 every list screen. The auth middleware in front of + * the admin guarantees the cookie store is populated by the time we + * get here. */ -import { cookies } from 'next/headers'; import Link from 'next/link'; import { Suspense, type ReactElement } from 'react'; import { Download, Plus } from 'lucide-react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Button } from '@/components/ui/button'; import { PostListClient } from './PostListClient'; @@ -79,41 +79,16 @@ function FetchFailureState({ reason }: { reason: string }): ReactElement { } /** - * Server-side fetch helper. Wraps the api-client's URL resolution with - * an explicit cookie forward so the session travels with the request, - * and a typed return type. Returns `null` on any failure so the caller - * can render a friendly state. + * Server-side fetch helper. Cookie forwarding is handled by + * `serverApiFetch`. Returns `null` on any failure so the caller can + * render a friendly state. */ async function fetchInitialPosts(): Promise<{ data: PostListResponse | null; error: string | null; }> { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - // `cookies()` can throw during static generation / certain build - // paths. We swallow and continue with an anonymous request — the - // API will return 401, which we treat as "no posts" below. - cookieHeader = ''; - } - - const url = `${apiBaseUrl}/api/v1/posts?status=any&limit=20`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - // Server-to-server call — `credentials: 'include'` is a browser- - // only concept; we forward auth via the Cookie header instead. - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/posts?status=any&limit=20'); if (!res.ok) { return { diff --git a/apps/admin/src/app/(authenticated)/redirects/[id]/page.tsx b/apps/admin/src/app/(authenticated)/redirects/[id]/page.tsx index 66df83d4..36cc1d01 100644 --- a/apps/admin/src/app/(authenticated)/redirects/[id]/page.tsx +++ b/apps/admin/src/app/(authenticated)/redirects/[id]/page.tsx @@ -6,12 +6,11 @@ * timeline is a thin paper-2 well that reuses the rule's own * server-side counters — no extra fetch. */ -import { cookies } from 'next/headers'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import type { ReactElement } from 'react'; import { ArrowLeft, ActivitySquare } from 'lucide-react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Badge } from '@/components/ui/badge'; import { RedirectForm } from '../RedirectForm'; @@ -20,25 +19,9 @@ import type { Redirect } from '../types'; export const dynamic = 'force-dynamic'; async function fetchRedirect(id: string): Promise { - let cookieHeader = ''; - try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const url = `${apiBaseUrl}/api/v1/admin/redirects/${encodeURIComponent(id)}`; - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch( + `/api/v1/admin/redirects/${encodeURIComponent(id)}`, + ); if (!res.ok) { return null; } diff --git a/apps/admin/src/app/(authenticated)/redirects/page.tsx b/apps/admin/src/app/(authenticated)/redirects/page.tsx index 57321efa..f1aed8e5 100644 --- a/apps/admin/src/app/(authenticated)/redirects/page.tsx +++ b/apps/admin/src/app/(authenticated)/redirects/page.tsx @@ -7,10 +7,9 @@ * delegates to which now wears the brand-token * card / tab / mono-path styling. */ -import { cookies } from 'next/headers'; import { Suspense, type ReactElement } from 'react'; import Link from 'next/link'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Button } from '@/components/ui/button'; import { RedirectsListClient } from './RedirectsListClient'; @@ -19,26 +18,8 @@ import type { RedirectListResponse } from './types'; export const dynamic = 'force-dynamic'; async function fetchInitial(): Promise<{ data: RedirectListResponse | null; error: string | null }> { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const url = `${apiBaseUrl}/api/v1/admin/redirects?limit=30`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/admin/redirects?limit=30'); if (!res.ok) { return { data: null, error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(authenticated)/users/page.tsx b/apps/admin/src/app/(authenticated)/users/page.tsx index 1728eb05..50ab0d8c 100644 --- a/apps/admin/src/app/(authenticated)/users/page.tsx +++ b/apps/admin/src/app/(authenticated)/users/page.tsx @@ -19,7 +19,7 @@ * placeholder route). */ import type { ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { UsersList } from './UsersList'; import type { AdminUser, UsersListResponse } from './types'; @@ -49,18 +49,14 @@ interface FetchResult { /** * Server-side fetch — runs on the Next.js server, not in the browser, so we * can't reuse the browser-oriented `apiRequest` helper (which sends cookies - * via `credentials: 'include'`). For server rendering the session forwarding - * lands with the auth wiring in a follow-up issue; for the scaffold we just - * fire an unauthenticated request and tolerate failure. + * via `credentials: 'include'`). `serverApiFetch` forwards the inbound + * session cookie via `next/headers`, so the API auth middleware sees the + * operator instead of returning 401 to an anonymous request. Failures + * still degrade to the empty state rather than crashing the page. */ async function fetchUsers(): Promise { - const url = `${apiBaseUrl.replace(/\/$/, '')}/api/v1/users?limit=20`; try { - const res = await fetch(url, { - headers: { Accept: 'application/json' }, - // Don't cache between requests; the list mutates on invite/suspend. - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/users?limit=20'); if (!res.ok) { return { users: [], error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(authenticated)/webhooks/[id]/page.tsx b/apps/admin/src/app/(authenticated)/webhooks/[id]/page.tsx index 0a603e40..7bed3ff1 100644 --- a/apps/admin/src/app/(authenticated)/webhooks/[id]/page.tsx +++ b/apps/admin/src/app/(authenticated)/webhooks/[id]/page.tsx @@ -11,11 +11,10 @@ * the italic accent so the operator immediately sees which endpoint * they're editing. */ -import { cookies } from 'next/headers'; import Link from 'next/link'; import { ChevronLeft } from 'lucide-react'; import { Suspense, type ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Card } from '@/components/ui/card'; import { WebhookDetailClient } from './WebhookDetailClient'; import type { DeliveryListResponse, Subscription } from '../types'; @@ -28,26 +27,12 @@ async function fetchSubscription( data: { subscription: Subscription; deliveries: DeliveryListResponse } | null; error: string | null; }> { - let cookieHeader = ''; - try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const headers: HeadersInit = { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }; - const subUrl = `${apiBaseUrl}/api/v1/admin/webhooks/${encodeURIComponent(id)}`; - const delUrl = `${apiBaseUrl}/api/v1/admin/webhooks/${encodeURIComponent(id)}/deliveries?limit=30`; + const subPath = `/api/v1/admin/webhooks/${encodeURIComponent(id)}`; + const delPath = `/api/v1/admin/webhooks/${encodeURIComponent(id)}/deliveries?limit=30`; try { const [subRes, delRes] = await Promise.all([ - fetch(subUrl, { headers, cache: 'no-store' }), - fetch(delUrl, { headers, cache: 'no-store' }), + serverApiFetch(subPath), + serverApiFetch(delPath), ]); if (!subRes.ok) return { data: null, error: `HTTP ${subRes.status}` }; const subscription = (await subRes.json()) as Subscription; diff --git a/apps/admin/src/app/(authenticated)/webhooks/page.tsx b/apps/admin/src/app/(authenticated)/webhooks/page.tsx index 3762a9db..c4681cbb 100644 --- a/apps/admin/src/app/(authenticated)/webhooks/page.tsx +++ b/apps/admin/src/app/(authenticated)/webhooks/page.tsx @@ -10,11 +10,10 @@ * instrument-panel feel as the DLQ surface — Headline ("Webhook * *subscriptions*."), eyebrow, Geist body, primary CTA to create. */ -import { cookies } from 'next/headers'; import Link from 'next/link'; import { Plus } from 'lucide-react'; import { Suspense, type ReactElement } from 'react'; -import { apiBaseUrl } from '@/lib/api-client'; +import { serverApiFetch } from '@/lib/server-api'; import { Headline } from '@/components/ui/headline'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -27,26 +26,8 @@ async function fetchInitialSubscriptions(): Promise<{ data: SubscriptionListResponse | null; error: string | null; }> { - let cookieHeader = ''; try { - const store = await cookies(); - cookieHeader = store - .getAll() - .map((c) => `${c.name}=${c.value}`) - .join('; '); - } catch { - cookieHeader = ''; - } - const url = `${apiBaseUrl}/api/v1/admin/webhooks?limit=30`; - try { - const res = await fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(cookieHeader ? { Cookie: cookieHeader } : {}), - }, - cache: 'no-store', - }); + const res = await serverApiFetch('/api/v1/admin/webhooks?limit=30'); if (!res.ok) { return { data: null, error: `HTTP ${res.status}` }; } diff --git a/apps/admin/src/app/(public)/setup/page.tsx b/apps/admin/src/app/(public)/setup/page.tsx index 1b194845..a212d1e6 100644 --- a/apps/admin/src/app/(public)/setup/page.tsx +++ b/apps/admin/src/app/(public)/setup/page.tsx @@ -20,6 +20,7 @@ */ import type { ReactElement } from 'react'; import { redirect } from 'next/navigation'; +import { serverApiGet } from '@/lib/server-api'; import SetupWizard from './SetupWizard'; import type { SetupStatus } from './types'; @@ -29,31 +30,20 @@ import type { SetupStatus } from './types'; // should have closed. export const dynamic = 'force-dynamic'; -// Resolve the API base for server-side fetch. NEXT_PUBLIC_API_URL is -// the canonical client-side var; for SSR we fall back to the API -// service's intra-cluster name. In `make up` both line up at -// `http://localhost:8080`. -function apiBaseURL(): string { - return ( - process.env.GONEXT_API_URL ?? - process.env.NEXT_PUBLIC_API_URL ?? - 'http://localhost:8080' - ); -} - /** * Server-side fetch of the install status. Returns either the parsed * payload or null when the API is unreachable / responds non-200. + * + * Uses `serverApiGet` for a consistent code path with the rest of the + * admin's server-side fetches — no cookie is expected on `/setup` + * (the user isn't signed in yet) but the helper happily forwards an + * empty cookie store and the API ignores it. */ async function fetchStatus(): Promise { try { - const res = await fetch(`${apiBaseURL()}/api/v1/setup/status`, { - method: 'GET', - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - if (!res.ok) return null; - const json = (await res.json()) as Partial; + const json = await serverApiGet>( + '/api/v1/setup/status', + ); if (typeof json.installation_completed !== 'boolean') return null; if (typeof json.user_count !== 'number') return null; return { diff --git a/apps/admin/src/lib/server-api.ts b/apps/admin/src/lib/server-api.ts new file mode 100644 index 00000000..84ec48a7 --- /dev/null +++ b/apps/admin/src/lib/server-api.ts @@ -0,0 +1,108 @@ +/** + * @gonext/admin — server-side API helpers. + * + * Every Next.js Server Component that talks to the GoNext API needs to + * forward the operator's `gonext_session` cookie so the API auth + * middleware sees the session. Without that header the fetch runs + * anonymously and every admin list / detail page renders the + * "Couldn't load X (HTTP 401)" empty state — even when the operator is + * signed in. + * + * `credentials: 'include'` (the browser-side api-client.ts default) is + * a no-op on the Next.js server runtime: there is no document.cookie + * jar to attach. We instead pull the inbound request's cookies via + * `next/headers` and stamp them onto the outbound request explicitly. + * + * Two surfaces: + * + * - `serverApiGet(path)` — JSON GET. Throws when status is outside + * 2xx so the caller can render an error state with the HTTP code. + * - `serverApiFetch(path, init?)` — escape hatch. Returns the raw + * `Response`; the caller decides whether non-2xx is fatal. Used by + * callers that already have bespoke error / fallback shapes + * (graceful empty states, 404 → notFound(), etc.). + * + * Both helpers set `cache: 'no-store'` because every screen they back + * is operator-facing dynamic data — there is no static surface that + * benefits from Next's fetch cache, and a cached page would leak one + * operator's view to the next. + */ +import { cookies } from 'next/headers'; +import { apiBaseUrl } from './api-client'; + +/** + * Build the request headers for a server-side API call. + * + * The cookie header is forwarded from `next/headers` so the inbound + * session travels with the outbound fetch. `cookies()` can throw + * during certain build paths (e.g. static prerender of a page that + * later flips to `force-dynamic`), in which case we drop the cookie + * header and let the API return whatever it would for an anonymous + * request — the caller renders its empty/error state from there. + */ +async function buildHeaders( + extra?: Record, +): Promise { + let cookie = ''; + try { + const cookieStore = await cookies(); + cookie = cookieStore.toString(); + } catch { + cookie = ''; + } + return { + Accept: 'application/json', + ...(cookie ? { cookie } : {}), + ...(extra ?? {}), + }; +} + +function joinUrl(path: string): string { + return `${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`; +} + +/** + * JSON GET against the GoNext API. Throws on non-2xx so the caller's + * `try { ... } catch (err) { ... }` produces the same shape it would + * have when the previous hand-rolled `fetch` returned `!res.ok`. + */ +export async function serverApiGet(path: string): Promise { + const res = await fetch(joinUrl(path), { + headers: await buildHeaders(), + cache: 'no-store', + }); + if (!res.ok) { + throw new Error(`API ${res.status}: ${res.statusText}`); + } + return res.json() as Promise; +} + +/** + * Lower-level server-side fetch. Returns the raw `Response` so the + * caller can decide how to handle non-2xx (a graceful empty state, a + * 404 → `notFound()`, a permission-denied notice, etc.). The cookie + * header is forwarded the same way as `serverApiGet`. + * + * `body`, when provided, is JSON-encoded and a `Content-Type: + * application/json` header is added. Callers that need multipart + * uploads should keep using `fetch` directly — those flows run from + * client components anyway. + */ +export async function serverApiFetch( + path: string, + init?: { + method?: string; + body?: unknown; + headers?: Record; + }, +): Promise { + return fetch(joinUrl(path), { + method: init?.method ?? 'GET', + headers: await buildHeaders({ + ...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }), + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, + cache: 'no-store', + }); +}