Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s

This commit is contained in:
2026-05-25 14:08:18 +02:00
parent a1769eeb00
commit 323786672f
2 changed files with 57 additions and 14 deletions

View File

@@ -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 {

View File

@@ -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;
}, },
}, },