Compare commits

...

4 Commits

Author SHA1 Message Date
9cd9879a18 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 20:21:26 +02:00
323786672f Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m42s
2026-05-25 14:08:18 +02:00
a1769eeb00 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 13:50:16 +02:00
002867850d Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:28:56 +02:00
11 changed files with 603 additions and 13 deletions

View File

@@ -20,8 +20,9 @@ export default async function SettingsPage() {
const t = await getTranslations("settings"); const t = await getTranslations("settings");
// Build the list of settings cards. Each entry has a stable key, a // Build the list of settings cards. Each entry has a stable key, a
// route, and a visibility predicate. Currently only billing; this // route, and a visibility predicate. Phase 6 fix5: profile is
// shape leaves headroom for adding more without restructuring. // visible to every signed-in user (it's their own identity).
// Billing stays gated behind canMutate.
const sections: Array<{ const sections: Array<{
key: string; key: string;
href: string; href: string;
@@ -29,6 +30,14 @@ export default async function SettingsPage() {
description: string; description: string;
visible: boolean; 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", key: "billing",
href: "/settings/billing", href: "/settings/billing",

View File

@@ -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 (
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="mb-8 animate-in">
<h1 className="font-display text-2xl font-semibold accent-rule">
{t("title")}
</h1>
<p className="text-sm text-text-secondary mt-3">
{user.isPersonal ? t("subtitlePersonal") : t("subtitle")}
</p>
</div>
<div className="animate-in animate-in-delay-1">
<ProfileSettingsForm
initial={initial}
isPersonal={user.isPersonal}
orgName={user.orgName}
/>
</div>
</main>
);
}

View File

@@ -252,11 +252,24 @@ export async function POST(request: Request) {
} }
} }
// For follow-up instances, prefer the on-file company name and contact // The audit copy of company name on this request stays inherited
// details; the user can't change those by re-typing them in the wizard. // from the first request in the org — it's a historical snapshot
// of the company name at the time the request was created, and
// org_billing is now the canonical source for current values.
//
// Phase 6 fix4: contactName and contactEmail are NOT inherited.
// They identify whoever submitted THIS specific request (drives
// admin display, support ticket routing, and email greetings).
// The previous "prior?.contactName ?? user.name" pattern locked
// the contact to whoever first onboarded the org, which broke for
// any subsequent submission by a different user — admin saw the
// wrong name, support emails went to the wrong person, and the
// actual submitter had no way to correct it because the wizard
// doesn't expose a contact-name input. The fix is simply to use
// the current session user every time.
const companyName = prior?.companyName ?? user.orgName; const companyName = prior?.companyName ?? user.orgName;
const contactName = prior?.contactName ?? user.name; const contactName = user.name;
const contactEmail = prior?.contactEmail ?? user.email; const contactEmail = user.email;
// Bug 35: org-scoped billing. // Bug 35: org-scoped billing.
// //

