zudoku 0.78.0 → 0.78.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/cli/cli.js +602 -160
  2. package/dist/cli/worker.js +6 -4
  3. package/dist/declarations/app/adapter.d.ts +12 -0
  4. package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
  5. package/dist/declarations/app/adapters/lambda.d.ts +13 -0
  6. package/dist/declarations/app/adapters/node.d.ts +12 -0
  7. package/dist/declarations/app/adapters/vercel.d.ts +14 -0
  8. package/dist/declarations/app/entry.client.d.ts +2 -0
  9. package/dist/declarations/app/entry.server.d.ts +13 -0
  10. package/dist/declarations/app/protectChunks.d.ts +17 -0
  11. package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
  12. package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
  13. package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
  14. package/dist/declarations/config/validators/icon-types.d.ts +1 -1
  15. package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
  16. package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
  17. package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
  18. package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
  19. package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
  20. package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
  21. package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
  22. package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
  23. package/dist/declarations/lib/authentication/state.d.ts +7 -0
  24. package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
  25. package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
  26. package/dist/declarations/lib/components/Heading.d.ts +1 -1
  27. package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
  28. package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
  29. package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
  30. package/dist/declarations/lib/manifest.d.ts +25 -0
  31. package/dist/declarations/lib/util/url.d.ts +2 -0
  32. package/dist/flat-config.d.ts +1 -1
  33. package/docs/configuration/protected-routes.md +21 -4
  34. package/docs/guides/server-side-content-protection.md +207 -0
  35. package/package.json +26 -4
  36. package/src/app/adapter.ts +16 -0
  37. package/src/app/adapters/cloudflare.ts +18 -0
  38. package/src/app/adapters/lambda.ts +36 -0
  39. package/src/app/adapters/node.ts +32 -0
  40. package/src/app/adapters/vercel.ts +39 -0
  41. package/src/app/demo.tsx +2 -2
  42. package/src/app/entry.client.tsx +19 -7
  43. package/src/app/entry.server.tsx +133 -9
  44. package/src/app/main.tsx +21 -3
  45. package/src/app/protectChunks.ts +64 -0
  46. package/src/app/standalone.tsx +2 -2
  47. package/src/app/wrapProtectedRoutes.ts +82 -0
  48. package/src/config/validators/icon-types.ts +17 -0
  49. package/src/lib/authentication/authentication.ts +15 -0
  50. package/src/lib/authentication/cookie-sync.ts +90 -0
  51. package/src/lib/authentication/cookies.ts +54 -0
  52. package/src/lib/authentication/hook.ts +13 -0
  53. package/src/lib/authentication/providers/azureb2c.tsx +70 -2
  54. package/src/lib/authentication/providers/clerk.tsx +49 -0
  55. package/src/lib/authentication/providers/openid.tsx +46 -0
  56. package/src/lib/authentication/providers/supabase.tsx +30 -2
  57. package/src/lib/authentication/session-handler.ts +164 -0
  58. package/src/lib/authentication/state.ts +36 -5
  59. package/src/lib/authentication/verify-cache.ts +32 -0
  60. package/src/lib/components/Bootstrap.tsx +20 -14
  61. package/src/lib/components/Header.tsx +56 -57
  62. package/src/lib/components/MobileTopNavigation.tsx +66 -67
  63. package/src/lib/components/Zudoku.tsx +14 -1
  64. package/src/lib/components/context/RenderContext.ts +8 -0
  65. package/src/lib/components/context/ZudokuContext.ts +2 -1
  66. package/src/lib/core/RouteGuard.tsx +50 -29
  67. package/src/lib/core/ZudokuContext.ts +39 -6
  68. package/src/lib/errors/RouterError.tsx +43 -1
  69. package/src/lib/manifest.ts +62 -0
  70. package/src/lib/oas/parser/dereference/index.ts +2 -1
  71. package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
  72. package/src/lib/oas/parser/index.ts +1 -1
  73. package/src/lib/plugins/openapi/client/createServer.ts +13 -4
  74. package/src/lib/plugins/search-pagefind/index.tsx +1 -4
  75. package/src/lib/util/os.ts +1 -0
  76. package/src/lib/util/url.ts +13 -0
  77. package/src/vite/build.ts +84 -24
  78. package/src/vite/config.ts +51 -5
  79. package/src/vite/dev-server.ts +61 -8
  80. package/src/vite/manifest.ts +15 -0
  81. package/src/vite/plugin-api.ts +3 -1
  82. package/src/vite/plugin-markdown-export.ts +3 -9
  83. package/src/vite/prerender/worker.ts +2 -4
  84. package/src/vite/protected/annotator.ts +136 -0
  85. package/src/vite/protected/build.ts +151 -0
  86. package/src/vite/protected/registry.ts +82 -0
  87. package/src/vite/ssr-templates/cloudflare.ts +5 -18
  88. package/src/vite/ssr-templates/lambda.ts +4 -0
  89. package/src/vite/ssr-templates/node.ts +7 -22
  90. package/src/vite/ssr-templates/vercel.ts +6 -20
  91. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,164 @@
