mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
fix(i18n): survive Safari's 7-day cookie cap on the chosen locale
The first-paint ladder consulted only the healthlog-locale cookie and Accept-Language — never User.locale. The cookie is written client-side via document.cookie, which Safari ITP caps at 7 days, so a chosen language silently reverted to the browser language once a week. Three seams close it: - resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts) now reads User.locale on a cookie miss via a lightweight session read (no sliding-expiry write, no cookie mutation, fail-soft) before the Accept-Language fallback. - PUT /api/auth/me/locale mirrors the accepted locale into the cookie via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via shouldEmitSecureCookie) — server-set cookies are exempt from the ITP cap, and the mount-time backfill hitting the route doubles as the weekly refresh. - getSession re-emits the cookie when the user has a persisted locale but the cookie is absent (absent-only, so a mid-switch cookie is never reverted before the PUT lands). Users who never chose a language stay on Accept-Language.
This commit is contained in:
1 parent
ef82545870
commit
e9692f0b81
6 files changed
+233
-20
No files matched your search
@@ -20,11 +20,13 @@
|
||||
* holds a value the resolver would later have to defend against.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -58,5 +60,15 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ meta: { locale_next: locale } });
|
||||
}
|
||||
|
||||
// Mirror the accepted locale into the `healthlog-locale` cookie via
|
||||
// Set-Cookie. The client's own `document.cookie` write is capped at 7
|
||||
// days by Safari ITP; the server-set copy is not, so an explicit choice
|
||||
// survives on every device. Runs on the idempotent path too — the
|
||||
// mount-time backfill hitting this route is exactly the weekly refresh
|
||||
// that keeps the cookie alive. Harmless no-op payload for Bearer
|
||||
// callers (native clients ignore cookies).
|
||||
const cookieStore = await cookies();
|
||||
setLocaleCookie(cookieStore, locale);
|
||||
|
||||
return apiSuccess({ locale });
|
||||
});
|
||||
+2
-20
@@ -1,32 +1,14 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { allMessages } from "@/lib/i18n/shared-resolve";
|
||||
|
||||
async function resolveInitialLocale(): Promise<Locale> {
|
||||
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
|
||||
// fall back to the default so a locale hiccup never crashes the root
|
||||
// layout into global-error.tsx.
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
|
||||
Reference in new issue
Block a user