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 <livio.a@gmail.com>
This commit is contained in:
Max Peintner
2026-02-24 08:07:58 +00:00
committed by GitHub
co-authored by Livio Spring
parent d27833e1bc
commit ab0f045499
5 changed files with 89 additions and 24 deletions
+20 -1
View File
@@ -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<Metadata> {
}
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 (
<html className={`${lato.className}`} suppressHydrationWarning>
<head />
@@ -52,7 +71,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<div className="relative mx-auto w-full max-w-[1100px] py-8">
<div>{children}</div>
<div className="flex flex-row items-center justify-end space-x-4 py-4 px-4 md:px-8 max-w-[440px] mx-auto md:max-w-full">
<LanguageSwitcher />
<LanguageSwitcher languages={languages} />
<ThemeSwitch />
</div>
</div>
@@ -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) => (
<ListboxOption
key={lang.code}
value={lang}
+44 -15
View File
@@ -1,6 +1,6 @@
import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n";
import { getServiceConfig } from "@/lib/service-url";
import { getHostedLoginTranslation } from "@/lib/zitadel";
import { getHostedLoginTranslation, getAllowedLanguages } from "@/lib/zitadel";
import { JsonObject } from "@zitadel/client";
import deepmerge from "deepmerge";
import { getRequestConfig } from "next-intl/server";
@@ -10,23 +10,43 @@ export default getRequestConfig(async () => {
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<string, string>,
messages: deepmerge.all([fallbackMessages, localeMessages, customMessages]) as Record<string, string>,
};
});
+13 -1
View File
@@ -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,
};
}
+8 -3
View File
@@ -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<typeof SettingsService> = 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;
}