Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd9879a18 | |||
| 323786672f |
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -35,15 +34,15 @@ interface Props {
|
|||||||
* On save, we trigger NextAuth's `update()` from useSession() with
|
* On save, we trigger NextAuth's `update()` from useSession() with
|
||||||
* the new display name. That routes through our jwt callback
|
* the new display name. That routes through our jwt callback
|
||||||
* (trigger='update' branch) which overlays token.name without a
|
* (trigger='update' branch) which overlays token.name without a
|
||||||
* logout/login. The whole UI sees the new name on the next render.
|
* logout/login. After the cookie is updated we trigger a full page
|
||||||
*
|
* reload — every server-rendered surface (nav-shell, dashboard
|
||||||
* router.refresh() additionally re-runs the server component, so
|
* welcome, instance cards) re-reads the cookie on the next request
|
||||||
* the page's own server-fetched values pick up the new state if the
|
* and renders with the new name. router.refresh() alone wasn't
|
||||||
* user immediately returns.
|
* enough: it re-runs only the current route's server components,
|
||||||
|
* leaving outer-tree segments stale until the user navigates.
|
||||||
*/
|
*/
|
||||||
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) {
|
||||||
const t = useTranslations("settingsProfile");
|
const t = useTranslations("settingsProfile");
|
||||||
const router = useRouter();
|
|
||||||
const { update } = useSession();
|
const { update } = useSession();
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
firstName: initial.firstName,
|
firstName: initial.firstName,
|
||||||
@@ -80,7 +79,16 @@ 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);
|
||||||
router.refresh();
|
// 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.
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 800);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message ?? String(e));
|
setError(e?.message ?? String(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -54,18 +54,23 @@ export const authConfig: NextAuthConfig = {
|
|||||||
// route through this branch. We trust the new value because the
|
// route through this branch. We trust the new value because the
|
||||||
// PUT /api/settings/profile route already wrote it to ZITADEL
|
// PUT /api/settings/profile route already wrote it to ZITADEL
|
||||||
// and re-fetched the canonical displayName before returning.
|
// and re-fetched the canonical displayName before returning.
|
||||||
// NextAuth maps token.name → session.user.name on the next
|
// The session callback reads token.name directly (see below) so
|
||||||
// session callback, so downstream useSession() consumers see
|
// the update propagates without depending on auth.js's implicit
|
||||||
// the new name without a logout/login cycle.
|
// token→session.user mapping, which is flaky for the name claim
|
||||||
|
// in the v5 OIDC provider configuration.
|
||||||
//
|
//
|
||||||
// Defensive: only the `name` field is accepted from the update
|
// Defensive: only the `name` field is accepted from the update
|
||||||
// payload, even if the client passes additional keys. Other
|
// payload, even if the client passes additional keys. Other
|
||||||
// identity claims (orgId, roles, sub) come from ZITADEL at
|
// identity claims (orgId, roles, sub) come from ZITADEL at
|
||||||
// sign-in time and are not user-mutable from a settings page.
|
// sign-in time and are not user-mutable from a settings page.
|
||||||
|
//
|
||||||
|
// Returns a NEW token object (spread) rather than mutating, so
|
||||||
|
// there is no ambiguity for auth.js about whether the token
|
||||||
|
// changed and needs re-encoding into the session cookie.
|
||||||
if (trigger === "update" && session) {
|
if (trigger === "update" && session) {
|
||||||
const update = session as { name?: unknown };
|
const update = session as { name?: unknown };
|
||||||
if (typeof update.name === "string") {
|
if (typeof update.name === "string") {
|
||||||
(token as { name?: string }).name = update.name;
|
return { ...token, name: update.name };
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
claims["urn:zitadel:iam:org:project:roles"]
|
claims["urn:zitadel:iam:org:project:roles"]
|
||||||
);
|
);
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
|
// Phase 6 fix5: explicitly pin the standard name/email claims
|
||||||
|
// onto the token from the OIDC profile. Previously these came
|
||||||
|
// through auth.js's implicit mapping, which works on first
|
||||||
|
// sign-in but isn't reliable after update() — once the update
|
||||||
|
// path overrides token.name, the read-back path needs token
|
||||||
|
// to be the authoritative source. Setting them explicitly
|
||||||
|
// here keeps sign-in and update on the same path.
|
||||||
|
if (typeof profile.name === "string") {
|
||||||
|
token.name = profile.name;
|
||||||
|
}
|
||||||
|
if (typeof profile.email === "string") {
|
||||||
|
token.email = profile.email;
|
||||||
|
}
|
||||||
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
// Pin token.sub to the OIDC subject. Auth.js v5 otherwise puts a
|
||||||
// freshly generated UUID in token.sub on initial sign-in,
|
// freshly generated UUID in token.sub on initial sign-in,
|
||||||
// ignoring what profile() returns for `id`. That UUID then
|
// ignoring what profile() returns for `id`. That UUID then
|
||||||
@@ -99,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
|||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
const roles = (token.roles as Role[]) ?? [];
|
const roles = (token.roles as Role[]) ?? [];
|
||||||
const orgName = (token.orgName as string) ?? "";
|
const orgName = (token.orgName as string) ?? "";
|
||||||
|
// Phase 6 fix5: read name and email directly from the token.
|
||||||
|
// Previously this code relied on `session.user?.name`, expecting
|
||||||
|
// auth.js to map token.name → session.user.name automatically.
|
||||||
|
// That mapping is brittle: it works on first sign-in (because
|
||||||
|
// OIDC profile() populates session.user) but not after update()
|
||||||
|
// overrides token.name. Reading from token is the canonical
|
||||||
|
// path regardless of how the token was last written.
|
||||||
|
const tokenName = (token.name as string | undefined) ?? "";
|
||||||
|
const tokenEmail = (token.email as string | undefined) ?? "";
|
||||||
const sessionUser: SessionUser = {
|
const sessionUser: SessionUser = {
|
||||||
id: token.sub!,
|
id: token.sub!,
|
||||||
name: session.user?.name ?? "",
|
name: tokenName || session.user?.name || "",
|
||||||
email: session.user?.email ?? "",
|
email: tokenEmail || session.user?.email || "",
|
||||||
orgId: token.orgId as string,
|
orgId: token.orgId as string,
|
||||||
orgName,
|
orgName,
|
||||||
roles,
|
roles,
|
||||||
@@ -115,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
|||||||
isPersonal: isPersonalOrgName(orgName),
|
isPersonal: isPersonalOrgName(orgName),
|
||||||
};
|
};
|
||||||
(session as any).platformUser = sessionUser;
|
(session as any).platformUser = sessionUser;
|
||||||
|
// Also overwrite session.user so any client-side code that uses
|
||||||
|
// the standard NextAuth shape (session.user.name) sees the new
|
||||||
|
// value. Pre-fix5 code paths read from session.user.name; this
|
||||||
|
// keeps them working without per-component changes.
|
||||||
|
if (session.user) {
|
||||||
|
session.user.name = sessionUser.name;
|
||||||
|
session.user.email = sessionUser.email;
|
||||||
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -534,20 +534,24 @@ export async function registerCustomer(params: {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a human user's profile (first name + last name). Returns
|
* Update a human user's profile (first name + last name + display
|
||||||
* the new `details.changeDate` from ZITADEL so the caller can
|
* name). Returns the new `details.changeDate` from ZITADEL so the
|
||||||
* confirm the write landed.
|
* caller can confirm the write landed.
|
||||||
*
|
*
|
||||||
* The v2 user service endpoint is technically a PUT but accepts
|
* The v2 user service endpoint is technically a PUT but accepts
|
||||||
* partial bodies — only `profile.givenName` and `profile.familyName`
|
* partial bodies — only the `profile` block is sent. ZITADEL
|
||||||
* are sent. ZITADEL preserves email, password, and other fields
|
* preserves email, password, and other fields across the call
|
||||||
* across the call (verified empirically in stripe-node#7786 and
|
* (verified empirically in zitadel-server#7786 and documented in
|
||||||
* documented in v2.63+ of zitadel-server).
|
* v2.63+ of zitadel-server).
|
||||||
*
|
*
|
||||||
* `displayName` is intentionally NOT sent. ZITADEL recomputes it
|
* `displayName` IS sent explicitly, set to "givenName familyName".
|
||||||
* from givenName + familyName when not provided, which is what we
|
* Empirically (and contra what some docs suggest), ZITADEL does
|
||||||
* want — keeping displayName as a frozen value would let it drift
|
* NOT recompute displayName when only the name parts change — it
|
||||||
* out of sync with the name parts on subsequent edits.
|
* 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
|
* Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
|
||||||
* must have user-write permission in the user's resource org.
|
* must have user-write permission in the user's resource org.
|
||||||
@@ -556,7 +560,8 @@ export async function registerCustomer(params: {
|
|||||||
*/
|
*/
|
||||||
export interface UpdateHumanUserProfileResult {
|
export interface UpdateHumanUserProfileResult {
|
||||||
changeDate: string;
|
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;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,6 +571,11 @@ export async function updateHumanUserProfile(params: {
|
|||||||
familyName: string;
|
familyName: 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
|
||||||
|
// 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 = {
|
type ZitadelUpdateResponse = {
|
||||||
details?: { changeDate?: string };
|
details?: { changeDate?: string };
|
||||||
};
|
};
|
||||||
@@ -573,15 +583,16 @@ export async function updateHumanUserProfile(params: {
|
|||||||
profile: {
|
profile: {
|
||||||
givenName: params.givenName,
|
givenName: params.givenName,
|
||||||
familyName: params.familyName,
|
familyName: params.familyName,
|
||||||
|
displayName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Re-fetch the user so we can return the canonical displayName
|
// Re-fetch the user to read back the canonical displayName ZITADEL
|
||||||
// (ZITADEL computes "Given Family" itself; matching what NextAuth
|
// committed. Should match what we sent, but reading from the source
|
||||||
// sees in the next sign-in claim).
|
// of truth catches any sanitization ZITADEL might apply.
|
||||||
const detail = await getHumanUserDetail(params.userId);
|
const detail = await getHumanUserDetail(params.userId);
|
||||||
return {
|
return {
|
||||||
changeDate: new Date().toISOString(),
|
changeDate: new Date().toISOString(),
|
||||||
displayName: detail.displayName,
|
displayName: detail.displayName || displayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user