cloudflare-next-intl 0.3.2 → 0.4.0

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 (43) hide show
  1. package/README.md +40 -0
  2. package/dist/src/client/components/client_provider.d.ts +6 -1
  3. package/dist/src/client/components/client_provider.js +16 -4
  4. package/dist/src/client/functions/get_cookie.js +16 -2
  5. package/dist/src/config/cookie_key.d.ts +2 -0
  6. package/dist/src/config/cookie_key.js +2 -0
  7. package/dist/src/config/index.d.ts +1 -1
  8. package/dist/src/config/index.js +1 -1
  9. package/dist/src/config/middleware.js +5 -2
  10. package/dist/src/cookie_consent/client/components/cookie_consent_analytics.bench.d.ts +1 -0
  11. package/dist/src/cookie_consent/client/components/cookie_consent_analytics.bench.js +14 -0
  12. package/dist/src/cookie_consent/client/components/cookie_consent_analytics.d.ts +19 -0
  13. package/dist/src/cookie_consent/client/components/cookie_consent_analytics.js +91 -0
  14. package/dist/src/cookie_consent/client/components/cookie_consent_dialog.d.ts +28 -0
  15. package/dist/src/cookie_consent/client/components/cookie_consent_dialog.js +17 -0
  16. package/dist/src/cookie_consent/client/components/privacy_policy_update_dialog.d.ts +24 -0
  17. package/dist/src/cookie_consent/client/components/privacy_policy_update_dialog.js +17 -0
  18. package/dist/src/cookie_consent/client/cookie_consent_provider.d.ts +27 -0
  19. package/dist/src/cookie_consent/client/cookie_consent_provider.js +82 -0
  20. package/dist/src/cookie_consent/client/use_cookie_consent.d.ts +6 -0
  21. package/dist/src/cookie_consent/client/use_cookie_consent.js +14 -0
  22. package/dist/src/cookie_consent/index.d.ts +7 -0
  23. package/dist/src/cookie_consent/index.js +5 -0
  24. package/dist/src/cookie_consent/require_config.d.ts +7 -0
  25. package/dist/src/cookie_consent/require_config.js +13 -0
  26. package/dist/src/cookie_consent/types.d.ts +37 -0
  27. package/dist/src/cookie_consent/types.js +1 -0
  28. package/dist/src/firebase_auth/client/auth_actions.bench.js +1 -0
  29. package/dist/src/firebase_auth/client/auth_actions.js +4 -4
  30. package/dist/src/firebase_auth/client/auth_user_provider.js +53 -16
  31. package/dist/src/firebase_auth/client/firebase_client.d.ts +2 -0
  32. package/dist/src/firebase_auth/client/firebase_client.js +8 -0
  33. package/dist/src/firebase_auth/error_messages/firebase_auth_error_helper.js +3 -2
  34. package/dist/src/firebase_auth/middleware/update_session.js +1 -5
  35. package/dist/src/firebase_auth/server/auth_user_server_provider.d.ts +10 -5
  36. package/dist/src/firebase_auth/server/auth_user_server_provider.js +10 -5
  37. package/dist/src/firebase_auth/server/firebase_server.js +8 -2
  38. package/dist/src/server/components/server_provider.js +14 -4
  39. package/dist/src/server/functions/server.js +5 -2
  40. package/dist/src/types/index.d.ts +1 -1
  41. package/dist/src/types/types.d.ts +70 -0
  42. package/llms.txt +9 -0
  43. package/package.json +30 -1
package/README.md CHANGED
@@ -185,6 +185,46 @@ import ThemeSwitcher from "cloudflare-next-intl/ThemeSwitcher";
185
185
  <ThemeSwitcher lightLabelText="Light" darkLabelText="Dark" />
