mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
co-authored by
Livio Spring
parent
d27833e1bc
commit
ab0f045499
@@ -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}
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user