Files
pieced-portal/src/middleware.ts
admin 484696a8f5
All checks were successful
Build and Push / build (push) Successful in 1m47s
feat(i18n): make language a user profile attribute (register/profile/login)
2026-05-30 12:49:39 +02:00

101 lines
3.7 KiB
TypeScript

import createIntlMiddleware from "next-intl/middleware";
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { routing } from "@/i18n/routing";
const intlMiddleware = createIntlMiddleware(routing);
// One-time marker: set after we've applied the user's profile language
// once following sign-in, cleared whenever the login page is shown (so
// the next sign-in re-applies it). Keeps the header switcher a
// per-session override rather than forcing the profile locale on every
// navigation.
const LOCALE_INIT_COOKIE = "pieced_locale_init";
const LOCALE_INIT_OPTS = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
maxAge: 8 * 60 * 60,
};
const publicPaths = ["/login", "/register", "/api/auth", "/api/register"];
function isPublicPath(pathname: string): boolean {
// Strip locale prefix for comparison
const stripped = pathname.replace(/^\/(de|fr|it|en)/, "") || "/";
return (
publicPaths.some((p) => stripped === p || stripped.startsWith(`${p}/`)) ||
pathname.startsWith("/api/auth") ||
pathname.startsWith("/api/register")
);
}
export default async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// NextAuth API routes and register API pass through directly
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/register")) {
return NextResponse.next();
}
const stripped = pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/";
// Showing the login page resets the one-time locale marker so the
// next sign-in re-applies the user's profile language. Logout
// redirects here, which makes this the natural reset point.
if (stripped === "/login") {
const res = intlMiddleware(request);
res.cookies.delete(LOCALE_INIT_COOKIE);
return res;
}
// Auth guard for protected paths
if (!isPublicPath(pathname)) {
const session = await auth();
if (!session) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// One-time apply of the user's preferred language after sign-in.
// Gated by LOCALE_INIT_COOKIE (cleared on the /login view), so it
// fires at most once per login; afterwards the URL and the header
// switcher control the locale freely.
const applied = request.cookies.get(LOCALE_INIT_COOKIE)?.value === "1";
const pref = (session as { platformUser?: { locale?: string } })
.platformUser?.locale;
const base = pref?.split("-")[0];
if (!applied && base && routing.locales.includes(base as never)) {
const target =
base === routing.defaultLocale
? stripped
: `/${base}${stripped === "/" ? "" : stripped}`;
if (target !== pathname) {
const url = new URL(target, request.url);
url.search = request.nextUrl.search;
const res = NextResponse.redirect(url);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
// Already on the right locale — mark applied and continue.
const res = intlMiddleware(request);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
}
return intlMiddleware(request);
}
export const config = {
// Excludes _next/* internal routes, the favicon, api routes, AND any
// path containing a dot (covers all static files served from public/,
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
// and the file is not found.
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
};