Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontend/@types/negotiator/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
declare module "negotiator" {
type HeaderValues = Record<string, string | string[] | undefined>

class Negotiator {
constructor(request: { headers: HeaderValues })

language(available?: string[]): string | null
languages(available?: string[]): string[]
}

export = Negotiator
}
6 changes: 5 additions & 1 deletion frontend/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { IS_PRODUCTION, ASSET_BASE_URL } from "src/env"
import { routing } from "src/i18n/routing"
import { staticLocales } from "src/i18n/static-locales"
import { notFound } from "next/navigation"
import LocaleRedirector from "src/components/LocaleRedirector"

const inter = Inter({
subsets: ["latin"],
Expand Down Expand Up @@ -95,8 +96,11 @@ export default async function LocaleLayout({

return (
<html suppressHydrationWarning lang={locale} dir={getLangDir(locale)}>
<head />
<head>
<meta name="x-detected-locale" content={locale} />
</head>
<body className={inter.className}>
<LocaleRedirector />
<NextIntlClientProvider>
<ClientProviders locale={locale}>
<Main>{children}</Main>
Expand Down
69 changes: 54 additions & 15 deletions frontend/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import createMiddleware from "next-intl/middleware"
import { NextRequest, NextResponse } from "next/server"
import { routing } from "src/i18n/routing"
import { detectLocale } from "src/i18n/locale-detector"

// Routes that require authentication - updated to match the protected route group
const protectedRoutes = [
Expand Down Expand Up @@ -51,34 +50,74 @@ async function isAuthenticated(request: NextRequest): Promise<boolean> {
}

export default async function middleware(request: NextRequest) {
// Handle internationalization first - this will redirect if locale is missing
const i18nResponse = createMiddleware(routing)(request)
const { pathname } = request.nextUrl
const { locale, localeInPath } = detectLocale(request)

// If i18n middleware wants to redirect, let it
if (i18nResponse) {
return i18nResponse
}
const localizedPathname = localeInPath
? pathname
: `/${locale}${pathname === "/" ? "/" : pathname}`

const { pathname } = request.nextUrl
const response = localeInPath
? NextResponse.next()
: NextResponse.rewrite(new URL(localizedPathname, request.url))

// Then check authentication for protected routes
if (isProtectedRoute(pathname)) {
if (isProtectedRoute(localizedPathname)) {
const authenticated = await isAuthenticated(request)

if (!authenticated) {
const locale = pathname.split("/")[1] || "en"
const loginUrl = new URL(`/${locale}/login`, request.url)
loginUrl.searchParams.set(
"returnTo",
pathname.replace(`/${locale}`, "") || "/",
localeInPath
? pathname.replace(`/${locale}`, "") || "/"
: pathname || "/",
)

return NextResponse.redirect(loginUrl)
return withVaryHeaders(
persistDetectedLocale(
NextResponse.redirect(loginUrl),
localeInPath,
locale,
),
)
}
}

// If no redirect needed, let request continue
return NextResponse.next()
return withVaryHeaders(persistDetectedLocale(response, localeInPath, locale))
}

const persistDetectedLocale = (
response: NextResponse,
localeInPath: boolean,
locale: string,
) => {
if (localeInPath) {
return response
}

response.cookies.set("NEXT_LOCALE", locale, {
sameSite: "strict",
maxAge: 31536000,
path: "/",
})
response.headers.set("x-detected-locale", locale)

return response
}

const withVaryHeaders = (response: NextResponse) => {
const vary = response.headers.get("Vary")
const values = new Set(
(vary ? vary.split(",") : []).map((value) => value.trim()).filter(Boolean),
)

values.add("Cookie")
values.add("Accept-Language")

response.headers.set("Vary", Array.from(values).join(", "))

return response
}

export const config = {
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"linkifyjs": "^4.3.2",
"lucide-react": "^0.546.0",
"meilisearch": "^0.54.0",
"negotiator": "^1.0.0",
"new-github-issue-url": "^1.1.0",
"next": "15.5.4",
"next-intl": "^4.3.9",
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -685,4 +685,4 @@
"vending": {
"purchase-not-available": "This application is not available for purchase."
}
}
}
71 changes: 71 additions & 0 deletions frontend/src/components/LocaleRedirector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client"

import { useEffect, useRef } from "react"
import { usePathname, useRouter } from "next/navigation"
import { routing } from "src/i18n/routing"

const localeSet = new Set<string>(routing.locales)
const LOCALE_COOKIE = "NEXT_LOCALE"

const isLocalizedPathname = (pathname: string) => {
const [maybeLocale] = pathname.split("/").filter(Boolean)
return maybeLocale !== undefined && localeSet.has(maybeLocale)
}

const readCookieLocale = () => {
const match = document.cookie
.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith(`${LOCALE_COOKIE}=`))

if (!match) return undefined

const value = decodeURIComponent(match.split("=").at(1) ?? "")
return localeSet.has(value) ? value : undefined
}

const readMetaLocale = () => {
const content = document
.querySelector('meta[name="x-detected-locale"]')
?.getAttribute("content")

return content && localeSet.has(content) ? content : undefined
}

const getDetectedLocale = () => readCookieLocale() ?? readMetaLocale()

const buildLocalizedPathname = (pathname: string, locale: string) => {
if (pathname === "/") {
return `/${locale}/`
}

return `/${locale}${pathname}`
}

const buildUrl = (pathname: string, locale: string) => {
const search = window.location.search ?? ""
const hash = window.location.hash ?? ""

return `${buildLocalizedPathname(pathname, locale)}${search}${hash}`
}

const LocaleRedirector = () => {
const router = useRouter()
const pathname = usePathname()
const hasRun = useRef(false)

useEffect(() => {
if (hasRun.current) return
hasRun.current = true

if (!pathname || isLocalizedPathname(pathname)) return

const locale = getDetectedLocale() ?? routing.defaultLocale

router.replace(buildUrl(pathname, locale), { scroll: false })
}, [pathname, router])

return null
}

export default LocaleRedirector
56 changes: 56 additions & 0 deletions frontend/src/i18n/locale-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Negotiator from "negotiator"
import { NextRequest } from "next/server"
import { routing } from "./routing"

const localeSet = new Set<string>(routing.locales)
const LOCALE_COOKIE = "NEXT_LOCALE"

const isSupportedLocale = (locale?: string | null): locale is string =>
!!locale && localeSet.has(locale)

const getLocaleFromPathname = (pathname: string) => {
const [maybeLocale] = pathname.split("/").filter(Boolean)
return isSupportedLocale(maybeLocale) ? maybeLocale : undefined
}

const getLocaleFromCookie = (request: NextRequest) => {
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value
return isSupportedLocale(cookieLocale) ? cookieLocale : undefined
}

const getLocaleFromAcceptLanguage = (request: NextRequest) => {
const acceptLanguage = request.headers.get("accept-language")
if (!acceptLanguage) return undefined

try {
const headers = Object.fromEntries(request.headers.entries())
const negotiator = new Negotiator({ headers })
const locale = negotiator.language(routing.locales)

return isSupportedLocale(locale) ? locale : undefined
} catch (error) {
console.warn("Failed to parse Accept-Language header", error)
return undefined
}
}

export const detectLocale = (request: NextRequest) => {
const { pathname } = request.nextUrl

const pathnameLocale = getLocaleFromPathname(pathname)
if (pathnameLocale) {
return { locale: pathnameLocale, localeInPath: true }
}

const cookieLocale = getLocaleFromCookie(request)
if (cookieLocale) {
return { locale: cookieLocale, localeInPath: false }
}

const headerLocale = getLocaleFromAcceptLanguage(request)
if (headerLocale) {
return { locale: headerLocale, localeInPath: false }
}

return { locale: routing.defaultLocale, localeInPath: false }
}
1 change: 1 addition & 0 deletions frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8349,6 +8349,7 @@ __metadata:
meilisearch: "npm:^0.54.0"
msw: "npm:^2.11.2"
msw-storybook-addon: "npm:^2.0.5"
negotiator: "npm:^1.0.0"
new-github-issue-url: "npm:^1.1.0"
next: "npm:15.5.4"
next-intl: "npm:^4.3.9"
Expand Down
Loading