diff --git a/src/app/[locale]/register/page.tsx b/src/app/[locale]/register/page.tsx index fcc5832..c41324f 100644 --- a/src/app/[locale]/register/page.tsx +++ b/src/app/[locale]/register/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef, forwardRef } from "react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { useRouter, Link } from "@/i18n/navigation"; import { Card } from "@/components/ui/card"; @@ -41,11 +41,17 @@ export default function RegisterPage() { const [accountType, setAccountType] = useState(null); + const locale = useLocale(); + const [form, setForm] = useState({ companyName: "", givenName: "", familyName: "", 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("idle"); const [error, setError] = useState(""); @@ -94,6 +100,7 @@ export default function RegisterPage() { givenName: form.givenName, familyName: form.familyName, email: form.email, + preferredLanguage: form.preferredLanguage, isPersonal, }; if (!isPersonal) { @@ -295,6 +302,29 @@ export default function RegisterPage() { /> + {/* Preferred language */} +
+ + +
+ {error && (
{error} diff --git a/src/app/[locale]/settings/profile/page.tsx b/src/app/[locale]/settings/profile/page.tsx index 9793bf5..302c966 100644 --- a/src/app/[locale]/settings/profile/page.tsx +++ b/src/app/[locale]/settings/profile/page.tsx @@ -30,13 +30,14 @@ export default async function ProfileSettingsPage() { const t = await getTranslations("settingsProfile"); - let initial = { firstName: "", lastName: "", email: user.email }; + let initial = { firstName: "", lastName: "", email: user.email, language: "" }; try { const profile = await getHumanUserDetail(user.id); initial = { firstName: profile.givenName, lastName: profile.familyName, email: profile.email || user.email, + language: profile.preferredLanguage, }; } catch (e) { // Identity provider unreachable: render the form with whatever diff --git a/src/app/api/settings/profile/route.ts b/src/app/api/settings/profile/route.ts index 0fbd58f..ed138ce 100644 --- a/src/app/api/settings/profile/route.ts +++ b/src/app/api/settings/profile/route.ts @@ -26,6 +26,7 @@ import { const updateSchema = z.object({ firstName: 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() { @@ -66,6 +67,7 @@ export async function PUT(request: Request) { userId: user.id, givenName: parsed.data.firstName, familyName: parsed.data.lastName, + preferredLanguage: parsed.data.language, }); return NextResponse.json({ displayName: result.displayName, diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx index 21fecdb..50ef2d9 100644 --- a/src/components/settings/profile-form.tsx +++ b/src/components/settings/profile-form.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useSession } from "next-auth/react"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; import { Card } from "@/components/ui/card"; interface Props { @@ -10,6 +10,8 @@ interface Props { firstName: string; lastName: string; email: string; + /** Current ZITADEL preferredLanguage; "" if never set. */ + language: string; }; /** * Personal-account flag. Drives a small hint about how the ZITADEL @@ -43,10 +45,15 @@ interface Props { */ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) { const t = useTranslations("settingsProfile"); + const locale = useLocale(); const { update } = useSession(); const [form, setForm] = useState({ firstName: initial.firstName, 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 [error, setError] = useState(null); @@ -67,6 +74,7 @@ export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) { body: JSON.stringify({ firstName: form.firstName.trim(), lastName: form.lastName.trim(), + language: form.language, }), }); 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. await update({ name: data.displayName }); setSavedFlash(true); - // Force a full reload so EVERY server-rendered component picks - // up the new session cookie immediately — router.refresh() only - // re-runs the current route's server components, leaving the - // nav-shell (rendered higher in the tree) and other cached - // segments showing the old name until the user navigates. - // The 800ms delay lets the "Saved" flash render briefly before - // the page reloads, so the user gets visible feedback. + // If the language changed, land the user on the new locale (a + // full navigation so every server-rendered surface re-renders in + // the new language). Otherwise just reload so the new name + // propagates. The 800ms delay lets the "Saved" flash show first. + const localeChanged = form.language && form.language !== locale; + const target = localeChanged ? localePath(form.language) : null; setTimeout(() => { - window.location.reload(); + if (target) window.location.assign(target); + else window.location.reload(); }, 800); } catch (e: any) { 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" /> + + + {/* Personal vs company hint. Personals get the "this won't change your invoice name" warning since their 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({ label, required, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6798985..6930cd5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index e5ab547..442ee5d 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -569,6 +569,7 @@ export async function updateHumanUserProfile(params: { userId: string; givenName: string; familyName: string; + preferredLanguage?: string; }): Promise { 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(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(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 ?? "", }; } diff --git a/src/messages/de.json b/src/messages/de.json index ba1156f..d2518cd 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -48,7 +48,8 @@ "personalCardTitle": "Privat", "personalCardDescription": "Für Sie persönlich.", "companyCardTitle": "Unternehmen", - "companyCardDescription": "Für Ihr Unternehmen oder Team." + "companyCardDescription": "Für Ihr Unternehmen oder Team.", + "languageLabel": "Sprache" }, "onboarding": { "loading": "Status wird geladen…", @@ -994,7 +995,9 @@ "saveChanges": "Änderungen speichern", "saving": "Speichern…", "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": { "title": "Etwas ist schiefgelaufen", diff --git a/src/messages/en.json b/src/messages/en.json index c3cb74c..6a6f5f7 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -48,7 +48,8 @@ "personalCardTitle": "Personal", "personalCardDescription": "For yourself.", "companyCardTitle": "Company", - "companyCardDescription": "For your business or team." + "companyCardDescription": "For your business or team.", + "languageLabel": "Language" }, "onboarding": { "loading": "Loading status…", @@ -994,7 +995,9 @@ "saveChanges": "Save changes", "saving": "Saving…", "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": { "title": "Something went wrong", diff --git a/src/messages/fr.json b/src/messages/fr.json index 58ff7fa..a5c06f0 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -48,7 +48,8 @@ "personalCardTitle": "Particulier", "personalCardDescription": "Pour vous.", "companyCardTitle": "Entreprise", - "companyCardDescription": "Pour votre entreprise ou équipe." + "companyCardDescription": "Pour votre entreprise ou équipe.", + "languageLabel": "Langue" }, "onboarding": { "loading": "Chargement du statut…", @@ -994,7 +995,9 @@ "saveChanges": "Enregistrer les modifications", "saving": "Enregistrement…", "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": { "title": "Une erreur est survenue", diff --git a/src/messages/it.json b/src/messages/it.json index d39e258..b92a46b 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -48,7 +48,8 @@ "personalCardTitle": "Privato", "personalCardDescription": "Per lei.", "companyCardTitle": "Azienda", - "companyCardDescription": "Per la sua azienda o team." + "companyCardDescription": "Per la sua azienda o team.", + "languageLabel": "Lingua" }, "onboarding": { "loading": "Caricamento stato…", @@ -994,7 +995,9 @@ "saveChanges": "Salvi modifiche", "saving": "Salvataggio…", "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": { "title": "Si è verificato un errore", diff --git a/src/middleware.ts b/src/middleware.ts index 902cec5..c29dd42 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -6,6 +6,20 @@ import { routing } from "@/i18n/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"]; function isPublicPath(pathname: string): boolean { @@ -26,6 +40,17 @@ export default async function middleware(request: NextRequest) { 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 if (!isPublicPath(pathname)) { const session = await auth(); @@ -34,6 +59,32 @@ export default async function middleware(request: NextRequest) { loginUrl.searchParams.set("callbackUrl", pathname); 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); diff --git a/src/types/index.ts b/src/types/index.ts index 0c4cd06..988cb4e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,8 @@ export interface ZitadelClaims { "urn:zitadel:iam:user:resourceowner:id": string; "urn:zitadel:iam:user:resourceowner:name": string; "urn:zitadel:iam:org:project:roles"?: Record>; + /** 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). */ 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)