View File

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

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
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 <org>" 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. After the cookie is updated we trigger a full page
* reload — every server-rendered surface (nav-shell, dashboard
* welcome, instance cards) re-reads the cookie on the next request
* and renders with the new name. router.refresh() alone wasn't
* 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) {
const t = useTranslations("settingsProfile");
const { update } = useSession();
const [form, setForm] = useState({
firstName: initial.firstName,
lastName: initial.lastName,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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);
// 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) {
setError(e?.message ?? String(e));
} finally {
setBusy(false);
}
};
return (
<Card>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label={t("firstNameLabel")} required>
<input
type="text"
value={form.firstName}
onChange={(e) =>
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"
/>
</Field>
<Field label={t("lastNameLabel")} required>
<input
type="text"
value={form.lastName}
onChange={(e) =>
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"
/>
</Field>
</div>
<Field label={t("emailLabel")} hint={t("emailReadOnlyHint")}>
<input
type="email"
value={initial.email}
readOnly
disabled
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border text-sm text-text-muted cursor-not-allowed"
/>
</Field>
{/* 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 ? (
<p className="text-xs text-text-muted italic">
{t("personalAccountHint")}
</p>
) : (
<p className="text-xs text-text-muted italic">
{t("companyAccountHint", { orgName })}
</p>
)}
{error && <p className="text-sm text-error">{error}</p>}
{savedFlash && <p className="text-sm text-success">{t("saved")}</p>}
<div className="flex justify-end">
<button
onClick={submit}
disabled={busy}
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
>
{busy ? t("saving") : t("saveChanges")}
</button>
</div>
</div>
</Card>
);
}
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: React.ReactNode;
}) {
return (
<div>
<label className="block text-xs uppercase tracking-wider text-text-muted mb-1">
{label}
{required && <span className="text-error ml-1">*</span>}
</label>
{children}
{hint && <p className="text-xs text-text-muted mt-1 italic">{hint}</p>}
</div>
);
}

View File

@@ -49,7 +49,31 @@ export const authConfig: NextAuthConfig = {
}, },
], ],
callbacks: { 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.
// The session callback reads token.name directly (see below) so
// the update propagates without depending on auth.js's implicit
// 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
// 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.
//
// 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) {
const update = session as { name?: unknown };
if (typeof update.name === "string") {
return { ...token, name: update.name };
}
return token;
}
if (account && profile) { if (account && profile) {
const claims = profile as unknown as ZitadelClaims; const claims = profile as unknown as ZitadelClaims;
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"]; token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
@@ -58,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
@@ -80,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,
@@ -96,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;
}, },
}, },

View File

@@ -528,3 +528,113 @@ export async function registerCustomer(params: {
throw err; throw err;
} }
} }
// ---------------------------------------------------------------------------
// v2 User API — profile updates (Phase 6 fix5)
// ---------------------------------------------------------------------------
/**
* 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 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 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.
* Today portal-zitadel-sa-pat already has user-write for
* createHumanUser etc. — same scope covers this.
*/
export interface UpdateHumanUserProfileResult {
changeDate: string;
/** The displayName ZITADEL stored, which the OIDC `name` claim will
* carry on the user's next session. */
displayName: string;
}
export async function updateHumanUserProfile(params: {
userId: string;
givenName: string;
familyName: string;
}): Promise<UpdateHumanUserProfileResult> {
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 };
};
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
profile: {
givenName: params.givenName,
familyName: params.familyName,
displayName,
},
});
// 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,
};
}
/**
* 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<HumanUserDetail> {
type ZitadelGetUserResponse = {
user?: {
userId?: string;
human?: {
profile?: {
givenName?: string;
familyName?: string;
displayName?: string;
};
email?: { email?: string };
};
};
};
const response = await zitadelFetch<ZitadelGetUserResponse>(
`/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 ?? "",
};
}

View File

@@ -480,7 +480,9 @@
"billingTitle": "Abrechnung", "billingTitle": "Abrechnung",
"billingDescription": "Adresse, MWST-Nummer und Rechnungs-E-Mail für alle Ihre Tenants.", "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.", "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": { "settingsBilling": {
"title": "Rechnungsdaten", "title": "Rechnungsdaten",
@@ -784,5 +786,20 @@
}, },
"failureBannerTitle": "Fehler in jüngsten Automatisierungsläufen", "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." "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."
} }
} }

View File

@@ -480,7 +480,9 @@
"billingTitle": "Billing", "billingTitle": "Billing",
"billingDescription": "Address, VAT number, and invoice email used for all your tenants.", "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.", "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": { "settingsBilling": {
"title": "Billing details", "title": "Billing details",
@@ -784,5 +786,20 @@
}, },
"failureBannerTitle": "Recent automation failures detected", "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." "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."
} }
} }

View File

@@ -480,7 +480,9 @@
"billingTitle": "Facturation", "billingTitle": "Facturation",
"billingDescription": "Adresse, numéro de TVA et e-mail de facturation utilisés pour tous vos locataires.", "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.", "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": { "settingsBilling": {
"title": "Informations de facturation", "title": "Informations de facturation",
@@ -784,5 +786,20 @@
}, },
"failureBannerTitle": "Échecs récents détectés", "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." "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."
} }
} }

View File

@@ -480,7 +480,9 @@
"billingTitle": "Fatturazione", "billingTitle": "Fatturazione",
"billingDescription": "Indirizzo, numero di IVA ed e-mail di fatturazione usati per tutti i tuoi tenant.", "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.", "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": { "settingsBilling": {
"title": "Dati di fatturazione", "title": "Dati di fatturazione",
@@ -784,5 +786,20 @@
}, },
"failureBannerTitle": "Fallimenti recenti rilevati", "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." "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."
} }
} }