gate locale behind functional consent
This commit is contained in:
@@ -3,11 +3,28 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import type { ConsentCategory, ConsentState } from './categories.js'
|
||||
|
||||
import { LOCALE_COOKIE_NAME } from '../i18n/index.js'
|
||||
import { ACCEPT_ALL_CONSENT, DEFAULT_CONSENT, REJECT_ALL_CONSENT } from './categories.js'
|
||||
import { updateConsent } from './googleConsent.js'
|
||||
import { CONSENT_COOKIE, CONSENT_MAX_AGE, parseConsent, serializeConsent } from './storage.js'
|
||||
|
||||
export function useConsent() {
|
||||
/**
|
||||
* Cookies to delete when consent for a category is withdrawn. Keyed by
|
||||
* category. The plugin knows about its own functional cookie (locale); clients
|
||||
* can extend this via the `cookieMap` arg to useConsent if they set their own.
|
||||
*/
|
||||
const DEFAULT_COOKIE_MAP: Partial<Record<ConsentCategory, string[]>> = {
|
||||
functional: [LOCALE_COOKIE_NAME],
|
||||
}
|
||||
|
||||
/** Expire a cookie now, across the whole site. */
|
||||
function deleteCookie(name: string) {
|
||||
document.cookie = `${name}=;path=/;max-age=0;samesite=lax`
|
||||
}
|
||||
|
||||
export function useConsent(
|
||||
cookieMap: Partial<Record<ConsentCategory, string[]>> = DEFAULT_COOKIE_MAP,
|
||||
) {
|
||||
const [state, setState] = useState<ConsentState>(DEFAULT_CONSENT)
|
||||
const [decided, setDecided] = useState(false)
|
||||
|
||||
@@ -23,16 +40,31 @@ export function useConsent() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const persist = useCallback((next: ConsentState) => {
|
||||
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`
|
||||
setState(next)
|
||||
setDecided(true)
|
||||
// Tell Google right away. Tags are already on the page holding whatever
|
||||
// defaults Analytics set at load; without this update they keep them for
|
||||
// the rest of the session and never write their cookies — the banner would
|
||||
// look like it worked while nothing changed.
|
||||
updateConsent(next)
|
||||
}, [])
|
||||
const persist = useCallback(
|
||||
(next: ConsentState) => {
|
||||
// Withdrawal cleanup: for any category flipping true → false, delete the
|
||||
// cookies tied to it. GDPR: withdrawing consent must stop the processing,
|
||||
// so a functional cookie (e.g. locale) written under consent has to go when
|
||||
// that consent is revoked. Uses the current `state` as the previous value.
|
||||
for (const cat of Object.keys(cookieMap) as ConsentCategory[]) {
|
||||
const wasGranted = state[cat] === true
|
||||
const nowDenied = next[cat] !== true
|
||||
if (wasGranted && nowDenied) {
|
||||
for (const name of cookieMap[cat] ?? []) {deleteCookie(name)}
|
||||
}
|
||||
}
|
||||
|
||||
document.cookie = `${CONSENT_COOKIE}=${serializeConsent(next)};path=/;max-age=${CONSENT_MAX_AGE};samesite=lax`
|
||||
setState(next)
|
||||
setDecided(true)
|
||||
// Tell Google right away. Tags are already on the page holding whatever
|
||||
// defaults Analytics set at load; without this update they keep them for
|
||||
// the rest of the session and never write their cookies — the banner would
|
||||
// look like it worked while nothing changed.
|
||||
updateConsent(next)
|
||||
},
|
||||
[state, cookieMap],
|
||||
)
|
||||
|
||||
const acceptAll = useCallback(() => persist(ACCEPT_ALL_CONSENT), [persist])
|
||||
const rejectAll = useCallback(() => persist(REJECT_ALL_CONSENT), [persist])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { I18nConfig } from './types.js'
|
||||
|
||||
import { CONSENT_COOKIE, parseConsent } from '../consent/storage.js'
|
||||
import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
@@ -14,15 +15,26 @@ type MiddlewareRequest = {
|
||||
}
|
||||
|
||||
/**
|
||||
* What the factory returns — the caller (in Next next-middleware.ts) decides how to
|
||||
* What the factory returns — the caller (in Next middleware.ts) decides how to
|
||||
* act: `redirect` means send a 307 to `location` and set the locale cookie;
|
||||
* `next` means let the request pass through untouched.
|
||||
*/
|
||||
export type LocaleMiddlewareResult =
|
||||
{ cookie: { name: string; value: string }; location: string; type: 'redirect' } | { type: 'next' }
|
||||
| { cookie?: { name: string; value: string }; location: string; type: 'redirect' }
|
||||
| { type: 'next' }
|
||||
|
||||
type CreateLocaleMiddlewareArgs = {
|
||||
config: I18nConfig
|
||||
/**
|
||||
* Consent category that gates *persisting* the locale cookie. The locale is
|
||||
* always detected (routing works regardless), but the choice is only written
|
||||
* to a cookie once the visitor has consented to this category. Defaults to
|
||||
* 'functional'. Pass 'necessary' to always persist (treat locale as strictly
|
||||
* necessary), which restores the pre-consent behaviour.
|
||||
*/
|
||||
consentCategory?: 'functional' | 'necessary'
|
||||
/** Name of the consent cookie to read. Defaults to CONSENT_COOKIE. */
|
||||
consentCookieName?: string
|
||||
/** Cookie name for the locale choice. Defaults to LOCALE_COOKIE_NAME. */
|
||||
cookieName?: string
|
||||
}
|
||||
@@ -44,24 +56,28 @@ function firstSegment(pathname: string): string {
|
||||
* 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,
|
||||
consentCategory = 'functional',
|
||||
consentCookieName = CONSENT_COOKIE,
|
||||
cookieName = LOCALE_COOKIE_NAME,
|
||||
}: CreateLocaleMiddlewareArgs) {
|
||||
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
|
||||
@@ -83,10 +99,23 @@ export function createLocaleMiddleware({
|
||||
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(),
|
||||
...(mayPersist ? { cookie: { name: cookieName, value: locale } } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user