Compare commits

...

4 Commits

Author SHA1 Message Date
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 743 additions and 53 deletions

View File

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

View File

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

View File

@@ -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",

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
// details; the user can't change those by re-typing them in the wizard.
// The audit copy of company name on this request stays inherited
// 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 contactName = prior?.contactName ?? user.name;
const contactEmail = prior?.contactEmail ?? user.email;
const contactName = user.name;
const contactEmail = user.email;
// Bug 35: org-scoped billing.
//

View File

@@ -22,6 +22,10 @@ import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
const upsertSchema = z.object({
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),
postalCode: z.string().trim().min(1).max(20),
city: z.string().trim().min(1).max(100),
@@ -73,6 +77,7 @@ export async function PUT(request: Request) {
const billing = await upsertOrgBilling({
zitadelOrgId: user.orgId,
companyName: data.companyName,
contactName: data.contactName ?? null,
streetAddress: data.streetAddress,
postalCode: data.postalCode,
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 { OnboardingWizard } from "./wizard";
import type { OrgBilling } from "@/types";
interface OnboardingFlowProps {
orgName: string;
@@ -19,6 +20,12 @@ interface OnboardingFlowProps {
* /settings/billing.
*/
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
* the given pending request. See `OnboardingWizard` for the full
@@ -45,6 +52,7 @@ export function OnboardingFlow({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
@@ -55,6 +63,7 @@ export function OnboardingFlow({
userName={userName}
userEmail={userEmail}
hasOrgBilling={hasOrgBilling}
existingOrgBilling={existingOrgBilling}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The

View File

@@ -13,6 +13,7 @@ import {
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
import type { OrgBilling } from "@/types";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -96,6 +97,17 @@ interface WizardProps {
* fix it before admin approves.
*/
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
* are pre-populated from the request, the SOUL.md auto-fetch is
@@ -134,6 +146,7 @@ export function OnboardingWizard({
userName,
userEmail,
hasOrgBilling,
existingOrgBilling,
editingRequest,
onComplete,
}: WizardProps) {
@@ -319,7 +332,23 @@ export function OnboardingWizard({
}
// confirm: validate the union (defence in depth — submit handler
// 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) {
setErrors({});
return true;
@@ -1101,42 +1130,84 @@ export function OnboardingWizard({
<ReviewRow
label={t("reviewBillingTo")}
value={
<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 &&
config.billingAddress.company &&
config.billingAddress.company.trim().length > 0 && (
<div>{config.billingAddress.company}</div>
)}
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</div>
(() => {
// Phase 6 fix3: when the org has billing on file
// and we're not editing, render the saved
// org_billing record (the authoritative source)
// rather than config.billingAddress, which is the
// wizard's empty default state because the billing
// step was skipped. In edit mode, fall back to
// config.billingAddress, which is pre-populated
// from the request being edited.
const useSaved =
hasOrgBilling && !isEditing && existingOrgBilling;
const company = useSaved
? existingOrgBilling!.companyName
: config.billingAddress.company;
const street = useSaved
? existingOrgBilling!.streetAddress
: config.billingAddress.street;
const postalCode = useSaved
? existingOrgBilling!.postalCode
: config.billingAddress.postalCode;
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
they can verify the VAT id they typed before submitting.
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 &&
config.billingAddress.vatNumber &&
config.billingAddress.vatNumber.trim().length > 0 && (
<ReviewRow
label={t("billingVatNumber")}
value={config.billingAddress.vatNumber}
mono
/>
)}
(() => {
const vat =
hasOrgBilling && !isEditing && existingOrgBilling
? existingOrgBilling.vatNumber
: config.billingAddress.vatNumber;
return vat && vat.trim().length > 0 ? (
<ReviewRow
label={t("billingVatNumber")}
value={vat}
mono
/>
) : null;
})()}
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}

View File

@@ -37,6 +37,7 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
const router = useRouter();
const [form, setForm] = useState({
companyName: initial?.companyName ?? "",
contactName: initial?.contactName ?? "",
streetAddress: initial?.streetAddress ?? "",
postalCode: initial?.postalCode ?? "",
city: initial?.city ?? "",
@@ -84,6 +85,10 @@ export function BillingSettingsForm({ initial, isPersonal }: Props) {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
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(),
postalCode: form.postalCode.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"
/>
</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>
<input
type="text"

View File

@@ -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 <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. 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<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);
router.refresh();
} 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,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"];

View File

@@ -80,6 +80,11 @@ interface PdfStrings {
dueDate: string;
period: 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;
quantity: string;
unitPrice: string;
@@ -107,6 +112,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Zahlbar bis",
period: "Abrechnungsperiode",
billTo: "Rechnungsempfänger",
attentionPrefix: "z.Hd.",
description: "Beschreibung",
quantity: "Menge",
unitPrice: "Einzelpreis",
@@ -139,6 +145,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Due date",
period: "Billing period",
billTo: "Bill to",
attentionPrefix: "Attn:",
description: "Description",
quantity: "Qty",
unitPrice: "Unit price",
@@ -171,6 +178,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Échéance",
period: "Période de facturation",
billTo: "Destinataire",
attentionPrefix: "À l'attention de",
description: "Description",
quantity: "Qté",
unitPrice: "Prix unitaire",
@@ -203,6 +211,7 @@ const MESSAGES: Record<string, PdfStrings> = {
dueDate: "Scadenza",
period: "Periodo di fatturazione",
billTo: "Destinatario",
attentionPrefix: "c.a.",
description: "Descrizione",
quantity: "Qtà",
unitPrice: "Prezzo unitario",
@@ -524,6 +533,15 @@ const InvoicePdf: React.FC<InvoicePdfProps> = ({ invoice, lines }) => {
<View style={styles.billToBlock}>
<Text style={styles.billToLabel}>{s.billTo}</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.postalCode} {snap.city}

View File

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

View File

@@ -198,6 +198,12 @@ const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS org_billing (
zitadel_org_id TEXT PRIMARY KEY,
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,
postal_code TEXT NOT NULL,
city TEXT NOT NULL,
@@ -208,6 +214,10 @@ const MIGRATION_SQL = `
created_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.
-- Scoped strictly per-user (zitadel_user_id), not per-org —
@@ -1262,6 +1272,7 @@ function rowToOrgBilling(row: any): OrgBilling {
return {
zitadelOrgId: row.zitadel_org_id,
companyName: row.company_name,
contactName: row.contact_name ?? null,
streetAddress: row.street_address,
postalCode: row.postal_code,
city: row.city,
@@ -1306,12 +1317,13 @@ export async function upsertOrgBilling(
await ensureSchema();
const result = await getPool().query(
`INSERT INTO org_billing (
zitadel_org_id, company_name, street_address, postal_code,
city, country, vat_number, billing_email, notes
zitadel_org_id, company_name, contact_name, street_address,
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
company_name = EXCLUDED.company_name,
contact_name = EXCLUDED.contact_name,
street_address = EXCLUDED.street_address,
postal_code = EXCLUDED.postal_code,
city = EXCLUDED.city,
@@ -1324,6 +1336,7 @@ export async function upsertOrgBilling(
[
data.zitadelOrgId,
data.companyName,
data.contactName ?? null,
data.streetAddress,
data.postalCode,
data.city,

View File

@@ -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<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",
"billingVatNumber": "MWST-Nummer",
"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": {
"title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Ländercode muss aus 2 Buchstaben bestehen (z.B. CH).",
"invalidEmail": "Bitte eine gültige E-Mail-Adresse eingeben.",
"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": {
"title": "Support",
@@ -781,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."
}
}

View File

@@ -121,7 +121,8 @@
"saveChanges": "Save changes",
"billingVatNumber": "VAT number",
"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": {
"title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Country code must be 2 letters (e.g. CH).",
"invalidEmail": "Please enter a valid email address.",
"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": {
"title": "Support",
@@ -781,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."
}
}

View File

@@ -121,7 +121,8 @@
"saveChanges": "Enregistrer les modifications",
"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.",
"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": {
"title": "Tableau de bord",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Le code pays doit comporter 2 lettres (p. ex. CH).",
"invalidEmail": "Veuillez saisir une adresse e-mail valide.",
"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": {
"title": "Support",
@@ -781,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."
}
}

View File

@@ -121,7 +121,8 @@
"saveChanges": "Salva modifiche",
"billingVatNumber": "Partita IVA",
"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": {
"title": "Dashboard",
@@ -479,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",
@@ -504,7 +507,9 @@
"invalidCountry": "Il codice paese deve essere di 2 lettere (es. CH).",
"invalidEmail": "Inserisci un indirizzo e-mail valido.",
"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": {
"title": "Supporto",
@@ -781,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."
}
}

View File

@@ -234,6 +234,12 @@ export interface BillingAddress {
export interface OrgBilling {
zitadelOrgId: 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;
postalCode: string;
city: string;
@@ -575,6 +581,7 @@ export type InvoiceLineKind =
*/
export interface InvoiceBillingSnapshot {
companyName: string;
contactName: string | null;
streetAddress: string;
postalCode: string;
city: string;