Compare commits

...

1 Commits

Author SHA1 Message Date
484696a8f5 feat(i18n): make language a user profile attribute (register/profile/login)
All checks were successful
Build and Push / build (push) Successful in 1m47s
2026-05-30 12:49:39 +02:00
12 changed files with 185 additions and 26 deletions

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useRef, forwardRef } from "react"; import { useState, useRef, forwardRef } from "react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { useRouter, Link } from "@/i18n/navigation"; import { useRouter, Link } from "@/i18n/navigation";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -41,11 +41,17 @@ export default function RegisterPage() {
const [accountType, setAccountType] = useState<AccountType | null>(null); const [accountType, setAccountType] = useState<AccountType | null>(null);
const locale = useLocale();
const [form, setForm] = useState({ const [form, setForm] = useState({
companyName: "", companyName: "",
givenName: "", givenName: "",
familyName: "", familyName: "",
email: "", email: "",
// Default to the language the register page is being viewed in;
// the user can change it below. This becomes their ZITADEL
// preferredLanguage and the UI language they land on after login.
preferredLanguage: locale,
}); });
const [state, setState] = useState<FormState>("idle"); const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -94,6 +100,7 @@ export default function RegisterPage() {
givenName: form.givenName, givenName: form.givenName,
familyName: form.familyName, familyName: form.familyName,
email: form.email, email: form.email,
preferredLanguage: form.preferredLanguage,
isPersonal, isPersonal,
}; };
if (!isPersonal) { if (!isPersonal) {
@@ -295,6 +302,29 @@ export default function RegisterPage() {
/> />
</div> </div>
{/* Preferred language */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("languageLabel")}
</label>
<select
name="preferredLanguage"
value={form.preferredLanguage}
onChange={(e) =>
setForm((prev) => ({
...prev,
preferredLanguage: e.target.value,
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
</select>
</div>
{error && ( {error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2"> <div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error} {error}

View File

@@ -30,13 +30,14 @@ export default async function ProfileSettingsPage() {
const t = await getTranslations("settingsProfile"); const t = await getTranslations("settingsProfile");
let initial = { firstName: "", lastName: "", email: user.email }; let initial = { firstName: "", lastName: "", email: user.email, language: "" };
try { try {
const profile = await getHumanUserDetail(user.id); const profile = await getHumanUserDetail(user.id);
initial = { initial = {
firstName: profile.givenName, firstName: profile.givenName,
lastName: profile.familyName, lastName: profile.familyName,
email: profile.email || user.email, email: profile.email || user.email,
language: profile.preferredLanguage,
}; };
} catch (e) { } catch (e) {
// Identity provider unreachable: render the form with whatever // Identity provider unreachable: render the form with whatever

View File

@@ -26,6 +26,7 @@ import {
const updateSchema = z.object({ const updateSchema = z.object({
firstName: z.string().trim().min(1).max(100), firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100),
language: z.enum(["de", "en", "fr", "it"]).optional(),
}); });
export async function GET() { export async function GET() {
@@ -66,6 +67,7 @@ export async function PUT(request: Request) {
userId: user.id, userId: user.id,
givenName: parsed.data.firstName, givenName: parsed.data.firstName,
familyName: parsed.data.lastName, familyName: parsed.data.lastName,
preferredLanguage: parsed.data.language,
}); });
return NextResponse.json({ return NextResponse.json({
displayName: result.displayName, displayName: result.displayName,

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
interface Props { interface Props {
@@ -10,6 +10,8 @@ interface Props {
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
/** Current ZITADEL preferredLanguage; "" if never set. */
language: string;
}; };
/** /**
* Personal-account flag. Drives a small hint about how the ZITADEL * Personal-account flag. Drives a small hint about how the ZITADEL
@@ -43,10 +45,15 @@ interface Props {
*/ */
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) { export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
const t = useTranslations("settingsProfile"); const t = useTranslations("settingsProfile");
const locale = useLocale();
const { update } = useSession(); const { update } = useSession();
const [form, setForm] = useState({ const [form, setForm] = useState({
firstName: initial.firstName, firstName: initial.firstName,
lastName: initial.lastName, lastName: initial.lastName,
// Fall back to the current UI locale when the profile has no stored
// preference yet (older accounts), so the selector shows something
// sensible rather than blank.
language: initial.language || locale,
}); });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -67,6 +74,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
body: JSON.stringify({ body: JSON.stringify({
firstName: form.firstName.trim(), firstName: form.firstName.trim(),
lastName: form.lastName.trim(), lastName: form.lastName.trim(),
language: form.language,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -79,15 +87,15 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
// to session.user.name. No re-login needed. // to session.user.name. No re-login needed.
await update({ name: data.displayName }); await update({ name: data.displayName });
setSavedFlash(true); setSavedFlash(true);
// Force a full reload so EVERY server-rendered component picks // If the language changed, land the user on the new locale (a
// up the new session cookie immediately — router.refresh() only // full navigation so every server-rendered surface re-renders in
// re-runs the current route's server components, leaving the // the new language). Otherwise just reload so the new name
// nav-shell (rendered higher in the tree) and other cached // propagates. The 800ms delay lets the "Saved" flash show first.
// segments showing the old name until the user navigates. const localeChanged = form.language && form.language !== locale;
// The 800ms delay lets the "Saved" flash render briefly before const target = localeChanged ? localePath(form.language) : null;
// the page reloads, so the user gets visible feedback.
setTimeout(() => { setTimeout(() => {
window.location.reload(); if (target) window.location.assign(target);
else window.location.reload();
}, 800); }, 800);
} catch (e: any) { } catch (e: any) {
setError(e?.message ?? String(e)); setError(e?.message ?? String(e));
@@ -132,6 +140,20 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed" className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
/> />
</Field> </Field>
<Field label={t("languageLabel")} hint={t("languageHint")}>
<select
value={form.language}
onChange={(e) =>
setForm((f) => ({ ...f, language: e.target.value }))
}
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
</select>
</Field>
{/* Personal vs company hint. Personals get the {/* Personal vs company hint. Personals get the
"this won't change your invoice name" warning since their "this won't change your invoice name" warning since their
ZITADEL name and their invoice identity are intentionally ZITADEL name and their invoice identity are intentionally
@@ -163,6 +185,15 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
); );
} }
// Build the as-needed-prefixed path for a target locale from the
// current URL (default locale `de` is unprefixed). Client-only — uses
// window; called from the save handler.
function localePath(lang: string): string {
const p =
window.location.pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/";
return lang === "de" ? p : `/${lang}${p === "/" ? "" : p}`;
}
function Field({ function Field({
label, label,
required, required,

View File

@@ -111,6 +111,13 @@ export const authConfig: NextAuthConfig = {
if (typeof profile.sub === "string") { if (typeof profile.sub === "string") {
token.sub = profile.sub; 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; return token;
}, },
@@ -140,6 +147,7 @@ export const authConfig: NextAuthConfig = {
// both legacy " (Personal)" suffix and current "personal-{8hex}" // both legacy " (Personal)" suffix and current "personal-{8hex}"
// opaque names. // opaque names.
isPersonal: isPersonalOrgName(orgName), isPersonal: isPersonalOrgName(orgName),
locale: (token.locale as string | undefined) ?? undefined,
}; };
(session as any).platformUser = sessionUser; (session as any).platformUser = sessionUser;
// Also overwrite session.user so any client-side code that uses // Also overwrite session.user so any client-side code that uses

View File

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

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Privat", "personalCardTitle": "Privat",
"personalCardDescription": "Für Sie persönlich.", "personalCardDescription": "Für Sie persönlich.",
"companyCardTitle": "Unternehmen", "companyCardTitle": "Unternehmen",
"companyCardDescription": "Für Ihr Unternehmen oder Team." "companyCardDescription": "Für Ihr Unternehmen oder Team.",
"languageLabel": "Sprache"
}, },
"onboarding": { "onboarding": {
"loading": "Status wird geladen…", "loading": "Status wird geladen…",
@@ -994,7 +995,9 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"saving": "Speichern…", "saving": "Speichern…",
"saved": "Gespeichert.", "saved": "Gespeichert.",
"missingRequired": "Vor- und Nachname sind erforderlich." "missingRequired": "Vor- und Nachname sind erforderlich.",
"languageLabel": "Sprache",
"languageHint": "Wird nach der Anmeldung als Ihre Oberflächensprache verwendet."
}, },
"errors": { "errors": {
"title": "Etwas ist schiefgelaufen", "title": "Etwas ist schiefgelaufen",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Personal", "personalCardTitle": "Personal",
"personalCardDescription": "For yourself.", "personalCardDescription": "For yourself.",
"companyCardTitle": "Company", "companyCardTitle": "Company",
"companyCardDescription": "For your business or team." "companyCardDescription": "For your business or team.",
"languageLabel": "Language"
}, },
"onboarding": { "onboarding": {
"loading": "Loading status…", "loading": "Loading status…",
@@ -994,7 +995,9 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"saving": "Saving…", "saving": "Saving…",
"saved": "Saved.", "saved": "Saved.",
"missingRequired": "First and last name are required." "missingRequired": "First and last name are required.",
"languageLabel": "Language",
"languageHint": "Used as your interface language after you sign in."
}, },
"errors": { "errors": {
"title": "Something went wrong", "title": "Something went wrong",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Particulier", "personalCardTitle": "Particulier",
"personalCardDescription": "Pour vous.", "personalCardDescription": "Pour vous.",
"companyCardTitle": "Entreprise", "companyCardTitle": "Entreprise",
"companyCardDescription": "Pour votre entreprise ou équipe." "companyCardDescription": "Pour votre entreprise ou équipe.",
"languageLabel": "Langue"
}, },
"onboarding": { "onboarding": {
"loading": "Chargement du statut…", "loading": "Chargement du statut…",
@@ -994,7 +995,9 @@
"saveChanges": "Enregistrer les modifications", "saveChanges": "Enregistrer les modifications",
"saving": "Enregistrement…", "saving": "Enregistrement…",
"saved": "Enregistré.", "saved": "Enregistré.",
"missingRequired": "Le prénom et le nom sont obligatoires." "missingRequired": "Le prénom et le nom sont obligatoires.",
"languageLabel": "Langue",
"languageHint": "Utilisée comme langue d'interface après votre connexion."
}, },
"errors": { "errors": {
"title": "Une erreur est survenue", "title": "Une erreur est survenue",

View File

@@ -48,7 +48,8 @@
"personalCardTitle": "Privato", "personalCardTitle": "Privato",
"personalCardDescription": "Per lei.", "personalCardDescription": "Per lei.",
"companyCardTitle": "Azienda", "companyCardTitle": "Azienda",
"companyCardDescription": "Per la sua azienda o team." "companyCardDescription": "Per la sua azienda o team.",
"languageLabel": "Lingua"
}, },
"onboarding": { "onboarding": {
"loading": "Caricamento stato…", "loading": "Caricamento stato…",
@@ -994,7 +995,9 @@
"saveChanges": "Salvi modifiche", "saveChanges": "Salvi modifiche",
"saving": "Salvataggio…", "saving": "Salvataggio…",
"saved": "Salvato.", "saved": "Salvato.",
"missingRequired": "Nome e cognome sono obbligatori." "missingRequired": "Nome e cognome sono obbligatori.",
"languageLabel": "Lingua",
"languageHint": "Usata come lingua dell'interfaccia dopo l'accesso."
}, },
"errors": { "errors": {
"title": "Si è verificato un errore", "title": "Si è verificato un errore",

View File

@@ -6,6 +6,20 @@ import { routing } from "@/i18n/routing";
const intlMiddleware = createIntlMiddleware(routing); const intlMiddleware = createIntlMiddleware(routing);
// One-time marker: set after we've applied the user's profile language
// once following sign-in, cleared whenever the login page is shown (so
// the next sign-in re-applies it). Keeps the header switcher a
// per-session override rather than forcing the profile locale on every
// navigation.
const LOCALE_INIT_COOKIE = "pieced_locale_init";
const LOCALE_INIT_OPTS = {
path: "/",
httpOnly: true,
sameSite: "lax" as const,
maxAge: 8 * 60 * 60,
};
const publicPaths = ["/login", "/register", "/api/auth", "/api/register"]; const publicPaths = ["/login", "/register", "/api/auth", "/api/register"];
function isPublicPath(pathname: string): boolean { function isPublicPath(pathname: string): boolean {
@@ -26,6 +40,17 @@ export default async function middleware(request: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
const stripped = pathname.replace(/^\/(de|fr|it|en)(?=\/|$)/, "") || "/";
// Showing the login page resets the one-time locale marker so the
// next sign-in re-applies the user's profile language. Logout
// redirects here, which makes this the natural reset point.
if (stripped === "/login") {
const res = intlMiddleware(request);
res.cookies.delete(LOCALE_INIT_COOKIE);
return res;
}
// Auth guard for protected paths // Auth guard for protected paths
if (!isPublicPath(pathname)) { if (!isPublicPath(pathname)) {
const session = await auth(); const session = await auth();
@@ -34,6 +59,32 @@ export default async function middleware(request: NextRequest) {
loginUrl.searchParams.set("callbackUrl", pathname); loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }
// One-time apply of the user's preferred language after sign-in.
// Gated by LOCALE_INIT_COOKIE (cleared on the /login view), so it
// fires at most once per login; afterwards the URL and the header
// switcher control the locale freely.
const applied = request.cookies.get(LOCALE_INIT_COOKIE)?.value === "1";
const pref = (session as { platformUser?: { locale?: string } })
.platformUser?.locale;
const base = pref?.split("-")[0];
if (!applied && base && routing.locales.includes(base as never)) {
const target =
base === routing.defaultLocale
? stripped
: `/${base}${stripped === "/" ? "" : stripped}`;
if (target !== pathname) {
const url = new URL(target, request.url);
url.search = request.nextUrl.search;
const res = NextResponse.redirect(url);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
// Already on the right locale — mark applied and continue.
const res = intlMiddleware(request);
res.cookies.set(LOCALE_INIT_COOKIE, "1", LOCALE_INIT_OPTS);
return res;
}
} }
return intlMiddleware(request); return intlMiddleware(request);

View File

@@ -3,6 +3,8 @@ export interface ZitadelClaims {
"urn:zitadel:iam:user:resourceowner:id": string; "urn:zitadel:iam:user:resourceowner:id": string;
"urn:zitadel:iam:user:resourceowner:name": string; "urn:zitadel:iam:user:resourceowner:name": string;
"urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>; "urn:zitadel:iam:org:project:roles"?: Record<string, Record<string, string>>;
/** Standard OIDC claim; ZITADEL maps the user's preferredLanguage. */
locale?: string;
} }
/** /**
@@ -64,6 +66,14 @@ export interface SessionUser {
* user's display name instead (Bug 9 — the org name is opaque). * user's display name instead (Bug 9 — the org name is opaque).
*/ */
isPersonal: boolean; isPersonal: boolean;
/**
* The user's preferred UI language, sourced from the ZITADEL profile
* (`preferredLanguage`) via the OIDC `locale` claim at sign-in. Used
* once after login to land the user on their language; the header
* switcher is a per-session URL override that does not change this.
* Undefined for users whose ZITADEL profile predates the claim.
*/
locale?: string;
} }
// PiecedTenant CR (pieced.ch/v1alpha1) // PiecedTenant CR (pieced.ch/v1alpha1)