gate locale behind functional consent

This commit is contained in:
2026-08-12 18:03:11 +02:00
parent 195d4169f5
commit 4bf50514bf
212 changed files with 173 additions and 840 deletions
+28 -11
View File
@@ -1,3 +1,4 @@
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js';
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js';
/**
* First path segment of a URL pathname, or '' for root.
@@ -14,22 +15,24 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
* comes from negotiateLocale (cookie → Accept-Language → default)
* - the chosen locale is written to a cookie so the next visit is stable
*
* The plugin returns a decision; the thin next-middleware.ts in the client project
* The plugin returns a decision; the thin middleware.ts in the client project
* turns it into a NextResponse. This keeps all logic in the plugin while
* respecting that next-middleware.ts must physically live in the client app.
* respecting that middleware.ts must physically live in the client app.
*
* @example
* // next-middleware.ts (client project) — one wiring file, no logic:
* // middleware.ts (client project) — one wiring file, no logic:
* import { NextResponse } from 'next/server'
* import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function middleware(req) {
* export function proxy(req) {
* const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next()
* const res = NextResponse.redirect(r.location)
* res.cookies.set(r.cookie.name, r.cookie.value)
* // cookie is optional: only present when the visitor consented to the
* // gating category (functional by default). Guard before setting.
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res
* }
*/ export function createLocaleMiddleware({ config, cookieName = LOCALE_COOKIE_NAME }) {
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
return function localeMiddleware(request) {
const { pathname } = request.nextUrl;
// Already locale-prefixed → nothing to do
@@ -47,13 +50,27 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
// Redirect to the locale-prefixed path, preserving the rest
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
// Persist the locale choice ONLY if the visitor consented to the gating
// category. 'necessary' is always granted, so passing consentCategory:
// 'necessary' always persists. For 'functional' (default), we read the
// consent cookie and only write the locale cookie when functional is true.
// Without consent the locale is still detected each request (routing works),
// it just isn't remembered across visits — which is the whole point of
// gating a functional cookie behind consent.
let mayPersist = consentCategory === 'necessary';
if (!mayPersist) {
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
mayPersist = consent?.[consentCategory] === true;
}
return {
type: 'redirect',
cookie: {
name: cookieName,
value: locale
},
location: url.toString()
location: url.toString(),
...mayPersist ? {
cookie: {
name: cookieName,
value: locale
}
} : {}
};
};
}