From 9cd9879a1878862546a433a67db94150cd8f3870 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 25 May 2026 20:21:26 +0200 Subject: [PATCH] Phase6c: Optional Company contact name --- src/lib/zitadel.ts | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/lib/zitadel.ts b/src/lib/zitadel.ts index c1114e3..e5ab547 100644 --- a/src/lib/zitadel.ts +++ b/src/lib/zitadel.ts @@ -534,20 +534,24 @@ export async function registerCustomer(params: { // --------------------------------------------------------------------------- /** - * 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. + * Update a human user's profile (first name + last name + display + * 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). + * partial bodies — only the `profile` block is sent. ZITADEL + * preserves email, password, and other fields across the call + * (verified empirically in zitadel-server#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. + * `displayName` IS sent explicitly, set to "givenName familyName". + * Empirically (and contra what some docs suggest), ZITADEL does + * NOT recompute displayName when only the name parts change — it + * keeps whatever displayName was previously stored, including the + * one set at user creation time. That stale displayName is what + * ZITADEL surfaces in the OIDC `name` claim, so without this + * explicit write the portal session would never see the updated + * name (even after sign-out / sign-in). * * Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT * must have user-write permission in the user's resource org. @@ -556,7 +560,8 @@ export async function registerCustomer(params: { */ export interface UpdateHumanUserProfileResult { changeDate: string; - /** ZITADEL recomputes this from given+family unless overridden. */ + /** The displayName ZITADEL stored, which the OIDC `name` claim will + * carry on the user's next session. */ displayName: string; } @@ -566,6 +571,11 @@ export async function updateHumanUserProfile(params: { familyName: string; }): Promise { const path = `/v2/users/human/${encodeURIComponent(params.userId)}`; + // Compose the displayName ourselves so ZITADEL stores something + // sensible. Empty-string fallback only triggers if both name parts + // are blank, which the API zod schema prevents anyway. + const displayName = + `${params.givenName.trim()} ${params.familyName.trim()}`.trim(); type ZitadelUpdateResponse = { details?: { changeDate?: string }; }; @@ -573,15 +583,16 @@ export async function updateHumanUserProfile(params: { profile: { givenName: params.givenName, familyName: params.familyName, + displayName, }, }); - // 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). + // 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. const detail = await getHumanUserDetail(params.userId); return { changeDate: new Date().toISOString(), - displayName: detail.displayName, + displayName: detail.displayName || displayName, }; }