cloudflare-next-intl 0.6.17 → 0.6.19

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.
@@ -7,7 +7,14 @@
7
7
  // Auto-prepending `/` here fixes the common typo (`'login'` instead of
8
8
  // `'/login'`) at the source, for every consumer, instead of requiring each
9
9
  // one to notice and fix it themselves.
10
- const FIREBASE_AUTH_PATH_FIELDS = ['redirectAuthPath', 'homePath', 'verifyEmailPath'];
10
+ const FIREBASE_AUTH_PATH_FIELDS = [
11
+ 'redirectAuthPath',
12
+ 'homePath',
13
+ 'verifyEmailPath',
14
+ 'resetPasswordPath',
15
+ 'recoverEmailPath',
16
+ 'actionLinkPath',
17
+ ];
11
18
  function normalizeFirebaseAuthPaths(config) {
12
19
  const fa = config.firebaseAuth;
13
20
  if (!fa)
@@ -18,23 +18,24 @@ const ClarityScript = dynamic(() => import('./clarity_script'));
18
18
  * `false` — render manually instead if you set `autoWireAnalytics: false`.
19
19
  */
20
20
  export default function CookieConsentAnalytics({ config }) {
21
- const { consent } = useCookieConsent();
21
+ const { consent, requiresConsent } = useCookieConsent();
22
+ const granted = consent === true || !requiresConsent;
22
23
  useEffect(() => {
23
- if (consent === null)
24
+ if (consent === null && requiresConsent)
24
25
  return;
25
26
  const w = window;
26
27
  if (typeof w.gtag !== 'function')
27
28
  return;
28
- const state = consent ? 'granted' : 'denied';
29
+ const state = granted ? 'granted' : 'denied';
29
30
  w.gtag('consent', 'update', {
30
31
  ad_storage: state,
31
32
  ad_user_data: state,
32
33
  ad_personalization: state,
33
34
  analytics_storage: state,
34
35
  });
35
- }, [consent]);
36
+ }, [consent, requiresConsent, granted]);
36
37
  const hasGoogle = Boolean(config.googleAnalyticsId || config.googleAdsId || config.googleAdSenseId);
37
- return (_jsxs(_Fragment, { children: [hasGoogle && (_jsx("script", { id: "cookie-consent-google-consent-mode", dangerouslySetInnerHTML: { __html: googleConsentModeBootstrapScript(config) } })), consent === true && config.cloudflareBeaconToken && (_jsx("script", { defer: true, src: "https://static.cloudflareinsights.com/beacon.min.js", "data-cf-beacon": config.cloudflareBeaconToken })), consent === true && config.clarityProjectId && _jsx(ClarityScript, { projectId: config.clarityProjectId })] }));
38
+ return (_jsxs(_Fragment, { children: [hasGoogle && (_jsx("script", { id: "cookie-consent-google-consent-mode", dangerouslySetInnerHTML: { __html: googleConsentModeBootstrapScript(config) } })), granted && config.cloudflareBeaconToken && (_jsx("script", { defer: true, src: "https://static.cloudflareinsights.com/beacon.min.js", "data-cf-beacon": config.cloudflareBeaconToken })), granted && config.clarityProjectId && _jsx(ClarityScript, { projectId: config.clarityProjectId })] }));
38
39
  }
39
40
  /**
40
41
  * Denies storage by default and loads the configured Google tags; the
@@ -13,8 +13,8 @@ import { defaultCookieDialogClassNames } from './default_dialog_styles';
13
13
  * particular design system.
14
14
  */
