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 ( +
+
+

+ {t("title")} +

+

+ {user.isPersonal ? t("subtitlePersonal") : t("subtitle")} +

+
+
+ +
+
+ ); +} diff --git a/src/app/api/settings/profile/route.ts b/src/app/api/settings/profile/route.ts new file mode 100644 index 0000000..0fbd58f --- /dev/null +++ b/src/app/api/settings/profile/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getSessionUser } from "@/lib/session"; +import { + getHumanUserDetail, + updateHumanUserProfile, +} from "@/lib/zitadel"; + +/** + * GET /api/settings/profile — read the caller's ZITADEL profile. + * Returns first/last/display name and email. Used by the settings + * page server component to populate the form. + * + * PUT /api/settings/profile — update first + last name. Email is + * NOT mutable here — changing email needs verification flow that + * ZITADEL's own self-service UI already provides; we don't + * duplicate that. + * + * Authorization: any authenticated user can edit their own profile. + * The PAT (ZITADEL_SA_PAT) is used to call the ZITADEL v2 user + * service, but only against the caller's own userId. There is no + * userId field on the request — it's always derived from the + * session, so the route can't be abused to edit other users. + */ + +const updateSchema = z.object({ + firstName: z.string().trim().min(1).max(100), + lastName: z.string().trim().min(1).max(100), +}); + +export async function GET() { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const profile = await getHumanUserDetail(user.id); + return NextResponse.json({ profile }); + } catch (e: any) { + // Surface ZITADEL-side failures (e.g. user not found, PAT expired) + // as 502 — the portal couldn't reach its identity provider, which + // is operationally different from a 4xx on the caller's input. + console.error("getHumanUserDetail failed:", e); + return NextResponse.json( + { error: "Could not load profile from identity provider" }, + { status: 502 } + ); + } +} + +export async function PUT(request: Request) { + const user = await getSessionUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const body = await request.json().catch(() => ({})); + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + try { + const result = await updateHumanUserProfile({ + userId: user.id, + givenName: parsed.data.firstName, + familyName: parsed.data.lastName, + }); + return NextResponse.json({ + displayName: result.displayName, + changeDate: result.changeDate, + }); + } catch (e: any) { + console.error("updateHumanUserProfile failed:", e); + return NextResponse.json( + { error: "Could not update profile in identity provider" }, + { status: 502 } + ); + } +} diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx new file mode 100644 index 0000000..cdd69e2 --- /dev/null +++ b/src/components/settings/profile-form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/components/ui/card"; + +interface Props { + initial: { + firstName: string; + lastName: string; + email: string; + }; + /** + * Personal-account flag. Drives a small hint about how the ZITADEL + * name relates (or doesn't) to invoice identity — see the page + * server component for the long explanation. + */ + isPersonal: boolean; + /** + * For company accounts: the display org name. Shown in a small + * read-only "Member of " hint so the user understands which + * identity they're editing. Ignored for personals (orgName is an + * opaque "personal-XXXX" string in that case). + */ + orgName: string; +} + +/** + * Edits first/last name in ZITADEL via PUT /api/settings/profile. + * Email is shown read-only — changing email requires verification + * flow that ZITADEL's own self-service UI handles. + * + * On save, we trigger NextAuth's `update()` from useSession() with + * the new display name. That routes through our jwt callback + * (trigger='update' branch) which overlays token.name without a + * logout/login. The whole UI sees the new name on the next render. + * + * router.refresh() additionally re-runs the server component, so + * the page's own server-fetched values pick up the new state if the + * user immediately returns. + */ +export function ProfileSettingsForm({ initial, isPersonal, orgName }: Props) { + const t = useTranslations("settingsProfile"); + const router = useRouter(); + const { update } = useSession(); + const [form, setForm] = useState({ + firstName: initial.firstName, + lastName: initial.lastName, + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [savedFlash, setSavedFlash] = useState(false); + + const submit = async () => { + setError(null); + setSavedFlash(false); + if (!form.firstName.trim() || !form.lastName.trim()) { + setError(t("missingRequired")); + return; + } + setBusy(true); + try { + const res = await fetch("/api/settings/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + firstName: form.firstName.trim(), + lastName: form.lastName.trim(), + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error ?? `HTTP ${res.status}`); + } + // Phase 6 fix5: push the new display name into the session + // token. The jwt callback handles trigger='update' and overlays + // token.name; the next session callback maps token.name back + // to session.user.name. No re-login needed. + await update({ name: data.displayName }); + setSavedFlash(true); + router.refresh(); + } catch (e: any) { + setError(e?.message ?? String(e)); + } finally { + setBusy(false); + } + }; + + return ( + +
+
+ + + setForm((f) => ({ ...f, firstName: e.target.value })) + } + maxLength={100} + className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm" + /> + + + + setForm((f) => ({ ...f, lastName: e.target.value })) + } + maxLength={100} + className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm" + /> + +
+ + + + {/* 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 ? ( +

+ {t("personalAccountHint")} +

+ ) : ( +

+ {t("companyAccountHint", { orgName })} +

+ )} + {error &&

{error}

} + {savedFlash &&

{t("saved")}

} +
+ +
+
+
+ ); +} + +function Field({ + label, + required, + hint, + children, +}: { + label: string; + required?: boolean; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} 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." } }