1
+ import { Hono } from "hono";
2
+ import { deleteCookie, setCookie } from "hono/cookie";
3
+ import type { CookieOptions } from "hono/utils/cookie";
4
+ import type { VerifyAccessTokenResult } from "./authentication.js";
5
+ import {
6
+ ACCESS_TOKEN_COOKIE,
7
+ AUTH_PROFILE_COOKIE,
8
+ REFRESH_TOKEN_COOKIE,
9
+ } from "./cookies.js";
10
+
11
+ export type VerifyAccessToken = (
12
+ token: string,
13
+ ) => Promise<VerifyAccessTokenResult>;
14
+
15
+ const baseCookieOptions: Omit<CookieOptions, "maxAge"> = {
16
+ httpOnly: true,
17
+ path: "/",
18
+ sameSite: "Lax",
19
+ secure: import.meta.env.PROD,
20
+ };
21
+
22
+ const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
23
+ const DEFAULT_SESSION_MAX_AGE = 60 * 60; // 1 hour fallback when verifier omits expiresAt
24
+
25
+ const cookieMaxAge = (
26
+ expiresAt: number | undefined,
27
+ fallback: number,
28
+ ): number => {
29
+ if (typeof expiresAt !== "number") return fallback;
30
+ const remaining = Math.floor(expiresAt - Date.now() / 1000);
31
+ return remaining > 0 ? Math.min(remaining, fallback) : fallback;
32
+ };
33
+
34
+ const MAX_COOKIE_SIZE = 3900; // Leave margin under 4096 browser limit
35
+ const MAX_BODY_SIZE = 64 * 1024;
36
+
37
+ const sameOriginCheck = (c: {
38
+ req: { header: (name: string) => string | undefined };
39
+ }): boolean => {
40
+ // Sec-Fetch-Site is set by the browser and cannot be forged from JS, so
41
+ // prefer it. The Origin/Host comparison breaks behind any proxy/CDN that
42
+ // rewrites the Host header (CloudFront, etc.).
43
+ const fetchSite = c.req.header("Sec-Fetch-Site");
44
+ if (fetchSite) {
45
+ return fetchSite === "same-origin";
46
+ }
47
+
48
+ const origin = c.req.header("Origin");
49
+ const host = c.req.header("Host");
50
+ if (origin && host) {
51
+ try {
52
+ return new URL(origin).host === host;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ return false;
59
+ };
60
+
61
+ /**
62
+ * Build the Hono sub-app that manages SSR auth cookies.
63
+ *
64
+ * Profile is derived solely from `verify(token)`; a client-submitted profile
65
+ * is ignored. Callers must send only `{ accessToken, refreshToken? }`.
66
+ * `verify` is omitted when no provider supports SSR auth (→ 501).
67
+ */
68
+ export const createSessionHandler = (verify: VerifyAccessToken | undefined) =>
69
+ new Hono()
70
+ .post("/", async (c) => {
71
+ if (!sameOriginCheck(c)) {
72
+ return c.json({ error: "CSRF check failed" }, 403);
73
+ }
74
+
75
+ if (!verify) {
76
+ return c.json(
77
+ { error: "SSR authentication is not supported for this provider" },
78
+ 501,
79
+ );
80
+ }
81
+
82
+ const contentLength = Number(c.req.header("Content-Length") ?? 0);
83
+ if (contentLength > MAX_BODY_SIZE) {
84
+ return c.json({ error: "Request body too large" }, 413);
85
+ }
86
+ const raw = await c.req.text().catch(() => "");
87
+ if (raw.length > MAX_BODY_SIZE) {
88
+ return c.json({ error: "Request body too large" }, 413);
89
+ }
90
+ let body: { accessToken?: unknown; refreshToken?: unknown } | undefined;
91
+ try {
92
+ body = raw ? JSON.parse(raw) : undefined;
93
+ } catch {
94
+ body = undefined;
95
+ }
96
+
97
+ const accessToken =
98
+ typeof body?.accessToken === "string" ? body.accessToken : undefined;
99
+ if (!accessToken) {
100
+ return c.json({ error: "Missing access token" }, 400);
101
+ }
102
+ if (accessToken.length > MAX_COOKIE_SIZE) {
103
+ return c.json({ error: "Access token exceeds cookie size limit" }, 400);
104
+ }
105
+
106
+ const refreshToken =
107
+ typeof body?.refreshToken === "string" ? body.refreshToken : undefined;
108
+ if (refreshToken && refreshToken.length > MAX_COOKIE_SIZE) {
109
+ return c.json(
110
+ { error: "Refresh token exceeds cookie size limit" },
111
+ 400,
112
+ );
113
+ }
114
+
115
+ let verified: VerifyAccessTokenResult;
116
+ try {
117
+ verified = await verify(accessToken);
118
+ } catch (e) {
119
+ // biome-ignore lint/suspicious/noConsole: Surface verifier failures
120
+ console.error("SSR auth verifier error:", e);
121
+ return c.json({ error: "Verifier error" }, 502);
122
+ }
123
+ if (!verified) {
124
+ return c.json({ error: "Invalid access token" }, 401);
125
+ }
126
+
127
+ const sessionOptions: CookieOptions = {
128
+ ...baseCookieOptions,
129
+ maxAge: cookieMaxAge(verified.expiresAt, DEFAULT_SESSION_MAX_AGE),
130
+ };
131
+ const refreshOptions: CookieOptions = {
132
+ ...baseCookieOptions,
133
+ maxAge: cookieMaxAge(verified.refreshExpiresAt, REFRESH_TOKEN_MAX_AGE),
134
+ };
135
+
136
+ let profileJson: string;
137
+ try {
138
+ profileJson = JSON.stringify(verified.profile);
139
+ } catch {
140
+ return c.json({ error: "Profile is not serializable" }, 500);
141
+ }
142
+ if (profileJson.length > MAX_COOKIE_SIZE) {
143
+ return c.json({ error: "Profile exceeds cookie size limit" }, 413);
144
+ }
145
+
146
+ setCookie(c, AUTH_PROFILE_COOKIE, profileJson, sessionOptions);
147
+ setCookie(c, ACCESS_TOKEN_COOKIE, accessToken, sessionOptions);
148
+ if (refreshToken) {
149
+ setCookie(c, REFRESH_TOKEN_COOKIE, refreshToken, refreshOptions);
150
+ }
151
+
152
+ return c.json({ ok: true });
153
+ })
154
+ .delete("/", (c) => {
155
+ if (!sameOriginCheck(c)) {
156
+ return c.json({ error: "CSRF check failed" }, 403);
157
+ }
158
+
159
+ deleteCookie(c, ACCESS_TOKEN_COOKIE, baseCookieOptions);
160
+ deleteCookie(c, REFRESH_TOKEN_COOKIE, baseCookieOptions);
161
+ deleteCookie(c, AUTH_PROFILE_COOKIE, baseCookieOptions);
162
+
163
+ return c.json({ ok: true });
164
+ });
@@ -1,5 +1,9 @@
1
1
  import { create } from "zustand";
2
- import { createJSONStorage, persist } from "zustand/middleware";
2
+ import {
3
+ createJSONStorage,
4
+ persist,
5
+ type StateStorage,
6
+ } from "zustand/middleware";
3
7
  import { syncZustandState } from "../util/syncZustandState.js";
4
8
 
5
9
  /**
@@ -34,12 +38,29 @@ export interface AuthState {
34
38
  }) => void;
35
39
  }
36
40
 
41
+ const noopStorage: StateStorage = {
42
+ getItem: () => null,
43
+ setItem: () => {},
44
+ removeItem: () => {},
45
+ };
46
+
47
+ // Seed from the SSR-injected signal. Object present = server checked;
48
+ // `profile: null` = authoritative anon. Absent = fall back to pending.
49
+ const ssrAuthInitial =
50
+ typeof window !== "undefined" ? window.ZUDOKU_SSR_AUTH : undefined;
51
+
52
+ // SSR builds use cookies as the source of truth; SSG uses localStorage.
53
+ // `import.meta.env` is missing when this module is loaded outside a Vite
54
+ // build (e.g. esbuild bundling a vite.*.config.ts), so guard the access.
55
+ const ssrMode =
56
+ typeof import.meta.env !== "undefined" && import.meta.env.ZUDOKU_HAS_SERVER;
57
+
37
58
  export const authState = create<AuthState>()(
38
59
  persist(
39
60
  (set) => ({
40
- isAuthenticated: false,
41
- isPending: true,
42
- profile: null,
61
+ isAuthenticated: !!ssrAuthInitial?.profile,
62
+ isPending: ssrAuthInitial === undefined,
63
+ profile: ssrAuthInitial?.profile ?? null,
43
64
  providerData: null,
44
65
  setAuthenticationPending: () =>
45
66
  set(() => ({
@@ -72,7 +93,9 @@ export const authState = create<AuthState>()(
72
93
  };
73
94
  },
74
95
  name: "auth-state",
75
- storage: createJSONStorage(() => localStorage),
96
+ storage: createJSONStorage(() =>
97
+ typeof window === "undefined" || ssrMode ? noopStorage : localStorage,
98
+ ),
76
99
  },
77
100
  ),
78
101
  );
@@ -102,3 +125,11 @@ export interface UserProfile {
102
125
  pictureUrl: string | undefined;
103
126
  [key: string]: CustomClaim;
104
127
  }
128
+
129
+ // Injected by entry.server.tsx before </body>. Augment Window here so
130
+ // packages that typecheck through this module (e.g. plugins) see the property.
131
+ declare global {
132
+ interface Window {
133
+ ZUDOKU_SSR_AUTH?: { profile: UserProfile | null };
134
+ }
135
+ }
@@ -0,0 +1,32 @@
1
+ import QuickLRU from "quick-lru";
2
+
3
+ // Cache verifier calls to prevent high-volume requests from overloading the identity provider.
4
+ // 60s TTL means revoked tokens are rejected within a minute; the 1h cookie is the max window.
5
+ const cache = new QuickLRU<string, unknown>({
6
+ maxSize: 1000,
7
+ maxAge: 60_000,
8
+ });
9
+
10
+ const hash = async (token: string) => {
11
+ const buf = await crypto.subtle.digest(
12
+ "SHA-256",
13
+ new TextEncoder().encode(token),
14
+ );
15
+ return Array.from(new Uint8Array(buf), (b) =>
16
+ b.toString(16).padStart(2, "0"),
17
+ ).join("");
18
+ };
19
+
20
+ export const clearVerifyCache = () => cache.clear();
21
+
22
+ export const cachedVerifyAccessToken = async <T>(
23
+ verify: (token: string) => Promise<T>,
24
+ token: string,
25
+ ): Promise<T> => {
26
+ const key = await hash(token);
27
+ if (cache.has(key)) return cache.get(key) as T;
28
+ const result = await verify(token);
29
+ cache.set(key, result);
30
+
31
+ return result;
32
+ };
@@ -25,7 +25,7 @@ const queryClient = new QueryClient({
25
25
  },
26
26
  });
27
27
 
28
- const Bootstrap = ({
28
+ export const BootstrapClient = ({
29
29
  router,
30
30
  hydrate = false,
31
31
  }: {
@@ -34,10 +34,11 @@ const Bootstrap = ({
34
34
  }) => (
35
35
  <StrictMode>
36
36
  <QueryClientProvider client={queryClient}>
37
- {/* biome-ignore lint/suspicious/noExplicitAny: Allow any type */}
38
- <HydrationBoundary state={hydrate ? (window as any).DATA : undefined}>
37
+ <HydrationBoundary state={hydrate ? window.ZUDOKU_DATA : undefined}>
39
38
  <HelmetProvider>
40
- <RouterProvider router={router} />
39
+ <RenderContext value={{ status: 200, bypassProtection: false }}>
40
+ <RouterProvider router={router} />
41
+ </RenderContext>
41
42
  </HelmetProvider>
42
43
  </HydrationBoundary>
43
44
  </QueryClientProvider>
@@ -61,17 +62,22 @@ const BootstrapStatic = ({
61
62
  }) => (
62
63
  <StrictMode>
63
64
  <QueryClientProvider client={queryClient}>
64
- <HelmetProvider context={helmetContext}>
65
- <RenderContext
66
- value={
67
- renderContext ?? { status: 200, bypassProtection: bypassProtection }
68
- }
69
- >
70
- <StaticRouterProvider router={router} context={context} />
71
- </RenderContext>
72
- </HelmetProvider>
65
+ <HydrationBoundary state={undefined}>
66
+ <HelmetProvider context={helmetContext}>
67
+ <RenderContext
68
+ value={
69
+ renderContext ?? {
70
+ status: 200,
71
+ bypassProtection: bypassProtection,
72
+ }
73
+ }
74
+ >
75
+ <StaticRouterProvider router={router} context={context} />
76
+ </RenderContext>
77
+ </HelmetProvider>
78
+ </HydrationBoundary>
73
79
  </QueryClientProvider>
74
80
  </StrictMode>
75
81
  );
76
82
 
77
- export { Bootstrap, BootstrapStatic };
83
+ export { BootstrapStatic };
@@ -21,7 +21,6 @@ import {
21
21
  import { cn } from "../util/cn.js";
22
22
  import { joinUrl } from "../util/joinUrl.js";
23
23
  import { Banner } from "./Banner.js";
24
- import { ClientOnly } from "./ClientOnly.js";
25
24
  import { useZudoku } from "./context/ZudokuContext.js";
26
25
  import { HeaderNavigation } from "./HeaderNavigation.js";
27
26
  import { MobileTopNavigation } from "./MobileTopNavigation.js";
@@ -63,66 +62,66 @@ const ProfileMenu = () => {
63
62
  const context = useZudoku();
64
63
  const profileItems = context.getProfileMenuItems();
65
64
  const auth = useAuth();
66
- const { isAuthEnabled, isAuthenticated, profile } = auth;
65
+ const { isAuthEnabled, isPending, isAuthenticated, profile } = auth;
67
66
 
68
67
  if (!isAuthEnabled) return null;
69
68
 
70
- return (
71
- <ClientOnly fallback={<Skeleton className="rounded-sm h-5 w-24 mr-4" />}>
72
- {!isAuthenticated ? (
73
- <Button size="lg" variant="ghost" onClick={() => auth.login()}>
74
- Login
69
+ if (isPending) {
70
+ return <Skeleton className="rounded-sm h-8 w-16" />;
71
+ }
72
+
73
+ return !isAuthenticated ? (
74
+ <Button size="lg" variant="ghost" onClick={() => auth.login()}>
75
+ Login
76
+ </Button>
77
+ ) : (
78
+ <DropdownMenu modal={false}>
79
+ <DropdownMenuTrigger asChild>
80
+ <Button size="lg" variant="ghost">
81
+ {profile?.name ?? "My Account"}
75
82
  </Button>
76
- ) : (
77
- <DropdownMenu modal={false}>
78
- <DropdownMenuTrigger asChild>
79
- <Button size="lg" variant="ghost">
80
- {profile?.name ?? "My Account"}
81
- </Button>
82
- </DropdownMenuTrigger>
83
- <DropdownMenuContent className="w-56">
84
- <DropdownMenuLabel>
85
- {profile?.name ?? "My Account"}
86
- {profile?.email && profile.email !== profile?.name && (
87
- <div className="font-normal text-muted-foreground">
88
- {profile.email}
89
- </div>
90
- )}
91
- </DropdownMenuLabel>
92
- {profileItems.filter((i) => i.category === "top").length > 0 && (
93
- <DropdownMenuSeparator />
94
- )}
95
- {profileItems
96
- .filter((i) => i.category === "top")
97
- .map((i) => (
98
- <RecursiveMenu key={i.label} item={i} />
99
- ))}
100
- {profileItems.filter((i) => !i.category || i.category === "middle")
101
- .length > 0 && <DropdownMenuSeparator />}
102
- {profileItems
103
- .filter((i) => !i.category || i.category === "middle")
104
- .map((i) => (
105
- <RecursiveMenu key={i.label} item={i} />
106
- ))}
107
- {profileItems.filter((i) => i.category === "bottom").length > 0 && (
108
- <DropdownMenuSeparator />
109
- )}
110
- {profileItems
111
- .filter((i) => i.category === "bottom")
112
- .map((i) => (
113
- <RecursiveMenu key={i.label} item={i} />
114
- ))}
115
- <DropdownMenuSeparator />
116
- <Link to="/signout">
117
- <DropdownMenuItem className="flex gap-2">
118
- <LogOutIcon size={16} strokeWidth={1} absoluteStrokeWidth />
119
- Logout
120
- </DropdownMenuItem>
121
- </Link>
122
- </DropdownMenuContent>
123
- </DropdownMenu>
124
- )}
125
- </ClientOnly>
83
+ </DropdownMenuTrigger>
84
+ <DropdownMenuContent className="w-56">
85
+ <DropdownMenuLabel>
86
+ {profile?.name ?? "My Account"}
87
+ {profile?.email && profile.email !== profile?.name && (
88
+ <div className="font-normal text-muted-foreground">
89
+ {profile.email}
90
+ </div>
91
+ )}
92
+ </DropdownMenuLabel>
93
+ {profileItems.filter((i) => i.category === "top").length > 0 && (
94
+ <DropdownMenuSeparator />
95
+ )}
96
+ {profileItems
97
+ .filter((i) => i.category === "top")
98
+ .map((i) => (
99
+ <RecursiveMenu key={i.label} item={i} />
100
+ ))}
101
+ {profileItems.filter((i) => !i.category || i.category === "middle")
102
+ .length > 0 && <DropdownMenuSeparator />}
103
+ {profileItems
104
+ .filter((i) => !i.category || i.category === "middle")
105
+ .map((i) => (
106
+ <RecursiveMenu key={i.label} item={i} />
107
+ ))}
108
+ {profileItems.filter((i) => i.category === "bottom").length > 0 && (
109
+ <DropdownMenuSeparator />
110
+ )}
111
+ {profileItems
112
+ .filter((i) => i.category === "bottom")
113
+ .map((i) => (
114
+ <RecursiveMenu key={i.label} item={i} />
115
+ ))}
116
+ <DropdownMenuSeparator />
117
+ <Link to="/signout">
118
+ <DropdownMenuItem className="flex gap-2">
119
+ <LogOutIcon size={16} strokeWidth={1} absoluteStrokeWidth />
120
+ Logout
121
+ </DropdownMenuItem>
122
+ </Link>
123
+ </DropdownMenuContent>
124
+ </DropdownMenu>
126
125
  );
127
126
  };
128
127
  export const Header = memo(function HeaderInner() {
@@ -28,7 +28,6 @@ import {
28
28
  DrawerTitle,
29
29
  DrawerTrigger,
30
30
  } from "../ui/Drawer.js";
31
- import { ClientOnly } from "./ClientOnly.js";
32
31
  import { useCurrentNavigation, useZudoku } from "./context/ZudokuContext.js";
33
32
  import { PoweredByZudoku } from "./navigation/PoweredByZudoku.js";
34
33
  import { getFirstMatchingPath, shouldShowItem } from "./navigation/utils.js";
@@ -130,7 +129,7 @@ export const MobileTopNavigation = () => {
130
129
  } = context;
131
130
  const headerNavigation = header?.navigation ?? [];
132
131
  const themeSwitcherEnabled = header?.themeSwitcher?.enabled ?? true;
133
- const { isAuthenticated, profile, isAuthEnabled } = authState;
132
+ const { isAuthenticated, isPending, profile, isAuthEnabled } = authState;
134
133
  const [drawerOpen, setDrawerOpen] = useState(false);
135
134
 
136
135
  const accountItems = getProfileMenuItems();
@@ -191,75 +190,75 @@ export const MobileTopNavigation = () => {
191
190
  </li>
192
191
  );
193
192
  })}
194
- {isAuthEnabled && isAuthenticated && (
195
- <ClientOnly
196
- fallback={<Skeleton className="rounded-sm h-5 w-24" />}
197
- >
198
- <Separator className="my-2" />
199
- <li className="py-2">
200
- <div className="text-base font-medium">
201
- {profile?.name ?? "My Account"}
202
- </div>
203
- {profile?.email && profile.email !== profile?.name && (
204
- <div className="text-sm text-muted-foreground">
205
- {profile.email}
206
- </div>
207
- )}
208
- </li>
209
- {accountItems.map((i) => (
210
- <li key={i.label}>
211
- <Link
212
- to={i.path ?? ""}
213
- target={i.target}
214
- rel={
215
- i.target === "_blank"
216
- ? "noopener noreferrer"
217
- : undefined
218
- }
219
- onClick={() => setDrawerOpen(false)}
220
- className="flex items-center py-2 text-base font-medium text-foreground/75 hover:text-foreground"
221
- >
222
- {i.label}
223
- </Link>
224
- </li>
225
- ))}
226
- </ClientOnly>
227
- )}
193
+ {isAuthEnabled &&
194
+ (isPending ? (
195
+ <Skeleton className="rounded-sm h-5 w-24" />
196
+ ) : (
197
+ isAuthenticated && (
198
+ <>
199
+ <Separator className="my-2" />
200
+ <li className="py-2">
201
+ <div className="text-base font-medium">
202
+ {profile?.name ?? "My Account"}
203
+ </div>
204
+ {profile?.email && profile.email !== profile?.name && (
205
+ <div className="text-sm text-muted-foreground">
206
+ {profile.email}
207
+ </div>
208
+ )}
209
+ </li>
210
+ {accountItems.map((i) => (
211
+ <li key={i.label}>
212
+ <Link
213
+ to={i.path ?? ""}
214
+ target={i.target}
215
+ rel={
216
+ i.target === "_blank"
217
+ ? "noopener noreferrer"
218
+ : undefined
219
+ }
220
+ onClick={() => setDrawerOpen(false)}
221
+ className="flex items-center py-2 text-base font-medium text-foreground/75 hover:text-foreground"
222
+ >
223
+ {i.label}
224
+ </Link>
225
+ </li>
226
+ ))}
227
+ </>
228
+ )
229
+ ))}
228
230
  </ul>
229
231
  </div>
230
232
  <div className="border-t shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] px-4 pt-3 flex flex-col gap-2">
231
233
  <div className="flex items-center justify-between">
232
- {isAuthEnabled && (
233
- <ClientOnly
234
- fallback={<Skeleton className="rounded-sm h-8 w-16" />}
235
- >
236
- {isAuthenticated ? (
237
- <Button asChild variant="outline">
238
- <Link
239
- to="/signout"
240
- onClick={() => setDrawerOpen(false)}
241
- className="flex items-center gap-2"
242
- >
243
- <LogOutIcon
244
- size={16}
245
- strokeWidth={1}
246
- absoluteStrokeWidth
247
- />
248
- Logout
249
- </Link>
250
- </Button>
251
- ) : (
252
- <Button asChild variant="outline">
253
- <Link
254
- to={`/signin?redirect=${encodeURIComponent(location.pathname)}`}
255
- onClick={() => setDrawerOpen(false)}
256
- >
257
- Login
258
- </Link>
259
- </Button>
260
- )}
261
- </ClientOnly>
262
- )}
234
+ {isAuthEnabled &&
235
+ (isPending ? (
236
+ <Skeleton className="rounded-sm h-8 w-16" />
237
+ ) : isAuthenticated ? (
238
+ <Button asChild variant="outline">
239
+ <Link
240
+ to="/signout"
241
+ onClick={() => setDrawerOpen(false)}
242
+ className="flex items-center gap-2"
243
+ >
244
+ <LogOutIcon
245
+ size={16}
246
+ strokeWidth={1}
247
+ absoluteStrokeWidth
248
+ />
249
+ Logout
250
+ </Link>
251
+ </Button>
252
+ ) : (
253
+ <Button asChild variant="outline">
254
+ <Link
255
+ to={`/signin?redirect=${encodeURIComponent(location.pathname)}`}
256
+ onClick={() => setDrawerOpen(false)}
257
+ >
258
+ Login
259
+ </Link>
260
+ </Button>
261
+ ))}
263
262
  {themeSwitcherEnabled && <ThemeSwitch />}
264
263
  </div>
265
264
  {site?.showPoweredBy !== false && (
@@ -4,6 +4,7 @@ import { ThemeProvider } from "next-themes";
4
4
  import {
5
5
  memo,
6
6
  type PropsWithChildren,
7
+ use,
7
8
  useEffect,
8
9
  useMemo,
9
10
  useState,
@@ -17,6 +18,7 @@ import {
17
18
  } from "../core/ZudokuContext.js";
18
19
  import { TopLevelError } from "../errors/TopLevelError.js";
19
20
  import { MdxComponents } from "../util/MdxComponents.js";
21
+ import { RenderContext } from "./context/RenderContext.js";
20
22
  import { RouterEventsEmitter } from "./context/RouterEventsEmitter.js";
21
23
  import { SlotProvider } from "./context/SlotProvider.js";
22
24
  import { ViewportAnchorProvider } from "./context/ViewportAnchorContext.js";
@@ -61,7 +63,18 @@ const ZudokuInner = memo(
61
63
  setDidNavigate(true);
62
64
  }, [didNavigate, navigation.location]);
63
65
 
64
- zudokuContext ??= new ZudokuContext(props, queryClient, env);
66
+ const renderContext = use(RenderContext);
67
+ if (typeof window === "undefined") {
68
+ // Fresh context per SSR request to avoid leaking
69
+ zudokuContext = new ZudokuContext(
70
+ props,
71
+ queryClient,
72
+ env,
73
+ renderContext.ssrAuth,
74
+ );
75
+ } else {
76
+ zudokuContext ??= new ZudokuContext(props, queryClient, env);
77
+ }
65
78
 
66
79
  return (
67
80
  <>
@@ -1,8 +1,16 @@
1
1
  import { createContext } from "react";
2
+ import type { UserProfile } from "../../authentication/state.js";
3
+
4
+ export type SSRAuthState = {
5
+ accessToken?: string;
6
+ // `null` means the user is logged out; undefined means auth isn't configured.
7
+ profile: UserProfile | null;
8
+ };
2
9
 
3
10
  export type RenderContextValue = {
4
11
  status: number;
5
12
  bypassProtection: boolean;
13
+ ssrAuth?: SSRAuthState;
6
14
  };
7
15
 
8
16
  export const RenderContext = createContext<RenderContextValue>({