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.
- package/dist/cli/cli.js +602 -160
- package/dist/cli/worker.js +6 -4
- package/dist/declarations/app/adapter.d.ts +12 -0
- package/dist/declarations/app/adapters/cloudflare.d.ts +8 -0
- package/dist/declarations/app/adapters/lambda.d.ts +13 -0
- package/dist/declarations/app/adapters/node.d.ts +12 -0
- package/dist/declarations/app/adapters/vercel.d.ts +14 -0
- package/dist/declarations/app/entry.client.d.ts +2 -0
- package/dist/declarations/app/entry.server.d.ts +13 -0
- package/dist/declarations/app/protectChunks.d.ts +17 -0
- package/dist/declarations/app/wrapProtectedRoutes.d.ts +4 -0
- package/dist/declarations/config/validators/HeaderNavigationSchema.d.ts +216 -80
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +81 -30
- package/dist/declarations/config/validators/icon-types.d.ts +1 -1
- package/dist/declarations/lib/authentication/authentication.d.ts +7 -0
- package/dist/declarations/lib/authentication/cookie-sync.d.ts +3 -0
- package/dist/declarations/lib/authentication/cookies.d.ts +10 -0
- package/dist/declarations/lib/authentication/providers/azureb2c.d.ts +6 -1
- package/dist/declarations/lib/authentication/providers/clerk.d.ts +1 -0
- package/dist/declarations/lib/authentication/providers/openid.d.ts +2 -1
- package/dist/declarations/lib/authentication/providers/supabase.d.ts +2 -0
- package/dist/declarations/lib/authentication/session-handler.d.ts +81 -0
- package/dist/declarations/lib/authentication/state.d.ts +7 -0
- package/dist/declarations/lib/authentication/verify-cache.d.ts +2 -0
- package/dist/declarations/lib/components/Bootstrap.d.ts +2 -2
- package/dist/declarations/lib/components/Heading.d.ts +1 -1
- package/dist/declarations/lib/components/context/RenderContext.d.ts +6 -0
- package/dist/declarations/lib/core/RouteGuard.d.ts +11 -0
- package/dist/declarations/lib/core/ZudokuContext.d.ts +5 -2
- package/dist/declarations/lib/manifest.d.ts +25 -0
- package/dist/declarations/lib/util/url.d.ts +2 -0
- package/dist/flat-config.d.ts +1 -1
- package/docs/configuration/protected-routes.md +21 -4
- package/docs/guides/server-side-content-protection.md +207 -0
- package/package.json +26 -4
- package/src/app/adapter.ts +16 -0
- package/src/app/adapters/cloudflare.ts +18 -0
- package/src/app/adapters/lambda.ts +36 -0
- package/src/app/adapters/node.ts +32 -0
- package/src/app/adapters/vercel.ts +39 -0
- package/src/app/demo.tsx +2 -2
- package/src/app/entry.client.tsx +19 -7
- package/src/app/entry.server.tsx +133 -9
- package/src/app/main.tsx +21 -3
- package/src/app/protectChunks.ts +64 -0
- package/src/app/standalone.tsx +2 -2
- package/src/app/wrapProtectedRoutes.ts +82 -0
- package/src/config/validators/icon-types.ts +17 -0
- package/src/lib/authentication/authentication.ts +15 -0
- package/src/lib/authentication/cookie-sync.ts +90 -0
- package/src/lib/authentication/cookies.ts +54 -0
- package/src/lib/authentication/hook.ts +13 -0
- package/src/lib/authentication/providers/azureb2c.tsx +70 -2
- package/src/lib/authentication/providers/clerk.tsx +49 -0
- package/src/lib/authentication/providers/openid.tsx +46 -0
- package/src/lib/authentication/providers/supabase.tsx +30 -2
- package/src/lib/authentication/session-handler.ts +164 -0
- package/src/lib/authentication/state.ts +36 -5
- package/src/lib/authentication/verify-cache.ts +32 -0
- package/src/lib/components/Bootstrap.tsx +20 -14
- package/src/lib/components/Header.tsx +56 -57
- package/src/lib/components/MobileTopNavigation.tsx +66 -67
- package/src/lib/components/Zudoku.tsx +14 -1
- package/src/lib/components/context/RenderContext.ts +8 -0
- package/src/lib/components/context/ZudokuContext.ts +2 -1
- package/src/lib/core/RouteGuard.tsx +50 -29
- package/src/lib/core/ZudokuContext.ts +39 -6
- package/src/lib/errors/RouterError.tsx +43 -1
- package/src/lib/manifest.ts +62 -0
- package/src/lib/oas/parser/dereference/index.ts +2 -1
- package/src/lib/oas/parser/dereference/resolveRef.ts +2 -1
- package/src/lib/oas/parser/index.ts +1 -1
- package/src/lib/plugins/openapi/client/createServer.ts +13 -4
- package/src/lib/plugins/search-pagefind/index.tsx +1 -4
- package/src/lib/util/os.ts +1 -0
- package/src/lib/util/url.ts +13 -0
- package/src/vite/build.ts +84 -24
- package/src/vite/config.ts +51 -5
- package/src/vite/dev-server.ts +61 -8
- package/src/vite/manifest.ts +15 -0
- package/src/vite/plugin-api.ts +3 -1
- package/src/vite/plugin-markdown-export.ts +3 -9
- package/src/vite/prerender/worker.ts +2 -4
- package/src/vite/protected/annotator.ts +136 -0
- package/src/vite/protected/build.ts +151 -0
- package/src/vite/protected/registry.ts +82 -0
- package/src/vite/ssr-templates/cloudflare.ts +5 -18
- package/src/vite/ssr-templates/lambda.ts +4 -0
- package/src/vite/ssr-templates/node.ts +7 -22
- package/src/vite/ssr-templates/vercel.ts +6 -20
- 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 {
|
|
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:
|
|
41
|
-
isPending:
|
|
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(() =>
|
|
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
|
|
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
|
-
{
|
|
38
|
-
<HydrationBoundary state={hydrate ? (window as any).DATA : undefined}>
|
|
37
|
+
<HydrationBoundary state={hydrate ? window.ZUDOKU_DATA : undefined}>
|
|
39
38
|
<HelmetProvider>
|
|
40
|
-
<
|
|
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
|
-
<
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 {
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 &&
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
{profile.email
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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>({
|