feat(i18n): make language a user profile attribute (register/profile/login)
All checks were successful
Build and Push / build (push) Successful in 1m47s

This commit is contained in:
2026-05-30 12:49:39 +02:00
parent ca1a014c01
commit 484696a8f5
12 changed files with 185 additions and 26 deletions

View File

@@ -111,6 +111,13 @@ export const authConfig: NextAuthConfig = {
if (typeof profile.sub === "string") {
token.sub = profile.sub;
}
// Capture the user's preferred language (OIDC `locale` claim,
// mapped from ZITADEL preferredLanguage). Read once at sign-in;
// middleware uses it to land the user on their language a
// single time per login. Stored as-is and validated downstream.
if (typeof claims.locale === "string") {
token.locale = claims.locale;
}
}
return token;
},
@@ -140,6 +147,7 @@ export const authConfig: NextAuthConfig = {
// both legacy " (Personal)" suffix and current "personal-{8hex}"
// opaque names.
isPersonal: isPersonalOrgName(orgName),
locale: (token.locale as string | undefined) ?? undefined,
};
(session as any).platformUser = sessionUser;
// Also overwrite session.user so any client-side code that uses

View File

@@ -569,6 +569,7 @@ export async function updateHumanUserProfile(params: {
userId: string;
givenName: string;
familyName: string;
preferredLanguage?: string;
}): Promise<UpdateHumanUserProfileResult> {
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
// Compose the displayName ourselves so ZITADEL stores something
@@ -579,13 +580,22 @@ export async function updateHumanUserProfile(params: {
type ZitadelUpdateResponse = {
details?: { changeDate?: string };
};
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
profile: {
givenName: params.givenName,
familyName: params.familyName,
displayName,
},
});
// preferredLanguage is part of the same `profile` block; include it
// only when provided so a name-only update doesn't clobber it.
const profile: {
givenName: string;
familyName: string;
displayName: string;
preferredLanguage?: string;
} = {
givenName: params.givenName,
familyName: params.familyName,
displayName,
};
if (params.preferredLanguage) {
profile.preferredLanguage = params.preferredLanguage;
}
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", { profile });
// Re-fetch the user to read back the canonical displayName ZITADEL
// committed. Should match what we sent, but reading from the source
// of truth catches any sanitization ZITADEL might apply.
@@ -607,6 +617,8 @@ export interface HumanUserDetail {
familyName: string;
displayName: string;
email: string;
/** ZITADEL profile preferredLanguage (e.g. "de"); "" if unset. */
preferredLanguage: string;
}
export async function getHumanUserDetail(
@@ -620,6 +632,7 @@ export async function getHumanUserDetail(
givenName?: string;
familyName?: string;
displayName?: string;
preferredLanguage?: string;
};
email?: { email?: string };
};
@@ -636,5 +649,6 @@ export async function getHumanUserDetail(
familyName: human?.profile?.familyName ?? "",
displayName: human?.profile?.displayName ?? "",
email: human?.email?.email ?? "",
preferredLanguage: human?.profile?.preferredLanguage ?? "",
};
}