From 7a970da4fc9801e4dd3df237eaa285b70fee5542 Mon Sep 17 00:00:00 2001 From: Jiri Manas <169147815+manana2520@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:50:53 +0100 Subject: [PATCH 1/2] Fix SSR auth for private repository pages (#18) Private repos returned "Repository Not Found" on direct navigation because SSR fetches had no Authorization header. JWT was stored only in localStorage, which is inaccessible during server-side rendering. - Store JWT in cookie alongside localStorage on login/logout - Add getServerToken() to read cookie via next/headers during SSR - Add getSSRAuthHeaders() helper in repository-api.ts - Inject auth headers in all SSR fetch functions (fetchRepoTree, fetchRepoBranches, fetchRepoDoc, checkGitHubRepo, fetchRepoStatus, fetchProcessingLogs, fetchRepositoryList, fetchGitBranches, fetchMindMap) On client side, getServerToken() returns null (window exists) so client-side auth continues to work via apiClient and localStorage as before. No backend changes needed - backend already handles Bearer tokens correctly. --- web/lib/auth-api.ts | 21 +++++++++++++++++++++ web/lib/repository-api.ts | 29 ++++++++++++++++++++--------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/web/lib/auth-api.ts b/web/lib/auth-api.ts index c6a2fdf5..9c46e20b 100644 --- a/web/lib/auth-api.ts +++ b/web/lib/auth-api.ts @@ -49,20 +49,41 @@ export interface ApiResponse { } const TOKEN_KEY = "auth_token"; +const TOKEN_COOKIE = "deepwiki_token"; +const TOKEN_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days export function getToken(): string | null { if (typeof window === "undefined") return null; return localStorage.getItem(TOKEN_KEY); } +/** + * Read JWT from cookie during SSR. Uses require() to avoid build errors + * when this module is imported in client components. + */ +export function getServerToken(): string | null { + if (typeof window !== "undefined") return null; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { cookies } = require("next/headers"); + const cookieStore = cookies(); + return cookieStore.get(TOKEN_COOKIE)?.value ?? null; + } catch { + return null; + } +} + export function setToken(token: string): void { if (typeof window === "undefined") return; localStorage.setItem(TOKEN_KEY, token); + // Also set as cookie for SSR access to private repo pages + document.cookie = `${TOKEN_COOKIE}=${token}; path=/; max-age=${TOKEN_COOKIE_MAX_AGE}; SameSite=Lax`; } export function removeToken(): void { if (typeof window === "undefined") return; localStorage.removeItem(TOKEN_KEY); + document.cookie = `${TOKEN_COOKIE}=; path=/; max-age=0`; } export async function login(request: LoginRequest): Promise { diff --git a/web/lib/repository-api.ts b/web/lib/repository-api.ts index ad015ef2..19a52fd4 100644 --- a/web/lib/repository-api.ts +++ b/web/lib/repository-api.ts @@ -13,6 +13,17 @@ import type { MindMapResponse } from "@/types/repository"; import { api, buildApiUrl } from "./api-client"; +import { getServerToken } from "./auth-api"; + +/** + * Returns Authorization header for SSR fetches if a JWT cookie is present. + * On the client side (window exists), getServerToken() returns null so + * this returns empty headers -- client auth goes through apiClient instead. + */ +function getSSRAuthHeaders(): HeadersInit { + const token = getServerToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} function encodePathSegments(path: string) { return path @@ -26,7 +37,7 @@ export async function fetchRepoBranches(owner: string, repo: string) { `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, ); - const response = await fetch(url, { cache: "no-store" }); + const response = await fetch(url, { cache: "no-store", headers: getSSRAuthHeaders() }); if (!response.ok) { throw new Error("Failed to fetch repository branches"); @@ -44,7 +55,7 @@ export async function fetchGitBranches(gitUrl: string): Promise Date: Mon, 2 Mar 2026 12:09:41 +0100 Subject: [PATCH 2/2] Fix async cookies() for Next.js 16 SSR auth (#19) getServerToken() was calling cookies() synchronously, but in Next.js 15+ cookies() returns a Promise. The function got a Promise object instead of the cookie store, so the token was never read during SSR. - Make getServerToken() async with await cookies() - Make getSSRAuthHeaders() async - Await getSSRAuthHeaders() in all fetch call sites --- web/lib/auth-api.ts | 5 +++-- web/lib/repository-api.ts | 23 ++++++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/web/lib/auth-api.ts b/web/lib/auth-api.ts index 9c46e20b..dcddaed7 100644 --- a/web/lib/auth-api.ts +++ b/web/lib/auth-api.ts @@ -60,13 +60,14 @@ export function getToken(): string | null { /** * Read JWT from cookie during SSR. Uses require() to avoid build errors * when this module is imported in client components. + * Must be async because cookies() returns a Promise in Next.js 15+. */ -export function getServerToken(): string | null { +export async function getServerToken(): Promise { if (typeof window !== "undefined") return null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports const { cookies } = require("next/headers"); - const cookieStore = cookies(); + const cookieStore = await cookies(); return cookieStore.get(TOKEN_COOKIE)?.value ?? null; } catch { return null; diff --git a/web/lib/repository-api.ts b/web/lib/repository-api.ts index 19a52fd4..9045f57e 100644 --- a/web/lib/repository-api.ts +++ b/web/lib/repository-api.ts @@ -19,9 +19,10 @@ import { getServerToken } from "./auth-api"; * Returns Authorization header for SSR fetches if a JWT cookie is present. * On the client side (window exists), getServerToken() returns null so * this returns empty headers -- client auth goes through apiClient instead. + * Async because cookies() is async in Next.js 15+. */ -function getSSRAuthHeaders(): HeadersInit { - const token = getServerToken(); +async function getSSRAuthHeaders(): Promise { + const token = await getServerToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } @@ -37,7 +38,7 @@ export async function fetchRepoBranches(owner: string, repo: string) { `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`, ); - const response = await fetch(url, { cache: "no-store", headers: getSSRAuthHeaders() }); + const response = await fetch(url, { cache: "no-store", headers: await getSSRAuthHeaders() }); if (!response.ok) { throw new Error("Failed to fetch repository branches"); @@ -55,7 +56,7 @@ export async function fetchGitBranches(gitUrl: string): Promise