15
15
  export default function CookieConsentDialog({ message, link, privacyPolicyLinkText, acceptText, declineText, hideDecline = false, id = 'cookie-consent-dialog', classNames, styles, render, }) {
16
- const { consent, isMounted, setConsent, privacyPolicyPath } = useCookieConsent();
17
- if (!isMounted || consent !== null)
16
+ const { consent, requiresConsent, isMounted, setConsent, privacyPolicyPath } = useCookieConsent();
17
+ if (!isMounted || !requiresConsent || consent !== null)
18
18
  return null;
19
19
  if (render)
20
20
  return _jsx(_Fragment, { children: render({ setConsent }) });
@@ -65,19 +65,18 @@ export default function CookieConsentProvider({ requiresConsent = true, children
65
65
  const [isMounted, setIsMounted] = useState(false);
66
66
  const pathname = usePathname();
67
67
  useEffect(() => {
68
- // `undefined` (no cookie at all) means a genuine first visit auto-
69
- // accept applies. `'null'` (explicitly stored by `setConsent(null)`,
70
- // e.g. a "cookie settings" button) means the visitor already decided
71
- // once and is being asked to re-decide, so it must NOT auto-accept
72
- // again otherwise it'd immediately flip back to `true` whenever
73
- // `requiresConsent` is `false` (e.g. always in dev, see
74
- // `resolveRequiresConsent`), reopening then instantly re-closing the
75
- // dialog on the next navigation/refresh.
68
+ // `consent` reflects only what the visitor actually decidednever
69
+ // auto-set to `true` just because `requiresConsent` is `false`.
70
+ // Whether the banner shows / analytics unlock for a not-required
71
+ // visitor is `requiresConsent`'s job (see `CookieConsentDialog`/
72
+ // `CookieConsentAnalytics`), not something baked into `consent`
73
+ // itself; otherwise there's no way to tell "not required" apart
74
+ // from "explicitly accepted" (e.g. the settings button in the nav
75
+ // bar, which only renders once consent has been decided).
76
76
  const rawConsent = getCookie(consentCookieName);
77
77
  const storedConsent = parseConsent(rawConsent);
78
78
  const isFirstVisit = rawConsent === null;
79
- const autoAccepted = isFirstVisit && !requiresConsent;
80
- setConsentState(autoAccepted ? true : storedConsent);
79
+ setConsentState(storedConsent);
81
80
  setIsMounted(true);
82
81
  if (isFirstVisit || !policyDate)
83
82
  return;
@@ -121,6 +120,6 @@ export default function CookieConsentProvider({ requiresConsent = true, children
121
120
  acknowledgePrivacyPolicyUpdate();
122
121
  }
123
122
  }, [pathname, privacyPolicyUpdated, privacyPolicyPath, acknowledgePrivacyPolicyUpdate]);
124
- const contextValue = useMemo(() => ({ consent, privacyPolicyUpdated, isMounted, setConsent, acknowledgePrivacyPolicyUpdate, privacyPolicyPath }), [consent, privacyPolicyUpdated, isMounted, setConsent, acknowledgePrivacyPolicyUpdate, privacyPolicyPath]);
123
+ const contextValue = useMemo(() => ({ consent, requiresConsent, privacyPolicyUpdated, isMounted, setConsent, acknowledgePrivacyPolicyUpdate, privacyPolicyPath }), [consent, requiresConsent, privacyPolicyUpdated, isMounted, setConsent, acknowledgePrivacyPolicyUpdate, privacyPolicyPath]);
125
124
  return (_jsx(CookieConsentContext.Provider, { value: contextValue, children: children }));
126
125
  }
@@ -4,6 +4,15 @@ export type ConsentValue = boolean | null;
4
4
  export interface CookieConsentContextType {
5
5
  /** Current consent value; `null` until the visitor decides. */
6
6
  consent: ConsentValue;
7
+ /**
8
+ * Resolved server-side from `cookieConsent.getCountryCode`/
9
+ * `gdprCountries` (see `resolveRequiresConsent`). `false` means the
10
+ * visitor's country doesn't require consent at all — the default
11
+ * `CookieConsentDialog` stays hidden and `CookieConsentAnalytics`
12
+ * unlocks immediately, even while `consent` itself is still `null`
13
+ * (never decided, since there was nothing to decide).
14
+ */
15
+ requiresConsent: boolean;
7
16
  /**
8
17
  * `false` until the client has read the stored consent/date cookies once
9
18
  * (always `false` during SSR and the first client render, to avoid a
@@ -2,6 +2,7 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  export declare const defaultSessionCookieName = "__fa_session__";
3
3
  export declare const defaultRefreshTokenCookieName = "__fa_refresh_token__";
4
4
  export declare const defaultEmailVerifiedHintCookieName = "__fa_email_verified_hint__";
5
+ export declare const defaultResetPasswordPath = "/reset-password";
5
6
  /**
6
7
  * Layers Firebase session-cookie validation/refresh and auth redirects onto
7
8
  * an already-built middleware response. Called internally by `intlMiddleware`
@@ -10,6 +10,24 @@ export const defaultRefreshTokenCookieName = '__fa_refresh_token__';
10
10
  // — force one refresh). Readable client-side is fine: it carries no secret,
11
11
  // only a boolean mirror of a claim already inside the session JWT.
12
12
  export const defaultEmailVerifiedHintCookieName = '__fa_email_verified_hint__';
13
+ export const defaultResetPasswordPath = '/reset-password';
14
+ /**
15
+ * Firebase's console exposes ONE project-wide action URL, so every email
16
+ * template (password reset, email verification, email recovery) lands on that
17
+ * same URL and distinguishes itself only by `?mode=`. This maps those raw
18
+ * `mode` values onto the app's own pages so each link reaches the page that
19
+ * knows how to consume its `oobCode`.
20
+ */
21
+ function resolveActionModePaths(fa) {
22
+ const paths = {
23
+ resetPassword: fa.resetPasswordPath ?? defaultResetPasswordPath,
24
+ };
25
+ if (fa.verifyEmailPath)
26
+ paths.verifyEmail = fa.verifyEmailPath;
27
+ if (fa.recoverEmailPath)
28
+ paths.recoverEmail = fa.recoverEmailPath;
29
+ return { ...paths, ...fa.actionModePaths };
30
+ }
13
31
  const DEFAULT_SESSION_MAX_AGE = 60 * 60 * 24 * 5;
14
32
  const DEFAULT_REFRESH_MAX_AGE = 60 * 60 * 24 * 365;
15
33
  // Refresh slightly before the real expiry — treating a token as expired
@@ -175,6 +193,32 @@ export default async function updateSession(request, baseResponse, locale) {
175
193
  }
176
194
  const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
177
195
  const localeUrl = (target) => new URL(`${localePrefix}${target === '/' ? '' : target}` || '/', request.url);
196
+ // Emailed Firebase action links all arrive on the single project-wide
197
+ // action URL carrying `?mode=<action>&oobCode=...`. Forward them to the
198
+ // page for that mode BEFORE any auth/whitelist check below: these links
199
+ // are followed by users who are typically signed OUT (a password reset,
200
+ // or verification opened in another browser), so letting the guest
201
+ // redirect run first would bounce them to `redirectAuthPath` and discard
202
+ // the `oobCode` they came to spend. The whole query string is preserved
203
+ // so the destination page still receives `oobCode`/`continueUrl`/`lang`.
204
+ // When `actionLinkPath` is set, only requests to that exact (locale-
205
+ // stripped) path are eligible — matches a Firebase Console action URL
206
+ // pinned to one static path (e.g. "https://example.com/auth/action")
207
+ // rather than the bare domain root.
208
+ const isEligibleActionPath = !fa.actionLinkPath || path === fa.actionLinkPath;
209
+ if (fa.actionLinkRedirectEnabled !== false && isEligibleActionPath) {
210
+ const mode = request.nextUrl.searchParams.get('mode');
211
+ if (mode) {
212
+ const target = resolveActionModePaths(fa)[mode];
213
+ // Skip when already on the destination — the forward sets the same
214
+ // `?mode=` it matched on, so redirecting again would loop forever.
215
+ if (target && target !== path) {
216
+ const url = localeUrl(target);
217
+ url.search = request.nextUrl.search;
218
+ return buildRedirect(baseResponse, url);
219
+ }
220
+ }
221
+ }
178
222
  const isWhiteListed = fa.whiteListPaths?.includes(path) ?? false;
179
223
  if (isWhiteListed)
180
224
  return baseResponse;
@@ -0,0 +1,73 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { isDarkCookieKey } from '../../config/cookie_key';
3
+ const getCookieRegex = (name) => {
4
+ const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
5
+ return match ? decodeURIComponent(match[1]) : null;
6
+ };
7
+ const cache = new Map();
8
+ const getCookieCachedRegex = (name) => {
9
+ let re = cache.get(name);
10
+ if (!re) {
11
+ re = new RegExp(`(?:^|; )${name}=([^;]*)`);
12
+ cache.set(name, re);
13
+ }
14
+ const match = document.cookie.match(re);
15
+ return match ? decodeURIComponent(match[1]) : null;
16
+ };
17
+ const getCookieIndexOf = (name) => {
18
+ const raw = document.cookie;
19
+ const key = name + '=';
20
+ let i = raw.indexOf(key);
21
+ while (i !== -1) {
22
+ if (i === 0 || (raw[i - 1] === ' ' && raw[i - 2] === ';')) {
23
+ const end = raw.indexOf(';', i);
24
+ return raw.slice(i + key.length, end === -1 ? undefined : end);
25
+ }
26
+ i = raw.indexOf(key, i + 1);
27
+ }
28
+ return null;
29
+ };
30
+ document.cookie = `${isDarkCookieKey}=true`;
31
+ document.cookie = 'NEXT_LOCALE=uk';
32
+ document.cookie = 'session=abcdefghijklmnop';
33
+ describe('cookie read strategies', () => {
34
+ bench('regex constructed per call (current)', () => {
35
+ getCookieRegex(isDarkCookieKey);
36
+ });
37
+ bench('regex cached', () => {
38
+ getCookieCachedRegex(isDarkCookieKey);
39
+ });
40
+ bench('indexOf scan, no decode', () => {
41
+ getCookieIndexOf(isDarkCookieKey);
42
+ });
43
+ });
44
+ describe('theme apply', () => {
45
+ bench('guarded toggle (current)', () => {
46
+ const classList = document.documentElement.classList;
47
+ const isDark = true;
48
+ if (classList.contains('dark') !== isDark) {
49
+ classList.toggle('dark', isDark);
50
+ }
51
+ });
52
+ bench('unguarded toggle', () => {
53
+ document.documentElement.classList.toggle('dark', true);
54
+ });
55
+ });
56
+ describe('full inline script path', () => {
57
+ bench('current: read cookie + guarded toggle', () => {
58
+ const isDark = getCookieRegex(isDarkCookieKey);
59
+ const classList = document.documentElement.classList;
60
+ const want = isDark === 'true';
61
+ if (classList.contains('dark') !== want) {
62
+ classList.toggle('dark', want);
63
+ }
64
+ });
65
+ bench('optimized: indexOf + guarded toggle', () => {
66
+ const isDark = getCookieIndexOf(isDarkCookieKey);
67
+ const classList = document.documentElement.classList;
68
+ const want = isDark === 'true';
69
+ if (classList.contains('dark') !== want) {
70
+ classList.toggle('dark', want);
71
+ }
72
+ });
73
+ });
@@ -446,6 +446,48 @@ export interface FirebaseAuthRoutingConfig {
446
446
  homePath: string;
447
447
  /** Path to redirect unverified-email users to. Omit to skip email-verification redirects. Must start with "/" — `setIntlConfig` auto-corrects a missing leading slash with a warning. */
448
448
  verifyEmailPath?: string;
449
+ /**
450
+ * Path handling an emailed password-reset link. Firebase allows only ONE
451
+ * project-wide action URL, so every template lands on the same URL with
452
+ * a `?mode=` query param; the middleware reads that param and forwards
453
+ * the request (query string intact, `oobCode` included) to the path for
454
+ * that mode. Defaults to `'/reset-password'`. Must start with "/" —
455
+ * `setIntlConfig` auto-corrects a missing leading slash with a warning.
456
+ */
457
+ resetPasswordPath?: string;
458
+ /**
459
+ * Path handling an emailed `recoverEmail` action link (undo an email
460
+ * change). Omit to leave that mode unhandled — the request then falls
461
+ * through to normal routing instead of being forwarded. Must start with
462
+ * "/" — `setIntlConfig` auto-corrects a missing leading slash with a
463
+ * warning.
464
+ */
465
+ recoverEmailPath?: string;
466
+ /**
467
+ * Extra/overriding `?mode=` → path entries for the emailed-action-link
468
+ * forward described on {@link resetPasswordPath}. Merged over the
469
+ * defaults derived from `resetPasswordPath`/`verifyEmailPath`/
470
+ * `recoverEmailPath`, so this is how you handle a mode this config has
471
+ * no dedicated field for (e.g. `verifyAndChangeEmail`) or point one of
472
+ * the known modes somewhere else. Keys are raw Firebase `mode` values.
473
+ */
474
+ actionModePaths?: Readonly<Record<string, string>>;
475
+ /**
476
+ * Set `false` to disable the emailed-action-link forward entirely (see
477
+ * {@link resetPasswordPath}) and let `?mode=` URLs route normally.
478
+ * Defaults to `true`.
479
+ */
480
+ actionLinkRedirectEnabled?: boolean;
481
+ /**
482
+ * Restricts the emailed-action-link forward (see {@link resetPasswordPath})
483
+ * to this exact static path — set this to whatever path your Firebase
484
+ * Console "action URL" is pinned to (e.g. `'/auth/action'`) so a `?mode=`
485
+ * on any other page is left alone instead of being treated as an action
486
+ * link. Omit to match Firebase's bare-domain-root default: any path
487
+ * carrying `?mode=` is eligible. Must start with "/" — `setIntlConfig`
488
+ * auto-corrects a missing leading slash with a warning.
489
+ */
490
+ actionLinkPath?: string;
449
491
  /** Returns true if the given (locale-stripped) path is an auth page (login/signup/etc). */
450
492
  isAuthPath: (path: string) => boolean;
451
493
  /** Locale-stripped paths exempt from all auth redirects (e.g. public marketing pages). */
@@ -0,0 +1,2 @@
1
+ declare const _default: import("vite").UserConfig;
2
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'node:path';
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ setupFiles: ['./vitest.setup.ts'],
7
+ benchmark: {
8
+ include: ['src/server/components/helper_script.bench.ts'],
9
+ outputJson: '/private/tmp/claude-501/-Volumes-External-own-projects-cloudflare-next-intl/5816d729-594e-4dc8-b38e-1378f1a159bd/scratchpad/bres.json',
10
+ },
11
+ },
12
+ resolve: {
13
+ alias: {
14
+ '@intl-config': path.resolve(__dirname, './src/test_utils/mock_intl_config.ts'),
15
+ '@locale-file': path.resolve(__dirname, './src/test_utils/mock_locale_file'),
16
+ },
17
+ },
18
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
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",