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.
- package/dist/src/config/init_config.js +8 -1
- package/dist/src/cookie_consent/client/components/cookie_consent_analytics.js +6 -5
- package/dist/src/cookie_consent/client/components/cookie_consent_dialog.js +2 -2
- package/dist/src/cookie_consent/client/cookie_consent_provider.js +10 -11
- package/dist/src/cookie_consent/types.d.ts +9 -0
- package/dist/src/firebase_auth/middleware/update_session.d.ts +1 -0
- package/dist/src/firebase_auth/middleware/update_session.js +44 -0
- package/dist/src/server/components/helper_script.bench.d.ts +1 -0
- package/dist/src/server/components/helper_script.bench.js +73 -0
- package/dist/src/types/types.d.ts +42 -0
- package/dist/vitest.bench.config.d.ts +2 -0
- package/dist/vitest.bench.config.js +18 -0
- package/package.json +1 -1
|
@@ -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 = [
|
|
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 =
|
|
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) } })),
|
|
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
|
-
// `
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
68
|
+
// `consent` reflects only what the visitor actually decided — never
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,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
|
+
});
|