From ab0f045499d93797d13a7d46a6e4984cb1b46a9f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 24 Feb 2026 09:07:58 +0100 Subject: [PATCH] feat(login): dynamically configure languages based on settings (#11372) Closes #11199 # Which Problems Are Solved This PR refactors the login app to load allowed languages and the default language directly from the Zitadel API equivalents (`getGeneralSettings`), rather than relying on a hardcoded list. This ensures that the available languages in the UI and the locale selection logic match the instance's configuration. # How the Problems Are Solved - Fetches allowed languages server-side and passes them to the LanguageSwitcher - The NEXT_LOCALE cookie and Accept-Language headers are now strictly validated against the API-provided allowed languages. - Fallback: If a cookie requests an unsupported language, the system now falls back to the API's defaultLanguage (instead of valid but unconfigured defaults). # Additional Changes Helper: Added `getLanguage(code)` to map API language codes to display names (utilizing Intl.DisplayNames if the code isn't in our static mapping). --------- Co-authored-by: Livio Spring --- apps/login/src/app/(login)/layout.tsx | 21 ++++++- .../src/components/language-switcher.tsx | 8 +-- apps/login/src/i18n/request.ts | 59 ++++++++++++++----- apps/login/src/lib/i18n.ts | 14 ++++- apps/login/src/lib/zitadel.ts | 11 +++- 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/apps/login/src/app/(login)/layout.tsx b/apps/login/src/app/(login)/layout.tsx index 3e1955268..1aa9e3a82 100644 --- a/apps/login/src/app/(login)/layout.tsx +++ b/apps/login/src/app/(login)/layout.tsx @@ -11,6 +11,10 @@ import React, { Suspense } from "react"; import ThemeSwitch from "@/components/theme-switch"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import { getServiceConfig } from "@/lib/service-url"; +import { getAllowedLanguages } from "@/lib/zitadel"; +import { LANGS, getLanguage } from "@/lib/i18n"; const lato = Lato({ weight: ["400", "700", "900"], @@ -23,6 +27,21 @@ export async function generateMetadata(): Promise { } export default async function RootLayout({ children }: { children: React.ReactNode }) { + const _headers = await headers(); + const { serviceConfig } = getServiceConfig(_headers); + + let languages = LANGS; + try { + const settings = await getAllowedLanguages({ serviceConfig }); + if (settings.allowedLanguages?.length) { + languages = settings.allowedLanguages + .filter((code) => LANGS.find((l) => l.code === code)) + .map((code) => getLanguage(code)); + } + } catch (e) { + console.error("Failed to load supported languages", e); + } + return ( @@ -52,7 +71,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{children}
- +
diff --git a/apps/login/src/components/language-switcher.tsx b/apps/login/src/components/language-switcher.tsx index b1f1549a3..882a39532 100644 --- a/apps/login/src/components/language-switcher.tsx +++ b/apps/login/src/components/language-switcher.tsx @@ -1,7 +1,7 @@ "use client"; import { setLanguageCookie } from "@/lib/cookies"; -import { Lang, LANGS } from "@/lib/i18n"; +import { Lang } from "@/lib/i18n"; import { getThemeConfig, getComponentRoundness, APPEARANCE_STYLES } from "@/lib/theme"; import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react"; import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; @@ -22,12 +22,12 @@ function getLanguageSwitcherCardAppearance(): string { return appearance?.card || "bg-black/5 dark:bg-white/5"; // Fallback to current styling } -export function LanguageSwitcher() { +export function LanguageSwitcher({ languages }: { languages: Lang[] }) { const currentLocale = useLocale(); const switcherRoundness = getLanguageSwitcherRoundness(); const cardAppearance = getLanguageSwitcherCardAppearance(); - const [selected, setSelected] = useState(LANGS.find((l) => l.code === currentLocale) || LANGS[0]); + const [selected, setSelected] = useState(languages.find((l) => l.code === currentLocale) || languages[0]); const router = useRouter(); @@ -61,7 +61,7 @@ export function LanguageSwitcher() { "transition duration-100 ease-in data-[leave]:data-[closed]:opacity-0", )} > - {LANGS.map((lang) => ( + {languages.map((lang) => ( { const fallback = "en"; const cookiesList = await cookies(); - let locale: string = fallback; - const _headers = await headers(); const { serviceConfig } = getServiceConfig(_headers); + let allowedLanguages = LANGS.map((l) => l.code); + let defaultLanguage = fallback; + + try { + const settings = await getAllowedLanguages({ serviceConfig }); + if (settings.allowedLanguages?.length) { + const localLanguageCodes = LANGS.map((l) => l.code); + allowedLanguages = settings.allowedLanguages.filter((l) => localLanguageCodes.includes(l)); + } + if (settings.defaultLanguage) { + defaultLanguage = settings.defaultLanguage; + } + } catch (e) { + console.warn("Failed to load global settings", e); + } + + let locale: string = defaultLanguage; + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); if (languageHeader) { - const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code - if (LANGS.map((l) => l.code).includes(headerLocale)) { + // splits "en-US,en;q=0.9" to ["en", "US"] or ["en"] + const headerLocale = languageHeader.split(",")[0].split("-")[0]; + if (allowedLanguages.includes(headerLocale)) { locale = headerLocale; } } const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); if (languageCookie && languageCookie.value) { - if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + if (allowedLanguages.includes(languageCookie.value)) { locale = languageCookie.value; + } else { + // If the cookie tells a language that is other than the supported ones, fall back to the default. + locale = defaultLanguage; } } @@ -34,7 +54,9 @@ export default getRequestConfig(async () => { let translations: JsonObject | {} = {}; try { - const i18nJSON = await getHostedLoginTranslation({ serviceConfig, locale, + const i18nJSON = await getHostedLoginTranslation({ + serviceConfig, + locale, organization: i18nOrganization, }); @@ -46,16 +68,23 @@ export default getRequestConfig(async () => { } const customMessages = translations; - const localeMessages = (await import(`../../locales/${locale}.json`)).default; - const fallbackMessages = (await import(`../../locales/${fallback}.json`)) - .default; + + // Load locale messages, fall back to default language messages if locale not found + let localeMessages; + try { + localeMessages = (await import(`../../locales/${locale}.json`)).default; + } catch { + try { + localeMessages = (await import(`../../locales/${defaultLanguage}.json`)).default; + } catch { + localeMessages = (await import(`../../locales/${fallback}.json`)).default; + } + } + + const fallbackMessages = (await import(`../../locales/${fallback}.json`)).default; return { locale, - messages: deepmerge.all([ - fallbackMessages, - localeMessages, - customMessages, - ]) as Record, + messages: deepmerge.all([fallbackMessages, localeMessages, customMessages]) as Record, }; }); diff --git a/apps/login/src/lib/i18n.ts b/apps/login/src/lib/i18n.ts index fd00c2b03..d54d133d1 100644 --- a/apps/login/src/lib/i18n.ts +++ b/apps/login/src/lib/i18n.ts @@ -39,7 +39,7 @@ export const LANGS: Lang[] = [ { name: "Русский", code: "ru", - }, + }, { name: "Türkçe", code: "tr", @@ -60,3 +60,15 @@ export const LANGS: Lang[] = [ export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; export const LANGUAGE_HEADER_NAME = "accept-language"; + +export function getLanguage(code: string): Lang { + const lang = LANGS.find((l) => l.code === code); + if (lang) { + return lang; + } + + return { + code, + name: new Intl.DisplayNames([code], { type: "language" }).of(code) || code, + }; +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index e5077db65..a8c93119a 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -29,8 +29,8 @@ import { VerifyPasskeyRegistrationRequest, VerifyU2FRegistrationRequest, } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { unstable_cacheLife as cacheLife } from "next/cache"; import { getTranslations } from "next-intl/server"; +import { unstable_cacheLife as cacheLife } from "next/cache"; import { getUserAgent } from "./fingerprint"; import { createServiceForHost } from "./service"; @@ -159,10 +159,15 @@ export async function registerTOTP({ serviceConfig, userId }: WithServiceConfig< return userService.registerTOTP({ userId }, {}); } -export async function getGeneralSettings({ serviceConfig }: WithServiceConfig) { +export async function getAllowedLanguages({ serviceConfig }: WithServiceConfig) { const settingsService: Client = await createServiceForHost(SettingsService, serviceConfig); - const callback = settingsService.getGeneralSettings({}, {}).then((resp) => resp.supportedLanguages); + const callback = settingsService.getGeneralSettings({}, {}).then((resp) => { + return { + allowedLanguages: resp.allowedLanguages, + defaultLanguage: resp.defaultLanguage, + }; + }); return useCache ? cacheWrapper(callback) : callback; }