Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484696a8f5 |
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user