Compare commits

..

5 Commits

Author SHA1 Message Date
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
eea027b3b0 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m38s
2026-05-25 13:14:36 +02:00
522246e386 Phase6c: Optional Company contact name
All checks were successful
Build and Push / build (push) Successful in 1m40s
2026-05-25 12:54:12 +02:00
21 changed files with 788 additions and 55 deletions

View File

@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
userName={user.name} userName={user.name}
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/> />
</div> </div>
</div> </div>

View File

@@ -317,6 +317,7 @@ export default async function DashboardPage() {
userName={user.name} userName={user.name}
userEmail={user.email} userEmail={user.email}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={orgBilling}
/> />
</div> </div>
</div> </div>

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

@@ -22,6 +22,10 @@ import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
const upsertSchema = z.object({ const upsertSchema = z.object({
companyName: z.string().trim().min(1).max(200), companyName: z.string().trim().min(1).max(200),
// Phase 6 fix: optional "z.Hd." / "Attn:" line. Personal accounts
// never send this (the UI hides the field); orgs may set or leave
// it empty.
contactName: z.string().trim().max(200).optional().nullable(),
streetAddress: z.string().trim().min(1).max(200), streetAddress: z.string().trim().min(1).max(200),
postalCode: z.string().trim().min(1).max(20), postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100), city: z.string().trim().min(1).max(100),
@@ -73,6 +77,7 @@ export async function PUT(request: Request) {
const billing = await upsertOrgBilling({ const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId, zitadelOrgId: user.orgId,
companyName: data.companyName, companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress, streetAddress: data.streetAddress,
postalCode: data.postalCode, postalCode: data.postalCode,
city: data.city, city: data.city,

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

@@ -2,6 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingWizard } from "./wizard"; import { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps { interface OnboardingFlowProps {
orgName: string; orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing. * /settings/billing.
*/ */
hasOrgBilling?: boolean; hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record (or null). Drives
* the review-step "Billing to" rendering AND the confirm-step
* validation skip when the billing step was skipped.
*/
existingOrgBilling?: OrgBilling | null;
/** /**
* Bug 6: when present, the wizard is rendered in edit mode against * Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full * the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
}: OnboardingFlowProps) { }: OnboardingFlowProps) {
const router = useRouter(); const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName} userName={userName}
userEmail={userEmail} userEmail={userEmail}
hasOrgBilling={hasOrgBilling} hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest} editingRequest={editingRequest}
onComplete={() => { onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The // Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES, SUPPORTED_COUNTRIES,
type SupportedCountry, type SupportedCountry,
} from "@/lib/validation"; } from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm"; type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves. * fix it before admin approves.
*/ */
hasOrgBilling?: boolean; hasOrgBilling?: boolean;
/**
* Phase 6 fix3: the actual org_billing record when one exists.
* Used to render real values on the review-step "Billing to" block
* (rather than the wizard's empty default config.billingAddress)
* AND to skip the confirm-step's client-side validation of
* billingAddress — same logic that already strips billingAddress
* at submit time. Null when no org_billing row exists yet.
* Ignored in edit mode (the editingRequest carries its own
* billingAddress snapshot).
*/
existingOrgBilling?: OrgBilling | null;
/** /**
* Bug 6: when present, the wizard renders in "edit" mode — fields * Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is * are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName, userName,
userEmail, userEmail,
hasOrgBilling, hasOrgBilling,
existingOrgBilling,
editingRequest, editingRequest,
onComplete, onComplete,
}: WizardProps) { }: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
} }
// confirm: validate the union (defence in depth — submit handler // confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST). // also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config); //
// Phase 6 fix3: when hasOrgBilling=true AND not editing, the
// billing step was skipped and config.billingAddress is the
// empty default. zod's .optional() doesn't help here because the
// field IS present (empty object), so billingAddressSchema
// validates it and fails with required-field errors that the
// user has no way to fix — the form to enter the values was
// skipped on purpose. Strip the field for validation, matching
// the same strip we already do at submit time.
const configForValidation =
hasOrgBilling && !isEditing
? (() => {
const { billingAddress: _b, ...rest } = config;
return rest;
})()
: config;
const r = onboardingSchema.safeParse(configForValidation);
if (r.success) { if (r.success) {
setErrors({}); setErrors({});
return true; return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow <ReviewRow
label={t("reviewBillingTo")} label={t("reviewBillingTo")}
value={ value={
<div className="text-text-primary text-right"> (() => {
{/* For personal: skip the company line so the // Phase 6 fix3: when the org has billing on file
invoice rendering matches what the user actually // and we're not editing, render the saved
entered. For company: include it as the first // org_billing record (the authoritative source)
line. */} // rather than config.billingAddress, which is the
{!isPersonal && // wizard's empty default state because the billing
config.billingAddress.company && // step was skipped. In edit mode, fall back to
config.billingAddress.company.trim().length > 0 && ( // config.billingAddress, which is pre-populated
<div>{config.billingAddress.company}</div> // from the request being edited.
)} const useSaved =
<div>{config.billingAddress.street}</div> hasOrgBilling && !isEditing && existingOrgBilling;
<div> const company = useSaved
{config.billingAddress.postalCode}{" "} ? existingOrgBilling!.companyName
{config.billingAddress.city} : config.billingAddress.company;
</div> const street = useSaved
<div className="text-text-muted"> ? existingOrgBilling!.streetAddress
{tCountries( : config.billingAddress.street;
config.billingAddress.country as SupportedCountry const postalCode = useSaved
)} ? existingOrgBilling!.postalCode
</div> : config.billingAddress.postalCode;
</div> const city = useSaved
? existingOrgBilling!.city
: config.billingAddress.city;
const country = useSaved
? existingOrgBilling!.country
: config.billingAddress.country;
const contactName = useSaved
? existingOrgBilling!.contactName
: null;
return (
<div className="text-text-primary text-right">
{/* For personal: skip the company line so the
invoice rendering matches what the user actually
entered. For company: include it as the first
line. */}
{!isPersonal &&
company &&
company.trim().length > 0 && <div>{company}</div>}
{/* Phase 6 fix2: optional contact-person line
("z.Hd. <name>") only present when the saved
org_billing has it set. */}
{contactName && contactName.trim().length > 0 && (
<div className="text-text-muted">
{t("reviewContactPersonPrefix")} {contactName}
</div>
)}
<div>{street}</div>
<div>
{postalCode} {city}
</div>
<div className="text-text-muted">
{tCountries(country as SupportedCountry)}
</div>
</div>
);
})()
} }
/> />
{/* Bug 35: VAT review row. Company customers see this so {/* Bug 35: VAT review row. Company customers see this so
they can verify the VAT id they typed before submitting. they can verify the VAT id they typed before submitting.
Personal customers never see it — they don't have a Personal customers never see it — they don't have a
VAT number, the form didn't ask, the review hides it. */} VAT number, the form didn't ask, the review hides it.
Phase 6 fix3: when reading from existingOrgBilling,
the value comes from there too. */}
{!isPersonal && {!isPersonal &&
config.billingAddress.vatNumber && (() => {
config.billingAddress.vatNumber.trim().length > 0 && ( const vat =
<ReviewRow hasOrgBilling && !isEditing && existingOrgBilling
label={t("billingVatNumber")} ? existingOrgBilling.vatNumber
value={config.billingAddress.vatNumber} : config.billingAddress.vatNumber;
mono return vat && vat.trim().length > 0 ? (
/> <ReviewRow
)} label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow <ReviewRow
label={t("reviewContactEmail")} label={t("reviewContactEmail")}
value={userEmail || ""} value={userEmail || ""}

View File

@@ -37,6 +37,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ const [form, setForm] = useState({
companyName: initial?.companyName ?? "", companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "", streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "", postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "", city: initial?.city ?? "",
@@ -84,6 +85,10 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
companyName: form.companyName.trim(), companyName: form.companyName.trim(),
// Personal accounts don't have a contact-name field
// (companyName IS their name); force null so stale state
// from a previously-org-flagged account can't carry over.
contactName: isPersonal ? null : form.contactName.trim() || null,
streetAddress: form.streetAddress.trim(), streetAddress: form.streetAddress.trim(),
postalCode: form.postalCode.trim(), postalCode: form.postalCode.trim(),
city: form.city.trim(), city: form.city.trim(),
@@ -124,6 +129,17 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm" 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>
{!isPersonal && (
<Field label={t("contactNameLabel")} hint={t("contactNameHint")}>
<input
type="text"
value={form.contactName}
onChange={set("contactName")}
maxLength={200}
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("streetAddressLabel")} required> <Field label={t("streetAddressLabel")} required>
<input <input
type="text" type="text"

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

@@ -80,6 +80,11 @@ interface PdfStrings {
dueDate: string; dueDate: string;
period: string; period: string;
billTo: string; billTo: string;
// Phase 6 fix: prefix shown before the optional contact-person
// name on the bill-to block. "z.Hd." (DE) / "Attn:" (EN) /
// "À l'attention de" (FR) / "c.a." (IT). Empty/unused when the
// invoice has no contactName on its snapshot.
attentionPrefix: string;
description: string; description: string;
quantity: string; quantity: string;
unitPrice: string; unitPrice: string;
@@ -107,6 +112,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis", dueDate: "Zahlbar bis",
period: "Abrechnungsperiode", period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger", billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung", description: "Beschreibung",
quantity: "Menge", quantity: "Menge",
unitPrice: "Einzelpreis", unitPrice: "Einzelpreis",
@@ -139,6 +145,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date", dueDate: "Due date",
period: "Billing period", period: "Billing period",
billTo: "Bill to", billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description", description: "Description",
quantity: "Qty", quantity: "Qty",
unitPrice: "Unit price", unitPrice: "Unit price",
@@ -171,6 +178,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance", dueDate: "Échéance",
period: "Période de facturation", period: "Période de facturation",
billTo: "Destinataire", billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description", description: "Description",
quantity: "Qté", quantity: "Qté",
unitPrice: "Prix unitaire", unitPrice: "Prix unitaire",
@@ -203,6 +211,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza", dueDate: "Scadenza",
period: "Periodo di fatturazione", period: "Periodo di fatturazione",
billTo: "Destinatario", billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione", description: "Descrizione",
quantity: "Qtà", quantity: "Qtà",
unitPrice: "Prezzo unitario", unitPrice: "Prezzo unitario",
@@ -524,6 +533,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}> <View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</Text> <Text style={styles.billToLabel}>{s.billTo}</Text>
<Text style={styles.billToName}>{snap.companyName}</Text> <Text style={styles.billToName}>{snap.companyName}</Text>
{/* Phase 6 fix: optional "z.Hd." / "Attn:" line for routing
the printed invoice internally at the customer. Prints
between the company name and street address, in the
invoice's locale (frozen at issue time). */}
{snap.contactName && (
<Text>
{s.attentionPrefix} {snap.contactName}
</Text>
)}
<Text>{snap.streetAddress}</Text> <Text>{snap.streetAddress}</Text>
<Text> <Text>
{snap.postalCode} {snap.city} {snap.postalCode} {snap.city}

View File

@@ -645,6 +645,7 @@ export async function computeInvoiceDraft(opts: {
} }
const snapshot: InvoiceBillingSnapshot = { const snapshot: InvoiceBillingSnapshot = {
companyName: orgBilling.companyName, companyName: orgBilling.companyName,
contactName: orgBilling.contactName ?? null,
streetAddress: orgBilling.streetAddress, streetAddress: orgBilling.streetAddress,
postalCode: orgBilling.postalCode, postalCode: orgBilling.postalCode,
city: orgBilling.city, city: orgBilling.city,

View File

@@ -198,6 +198,12 @@ const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS org_billing ( CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY, zitadel_org_id TEXT PRIMARY KEY,
company_name TEXT NOT NULL, company_name TEXT NOT NULL,
-- Phase 6 fix: optional contact-person line shown on the
-- invoice PDF below the company name (e.g. "z.Hd. Herr Müller").
-- Not normally needed since invoices are delivered by email
-- link, but useful when customers forward the PDF internally
-- for AP routing in larger organizations.
contact_name TEXT,
street_address TEXT NOT NULL, street_address TEXT NOT NULL,
postal_code TEXT NOT NULL, postal_code TEXT NOT NULL,
city TEXT NOT NULL, city TEXT NOT NULL,
@@ -208,6 +214,10 @@ const MIGRATION_SQL = `
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Phase 6 fix: ensure the column exists on databases that were
-- created before contact_name was added to the base schema above.
-- IF NOT EXISTS makes this safe to run repeatedly via ensureSchema.
ALTER TABLE org_billing ADD COLUMN IF NOT EXISTS contact_name TEXT;
-- Feature 5: lightweight customer support / feedback tickets. -- Feature 5: lightweight customer support / feedback tickets.
-- Scoped strictly per-user (zitadel_user_id), not per-org — -- Scoped strictly per-user (zitadel_user_id), not per-org —
@@ -1262,6 +1272,7 @@ function rowToOrgBilling(row: any): OrgBilling {
return { return {
zitadelOrgId: row.zitadel_org_id, zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name, companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address, streetAddress: row.street_address,
postalCode: row.postal_code, postalCode: row.postal_code,
city: row.city, city: row.city,
@@ -1306,12 +1317,13 @@ export async function upsertOrgBilling(
await ensureSchema(); await ensureSchema();
const result = await getPool().query( const result = await getPool().query(
`INSERT INTO org_billing ( `INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code, zitadel_org_id, company_name, contact_name, street_address,
city, country, vat_number, billing_email, notes postal_code, city, country, vat_number, billing_email, notes
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (zitadel_org_id) DO UPDATE SET ON CONFLICT (zitadel_org_id) DO UPDATE SET
company_name = EXCLUDED.company_name, company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address, street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code, postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city, city = EXCLUDED.city,
@@ -1324,6 +1336,7 @@ export async function upsertOrgBilling(
[ [
data.zitadelOrgId, data.zitadelOrgId,
data.companyName, data.companyName,
data.contactName ?? null,
data.streetAddress, data.streetAddress,
data.postalCode, data.postalCode,
data.city, data.city,

View File

@@ -528,3 +528,102 @@ 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). 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<UpdateHumanUserProfileResult> {
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
type ZitadelUpdateResponse = {
details?: { changeDate?: string };
};
await zitadelFetch<ZitadelUpdateResponse>(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<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

@@ -121,7 +121,8 @@
"saveChanges": "Änderungen speichern", "saveChanges": "Änderungen speichern",
"billingVatNumber": "MWST-Nummer", "billingVatNumber": "MWST-Nummer",
"billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.", "billingVatHelp": "Ihre registrierte MWST-Nummer. Falls Ihre Firma von der MWST befreit ist, leer lassen und in den Notizen erläutern.",
"billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc." "billingNotesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc.",
"reviewContactPersonPrefix": "z.Hd."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).", "invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.", "invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"fullNameLabel": "Vor- und Nachname", "fullNameLabel": "Vor- und Nachname",
"subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können." "subtitlePersonal": "Ihre Rechnungsadresse und Rechnungskontakt. Erforderlich, bevor Rechnungen ausgestellt werden können.",
"contactNameLabel": "Ansprechperson (optional)",
"contactNameHint": "Erscheint als 'z.Hd. <Name>' auf der Rechnung unter dem Firmennamen. Hilfreich für die Zuordnung in der Buchhaltung grösserer Firmen."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -781,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

@@ -121,7 +121,8 @@
"saveChanges": "Save changes", "saveChanges": "Save changes",
"billingVatNumber": "VAT number", "billingVatNumber": "VAT number",
"billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.", "billingVatHelp": "Your registered VAT identifier. If your company is VAT-exempt, leave blank and explain in the notes field.",
"billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc." "billingNotesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc.",
"reviewContactPersonPrefix": "Attn:"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Country code must be 2 letters (e.g. CH).", "invalidCountry": "Country code must be 2 letters (e.g. CH).",
"invalidEmail": "Please enter a valid email address.", "invalidEmail": "Please enter a valid email address.",
"fullNameLabel": "Full name", "fullNameLabel": "Full name",
"subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued." "subtitlePersonal": "Your billing address and invoice contact. Required before invoices can be issued.",
"contactNameLabel": "Contact person (optional)",
"contactNameHint": "Prints as 'Attn: <name>' on the invoice below the company name. Useful for AP routing in larger organizations."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -781,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

@@ -121,7 +121,8 @@
"saveChanges": "Enregistrer les modifications", "saveChanges": "Enregistrer les modifications",
"billingVatNumber": "Numéro de TVA", "billingVatNumber": "Numéro de TVA",
"billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.", "billingVatHelp": "Votre identifiant TVA enregistré. Si votre entreprise est exonérée de TVA, laissez vide et précisez dans les notes.",
"billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc." "billingNotesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc.",
"reviewContactPersonPrefix": "À l'attention de"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "title": "Tableau de bord",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).", "invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"invalidEmail": "Veuillez saisir une adresse e-mail valide.", "invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"fullNameLabel": "Nom et prénom", "fullNameLabel": "Nom et prénom",
"subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture." "subtitlePersonal": "Votre adresse de facturation et votre contact. Requis avant l'émission de toute facture.",
"contactNameLabel": "Personne à contacter (facultatif)",
"contactNameHint": "S'imprime « À l'attention de <nom> » sur la facture, sous le nom de l'entreprise. Utile pour le routage en comptabilité dans les grandes organisations."
}, },
"support": { "support": {
"title": "Support", "title": "Support",
@@ -781,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

@@ -121,7 +121,8 @@
"saveChanges": "Salva modifiche", "saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA", "billingVatNumber": "Partita IVA",
"billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.", "billingVatHelp": "Il tuo identificativo IVA registrato. Se la tua azienda è esente IVA, lascia vuoto e spiega nelle note.",
"billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc." "billingNotesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc.",
"reviewContactPersonPrefix": "c.a."
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).", "invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"invalidEmail": "Inserisci un indirizzo e-mail valido.", "invalidEmail": "Inserisci un indirizzo e-mail valido.",
"fullNameLabel": "Nome e cognome", "fullNameLabel": "Nome e cognome",
"subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture." "subtitlePersonal": "Il tuo indirizzo di fatturazione e contatto. Necessari prima che possano essere emesse fatture.",
"contactNameLabel": "Persona di contatto (facoltativa)",
"contactNameHint": "Stampato come 'c.a. <nome>' sulla fattura, sotto il nome dell'azienda. Utile per l'instradamento contabile in grandi organizzazioni."
}, },
"support": { "support": {
"title": "Supporto", "title": "Supporto",
@@ -781,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."
} }
} }

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling { export interface OrgBilling {
zitadelOrgId: string; zitadelOrgId: string;
companyName: string; companyName: string;
// Optional contact-person line ("z.Hd. / Attn:") shown on the
// invoice PDF below the company name. Useful when invoicing
// larger companies where the mailroom needs a name to route
// the document. Personal accounts don't expose this in the UI —
// their "Full name" already lives in companyName.
contactName?: string | null;
streetAddress: string; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;
@@ -575,6 +581,7 @@ export type InvoiceLineKind =
*/ */
export interface InvoiceBillingSnapshot { export interface InvoiceBillingSnapshot {
companyName: string; companyName: string;
contactName: string | null;
streetAddress: string; streetAddress: string;
postalCode: string; postalCode: string;
city: string; city: string;