186
186
  ```
187
187
 
188
+ ### Cookie consent
189
+
190
+ Set `cookieConsent` on your `RoutingConfig` to enable — `IntlProvider` then
191
+ auto-wires `CookieConsentProvider` (and `CookieConsentAnalytics`, if
192
+ `cookieConsent.secrets`/`getSecrets` is set) with no manual nesting needed.
193
+
194
+ ```typescript
195
+ // intl-config.ts
196
+ export default setIntlConfig({
197
+ locales: ["en", "de"],
198
+ defaultLocale: "en",
199
+ cookieConsent: {
200
+ privacyPolicyDate: "2026-01-01",
201
+ },
202
+ });
203
+ ```
204
+
205
+ ```tsx
206
+ import { CookieConsentDialog, PrivacyPolicyUpdateDialog, useCookieConsent } from "cloudflare-next-intl/cookieConsent";
207
+
208
+ export default function Layout({ children }) {
209
+ return (
210
+ <>
211
+ {children}
212
+ <CookieConsentDialog />
213
+ <PrivacyPolicyUpdateDialog />
214
+ </>
215
+ );
216
+ }
217
+ ```
218
+
219
+ ```tsx
220
+ "use client";
221
+ import { useCookieConsent } from "cloudflare-next-intl/useCookieConsent";
222
+
223
+ const { consent, setConsent } = useCookieConsent();
224
+ ```
225
+
226
+ See [`package/src/cookie_consent/README.md`](package/src/cookie_consent/README.md) for layout, customization, and gotchas.
227
+
188
228
  ## License
189
229
 
190
230
  MIT
@@ -1,14 +1,19 @@
1
1
  import type { TranslationObject } from "../../types/types";
2
2
  import type { SerializedAuthUser } from "../../firebase_auth/types";
3
+ import type { CookieConsentAnalyticsSecrets } from "../../types/types";
3
4
  interface LocaleContextType {
4
5
  language: string;
5
6
  messages: TranslationObject;
6
7
  }
7
8
  export declare const LocaleContext: import("react").Context<LocaleContextType | undefined>;
8
- export default function LocationzationClientProvider({ language, messages, initialAuthUser, children }: {
9
+ export default function LocationzationClientProvider({ language, messages, initialAuthUser, skipAuthProvider, analyticsSecrets, children }: {
9
10
  language: string;
10
11
  messages: TranslationObject;
11
12
  initialAuthUser?: SerializedAuthUser | null;
13
+ /** Set when `firebaseAuth.autoWireClientProvider` is `false` — skips wrapping `children` in the client `AuthUserProvider` entirely. */
14
+ skipAuthProvider?: boolean;
15
+ /** Resolved server-side from `cookieConsent.secrets`/`getSecrets` when `autoWireAnalytics` isn't `false`. */
16
+ analyticsSecrets?: CookieConsentAnalyticsSecrets;
12
17
  children: React.ReactNode;
13
18
  }): Component;
14
19
  export {};
@@ -1,11 +1,21 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { setLocaleCache, setMessageForLocaleCache } from "../../general/cache_variables";
4
4
  import { createContext, useMemo } from "react";
5
5
  import dynamic from "next/dynamic";
6
6
  import config from "@intl-config";
7
7
  export const LocaleContext = createContext(undefined);
8
- export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, children }) {
8
+ // Hoisted to module scope calling `dynamic()` inside the component body
9
+ // creates a brand-new component identity every render, forcing React to
10
+ // unmount/remount `AuthUserProvider` on every render instead of reusing the
11
+ // existing instance. That remount re-subscribes `onIdTokenChanged`, which
12
+ // Firebase immediately replays with the current user, triggering a state
13
+ // update (and a `getIdToken(true)` refresh) that causes another render —
14
+ // an infinite loop of session-cookie writes, one per render.
15
+ const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
16
+ const CookieConsentProvider = dynamic(() => import("../../cookie_consent/client/cookie_consent_provider"));
17
+ const CookieConsentAnalytics = dynamic(() => import("../../cookie_consent/client/components/cookie_consent_analytics"));
18
+ export default function LocationzationClientProvider({ language, messages, initialAuthUser = null, skipAuthProvider = false, analyticsSecrets, children }) {
9
19
  setLocaleCache(language);
10
20
  setMessageForLocaleCache(language, messages);
11
21
  // `LocaleContext.Provider` stays the outermost element here — the
@@ -14,10 +24,12 @@ export default function LocationzationClientProvider({ language, messages, initi
14
24
  // sibling wrapping it, or those hooks would throw for running outside
15
25
  // the provider.
16
26
  let providedChildren = children;
17
- if (config.firebaseAuth) {
18
- const AuthUserProvider = dynamic(() => import("../../firebase_auth/client/auth_user_provider"));
27
+ if (config.firebaseAuth && !skipAuthProvider) {
19
28
  providedChildren = _jsx(AuthUserProvider, { initialUser: initialAuthUser, children: children });
20
29
  }
30
+ if (config.cookieConsent) {
31
+ providedChildren = _jsxs(CookieConsentProvider, { children: [providedChildren, analyticsSecrets && _jsx(CookieConsentAnalytics, { secrets: analyticsSecrets })] });
32
+ }
21
33
  const contextValue = useMemo(() => ({ language, messages }), [language, messages]);
22
34
  return _jsx(LocaleContext.Provider, { value: contextValue, children: providedChildren });
23
35
  }
@@ -10,8 +10,22 @@
10
10
  */
11
11
  export default function getCookie(name) {
12
12
  try {
13
- const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
14
- return match ? decodeURIComponent(match[1]) : null;
13
+ const cookie = document.cookie;
14
+ const prefix = `${name}=`;
15
+ let start = -1;
16
+ if (cookie.startsWith(prefix)) {
17
+ start = prefix.length;
18
+ }
19
+ else {
20
+ const idx = cookie.indexOf(`; ${prefix}`);
21
+ if (idx !== -1)
22
+ start = idx + 2 + prefix.length;
23
+ }
24
+ if (start === -1)
25
+ return null;
26
+ const end = cookie.indexOf(';', start);
27
+ const value = end === -1 ? cookie.slice(start) : cookie.slice(start, end);
28
+ return decodeURIComponent(value);
15
29
  }
16
30
  catch (e) {
17
31
  console.error(`Get cookie on client side error: ${e}`);
@@ -1,3 +1,5 @@
1
1
  export declare const localeCookieName = "__user_locale_key__";
2
2
  export declare const isBotCookieKey = "__is_bot_key__";
3
3
  export declare const isDarkCookieKey = "__is_dark_key__";
4
+ export declare const cookieConsentCookieKey = "__cookie_consent_key__";
5
+ export declare const privacyPolicyDateCookieKey = "__privacy_policy_date_key__";
@@ -1,3 +1,5 @@
1
1
  export const localeCookieName = '__user_locale_key__';
2
2
  export const isBotCookieKey = '__is_bot_key__';
3
3
  export const isDarkCookieKey = '__is_dark_key__';
4
+ export const cookieConsentCookieKey = '__cookie_consent_key__';
5
+ export const privacyPolicyDateCookieKey = '__privacy_policy_date_key__';
@@ -1,4 +1,4 @@
1
- export { isBotCookieKey, localeCookieName, isDarkCookieKey } from './cookie_key';
1
+ export { isBotCookieKey, localeCookieName, isDarkCookieKey, cookieConsentCookieKey, privacyPolicyDateCookieKey } from './cookie_key';
2
2
  export { default as intlMiddleware } from './middleware';
3
3
  export { setIntlConfig } from './init_config';
4
4
  export { default as generateIntlSitemap } from './intl_sitemap';
@@ -1,4 +1,4 @@
1
- export { isBotCookieKey, localeCookieName, isDarkCookieKey } from './cookie_key'; // Export specific middleware function
1
+ export { isBotCookieKey, localeCookieName, isDarkCookieKey, cookieConsentCookieKey, privacyPolicyDateCookieKey } from './cookie_key'; // Export specific middleware function
2
2
  export { default as intlMiddleware } from './middleware'; // Export specific middleware function
3
3
  export { setIntlConfig } from './init_config';
4
4
  export { default as generateIntlSitemap } from './intl_sitemap';
@@ -11,13 +11,16 @@ const defaultCookieOption = {
11
11
  secure: false, // Send cookie only over HTTPS in production
12
12
  sameSite: sameSite, // Protection against CSRF attacks. 'strict' or 'lax' are good choices.
13
13
  };
14
+ let userAgentModule;
14
15
  async function getIsBotValue(userAgent) {
15
16
  if (userAgent === null)
16
17
  return false;
17
- const { isBot } = await import('next/dist/server/web/spec-extension/user-agent');
18
+ if (!userAgentModule) {
19
+ userAgentModule = await import('next/dist/server/web/spec-extension/user-agent');
20
+ }
18
21
  // Unreachable: userAgent is already narrowed to non-null string above,
19
22
  // so the ?? '' fallback never triggers.
20
- return isBot(userAgent ?? '');
23
+ return userAgentModule.isBot(userAgent ?? '');
21
24
  }
22
25
  const getIsBotValueCache = cache(getIsBotValue);
23
26
  export const localesSet = new Set(config.locales);
@@ -0,0 +1,14 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { googleConsentModeBootstrapScript } from './cookie_consent_analytics';
3
+ describe('googleConsentModeBootstrapScript', () => {
4
+ bench('all providers configured', () => {
5
+ googleConsentModeBootstrapScript({
6
+ googleAnalyticsId: 'G-XXX',
7
+ googleAdsId: 'AW-YYY',
8
+ googleAdSenseId: 'ca-pub-ZZZ',
9
+ });
10
+ });
11
+ bench('no providers configured', () => {
12
+ googleConsentModeBootstrapScript({});
13
+ });
14
+ });
@@ -0,0 +1,19 @@
1
+ import type { CookieConsentAnalyticsSecrets } from '../../../types/types';
2
+ /**
3
+ * Renders whichever analytics/ads scripts have a resolved secret, gated on
4
+ * consent: Google Consent Mode bootstrap always loads (defaults to
5
+ * `denied`, only sends `update` once `consent` is decided); Cloudflare Web
6
+ * Analytics beacon and Microsoft Clarity only load once `consent === true`.
7
+ * Rendered automatically by `IntlProvider` when `cookieConsent.secrets`/
8
+ * `getSecrets` resolves at least one field and `autoWireAnalytics` isn't
9
+ * `false` — render manually instead if you set `autoWireAnalytics: false`.
10
+ */
11
+ export default function CookieConsentAnalytics({ secrets }: {
12
+ secrets: CookieConsentAnalyticsSecrets;
13
+ }): React.ReactElement | null;
14
+ /**
15
+ * Denies storage by default and loads the configured Google tags; the
16
+ * effect above sends the `update` once consent is known. Only IDs present
17
+ * in `secrets` are included.
18
+ */
19
+ export declare function googleConsentModeBootstrapScript(secrets: CookieConsentAnalyticsSecrets): string;
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect } from 'react';
4
+ import useCookieConsent from '../use_cookie_consent';
5
+ /**
6
+ * Renders whichever analytics/ads scripts have a resolved secret, gated on
7
+ * consent: Google Consent Mode bootstrap always loads (defaults to
8
+ * `denied`, only sends `update` once `consent` is decided); Cloudflare Web
9
+ * Analytics beacon and Microsoft Clarity only load once `consent === true`.
10
+ * Rendered automatically by `IntlProvider` when `cookieConsent.secrets`/
11
+ * `getSecrets` resolves at least one field and `autoWireAnalytics` isn't
12
+ * `false` — render manually instead if you set `autoWireAnalytics: false`.
13
+ */
14
+ export default function CookieConsentAnalytics({ secrets }) {
15
+ const { consent } = useCookieConsent();
16
+ useEffect(() => {
17
+ if (consent === null)
18
+ return;
19
+ const w = window;
20
+ if (typeof w.gtag !== 'function')
21
+ return;
22
+ const state = consent ? 'granted' : 'denied';
23
+ w.gtag('consent', 'update', {
24
+ ad_storage: state,
25
+ ad_user_data: state,
26
+ ad_personalization: state,
27
+ analytics_storage: state,
28
+ });
29
+ }, [consent]);
30
+ const hasGoogle = Boolean(secrets.googleAnalyticsId || secrets.googleAdsId || secrets.googleAdSenseId);
31
+ return (_jsxs(_Fragment, { children: [hasGoogle && (_jsx("script", { id: "cookie-consent-google-consent-mode", dangerouslySetInnerHTML: { __html: googleConsentModeBootstrapScript(secrets) } })), consent === true && secrets.cloudflareBeaconToken && (_jsx("script", { defer: true, src: "https://static.cloudflareinsights.com/beacon.min.js", "data-cf-beacon": secrets.cloudflareBeaconToken })), consent === true && secrets.clarityProjectId && _jsx(ClarityScript, { projectId: secrets.clarityProjectId })] }));
32
+ }
33
+ let cachedClarityModule;
34
+ function getClarityModule() {
35
+ if (!cachedClarityModule) {
36
+ cachedClarityModule = import('@microsoft/clarity');
37
+ }
38
+ return cachedClarityModule;
39
+ }
40
+ function ClarityScript({ projectId }) {
41
+ useEffect(() => {
42
+ getClarityModule()
43
+ .then(({ default: Clarity }) => {
44
+ Clarity.init(projectId);
45
+ Clarity.consent();
46
+ })
47
+ .catch((error) => console.error(`cloudflare-next-intl: failed to load @microsoft/clarity: ${error}`));
48
+ }, [projectId]);
49
+ return null;
50
+ }
51
+ /**
52
+ * Denies storage by default and loads the configured Google tags; the
53
+ * effect above sends the `update` once consent is known. Only IDs present
54
+ * in `secrets` are included.
55
+ */
56
+ export function googleConsentModeBootstrapScript(secrets) {
57
+ const configCalls = [secrets.googleAnalyticsId, secrets.googleAdsId]
58
+ .filter(Boolean)
59
+ .map((id) => `gtag('config', '${id}');`)
60
+ .join('\n');
61
+ const gtagLoader = secrets.googleAnalyticsId || secrets.googleAdsId
62
+ ? `(function(){
63
+ var s = document.createElement('script');
64
+ s.async = true;
65
+ s.src = 'https://www.googletagmanager.com/gtag/js?id=${secrets.googleAnalyticsId ?? secrets.googleAdsId}';
66
+ document.head.appendChild(s);
67
+ })();`
68
+ : '';
69
+ const adSenseLoader = secrets.googleAdSenseId
70
+ ? `(function(){
71
+ var a = document.createElement('script');
72
+ a.async = true;
73
+ a.crossOrigin = 'anonymous';
74
+ a.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${secrets.googleAdSenseId}';
75
+ document.head.appendChild(a);
76
+ })();`
77
+ : '';
78
+ return `window.dataLayer = window.dataLayer || [];
79
+ function gtag(){dataLayer.push(arguments);}
80
+ gtag('consent', 'default', {
81
+ 'ad_storage': 'denied',
82
+ 'ad_user_data': 'denied',
83
+ 'ad_personalization': 'denied',
84
+ 'analytics_storage': 'denied',
85
+ 'wait_for_update': 500
86
+ });
87
+ gtag('js', new Date());
88
+ ${configCalls}
89
+ ${gtagLoader}
90
+ ${adSenseLoader}`;
91
+ }
@@ -0,0 +1,28 @@
1
+ import type { CookieDialogClassNames, CookieDialogStyles } from '../../types';
2
+ export interface CookieConsentDialogProps {
3
+ /** Banner message text. */
4
+ message?: React.ReactNode;
5
+ /** Optional link element rendered right after `message` (e.g. a privacy-policy link). */
6
+ link?: React.ReactNode;
7
+ acceptText?: string;
8
+ declineText?: string;
9
+ /** Hides the decline ("necessary only") button, leaving only accept. */
10
+ hideDecline?: boolean;
11
+ id?: string;
12
+ classNames?: CookieDialogClassNames;
13
+ styles?: CookieDialogStyles;
14
+ /**
15
+ * Full custom render — receives the resolved consent state/actions and
16
+ * bypasses the default markup entirely. Use for a fully bespoke dialog.
17
+ */
18
+ render?: (props: {
19
+ setConsent: (value: boolean) => void;
20
+ }) => React.ReactNode;
21
+ }
22
+ /**
23
+ * Cookie-consent banner. Renders `null` once `consent` is already decided.
24
+ * Every visual aspect is overridable via `classNames`/`styles` (per-slot) or
25
+ * `render` (full custom markup) — none of it is hardcoded to Tailwind or any
26
+ * particular design system.
27
+ */
28
+ export default function CookieConsentDialog({ message, link, acceptText, declineText, hideDecline, id, classNames, styles, render, }: CookieConsentDialogProps): React.ReactElement | null;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import useCookieConsent from '../use_cookie_consent';
4
+ /**
5
+ * Cookie-consent banner. Renders `null` once `consent` is already decided.
6
+ * Every visual aspect is overridable via `classNames`/`styles` (per-slot) or
7
+ * `render` (full custom markup) — none of it is hardcoded to Tailwind or any
8
+ * particular design system.
9
+ */
10
+ export default function CookieConsentDialog({ message = 'We use cookies to improve your experience.', link, acceptText = 'Accept', declineText = 'Necessary only', hideDecline = false, id = 'cookie-consent-dialog', classNames, styles, render, }) {
11
+ const { consent, setConsent } = useCookieConsent();
12
+ if (consent !== null)
13
+ return null;
14
+ if (render)
15
+ return _jsx(_Fragment, { children: render({ setConsent }) });
16
+ return (_jsxs("div", { id: id, role: "dialog", "aria-modal": "false", "aria-labelledby": `${id}-title`, className: classNames?.root, style: styles?.root, children: [_jsxs("p", { id: `${id}-title`, className: classNames?.message, style: styles?.message, children: [message, link ? _jsxs("span", { className: classNames?.link, style: styles?.link, children: [" ", link] }) : null] }), _jsxs("div", { className: classNames?.actions, style: styles?.actions, children: [!hideDecline && (_jsx("button", { type: "button", onClick: () => setConsent(false), className: classNames?.declineButton, style: styles?.declineButton, children: declineText })), _jsx("button", { type: "button", onClick: () => setConsent(true), className: classNames?.acceptButton, style: styles?.acceptButton, children: acceptText })] })] }));
17
+ }
@@ -0,0 +1,24 @@
1
+ import type { CookieDialogClassNames, CookieDialogStyles } from '../../types';
2
+ export interface PrivacyPolicyUpdateDialogProps {
3
+ message?: React.ReactNode;
4
+ /** Optional link element rendered right after `message` (e.g. to your privacy-policy page). */
5
+ link?: React.ReactNode;
6
+ closeText?: string;
7
+ id?: string;
8
+ classNames?: CookieDialogClassNames;
9
+ styles?: CookieDialogStyles;
10
+ /**
11
+ * Full custom render — receives the acknowledge action and bypasses the
12
+ * default markup entirely.
13
+ */
14
+ render?: (props: {
15
+ acknowledge: () => void;
16
+ }) => React.ReactNode;
17
+ }
18
+ /**
19
+ * "Privacy policy updated" banner. Auto-enabled only when
20
+ * `cookieConsent.privacyPolicyDate` is set on the `RoutingConfig` — renders
21
+ * `null` otherwise, or once acknowledged. Every visual aspect is overridable
22
+ * via `classNames`/`styles` (per-slot) or `render` (full custom markup).
23
+ */
24
+ export default function PrivacyPolicyUpdateDialog({ message, link, closeText, id, classNames, styles, render, }: PrivacyPolicyUpdateDialogProps): React.ReactElement | null;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import useCookieConsent from '../use_cookie_consent';
4
+ /**
5
+ * "Privacy policy updated" banner. Auto-enabled only when
6
+ * `cookieConsent.privacyPolicyDate` is set on the `RoutingConfig` — renders
7
+ * `null` otherwise, or once acknowledged. Every visual aspect is overridable
8
+ * via `classNames`/`styles` (per-slot) or `render` (full custom markup).
9
+ */
10
+ export default function PrivacyPolicyUpdateDialog({ message = 'Our privacy policy has been updated.', link, closeText = 'Got it', id = 'privacy-policy-update-dialog', classNames, styles, render, }) {
11
+ const { privacyPolicyUpdated, acknowledgePrivacyPolicyUpdate } = useCookieConsent();
12
+ if (!privacyPolicyUpdated)
13
+ return null;
14
+ if (render)
15
+ return _jsx(_Fragment, { children: render({ acknowledge: acknowledgePrivacyPolicyUpdate }) });
16
+ return (_jsxs("div", { id: id, role: "dialog", "aria-modal": "false", "aria-labelledby": `${id}-title`, className: classNames?.root, style: styles?.root, children: [_jsxs("p", { id: `${id}-title`, className: classNames?.message, style: styles?.message, children: [message, link ? _jsxs("span", { className: classNames?.link, style: styles?.link, children: [" ", link] }) : null] }), _jsx("button", { type: "button", onClick: acknowledgePrivacyPolicyUpdate, "aria-label": closeText, className: classNames?.closeButton, style: styles?.closeButton, children: closeText })] }));
17
+ }
@@ -0,0 +1,27 @@
1
+ import type { CookieConsentContextType } from '../types';
2
+ export declare const CookieConsentContext: import("react").Context<CookieConsentContextType | null>;
3
+ /**
4
+ * Provides cookie-consent + privacy-policy-update state to
5
+ * `useCookieConsent()` and the default `CookieConsentDialog`/
6
+ * `PrivacyPolicyUpdateDialog` components. Requires `cookieConsent` to be set
7
+ * on the `RoutingConfig` passed to `setIntlConfig` — throws a descriptive
8
+ * error otherwise.
9
+ *
10
+ * The privacy-policy-update banner turns on automatically, and only when
11
+ * `cookieConsent.privacyPolicyDate` is configured: once a visitor has
12
+ * consented, if their stored consent date predates `privacyPolicyDate`,
13
+ * `privacyPolicyUpdated` becomes `true` until they call
14
+ * `acknowledgePrivacyPolicyUpdate()`.
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * <CookieConsentProvider>
19
+ * {children}
20
+ * <CookieConsentDialog />
21
+ * <PrivacyPolicyUpdateDialog />
22
+ * </CookieConsentProvider>
23
+ * ```
24
+ */
25
+ export default function CookieConsentProvider({ children }: {
26
+ children: React.ReactNode;
27
+ }): React.ReactElement;
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useEffect, useMemo, useState } from 'react';
4
+ import config from '../../config/intl_config';
5
+ import requireCookieConsentConfig from '../require_config';
6
+ import getCookie from '../../client/functions/get_cookie';
7
+ import setCookie from '../../client/functions/set_cookie';
8
+ import { cookieConsentCookieKey, privacyPolicyDateCookieKey } from '../../config/cookie_key';
9
+ export const CookieConsentContext = createContext(null);
10
+ function parseConsent(raw) {
11
+ if (raw === 'true')
12
+ return true;
13
+ if (raw === 'false')
14
+ return false;
15
+ return null;
16
+ }
17
+ /**
18
+ * Provides cookie-consent + privacy-policy-update state to
19
+ * `useCookieConsent()` and the default `CookieConsentDialog`/
20
+ * `PrivacyPolicyUpdateDialog` components. Requires `cookieConsent` to be set
21
+ * on the `RoutingConfig` passed to `setIntlConfig` — throws a descriptive
22
+ * error otherwise.
23
+ *
24
+ * The privacy-policy-update banner turns on automatically, and only when
25
+ * `cookieConsent.privacyPolicyDate` is configured: once a visitor has
26
+ * consented, if their stored consent date predates `privacyPolicyDate`,
27
+ * `privacyPolicyUpdated` becomes `true` until they call
28
+ * `acknowledgePrivacyPolicyUpdate()`.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <CookieConsentProvider>
33
+ * {children}
34
+ * <CookieConsentDialog />
35
+ * <PrivacyPolicyUpdateDialog />
36
+ * </CookieConsentProvider>
37
+ * ```
38
+ */
39
+ export default function CookieConsentProvider({ children }) {
40
+ const { consentCookieName, dateCookieName, maxAge, policyDate } = useMemo(() => {
41
+ const cc = requireCookieConsentConfig(config.cookieConsent);
42
+ return {
43
+ consentCookieName: cc.consentCookieName ?? cookieConsentCookieKey,
44
+ dateCookieName: cc.privacyPolicyDateCookieName ?? privacyPolicyDateCookieKey,
45
+ maxAge: cc.cookieMaxAge ?? 31536000,
46
+ policyDate: cc.privacyPolicyDate ? new Date(cc.privacyPolicyDate) : null,
47
+ };
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, []);
50
+ const [consent, setConsentState] = useState(null);
51
+ const [privacyPolicyUpdated, setPrivacyPolicyUpdated] = useState(false);
52
+ useEffect(() => {
53
+ const storedConsent = parseConsent(getCookie(consentCookieName));
54
+ setConsentState(storedConsent);
55
+ if (storedConsent === null || !policyDate)
56
+ return;
57
+ const storedDateRaw = getCookie(dateCookieName);
58
+ if (!storedDateRaw) {
59
+ setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
60
+ return;
61
+ }
62
+ const storedDate = new Date(storedDateRaw);
63
+ setPrivacyPolicyUpdated(!Number.isNaN(storedDate.getTime()) && storedDate < policyDate);
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, []);
66
+ const setConsent = useCallback((value) => {
67
+ setCookie({ name: consentCookieName, value, maxAge });
68
+ if (policyDate)
69
+ setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
70
+ setConsentState(value);
71
+ setPrivacyPolicyUpdated(false);
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, []);
74
+ const acknowledgePrivacyPolicyUpdate = useCallback(() => {
75
+ if (policyDate)
76
+ setCookie({ name: dateCookieName, value: policyDate.toISOString(), maxAge });
77
+ setPrivacyPolicyUpdated(false);
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, []);
80
+ const contextValue = useMemo(() => ({ consent, privacyPolicyUpdated, setConsent, acknowledgePrivacyPolicyUpdate }), [consent, privacyPolicyUpdated, setConsent, acknowledgePrivacyPolicyUpdate]);
81
+ return (_jsx(CookieConsentContext.Provider, { value: contextValue, children: children }));
82
+ }
@@ -0,0 +1,6 @@
1
+ import type { CookieConsentContextType } from '../types';
2
+ /**
3
+ * Reads cookie-consent + privacy-policy-update state. Must be called within
4
+ * a `CookieConsentProvider`.
5
+ */
6
+ export default function useCookieConsent(): CookieConsentContextType;
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+ import { useContext } from 'react';
3
+ import { CookieConsentContext } from './cookie_consent_provider';
4
+ /**
5
+ * Reads cookie-consent + privacy-policy-update state. Must be called within
6
+ * a `CookieConsentProvider`.
7
+ */
8
+ export default function useCookieConsent() {
9
+ const context = useContext(CookieConsentContext);
10
+ if (context === null) {
11
+ throw new Error('useCookieConsent must be used within a CookieConsentProvider');
12
+ }
13
+ return context;
14
+ }
@@ -0,0 +1,7 @@
1
+ export { default as CookieConsentProvider } from './client/cookie_consent_provider';
2
+ export { default as useCookieConsent } from './client/use_cookie_consent';
3
+ export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
4
+ export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
5
+ export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
6
+ export type { CookieConsentContextType, ConsentValue, CookieDialogClassNames, CookieDialogStyles } from './types';
7
+ export type { CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets } from '../types/types';
@@ -0,0 +1,5 @@
1
+ export { default as CookieConsentProvider } from './client/cookie_consent_provider';
2
+ export { default as useCookieConsent } from './client/use_cookie_consent';
3
+ export { default as CookieConsentDialog } from './client/components/cookie_consent_dialog';
4
+ export { default as PrivacyPolicyUpdateDialog } from './client/components/privacy_policy_update_dialog';
5
+ export { default as CookieConsentAnalytics } from './client/components/cookie_consent_analytics';
@@ -0,0 +1,7 @@
1
+ import type { CookieConsentRoutingConfig } from '../types/types';
2
+ /**
3
+ * Throws a descriptive error instead of silently no-op'ing when the
4
+ * `cookie_consent` submodule is used without `cookieConsent` set on the
5
+ * `RoutingConfig` passed to `setIntlConfig`.
6
+ */
7
+ export default function requireCookieConsentConfig(value: CookieConsentRoutingConfig | undefined): CookieConsentRoutingConfig;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Throws a descriptive error instead of silently no-op'ing when the
3
+ * `cookie_consent` submodule is used without `cookieConsent` set on the
4
+ * `RoutingConfig` passed to `setIntlConfig`.
5
+ */
6
+ export default function requireCookieConsentConfig(value) {
7
+ if (!value) {
8
+ throw new Error('cloudflare-next-intl: `cookieConsent` is not set on your `RoutingConfig`. ' +
9
+ 'Add a `cookieConsent` block (see `CookieConsentRoutingConfig`) to the config ' +
10
+ 'object passed to `setIntlConfig` before using `CookieConsentProvider`/`useCookieConsent`.');
11
+ }
12
+ return value;
13
+ }
@@ -0,0 +1,37 @@
1
+ /** Consent value: `true` accepted, `false` necessary-only, `null` not yet decided. */
2
+ export type ConsentValue = boolean | null;
3
+ /** Context value returned by `useCookieConsent()`. */
4
+ export interface CookieConsentContextType {
5
+ /** Current consent value; `null` until the visitor decides. */
6
+ consent: ConsentValue;
7
+ /**
8
+ * `true` once a privacy-policy update has been detected (stored consent
9
+ * predates `cookieConsent.privacyPolicyDate`) and hasn't been
10
+ * acknowledged yet. Always `false` when `privacyPolicyDate` is unset.
11
+ */
12
+ privacyPolicyUpdated: boolean;
13
+ /** Accepts (or rejects, with `false`) cookie consent and persists it. */
14
+ setConsent: (value: boolean) => void;
15
+ /** Acknowledges the privacy-policy update banner and persists the new date. */
16
+ acknowledgePrivacyPolicyUpdate: () => void;
17
+ }
18
+ /** Slot-level style/class overrides accepted by the default dialog components. */
19
+ export interface CookieDialogClassNames {
20
+ root?: string;
21
+ message?: string;
22
+ link?: string;
23
+ actions?: string;
24
+ acceptButton?: string;
25
+ declineButton?: string;
26
+ closeButton?: string;
27
+ }
28
+ /** Slot-level inline-style overrides accepted by the default dialog components. */
29
+ export interface CookieDialogStyles {
30
+ root?: React.CSSProperties;
31
+ message?: React.CSSProperties;
32
+ link?: React.CSSProperties;
33
+ actions?: React.CSSProperties;
34
+ acceptButton?: React.CSSProperties;
35
+ declineButton?: React.CSSProperties;
36
+ closeButton?: React.CSSProperties;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -11,6 +11,7 @@ const fa = {
11
11
  vi.mock('@intl-config', () => ({ default: { firebaseAuth: fa } }));
12
12
  vi.mock('./firebase_client', () => ({
13
13
  getFirebaseAuthClient: vi.fn(async () => ({ auth: {} })),
14
+ getFirebaseAuthModule: () => import('firebase/auth'),
14
15
  }));
15
16
  vi.mock('../error_messages/firebase_auth_error_helper', () => ({
16
17
  default: vi.fn(() => 'translated error'),
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import config from '@intl-config';
3
3
  import requireFirebaseAuthConfig from '../require_config';
4
- import { getFirebaseAuthClient } from './firebase_client';
4
+ import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
5
5
  import firebaseAuthErrorMessage from '../error_messages/firebase_auth_error_helper';
6
6
  function readCredentials(formData) {
7
7
  return {
@@ -27,7 +27,7 @@ export function createLoginAction(locale, messages) {
27
27
  return async function loginAction(_prevState, formData) {
28
28
  requireFirebaseAuthConfig(config.firebaseAuth);
29
29
  const { auth } = await getFirebaseAuthClient();
30
- const { signInWithEmailAndPassword } = await import('firebase/auth');
30
+ const { signInWithEmailAndPassword } = await getFirebaseAuthModule();
31
31
  const { email, password } = readCredentials(formData);
32
32
  try {
33
33
  await signInWithEmailAndPassword(auth, email, password);
@@ -57,7 +57,7 @@ export function createSignUpAction(locale, messages) {
57
57
  return async function signUpAction(_prevState, formData) {
58
58
  requireFirebaseAuthConfig(config.firebaseAuth);
59
59
  const { auth } = await getFirebaseAuthClient();
60
- const { createUserWithEmailAndPassword } = await import('firebase/auth');
60
+ const { createUserWithEmailAndPassword } = await getFirebaseAuthModule();
61
61
  const { email, password } = readCredentials(formData);
62
62
  const confirmPassword = (formData.get('confirmPassword')?.toString() ?? '').trim();
63
63
  if (messages.mismatch && password !== confirmPassword) {
@@ -89,7 +89,7 @@ export function createForgotPasswordAction(locale, messages) {
89
89
  return async function forgotPasswordAction(_prevState, formData) {
90
90
  requireFirebaseAuthConfig(config.firebaseAuth);
91
91
  const { auth } = await getFirebaseAuthClient();
92
- const { sendPasswordResetEmail } = await import('firebase/auth');
92
+ const { sendPasswordResetEmail } = await getFirebaseAuthModule();
93
93
  const email = (formData.get('email')?.toString() ?? '').trim();
94
94
  try {
95
95
  await sendPasswordResetEmail(auth, email);
@@ -5,18 +5,25 @@ import { useRouter } from 'next/navigation';
5
5
  import usePathname from '../../client/hooks/use_path_name';
6
6
  import config from '@intl-config';
7
7
  import requireFirebaseAuthConfig from '../require_config';
8
- import { getFirebaseAuthClient } from './firebase_client';
8
+ import { getFirebaseAuthClient, getFirebaseAuthModule } from './firebase_client';
9
9
  import { setAuthUserCache } from './auth_user_cache';
10
- import { defaultSessionCookieName } from '../middleware/update_session';
10
+ import { defaultRefreshTokenCookieName, defaultSessionCookieName } from '../middleware/update_session';
11
+ import setCookie from '../../client/functions/set_cookie';
11
12
  // `null` default (instead of a `{ loading: true, ... }` stand-in) lets
12
13
  // `useAuthUser` distinguish "not wrapped in AuthUserProvider" (throw) from
13
14
  // "wrapped, still loading" (`loading: true`).
14
15
  export const AuthUserContext = createContext(null);
15
16
  function writeSessionCookie(sessionCookieName, idToken, maxAge) {
16
- document.cookie = `${sessionCookieName}=${idToken}; path=/; max-age=${maxAge}`;
17
+ setCookie({ name: sessionCookieName, value: idToken, maxAge });
17
18
  }
18
19
  function clearSessionCookie(sessionCookieName) {
19
- document.cookie = `${sessionCookieName}=; path=/; max-age=0`;
20
+ setCookie({ name: sessionCookieName, value: '', maxAge: 0 });
21
+ }
22
+ function writeRefreshTokenCookie(refreshTokenCookieName, user, maxAge) {
23
+ setCookie({ name: refreshTokenCookieName, value: user.refreshToken, maxAge });
24
+ }
25
+ function clearRefreshTokenCookie(refreshTokenCookieName) {
26
+ setCookie({ name: refreshTokenCookieName, value: '', maxAge: 0 });
20
27
  }
21
28
  /**
22
29
  * Client-side auth-state provider for `firebase_auth`. Wrap your root layout
@@ -45,11 +52,21 @@ export default function AuthUserProvider({ initialUser = null, children }) {
45
52
  const isWhiteListed = fa.whiteListPaths?.includes(pathname) ?? false;
46
53
  const maxAge = fa.sessionCookieMaxAge ?? 60 * 60 * 24 * 5;
47
54
  const sessionCookieName = fa.sessionCookieName ?? defaultSessionCookieName;
55
+ const refreshTokenMaxAge = fa.refreshTokenCookieMaxAge ?? 60 * 60 * 24 * 365;
56
+ const refreshTokenCookieName = fa.refreshTokenCookieName ?? defaultRefreshTokenCookieName;
48
57
  const [state, setState] = useState({
49
58
  user: initialUser,
50
59
  loading: initialUser === null,
51
60
  });
61
+ // The signed-in state the last successful cookie write left behind, so a
62
+ // plain token refresh (same state) does not trigger a needless re-render.
52
63
  const syncedSignedIn = useRef(undefined);
64
+ // Consecutive `onIdTokenChanged(null)` callbacks since the last confirmed
65
+ // user. A single null here can be a transient client-SDK hiccup (e.g. its
66
+ // token-refresh scheduling misbehaving under local clock skew) rather
67
+ // than a real sign-out — the server already proved the session valid via
68
+ // `initialUser`, so redirecting on the very first null caused a
69
+ // login-then-bounce-home flash whenever the two disagreed.
53
70
  const consecutiveNulls = useRef(0);
54
71
  const [confirmedSignedOut, setConfirmedSignedOut] = useState(initialUser === null);
55
72
  useEffect(() => {
@@ -71,16 +88,24 @@ export default function AuthUserProvider({ initialUser = null, children }) {
71
88
  getFirebaseAuthClient().then(async ({ auth }) => {
72
89
  if (cancelled)
73
90
  return;
74
- const { onIdTokenChanged } = await import('firebase/auth');
91
+ const { onIdTokenChanged } = await getFirebaseAuthModule();
75
92
  unsubscribe = onIdTokenChanged(auth, async (user) => {
76
93
  const isSignedIn = !!user;
77
94
  const previous = syncedSignedIn.current;
78
95
  try {
79
96
  if (user) {
80
- writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
97
+ try {
98
+ writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
99
+ }
100
+ catch (e) {
101
+ console.error('AuthUserProvider: refresh-token cookie sync failed', e);
102
+ }
103
+ const token = await user.getIdToken(true);
104
+ writeSessionCookie(sessionCookieName, token, maxAge);
81
105
  }
82
106
  else if (previous) {
83
107
  clearSessionCookie(sessionCookieName);
108
+ clearRefreshTokenCookie(refreshTokenCookieName);
84
109
  }
85
110
  }
86
111
  catch (e) {
@@ -113,37 +138,49 @@ export default function AuthUserProvider({ initialUser = null, children }) {
113
138
  unsubscribe?.();
114
139
  };
115
140
  // eslint-disable-next-line react-hooks/exhaustive-deps
116
- }, [router, isAuthPage, maxAge, sessionCookieName]);
141
+ }, [router, isAuthPage, maxAge, sessionCookieName, refreshTokenMaxAge, refreshTokenCookieName]);
117
142
  const reloadUser = useCallback(async () => {
118
143
  const { auth } = await getFirebaseAuthClient();
119
144
  const user = auth.currentUser;
120
145
  if (!user)
121
146
  return;
122
- const { reload } = await import('firebase/auth');
123
- await reload(user);
124
- writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
125
- setAuthUserCache(user);
126
- setState({ user, loading: false });
127
- }, [maxAge, sessionCookieName]);
147
+ try {
148
+ const { reload } = await getFirebaseAuthModule();
149
+ await reload(user);
150
+ try {
151
+ writeRefreshTokenCookie(refreshTokenCookieName, user, refreshTokenMaxAge);
152
+ }
153
+ catch (e) {
154
+ console.error('AuthUserProvider: refresh-token cookie sync failed', e);
155
+ }
156
+ writeSessionCookie(sessionCookieName, await user.getIdToken(true), maxAge);
157
+ setAuthUserCache(user);
158
+ setState({ user, loading: false });
159
+ }
160
+ catch (e) {
161
+ console.error('AuthUserProvider: reloadUser failed', e);
162
+ }
163
+ }, []);
128
164
  const sendVerificationEmail = useCallback(async () => {
129
165
  const { auth } = await getFirebaseAuthClient();
130
166
  const user = auth.currentUser;
131
167
  if (!user)
132
168
  return;
133
- const { sendEmailVerification } = await import('firebase/auth');
169
+ const { sendEmailVerification } = await getFirebaseAuthModule();
134
170
  await sendEmailVerification(user);
135
171
  }, []);
136
172
  const logout = useCallback(async () => {
137
173
  try {
138
174
  const { auth } = await getFirebaseAuthClient();
139
- const { signOut } = await import('firebase/auth');
175
+ const { signOut } = await getFirebaseAuthModule();
140
176
  await signOut(auth);
141
177
  }
142
178
  finally {
143
179
  clearSessionCookie(sessionCookieName);
180
+ clearRefreshTokenCookie(refreshTokenCookieName);
144
181
  window.location.assign(fa.redirectAuthPath);
145
182
  }
146
183
  // eslint-disable-next-line react-hooks/exhaustive-deps
147
- }, [fa.redirectAuthPath, sessionCookieName]);
184
+ }, [fa.redirectAuthPath, sessionCookieName, refreshTokenCookieName]);
148
185
  return _jsx(AuthUserContext.Provider, { value: { ...state, reloadUser, sendVerificationEmail, logout }, children: children });
149
186
  }
@@ -16,3 +16,5 @@ export declare function getFirebaseAuthClientSync(): {
16
16
  app: FirebaseApp;
17
17
  auth: Auth;
18
18
  } | undefined;
19
+ /** Memoized `import('firebase/auth')` — see {@link getFirebaseAuthClient} for why this is worth caching. */
20
+ export declare function getFirebaseAuthModule(): Promise<typeof import('firebase/auth')>;
@@ -38,3 +38,11 @@ export async function getFirebaseAuthClient() {
38
38
  export function getFirebaseAuthClientSync() {
39
39
  return cached;
40
40
  }
41
+ let cachedAuthModule;
42
+ /** Memoized `import('firebase/auth')` — see {@link getFirebaseAuthClient} for why this is worth caching. */
43
+ export function getFirebaseAuthModule() {
44
+ if (!cachedAuthModule) {
45
+ cachedAuthModule = import('firebase/auth');
46
+ }
47
+ return cachedAuthModule;
48
+ }
@@ -1,5 +1,5 @@
1
1
  import { getTranslationsImpl } from '../../general/general_functions';
2
- import { getMessageCache } from '../../general/cache_variables';
2
+ import { getMessageCache, getTranslationCache } from '../../general/cache_variables';
3
3
  import { DEFAULT_MESSAGES_EN } from './default_messages.en';
4
4
  const ERROR_CODE_TO_KEY = {
5
5
  'auth/invalid-email': 'invalidEmail',
@@ -29,7 +29,8 @@ export default function firebaseAuthErrorMessage(locale, error) {
29
29
  const messages = getMessageCache(locale);
30
30
  if (messages) {
31
31
  try {
32
- const t = getTranslationsImpl(locale, messages, 'firebaseAuth');
32
+ const cacheKey = `${locale}-firebaseAuth`;
33
+ const t = getTranslationCache(cacheKey) ?? getTranslationsImpl(locale, messages, 'firebaseAuth', cacheKey);
33
34
  const translated = t(key);
34
35
  if (typeof translated === 'string' && translated !== key)
35
36
  return translated;
@@ -232,11 +232,7 @@ export default async function updateSession(request, baseResponse, locale) {
232
232
  }
233
233
  if (refreshedToken) {
234
234
  response.cookies.set(sessionCookieName, refreshedToken.idToken, {
235
- // Not httpOnly: the client provider also writes this cookie
236
- // directly (via document.cookie) after `getIdToken(true)`, so it
237
- // must stay client-writable — a JS cookie write can never carry
238
- // httpOnly anyway, and two same-name cookies with conflicting
239
- // flags is what actually caused ambiguity here.
235
+ httpOnly: true,
240
236
  secure: request.nextUrl.protocol === 'https',
241
237
  sameSite: 'lax',
242
238
  path: '/',
@@ -3,11 +3,16 @@ import type { SerializedAuthUser } from '../types';
3
3
  * Resolves the signed-in user from the session cookie and performs the
4
4
  * authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
5
5
  * `homePath` on auth pages) — middleware only checks cookie *presence*, not
6
- * validity. Plain async function, not a component: callers decide
7
- * where/how to use the resolved user relative to their own component tree
8
- * (see `AuthUserServerProvider` below for the simple case, and
9
- * `IntlProvider`'s auto-wiring for the case where ordering against
10
- * `LocaleContext` matters).
6
+ * validity; a forged, expired, or otherwise invalid-but-present cookie
7
+ * sails through it. Only this function's token validation
8
+ * (`getAuthenticatedAppForUser`) catches that, so this redirect must happen
9
+ * here, before any HTML is sent relying solely on the client
10
+ * `AuthUserProvider` effect to redirect afterwards produces a visible
11
+ * flash (page renders signed-in, then bounces). Plain async function, not
12
+ * a component: callers decide where/how to use the resolved user relative
13
+ * to their own component tree (see `AuthUserServerProvider` below for the
14
+ * simple case, and `IntlProvider`'s auto-wiring for the case where ordering
15
+ * against `LocaleContext` matters).
11
16
  */
12
17
  export declare function resolveAuthUserAndRedirect(): Promise<SerializedAuthUser | null>;
13
18
  /**
@@ -10,11 +10,16 @@ const AuthUserProvider = dynamic(() => import('../client/auth_user_provider'));
10
10
  * Resolves the signed-in user from the session cookie and performs the
11
11
  * authoritative pre-render redirect (guest→`redirectAuthPath`, signed-in→
12
12
  * `homePath` on auth pages) — middleware only checks cookie *presence*, not
13
- * validity. Plain async function, not a component: callers decide
14
- * where/how to use the resolved user relative to their own component tree
15
- * (see `AuthUserServerProvider` below for the simple case, and
16
- * `IntlProvider`'s auto-wiring for the case where ordering against
17
- * `LocaleContext` matters).
13
+ * validity; a forged, expired, or otherwise invalid-but-present cookie
14
+ * sails through it. Only this function's token validation
15
+ * (`getAuthenticatedAppForUser`) catches that, so this redirect must happen
16
+ * here, before any HTML is sent relying solely on the client
17
+ * `AuthUserProvider` effect to redirect afterwards produces a visible
18
+ * flash (page renders signed-in, then bounces). Plain async function, not
19
+ * a component: callers decide where/how to use the resolved user relative
20
+ * to their own component tree (see `AuthUserServerProvider` below for the
21
+ * simple case, and `IntlProvider`'s auto-wiring for the case where ordering
22
+ * against `LocaleContext` matters).
18
23
  */
19
24
  export async function resolveAuthUserAndRedirect() {
20
25
  const fa = config.firebaseAuth;
@@ -4,6 +4,8 @@ import config from '@intl-config';
4
4
  import requireFirebaseAuthConfig from '../require_config';
5
5
  import { defaultSessionCookieName } from '../middleware/update_session';
6
6
  let baseApp;
7
+ let firebaseAppModule;
8
+ let firebaseAuthModule;
7
9
  /**
8
10
  * Resolves the signed-in user on the server from the session cookie.
9
11
  * `initializeServerApp` validates the token with the Auth service, so a
@@ -22,8 +24,12 @@ export const getAuthenticatedAppForUser = cache(async function getAuthenticatedA
22
24
  return { firebaseServerApp: null, currentUser: null };
23
25
  }
24
26
  try {
25
- const { initializeApp, initializeServerApp } = await import('firebase/app');
26
- const { getAuth } = await import('firebase/auth');
27
+ if (!firebaseAppModule)
28
+ firebaseAppModule = await import('firebase/app');
29
+ if (!firebaseAuthModule)
30
+ firebaseAuthModule = await import('firebase/auth');
31
+ const { initializeApp, initializeServerApp } = firebaseAppModule;
32
+ const { getAuth } = firebaseAuthModule;
27
33
  const firebaseConfig = {
28
34
  apiKey: fa.apiKey,
29
35
  authDomain: fa.authDomain,
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
5
5
  import { localesSet } from "../../config/middleware";
6
6
  import config from "../../config/intl_config";
7
7
  const LocationzationClientProvider = dynamic(() => import("../../client/components/client_provider"));
8
+ let authUserServerProviderModule;
8
9
  /**
9
10
  * Server component that provides locale/messages context to the rest of the
10
11
  * tree. Exported publicly as `IntlProvider` from `cloudflare-next-intl/serverProvider`.
@@ -47,9 +48,18 @@ export default async function LocationzationProvider({ language, messages, child
47
48
  }
48
49
  const messagesValue = messages ?? await getMessage(language);
49
50
  let initialAuthUser = null;
50
- if (config.firebaseAuth) {
51
- const { resolveAuthUserAndRedirect } = await import("../../firebase_auth/server/auth_user_server_provider");
52
- initialAuthUser = await resolveAuthUserAndRedirect();
51
+ const autoWireClientProvider = config.firebaseAuth?.autoWireClientProvider !== false;
52
+ if (config.firebaseAuth && autoWireClientProvider) {
53
+ if (!authUserServerProviderModule) {
54
+ authUserServerProviderModule = await import("../../firebase_auth/server/auth_user_server_provider");
55
+ }
56
+ initialAuthUser = await authUserServerProviderModule.resolveAuthUserAndRedirect();
53
57
  }
54
- return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, children: children });
58
+ let analyticsSecrets;
59
+ if (config.cookieConsent && config.cookieConsent.autoWireAnalytics !== false) {
60
+ analyticsSecrets = config.cookieConsent.getSecrets
61
+ ? await config.cookieConsent.getSecrets()
62
+ : config.cookieConsent.secrets;
63
+ }
64
+ return _jsx(LocationzationClientProvider, { language: language, messages: messagesValue, initialAuthUser: initialAuthUser, skipAuthProvider: !autoWireClientProvider, analyticsSecrets: analyticsSecrets, children: children });
55
65
  }
@@ -5,6 +5,7 @@ import { getLocaleCache, getMessageCache, getTranslationCache, setLocaleCache, s
5
5
  import { cache } from "react";
6
6
  import { localesSet } from "../../config/middleware";
7
7
  const isDev = process.env.NODE_ENV === 'development';
8
+ let nextHeadersModule;
8
9
  /**
9
10
  * Loads and caches messages for a specific locale using dynamic import.
10
11
  * Prevents redundant file loads and handles import errors gracefully.
@@ -104,8 +105,10 @@ async function iGetLocale() {
104
105
  // Dynamically import "next/headers" only when needed.
105
106
  // This ensures it's loaded only on the server where cookies are accessible,
106
107
  // preventing client-side import errors and reducing bundle size.
107
- const { cookies } = await import("next/headers");
108
- const cookieStore = await cookies();
108
+ if (!nextHeadersModule) {
109
+ nextHeadersModule = await import("next/headers");
110
+ }
111
+ const cookieStore = await nextHeadersModule.cookies();
109
112
  const localeCookie = cookieStore.get(localeCookieName);
110
113
  // Use the cookie value or fall back to the default locale.
111
114
  const localeValue = localeCookie?.value ?? config.defaultLocale;
@@ -1 +1 @@
1
- export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, } from './types';
1
+ export type { CookieAttributes, LocalePrefixMode, Locales, ReturnType, RoutingConfig, TranslationEntry, TranslationObject, TranslatorReturnType, Alternates, changeFrequency, IntlSitemap, CookieConsentRoutingConfig, CookieConsentAnalyticsSecrets, } from './types';
@@ -81,6 +81,65 @@ export interface RoutingConfig<AppLocales extends Locales, AppLocalePrefixMode e
81
81
  * if this field is missing at call time rather than silently no-op'ing.
82
82
  */
83
83
  firebaseAuth?: FirebaseAuthRoutingConfig;
84
+ /**
85
+ * Configures the optional `cookie_consent` submodule (cookie-consent +
86
+ * privacy-policy-update banners). Omit entirely to keep it disabled —
87
+ * `useCookieConsent()`/`CookieConsentProvider` will throw a descriptive
88
+ * error if called without this set.
89
+ */
90
+ cookieConsent?: CookieConsentRoutingConfig;
91
+ }
92
+ export interface CookieConsentRoutingConfig {
93
+ /**
94
+ * Date the current privacy policy was last modified, e.g. `"2026-07-20"`
95
+ * or a `Date`. When set, the "privacy policy updated" banner
96
+ * automatically shows to any visitor whose stored consent predates this
97
+ * date. Omit to disable the privacy-policy-update banner entirely (the
98
+ * cookie-consent banner still works independently).
99
+ */
100
+ privacyPolicyDate?: string | Date;
101
+ /** Cookie-consent cookie name. Defaults to `'__cookie_consent_key__'`. */
102
+ consentCookieName?: string;
103
+ /** Privacy-policy-date cookie name. Defaults to `'__privacy_policy_date_key__'`. */
104
+ privacyPolicyDateCookieName?: string;
105
+ /** Cookie max-age in seconds for both cookies above. Defaults to 1 year (31536000). */
106
+ cookieMaxAge?: number;
107
+ /**
108
+ * Whether `IntlProvider` should automatically render the analytics/ads
109
+ * scripts (Cloudflare Web Analytics beacon, Google Consent Mode + gtag,
110
+ * Microsoft Clarity — whichever secrets resolve below) once consent is
111
+ * granted, and gate them behind the cookie-consent banner otherwise.
112
+ * Defaults to `true` when `secrets`/`getSecrets` is set; set `false` to
113
+ * keep `cookieConsent` configured for the dialogs/hook only and wire
114
+ * analytics yourself.
115
+ */
116
+ autoWireAnalytics?: boolean;
117
+ /**
118
+ * Static secrets/IDs for the analytics providers below. Use this OR
119
+ * `getSecrets`, not both — `getSecrets` takes precedence when both are
120
+ * set (e.g. secrets only available at request time from a Cloudflare
121
+ * `env` binding).
122
+ */
123
+ secrets?: CookieConsentAnalyticsSecrets;
124
+ /**
125
+ * Resolves the same secrets at request time — e.g. from Cloudflare's
126
+ * `getCloudflareContext().env` (via `@opennextjs/cloudflare`, not a
127
+ * dependency of this package — pass your own getter). Any field left
128
+ * `undefined` in the returned object disables that provider's script.
129
+ */
130
+ getSecrets?: () => CookieConsentAnalyticsSecrets | Promise<CookieConsentAnalyticsSecrets>;
131
+ }
132
+ export interface CookieConsentAnalyticsSecrets {
133
+ /** Cloudflare Web Analytics beacon token, e.g. `'{"token": "..."}'` (the raw `data-cf-beacon` attribute value). */
134
+ cloudflareBeaconToken?: string;
135
+ /** Google Analytics measurement ID, e.g. `"G-XXXXXXX"`. */
136
+ googleAnalyticsId?: string;
137
+ /** Google Ads conversion ID, e.g. `"AW-XXXXXXXXX"`. */
138
+ googleAdsId?: string;
139
+ /** Google AdSense publisher ID, e.g. `"ca-pub-XXXXXXXXXXXXXXXX"`. */
140
+ googleAdSenseId?: string;
141
+ /** Microsoft Clarity project ID. */
142
+ clarityProjectId?: string;
84
143
  }
85
144
  export interface FirebaseAuthRoutingConfig {
86
145
  /**
@@ -92,6 +151,17 @@ export interface FirebaseAuthRoutingConfig {
92
151
  * middleware redirect logic yourself instead.
93
152
  */
94
153
  middlewareEnabled?: boolean;
154
+ /**
155
+ * Whether `IntlProvider` should automatically wrap your app in the
156
+ * client `AuthUserProvider` and call `resolveAuthUser` server-side.
157
+ * Defaults to `true`. Set `false` if you drive auth entirely from your
158
+ * own middleware (like `middlewareEnabled: false`'s manual-override
159
+ * case, but for the client/RSC layer) and don't want this package
160
+ * rendering any auth-related React tree on top of it — e.g. if you
161
+ * only use `intlMiddleware`'s built-in session-refresh/redirect logic
162
+ * and have no use for `useAuthUser()`/`AuthUserProvider` at all.
163
+ */
164
+ autoWireClientProvider?: boolean;
95
165
  /** Firebase project's Web API key (`NEXT_PUBLIC_FIREBASE_API_KEY` equivalent). */
96
166
  apiKey: string;
97
167
  /** Firebase project's auth domain, e.g. "my-app.firebaseapp.com". */
package/llms.txt CHANGED
@@ -34,6 +34,15 @@ other subpath can be used.
34
34
  - `./firebaseAuthActions` — `createLoginAction`/`createSignUpAction`/`createForgotPasswordAction`: factories returning React `useActionState`-shaped form actions.
35
35
  - `./firebaseAuthMiddleware` — `updateSession`: session-cookie refresh, called automatically by `./middleware`'s default handler.
36
36
 
37
+ ## `cookieConsent*` subpaths (require `cookieConsent` set on your `RoutingConfig`)
38
+
39
+ - `./cookieConsent` — barrel: `CookieConsentProvider`, `useCookieConsent`, `CookieConsentDialog`, `PrivacyPolicyUpdateDialog`, `CookieConsentAnalytics`.
40
+ - `./CookieConsentProvider` — context provider; reads/writes consent + privacy-policy-date cookies. Auto-wired by `IntlProvider` when `cookieConsent` is configured — manual nesting is optional.
41
+ - `./useCookieConsent` — context hook; throws `"useCookieConsent must be used within a CookieConsentProvider"` if called outside one.
42
+ - `./CookieConsentDialog` — default cookie-consent banner; accepts per-slot `classNames`/`styles` or a `render` prop for fully custom markup.
43
+ - `./PrivacyPolicyUpdateDialog` — "privacy policy updated" banner; auto-enabled only when `cookieConsent.privacyPolicyDate` is set.
44
+ - `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`).
45
+
37
46
  ## Conventions
38
47
 
39
48
  - Every exported function/component has a JSDoc comment with an `@example` where usage isn't obvious from the signature alone.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -115,6 +115,30 @@
115
115
  "./firebaseAuthMiddleware": {
116
116
  "types": "./dist/src/firebase_auth/middleware/update_session.d.ts",
117
117
  "import": "./dist/src/firebase_auth/middleware/update_session.js"
118
+ },
119
+ "./cookieConsent": {
120
+ "types": "./dist/src/cookie_consent/index.d.ts",
121
+ "import": "./dist/src/cookie_consent/index.js"
122
+ },
123
+ "./CookieConsentProvider": {
124
+ "types": "./dist/src/cookie_consent/client/cookie_consent_provider.d.ts",
125
+ "import": "./dist/src/cookie_consent/client/cookie_consent_provider.js"
126
+ },
127
+ "./useCookieConsent": {
128
+ "types": "./dist/src/cookie_consent/client/use_cookie_consent.d.ts",
129
+ "import": "./dist/src/cookie_consent/client/use_cookie_consent.js"
130
+ },
131
+ "./CookieConsentDialog": {
132
+ "types": "./dist/src/cookie_consent/client/components/cookie_consent_dialog.d.ts",
133
+ "import": "./dist/src/cookie_consent/client/components/cookie_consent_dialog.js"
134
+ },
135
+ "./PrivacyPolicyUpdateDialog": {
136
+ "types": "./dist/src/cookie_consent/client/components/privacy_policy_update_dialog.d.ts",
137
+ "import": "./dist/src/cookie_consent/client/components/privacy_policy_update_dialog.js"
138
+ },
139
+ "./cookieConsentAnalytics": {
140
+ "types": "./dist/src/cookie_consent/client/components/cookie_consent_analytics.d.ts",
141
+ "import": "./dist/src/cookie_consent/client/components/cookie_consent_analytics.js"
118
142
  }
119
143
  },
120
144
  "scripts": {
@@ -158,6 +182,7 @@
158
182
  },
159
183
  "homepage": "https://github.com/demian-ilnytskyi/cloudflare-next-intl#readme",
160
184
  "peerDependencies": {
185
+ "@microsoft/clarity": ">=1.0.0",
161
186
  "firebase": ">=10.0.0",
162
187
  "next": ">=12.0.0",
163
188
  "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0",
@@ -169,11 +194,15 @@
169
194
  },
170
195
  "firebase": {
171
196
  "optional": true
197
+ },
198
+ "@microsoft/clarity": {
199
+ "optional": true
172
200
  }
173
201
  },
174
202
  "devDependencies": {
175
203
  "@eslint/eslintrc": "^3",
176
204
  "@eslint/js": "^9.27.0",
205
+ "@microsoft/clarity": "^1.0.2",
177
206
  "@testing-library/dom": "^10.4.1",
178
207
  "@testing-library/jest-dom": "^7.0.0",
179
208
  "@testing-library/react": "^16.3.2",