diff --git a/src/app/[locale]/settings/page.tsx b/src/app/[locale]/settings/page.tsx
index 7b111ba..c77887f 100644
--- a/src/app/[locale]/settings/page.tsx
+++ b/src/app/[locale]/settings/page.tsx
@@ -20,8 +20,9 @@ export default async function SettingsPage() {
const t = await getTranslations("settings");
// Build the list of settings cards. Each entry has a stable key, a
- // route, and a visibility predicate. Currently only billing; this
- // shape leaves headroom for adding more without restructuring.
+ // route, and a visibility predicate. Phase 6 fix5: profile is
+ // visible to every signed-in user (it's their own identity).
+ // Billing stays gated behind canMutate.
const sections: Array<{
key: string;
href: string;
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
description: string;
visible: boolean;
}> = [
+ {
+ key: "profile",
+ href: "/settings/profile",
+ title: t("profileTitle"),
+ description: t("profileDescription"),
+ // Every signed-in user can edit their own first/last name.
+ visible: true,
+ },
{
key: "billing",
href: "/settings/billing",
diff --git a/src/app/[locale]/settings/profile/page.tsx b/src/app/[locale]/settings/profile/page.tsx
new file mode 100644
index 0000000..9793bf5
--- /dev/null
+++ b/src/app/[locale]/settings/profile/page.tsx
@@ -0,0 +1,68 @@
+import { redirect } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { getSessionUser } from "@/lib/session";
+import { getHumanUserDetail } from "@/lib/zitadel";
+import { ProfileSettingsForm } from "@/components/settings/profile-form";
+
+/**
+ * /settings/profile — every authenticated user can edit their own
+ * first + last name. Email is shown read-only; changing it requires
+ * verification and is left to ZITADEL's own self-service flow.
+ *
+ * Personal vs company accounts:
+ * - Both can edit their first/last name in ZITADEL.
+ * - Personal accounts get an extra hint: editing the ZITADEL name
+ * does NOT change how the customer's name appears on invoices.
+ * Invoice identity is in org_billing.company_name (the "Full
+ * name" field on /settings/billing) and is intentionally
+ * editable separately, because legal/billing identity may not
+ * match preferred display identity.
+ * - Company accounts see an org-membership hint instead.
+ *
+ * Server-fetches the current profile from ZITADEL via the
+ * service-account PAT so the form starts with the canonical values
+ * rather than whatever happens to be in the JWT (the JWT name might
+ * be stale if the user updated their name in ZITADEL Console).
+ */
+export default async function ProfileSettingsPage() {
+ const user = await getSessionUser();
+ if (!user) redirect("/login");
+
+ const t = await getTranslations("settingsProfile");
+
+ let initial = { firstName: "", lastName: "", email: user.email };
+ try {
+ const profile = await getHumanUserDetail(user.id);
+ initial = {
+ firstName: profile.givenName,
+ lastName: profile.familyName,
+ email: profile.email || user.email,
+ };
+ } catch (e) {
+ // Identity provider unreachable: render the form with whatever
+ // we know from the session. The session has a combined `name`,
+ // not split parts, so we leave first/last empty and let the user
+ // re-enter. Server logs catch the underlying failure.
+ console.error("ProfileSettingsPage: getHumanUserDetail failed:", e);
+ }
+
+ return (
+
+
+
+
+
+ {/* 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
+ decoupled. Company accounts get a benign "member of"
+ context line so they know which org's identity they're
+ editing. */}
+ {isPersonal ? (
+
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index b80ec04..deba380 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -49,7 +49,26 @@ export const authConfig: NextAuthConfig = {
},
],
callbacks: {
- async jwt({ token, account, profile }) {
+ async jwt({ token, account, profile, trigger, session }) {
+ // Phase 6 fix5: client-side `useSession().update({ name })` calls
+ // route through this branch. We trust the new value because the
+ // PUT /api/settings/profile route already wrote it to ZITADEL
+ // and re-fetched the canonical displayName before returning.
+ // NextAuth maps token.name → session.user.name on the next
+ // session callback, so downstream useSession() consumers see
+ // the new name without a logout/login cycle.
+ //
+ // Defensive: only the `name` field is accepted from the update
+ // payload, even if the client passes additional keys. Other
+ // identity claims (orgId, roles, sub) come from ZITADEL at
+ // sign-in time and are not user-mutable from a settings page.
+ if (trigger === "update" && session) {
+ const update = session as { name?: unknown };
+ if (typeof update.name === "string") {
+ (token as { name?: string }).name = update.name;
+ }
+ return token;
+ }
if (account && profile) {
const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts
index 32886e2..c1114e3 100644
--- a/src/lib/zitadel.ts
+++ b/src/lib/zitadel.ts
@@ -528,3 +528,102 @@ export async function registerCustomer(params: {
throw err;
}
}
+
+// ---------------------------------------------------------------------------
+// v2 User API — profile updates (Phase 6 fix5)
+// ---------------------------------------------------------------------------
+
+/**
+ * Update a human user's profile (first name + last name). Returns
+ * the new `details.changeDate` from ZITADEL so the caller can
+ * confirm the write landed.
+ *
+ * The v2 user service endpoint is technically a PUT but accepts
+ * partial bodies — only `profile.givenName` and `profile.familyName`
+ * are sent. ZITADEL preserves email, password, and other fields
+ * across the call (verified empirically in stripe-node#7786 and
+ * documented in v2.63+ of zitadel-server).
+ *
+ * `displayName` is intentionally NOT sent. ZITADEL recomputes it
+ * from givenName + familyName when not provided, which is what we
+ * want — keeping displayName as a frozen value would let it drift
+ * out of sync with the name parts on subsequent edits.
+ *
+ * Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
+ * must have user-write permission in the user's resource org.
+ * Today portal-zitadel-sa-pat already has user-write for
+ * createHumanUser etc. — same scope covers this.
+ */
+export interface UpdateHumanUserProfileResult {
+ changeDate: string;
+ /** ZITADEL recomputes this from given+family unless overridden. */
+ displayName: string;
+}
+
+export async function updateHumanUserProfile(params: {
+ userId: string;
+ givenName: string;
+ familyName: string;
+}): Promise {
+ const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
+ type ZitadelUpdateResponse = {
+ details?: { changeDate?: string };
+ };
+ await zitadelFetch(path, "PUT", {
+ profile: {
+ givenName: params.givenName,
+ familyName: params.familyName,
+ },
+ });
+ // Re-fetch the user so we can return the canonical displayName
+ // (ZITADEL computes "Given Family" itself; matching what NextAuth
+ // sees in the next sign-in claim).
+ const detail = await getHumanUserDetail(params.userId);
+ return {
+ changeDate: new Date().toISOString(),
+ displayName: detail.displayName,
+ };
+}
+
+/**
+ * Fetch a human user's current profile (given/family/display name +
+ * email). Used by the settings page to populate the form and by the
+ * update helper above to read back the computed displayName.
+ */
+export interface HumanUserDetail {
+ userId: string;
+ givenName: string;
+ familyName: string;
+ displayName: string;
+ email: string;
+}
+
+export async function getHumanUserDetail(
+ userId: string
+): Promise {
+ type ZitadelGetUserResponse = {
+ user?: {
+ userId?: string;
+ human?: {
+ profile?: {
+ givenName?: string;
+ familyName?: string;
+ displayName?: string;
+ };
+ email?: { email?: string };
+ };
+ };
+ };
+ const response = await zitadelFetch(
+ `/v2/users/${encodeURIComponent(userId)}`,
+ "GET"
+ );
+ const human = response.user?.human;
+ return {
+ userId: response.user?.userId ?? userId,
+ givenName: human?.profile?.givenName ?? "",
+ familyName: human?.profile?.familyName ?? "",
+ displayName: human?.profile?.displayName ?? "",
+ email: human?.email?.email ?? "",
+ };
+}
diff --git a/src/messages/de.json b/src/messages/de.json
index c92ef94..6405668 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -480,7 +480,9 @@
"billingTitle": "Abrechnung",
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.",
"nothingForYou": "Für Ihre Rolle gibt es hier noch nichts. Inhaber können Organisationseinstellungen verwalten.",
- "billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants."
+ "billingDescriptionPersonal": "Adresse und Rechnungs-E-Mail für alle Ihre Tenants.",
+ "profileTitle": "Profil",
+ "profileDescription": "Bearbeiten Sie Ihren Vor- und Nachnamen, wie er im Portal erscheint."
},
"settingsBilling": {
"title": "Rechnungsdaten",
@@ -784,5 +786,20 @@
},
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen",
"failureBannerBody": "{count} Lauf/Läufe im aktuellen Fenster haben mindestens einen Fehler gemeldet. Bitte die Tabelle unten prüfen — betroffene Zeilen sind rot hervorgehoben."
+ },
+ "settingsProfile": {
+ "title": "Profil",
+ "subtitle": "Ihr Anzeigename, der im Portal, in Tenant-Anfragen und in Support-Tickets erscheint.",
+ "subtitlePersonal": "Ihr Anzeigename, der im Portal erscheint. Um Ihren Namen auf Rechnungen zu ändern, bearbeiten Sie ihn unter Rechnungsdaten.",
+ "firstNameLabel": "Vorname",
+ "lastNameLabel": "Nachname",
+ "emailLabel": "E-Mail",
+ "emailReadOnlyHint": "Die E-Mail-Adresse kann hier nicht geändert werden. Verwenden Sie die Selbstbedienungseinstellungen Ihres Identitätsanbieters.",
+ "personalAccountHint": "Dies ist ein persönliches Konto. Eine Änderung Ihres Namens hier ändert NICHT, wie Ihr Name auf Rechnungen erscheint — bearbeiten Sie diesen separat unter Rechnungsdaten.",
+ "companyAccountHint": "Sie sind als Mitglied von {orgName} angemeldet.",
+ "saveChanges": "Änderungen speichern",
+ "saving": "Speichern…",
+ "saved": "Gespeichert.",
+ "missingRequired": "Vor- und Nachname sind erforderlich."
}
}
diff --git a/src/messages/en.json b/src/messages/en.json
index ad5596b..e12037a 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -480,7 +480,9 @@
"billingTitle": "Billing",
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.",
"nothingForYou": "There's nothing here for your role yet. Owners can manage org settings.",
- "billingDescriptionPersonal": "Address and invoice email used for all your tenants."
+ "billingDescriptionPersonal": "Address and invoice email used for all your tenants.",
+ "profileTitle": "Profile",
+ "profileDescription": "Edit your first and last name as shown across the portal."
},
"settingsBilling": {
"title": "Billing details",
@@ -784,5 +786,20 @@
},
"failureBannerTitle": "Recent automation failures detected",
"failureBannerBody": "{count} run(s) in the recent window reported at least one failure. Review the table below — the affected rows are highlighted in red."
+ },
+ "settingsProfile": {
+ "title": "Profile",
+ "subtitle": "Your display name as shown across the portal, in tenant requests, and in support tickets.",
+ "subtitlePersonal": "Your display name as shown across the portal. To change how your name appears on invoices, edit it in Billing details.",
+ "firstNameLabel": "First name",
+ "lastNameLabel": "Last name",
+ "emailLabel": "Email",
+ "emailReadOnlyHint": "Email can't be changed here. Use your identity provider's self-service settings to change your email.",
+ "personalAccountHint": "This is a personal account. Changing your name here does NOT update how your name appears on invoices — edit that separately in Billing details.",
+ "companyAccountHint": "You're signed in as a member of {orgName}.",
+ "saveChanges": "Save changes",
+ "saving": "Saving…",
+ "saved": "Saved.",
+ "missingRequired": "First and last name are required."
}
}
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 1eb83c4..a2ec12b 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -480,7 +480,9 @@
"billingTitle": "Facturation",
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.",
"nothingForYou": "Il n'y a rien ici pour votre rôle pour le moment. Les propriétaires peuvent gérer les paramètres de l'organisation.",
- "billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires."
+ "billingDescriptionPersonal": "Adresse et e-mail de facturation utilisés pour tous vos locataires.",
+ "profileTitle": "Profil",
+ "profileDescription": "Modifiez votre prénom et nom tels qu'ils apparaissent dans le portail."
},
"settingsBilling": {
"title": "Informations de facturation",
@@ -784,5 +786,20 @@
},
"failureBannerTitle": "Échecs récents détectés",
"failureBannerBody": "{count} lancement(s) récent(s) ont signalé au moins un échec. Consultez le tableau ci-dessous — les lignes concernées sont en rouge."
+ },
+ "settingsProfile": {
+ "title": "Profil",
+ "subtitle": "Votre nom d'affichage tel qu'il apparaît dans le portail, les demandes de tenant et les tickets d'assistance.",
+ "subtitlePersonal": "Votre nom d'affichage tel qu'il apparaît dans le portail. Pour modifier votre nom sur les factures, modifiez-le dans Informations de facturation.",
+ "firstNameLabel": "Prénom",
+ "lastNameLabel": "Nom",
+ "emailLabel": "E-mail",
+ "emailReadOnlyHint": "L'e-mail ne peut pas être modifié ici. Utilisez les paramètres en libre-service de votre fournisseur d'identité.",
+ "personalAccountHint": "Ceci est un compte personnel. Modifier votre nom ici ne change PAS la façon dont votre nom apparaît sur les factures — modifiez-le séparément dans Informations de facturation.",
+ "companyAccountHint": "Vous êtes connecté en tant que membre de {orgName}.",
+ "saveChanges": "Enregistrer les modifications",
+ "saving": "Enregistrement…",
+ "saved": "Enregistré.",
+ "missingRequired": "Le prénom et le nom sont obligatoires."
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index 902932a..82c5595 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -480,7 +480,9 @@
"billingTitle": "Fatturazione",
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.",
"nothingForYou": "Al momento non c'è nulla qui per il tuo ruolo. I proprietari possono gestire le impostazioni dell'organizzazione.",
- "billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant."
+ "billingDescriptionPersonal": "Indirizzo ed e-mail di fatturazione usati per tutti i tuoi tenant.",
+ "profileTitle": "Profilo",
+ "profileDescription": "Modifica il tuo nome e cognome come appaiono nel portale."
},
"settingsBilling": {
"title": "Dati di fatturazione",
@@ -784,5 +786,20 @@
},
"failureBannerTitle": "Fallimenti recenti rilevati",
"failureBannerBody": "{count} esecuzione/i recente/i hanno segnalato almeno un fallimento. Controlla la tabella sotto — le righe interessate sono in rosso."
+ },
+ "settingsProfile": {
+ "title": "Profilo",
+ "subtitle": "Il tuo nome visualizzato come appare nel portale, nelle richieste tenant e nei ticket di supporto.",
+ "subtitlePersonal": "Il tuo nome visualizzato come appare nel portale. Per modificare il tuo nome in fattura, modificalo in Dati di fatturazione.",
+ "firstNameLabel": "Nome",
+ "lastNameLabel": "Cognome",
+ "emailLabel": "E-mail",
+ "emailReadOnlyHint": "L'e-mail non può essere modificata qui. Usa le impostazioni self-service del tuo provider di identità.",
+ "personalAccountHint": "Questo è un account personale. Modificare il tuo nome qui NON cambia come appare in fattura — modificalo separatamente in Dati di fatturazione.",
+ "companyAccountHint": "Sei connesso come membro di {orgName}.",
+ "saveChanges": "Salva modifiche",
+ "saving": "Salvataggio…",
+ "saved": "Salvato.",
+ "missingRequired": "Nome e cognome sono obbligatori."
}
}