Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd9879a18 | |||
| 323786672f | |||
| a1769eeb00 | |||
| 002867850d | |||
| eea027b3b0 | |||
| 522246e386 | |||
| b3131f7710 | |||
| fadfdd3435 |
@@ -49,7 +49,9 @@ export default async function CustomerBillingPage() {
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
|
||||
{t("currentPeriodHeading")}
|
||||
</h2>
|
||||
<RunningTotalWidget />
|
||||
{/* Phase 6: pass the owner flag so the no-config CTA shows
|
||||
the right call-to-action vs the right hint. */}
|
||||
<RunningTotalWidget isOwner={user.roles.includes("owner")} />
|
||||
</section>
|
||||
|
||||
<section className="animate-in animate-in-delay-2">
|
||||
|
||||
@@ -76,6 +76,7 @@ export default async function NewInstancePage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,6 +317,7 @@ export default async function DashboardPage() {
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
hasOrgBilling={hasOrgBilling}
|
||||
existingOrgBilling={orgBilling}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling } from "@/lib/db";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-settings-form";
|
||||
import { BillingSettingsForm } from "@/components/settings/billing-form";
|
||||
|
||||
/**
|
||||
* /settings/billing — view and edit org-scoped billing (Bug 34/35).
|
||||
* /settings/billing — customer-side billing details management.
|
||||
*
|
||||
* Server-side fetches the existing record (if any) and passes it to
|
||||
* the client form. The form posts to PUT /api/billing on submit.
|
||||
* Owner-only by visibility: non-owner members get a 404 (same
|
||||
* response as if the page didn't exist). The link to this page
|
||||
* is also hidden from non-owners on /billing and elsewhere, but
|
||||
* the page itself enforces too — a non-owner who learns the URL
|
||||
* still gets 404, not 403, so the page's existence doesn't leak.
|
||||
*
|
||||
* Access: same gate as the API — owners and platform admins. `user`
|
||||
* role redirects to /settings (which also wouldn't list billing for
|
||||
* them). 403 here would be friendlier than redirect, but the most
|
||||
* likely cause of a `user` landing on this URL is sharing a bookmark
|
||||
* with their owner — silent redirect is gentle.
|
||||
* First-time visitors see an empty form. Subsequent visits see
|
||||
* the current values, editable. Save creates or updates via the
|
||||
* shared upsert path; the row's existence drives whether the
|
||||
* monthly issuance cron will pick this org up.
|
||||
*/
|
||||
export default async function BillingSettingsPage() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canMutate(user)) {
|
||||
redirect("/settings");
|
||||
}
|
||||
const t = await getTranslations("settingsBilling");
|
||||
// Non-owners get a 404 — see comment above.
|
||||
if (!user.roles.includes("owner")) notFound();
|
||||
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
const t = await getTranslations("settingsBilling");
|
||||
const existing = await getOrgBilling(user.orgId);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto px-6 py-8">
|
||||
@@ -32,16 +33,16 @@ export default async function BillingSettingsPage() {
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-text-secondary mt-3">{t("subtitle")}</p>
|
||||
<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">
|
||||
<BillingSettingsForm
|
||||
initial={existing}
|
||||
isPersonal={user.isPersonal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BillingSettingsForm
|
||||
initial={billing}
|
||||
isPersonal={user.isPersonal}
|
||||
orgName={user.orgName}
|
||||
userName={user.name}
|
||||
userEmail={user.email}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
68
src/app/[locale]/settings/profile/page.tsx
Normal file
68
src/app/[locale]/settings/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
90
src/app/api/settings/billing/route.ts
Normal file
90
src/app/api/settings/billing/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { getOrgBilling, upsertOrgBilling } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* GET /api/settings/billing — read the caller's org_billing row.
|
||||
* Returns null if the org hasn't configured billing yet — the
|
||||
* form renders empty and the PUT will create on first save.
|
||||
*
|
||||
* PUT /api/settings/billing — upsert the row.
|
||||
*
|
||||
* Authorization: caller must have role "owner" in their org.
|
||||
* Non-owners get 403 (they shouldn't have reached the page UI
|
||||
* anyway, which hides the link, but the API enforces too — a
|
||||
* non-owner who hits this directly with curl gets refused).
|
||||
*
|
||||
* Personal accounts are inherently their own owner (single-user
|
||||
* org), so user.roles.includes("owner") returns true and they
|
||||
* can manage their own billing.
|
||||
*/
|
||||
|
||||
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),
|
||||
// ISO 3166-1 alpha-2. We normalise to uppercase server-side.
|
||||
country: z
|
||||
.string()
|
||||
.trim()
|
||||
.length(2)
|
||||
.regex(/^[A-Za-z]{2}$/, "Use a 2-letter ISO country code (CH, DE, …)"),
|
||||
vatNumber: z.string().trim().max(40).optional().nullable(),
|
||||
billingEmail: z.string().trim().email().max(200),
|
||||
notes: z.string().trim().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
function requireOwner(user: { roles: string[] } | null) {
|
||||
if (!user) return false;
|
||||
return user.roles.includes("owner");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const billing = await getOrgBilling(user.orgId);
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (!requireOwner(user as any)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = upsertSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request", details: parsed.error.flatten() },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const data = parsed.data;
|
||||
const billing = await upsertOrgBilling({
|
||||
zitadelOrgId: user.orgId,
|
||||
companyName: data.companyName,
|
||||
contactName: data.contactName ?? null,
|
||||
streetAddress: data.streetAddress,
|
||||
postalCode: data.postalCode,
|
||||
city: data.city,
|
||||
country: data.country.toUpperCase(),
|
||||
vatNumber: data.vatNumber ?? null,
|
||||
billingEmail: data.billingEmail,
|
||||
notes: data.notes ?? null,
|
||||
});
|
||||
return NextResponse.json({ billing });
|
||||
}
|
||||
81
src/app/api/settings/profile/route.ts
Normal file
81
src/app/api/settings/profile/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,23 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
// Phase 6: surface failures prominently. Any run in the recent
|
||||
// window with a non-zero failure_count drives a top-of-page
|
||||
// banner — the row in the table is already red, but a banner
|
||||
// means the admin doesn't have to scroll to notice.
|
||||
const recentFailures = recent.filter((r) => r.failureCount > 0);
|
||||
const hasRecentFailures = recentFailures.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{hasRecentFailures && (
|
||||
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
|
||||
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
|
||||
<p className="text-xs">
|
||||
{t("failureBannerBody", { count: recentFailures.length })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
||||
@@ -192,7 +207,12 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id} className="border-t border-border align-top">
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`border-t border-border align-top ${
|
||||
r.failureCount > 0 ? "bg-error/5" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{fmtRelative(r.startedAt)}
|
||||
</td>
|
||||
|
||||
@@ -11,6 +11,17 @@ type CurrentResponse =
|
||||
| { draft: InvoiceDraft }
|
||||
| { error: string; code?: string };
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Whether the viewing user has org-owner role. Drives the
|
||||
* "complete your billing details" CTA — only owners can edit
|
||||
* billing settings, so non-owners see a softer message asking
|
||||
* them to contact their org owner instead. The flag is computed
|
||||
* server-side and passed in to avoid a second API round-trip.
|
||||
*/
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live running total for the current calendar month.
|
||||
*
|
||||
@@ -28,7 +39,7 @@ type CurrentResponse =
|
||||
* No polling — the page is static enough that an explicit
|
||||
* "refresh" link is good enough if the user wants newer numbers.
|
||||
*/
|
||||
export function RunningTotalWidget() {
|
||||
export function RunningTotalWidget({ isOwner }: Props) {
|
||||
const t = useTranslations("customerBilling");
|
||||
const fmt = useFormatter();
|
||||
const [data, setData] = useState<CurrentResponse | null>(null);
|
||||
@@ -62,13 +73,29 @@ export function RunningTotalWidget() {
|
||||
);
|
||||
}
|
||||
if (!data || "error" in data) {
|
||||
const noConfig =
|
||||
data && "code" in data && data.code === "COMPUTE_FAILED";
|
||||
return (
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary py-2">
|
||||
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
||||
? t("noBillingConfig")
|
||||
: t("currentPeriodError")}
|
||||
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
|
||||
</p>
|
||||
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
|
||||
settings, so we show them a "contact owner" hint instead
|
||||
— that's gentler than a button that 404s on click. */}
|
||||
{noConfig && isOwner && (
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||
>
|
||||
{t("configureBillingCta")}
|
||||
</Link>
|
||||
)}
|
||||
{noConfig && !isOwner && (
|
||||
<p className="text-xs text-text-muted italic mt-2">
|
||||
{t("noBillingConfigNonOwner")}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ""}
|
||||
|
||||
263
src/components/settings/billing-form.tsx
Normal file
263
src/components/settings/billing-form.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { OrgBilling } from "@/types";
|
||||
|
||||
interface Props {
|
||||
initial: OrgBilling | null;
|
||||
/**
|
||||
* Personal-account (individual customer) flag from the session.
|
||||
* Individuals get a "Full name" field instead of "Company name",
|
||||
* and the VAT input is hidden entirely — they don't have one and
|
||||
* showing the field would only confuse. The underlying column is
|
||||
* still `company_name` in the DB and the invoice PDF; for an
|
||||
* individual that field carries their full name, which is
|
||||
* exactly what should print on the invoice.
|
||||
*/
|
||||
isPersonal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer billing settings form. Drives PUT /api/settings/billing
|
||||
* which upserts org_billing for the caller's org.
|
||||
*
|
||||
* Validation is the same regex as the server-side zod schema for
|
||||
* the country field (ISO 3166-1 alpha-2). Other fields are checked
|
||||
* for required + max-length client-side; the server is the
|
||||
* authority and re-validates everything.
|
||||
*
|
||||
* On success we router.refresh() the page so the server component
|
||||
* re-fetches and any "create now" -> "edit" wording flips.
|
||||
*/
|
||||
export function BillingSettingsForm({ initial, isPersonal }: Props) {
|
||||
const t = useTranslations("settingsBilling");
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState({
|
||||
companyName: initial?.companyName ?? "",
|
||||
contactName: initial?.contactName ?? "",
|
||||
streetAddress: initial?.streetAddress ?? "",
|
||||
postalCode: initial?.postalCode ?? "",
|
||||
city: initial?.city ?? "",
|
||||
country: initial?.country ?? "CH",
|
||||
vatNumber: initial?.vatNumber ?? "",
|
||||
billingEmail: initial?.billingEmail ?? "",
|
||||
notes: initial?.notes ?? "",
|
||||
});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedFlash, setSavedFlash] = useState(false);
|
||||
|
||||
const set =
|
||||
(field: keyof typeof form) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
setSavedFlash(false);
|
||||
// Client-side gate on required fields — the server re-validates.
|
||||
if (
|
||||
!form.companyName.trim() ||
|
||||
!form.streetAddress.trim() ||
|
||||
!form.postalCode.trim() ||
|
||||
!form.city.trim() ||
|
||||
!form.country.trim() ||
|
||||
!form.billingEmail.trim()
|
||||
) {
|
||||
setError(t("missingRequired"));
|
||||
return;
|
||||
}
|
||||
if (!/^[A-Za-z]{2}$/.test(form.country.trim())) {
|
||||
setError(t("invalidCountry"));
|
||||
return;
|
||||
}
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.billingEmail.trim())) {
|
||||
setError(t("invalidEmail"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings/billing", {
|
||||
method: "PUT",
|
||||
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(),
|
||||
country: form.country.trim().toUpperCase(),
|
||||
// Personal accounts never have a VAT number — force null
|
||||
// regardless of stale state, in case a value was stored
|
||||
// before the account got flagged as personal.
|
||||
vatNumber: isPersonal ? null : form.vatNumber.trim() || null,
|
||||
billingEmail: form.billingEmail.trim(),
|
||||
notes: form.notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setSavedFlash(true);
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
label={isPersonal ? t("fullNameLabel") : t("companyNameLabel")}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={form.companyName}
|
||||
onChange={set("companyName")}
|
||||
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>
|
||||
{!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"
|
||||
value={form.streetAddress}
|
||||
onChange={set("streetAddress")}
|
||||
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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field label={t("postalCodeLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.postalCode}
|
||||
onChange={set("postalCode")}
|
||||
maxLength={20}
|
||||
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("cityLabel")} required>
|
||||
<input
|
||||
type="text"
|
||||
value={form.city}
|
||||
onChange={set("city")}
|
||||
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("countryLabel")}
|
||||
required
|
||||
hint={t("countryHint")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={form.country}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
country: e.target.value.toUpperCase().slice(0, 2),
|
||||
}))
|
||||
}
|
||||
maxLength={2}
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm uppercase font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
{!isPersonal && (
|
||||
<Field label={t("vatNumberLabel")} hint={t("vatNumberHint")}>
|
||||
<input
|
||||
type="text"
|
||||
value={form.vatNumber}
|
||||
onChange={set("vatNumber")}
|
||||
maxLength={40}
|
||||
placeholder="CHE-123.456.789 MWST"
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm font-mono"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("billingEmailLabel")} required hint={t("billingEmailHint")}>
|
||||
<input
|
||||
type="email"
|
||||
value={form.billingEmail}
|
||||
onChange={set("billingEmail")}
|
||||
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("notesLabel")} hint={t("notesHint")}>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={set("notes")}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-md bg-surface-2 border border-border focus:border-accent focus:outline-none text-sm"
|
||||
/>
|
||||
</Field>
|
||||
{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") : initial ? t("saveChanges") : t("createBilling")}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
187
src/components/settings/profile-form.tsx
Normal file
187
src/components/settings/profile-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,31 @@ 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.
|
||||
// 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) {
|
||||
const claims = profile as unknown as ZitadelClaims;
|
||||
token.orgId = claims["urn:zitadel:iam:user:resourceowner:id"];
|
||||
@@ -58,6 +82,19 @@ export const authConfig: NextAuthConfig = {
|
||||
claims["urn:zitadel:iam:org:project:roles"]
|
||||
);
|
||||
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
|
||||
// freshly generated UUID in token.sub on initial sign-in,
|
||||
// ignoring what profile() returns for `id`. That UUID then
|
||||
@@ -80,10 +117,19 @@ export const authConfig: NextAuthConfig = {
|
||||
async session({ session, token }) {
|
||||
const roles = (token.roles as Role[]) ?? [];
|
||||
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 = {
|
||||
id: token.sub!,
|
||||
name: session.user?.name ?? "",
|
||||
email: session.user?.email ?? "",
|
||||
name: tokenName || session.user?.name || "",
|
||||
email: tokenEmail || session.user?.email || "",
|
||||
orgId: token.orgId as string,
|
||||
orgName,
|
||||
roles,
|
||||
@@ -96,6 +142,14 @@ export const authConfig: NextAuthConfig = {
|
||||
isPersonal: isPersonalOrgName(orgName),
|
||||
};
|
||||
(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;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -528,3 +528,113 @@ 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 + display
|
||||
* name). Returns the new `details.changeDate` from ZITADEL so the
|
||||
* caller can confirm the write landed.
|
||||
*
|
||||
* The v2 user service endpoint is technically a PUT but accepts
|
||||
* partial bodies — only the `profile` block is sent. ZITADEL
|
||||
* preserves email, password, and other fields across the call
|
||||
* (verified empirically in zitadel-server#7786 and documented in
|
||||
* v2.63+ of zitadel-server).
|
||||
*
|
||||
* `displayName` IS sent explicitly, set to "givenName familyName".
|
||||
* Empirically (and contra what some docs suggest), ZITADEL does
|
||||
* NOT recompute displayName when only the name parts change — it
|
||||
* keeps whatever displayName was previously stored, including the
|
||||
* one set at user creation time. That stale displayName is what
|
||||
* ZITADEL surfaces in the OIDC `name` claim, so without this
|
||||
* explicit write the portal session would never see the updated
|
||||
* name (even after sign-out / sign-in).
|
||||
*
|
||||
* Auth: the portal's service-account PAT (ZITADEL_SA_PAT). The PAT
|
||||
* must have user-write permission in the user's resource org.
|
||||
* Today portal-zitadel-sa-pat already has user-write for
|
||||
* createHumanUser etc. — same scope covers this.
|
||||
*/
|
||||
export interface UpdateHumanUserProfileResult {
|
||||
changeDate: string;
|
||||
/** The displayName ZITADEL stored, which the OIDC `name` claim will
|
||||
* carry on the user's next session. */
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export async function updateHumanUserProfile(params: {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
}): Promise<UpdateHumanUserProfileResult> {
|
||||
const path = `/v2/users/human/${encodeURIComponent(params.userId)}`;
|
||||
// Compose the displayName ourselves so ZITADEL stores something
|
||||
// sensible. Empty-string fallback only triggers if both name parts
|
||||
// are blank, which the API zod schema prevents anyway.
|
||||
const displayName =
|
||||
`${params.givenName.trim()} ${params.familyName.trim()}`.trim();
|
||||
type ZitadelUpdateResponse = {
|
||||
details?: { changeDate?: string };
|
||||
};
|
||||
await zitadelFetch<ZitadelUpdateResponse>(path, "PUT", {
|
||||
profile: {
|
||||
givenName: params.givenName,
|
||||
familyName: params.familyName,
|
||||
displayName,
|
||||
},
|
||||
});
|
||||
// Re-fetch the user to read back the canonical displayName ZITADEL
|
||||
// committed. Should match what we sent, but reading from the source
|
||||
// of truth catches any sanitization ZITADEL might apply.
|
||||
const detail = await getHumanUserDetail(params.userId);
|
||||
return {
|
||||
changeDate: new Date().toISOString(),
|
||||
displayName: detail.displayName || displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a human user's current profile (given/family/display name +
|
||||
* email). Used by the settings page to populate the form and by the
|
||||
* update helper above to read back the computed displayName.
|
||||
*/
|
||||
export interface HumanUserDetail {
|
||||
userId: string;
|
||||
givenName: string;
|
||||
familyName: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export async function getHumanUserDetail(
|
||||
userId: string
|
||||
): Promise<HumanUserDetail> {
|
||||
type ZitadelGetUserResponse = {
|
||||
user?: {
|
||||
userId?: string;
|
||||
human?: {
|
||||
profile?: {
|
||||
givenName?: string;
|
||||
familyName?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
email?: { email?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
const response = await zitadelFetch<ZitadelGetUserResponse>(
|
||||
`/v2/users/${encodeURIComponent(userId)}`,
|
||||
"GET"
|
||||
);
|
||||
const human = response.user?.human;
|
||||
return {
|
||||
userId: response.user?.userId ?? userId,
|
||||
givenName: human?.profile?.givenName ?? "",
|
||||
familyName: human?.profile?.familyName ?? "",
|
||||
displayName: human?.profile?.displayName ?? "",
|
||||
email: human?.email?.email ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,28 +480,36 @@
|
||||
"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": "Abrechnung",
|
||||
"subtitle": "Wird beim ersten Onboarding einmalig erfasst und für jeden Tenant Ihrer Organisation wiederverwendet. Aktualisieren Sie hier, wenn sich Ihre Abrechnungsdaten ändern.",
|
||||
"companyName": "Firmenname",
|
||||
"streetAddress": "Strasse",
|
||||
"postalCode": "PLZ",
|
||||
"city": "Ort",
|
||||
"country": "Land",
|
||||
"vatNumber": "MWST-Nummer",
|
||||
"vatHelp": "Ihre registrierte MWST-Nummer (z. B. CHE-123.456.789 MWST für die Schweiz).",
|
||||
"billingEmail": "Rechnungs-E-Mail",
|
||||
"billingEmailHelp": "An diese Adresse werden Rechnungen und Abrechnungskommunikation gesendet.",
|
||||
"notes": "Notizen",
|
||||
"notesPlaceholder": "Alles, was die Buchhaltung wissen muss – MWST-Befreiung, besondere Rechnungsstellung usw.",
|
||||
"save": "Speichern",
|
||||
"title": "Rechnungsdaten",
|
||||
"subtitle": "Rechnungsadresse, MWST-Nummer und Rechnungskontakt Ihres Unternehmens. Erforderlich, bevor Rechnungen für Ihre Organisation ausgestellt werden können.",
|
||||
"companyNameLabel": "Firmenname",
|
||||
"streetAddressLabel": "Strasse und Hausnummer",
|
||||
"postalCodeLabel": "PLZ",
|
||||
"cityLabel": "Ort",
|
||||
"countryLabel": "Ländercode",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — z.B. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "MWST-Nummer (optional)",
|
||||
"vatNumberHint": "Für Schweizer Kunden: CHE-XXX.XXX.XXX MWST. EU-Kunden mit USt-IdNr. erhalten eine Reverse-Charge-Rechnung (0% MWST).",
|
||||
"billingEmailLabel": "Rechnungs-E-Mail",
|
||||
"billingEmailHint": "Rechnungen und Zahlungserinnerungen werden an diese Adresse gesendet. Kann von Ihrer Konto-E-Mail abweichen.",
|
||||
"notesLabel": "Bemerkungen (optional)",
|
||||
"notesHint": "Referenznummern, Bestellnummern oder andere Angaben, die auf der Rechnung erscheinen sollen.",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
"createBilling": "Rechnungsdaten speichern",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert.",
|
||||
"saveFailed": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"lastUpdated": "Zuletzt aktualisiert {when}",
|
||||
"fullName": "Voller Name",
|
||||
"notesPlaceholderPersonal": "Was wir wissen sollten — bevorzugte Zahlungsart, Rechnungsreferenz, etc."
|
||||
"missingRequired": "Bitte alle Pflichtfelder ausfüllen.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -742,7 +751,9 @@
|
||||
"payWithCard": "Mit Karte bezahlen",
|
||||
"redirectingToStripe": "Weiterleitung…",
|
||||
"paymentReceived": "Zahlung erhalten — vielen Dank!",
|
||||
"paymentCancelled": "Zahlung abgebrochen."
|
||||
"paymentCancelled": "Zahlung abgebrochen.",
|
||||
"configureBillingCta": "Rechnungsdaten einrichten",
|
||||
"noBillingConfigNonOwner": "Nur der Organisations-Owner kann die Rechnungsdaten einrichten. Bitte wenden Sie sich an diese Person, um diesen Schritt abzuschliessen."
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Abrechnungsautomatisierung",
|
||||
@@ -772,6 +783,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Rechnungsstellung",
|
||||
"reminders": "Mahnungen"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +480,36 @@
|
||||
"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",
|
||||
"subtitle": "Captured once at first onboarding and reused for every tenant in your organization. Update here whenever your billing details change.",
|
||||
"companyName": "Company name",
|
||||
"streetAddress": "Street address",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"country": "Country",
|
||||
"vatNumber": "VAT number",
|
||||
"vatHelp": "Your registered VAT identifier (e.g. CHE-123.456.789 MWST for Switzerland).",
|
||||
"billingEmail": "Billing email",
|
||||
"billingEmailHelp": "Where invoices and billing communication will be sent.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Anything else accounting needs to know — VAT exemption, special invoicing arrangements, etc.",
|
||||
"save": "Save",
|
||||
"title": "Billing details",
|
||||
"subtitle": "Your company's billing address, VAT number, and invoice contact. Required before invoices can be issued for your organization.",
|
||||
"companyNameLabel": "Company name",
|
||||
"streetAddressLabel": "Street address",
|
||||
"postalCodeLabel": "Postal code",
|
||||
"cityLabel": "City",
|
||||
"countryLabel": "Country code",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — e.g. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "VAT number (optional)",
|
||||
"vatNumberHint": "For Swiss customers: CHE-XXX.XXX.XXX MWST. EU customers with a VAT number get a 0% reverse-charge invoice.",
|
||||
"billingEmailLabel": "Billing email",
|
||||
"billingEmailHint": "Invoices and payment reminders are sent here. Can differ from your account email.",
|
||||
"notesLabel": "Notes (optional)",
|
||||
"notesHint": "Reference numbers, purchase order tags, or anything else you'd like printed on invoices.",
|
||||
"saveChanges": "Save changes",
|
||||
"createBilling": "Save billing details",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved.",
|
||||
"saveFailed": "Could not save. Please try again.",
|
||||
"lastUpdated": "Last updated {when}",
|
||||
"fullName": "Full name",
|
||||
"notesPlaceholderPersonal": "Anything we should know — preferred payment method, billing reference, etc."
|
||||
"missingRequired": "Please fill in all required fields.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -742,7 +751,9 @@
|
||||
"payWithCard": "Pay with card",
|
||||
"redirectingToStripe": "Redirecting…",
|
||||
"paymentReceived": "Payment received — thank you!",
|
||||
"paymentCancelled": "Payment cancelled."
|
||||
"paymentCancelled": "Payment cancelled.",
|
||||
"configureBillingCta": "Configure billing details",
|
||||
"noBillingConfigNonOwner": "Only the organization owner can configure billing details. Please contact them to complete this step."
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Billing automation",
|
||||
@@ -772,6 +783,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Issuance",
|
||||
"reminders": "Reminders"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +480,36 @@
|
||||
"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": "Facturation",
|
||||
"subtitle": "Saisie une fois lors de l'inscription et réutilisée pour chaque locataire de votre organisation. Mettez à jour ici dès que vos coordonnées de facturation changent.",
|
||||
"companyName": "Nom de l'entreprise",
|
||||
"streetAddress": "Adresse",
|
||||
"postalCode": "Code postal",
|
||||
"city": "Ville",
|
||||
"country": "Pays",
|
||||
"vatNumber": "Numéro de TVA",
|
||||
"vatHelp": "Votre identifiant TVA enregistré (par ex. CHE-123.456.789 TVA pour la Suisse).",
|
||||
"billingEmail": "E-mail de facturation",
|
||||
"billingEmailHelp": "Adresse à laquelle les factures et la communication de facturation seront envoyées.",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Tout ce que la comptabilité doit savoir – exonération de TVA, modalités de facturation particulières, etc.",
|
||||
"save": "Enregistrer",
|
||||
"title": "Informations de facturation",
|
||||
"subtitle": "Adresse de facturation, numéro de TVA et contact pour les factures. Requis avant l'émission de toute facture pour votre organisation.",
|
||||
"companyNameLabel": "Nom de l'entreprise",
|
||||
"streetAddressLabel": "Adresse",
|
||||
"postalCodeLabel": "Code postal",
|
||||
"cityLabel": "Ville",
|
||||
"countryLabel": "Code pays",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — p. ex. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "Numéro de TVA (facultatif)",
|
||||
"vatNumberHint": "Pour les clients suisses : CHE-XXX.XXX.XXX TVA. Les clients UE avec un n° de TVA reçoivent une facture à 0% (autoliquidation).",
|
||||
"billingEmailLabel": "E-mail de facturation",
|
||||
"billingEmailHint": "Les factures et rappels de paiement sont envoyés à cette adresse. Peut différer de l'e-mail du compte.",
|
||||
"notesLabel": "Notes (facultatif)",
|
||||
"notesHint": "Numéros de référence, bons de commande, ou toute autre information à imprimer sur les factures.",
|
||||
"saveChanges": "Enregistrer les modifications",
|
||||
"createBilling": "Enregistrer les informations",
|
||||
"saving": "Enregistrement…",
|
||||
"saved": "Enregistré.",
|
||||
"saveFailed": "Impossible d'enregistrer. Veuillez réessayer.",
|
||||
"lastUpdated": "Dernière mise à jour {when}",
|
||||
"fullName": "Nom complet",
|
||||
"notesPlaceholderPersonal": "Tout ce que nous devons savoir — moyen de paiement préféré, référence de facturation, etc."
|
||||
"missingRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -742,7 +751,9 @@
|
||||
"payWithCard": "Payer par carte",
|
||||
"redirectingToStripe": "Redirection…",
|
||||
"paymentReceived": "Paiement reçu — merci !",
|
||||
"paymentCancelled": "Paiement annulé."
|
||||
"paymentCancelled": "Paiement annulé.",
|
||||
"configureBillingCta": "Configurer les informations de facturation",
|
||||
"noBillingConfigNonOwner": "Seul le propriétaire de l'organisation peut configurer les informations de facturation. Veuillez le contacter pour terminer cette étape."
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automatisation de la facturation",
|
||||
@@ -772,6 +783,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Émission",
|
||||
"reminders": "Rappels"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +480,36 @@
|
||||
"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": "Fatturazione",
|
||||
"subtitle": "Acquisita una sola volta al primo onboarding e riutilizzata per ogni tenant della tua organizzazione. Aggiorna qui ogni volta che i dati di fatturazione cambiano.",
|
||||
"companyName": "Ragione sociale",
|
||||
"streetAddress": "Indirizzo",
|
||||
"postalCode": "CAP",
|
||||
"city": "Città",
|
||||
"country": "Paese",
|
||||
"vatNumber": "Partita IVA",
|
||||
"vatHelp": "Il tuo identificativo IVA registrato (es. CHE-123.456.789 IVA per la Svizzera).",
|
||||
"billingEmail": "E-mail di fatturazione",
|
||||
"billingEmailHelp": "Indirizzo a cui verranno inviate le fatture e le comunicazioni di fatturazione.",
|
||||
"notes": "Note",
|
||||
"notesPlaceholder": "Qualsiasi cosa la contabilità debba sapere — esenzione IVA, modalità di fatturazione particolari, ecc.",
|
||||
"save": "Salva",
|
||||
"title": "Dati di fatturazione",
|
||||
"subtitle": "Indirizzo di fatturazione, partita IVA e contatto fatture della tua azienda. Necessari prima che possano essere emesse fatture per la tua organizzazione.",
|
||||
"companyNameLabel": "Nome azienda",
|
||||
"streetAddressLabel": "Indirizzo",
|
||||
"postalCodeLabel": "CAP",
|
||||
"cityLabel": "Città",
|
||||
"countryLabel": "Codice paese",
|
||||
"countryHint": "ISO 3166-1 alpha-2 — es. CH, DE, AT, FR, IT, GB, US",
|
||||
"vatNumberLabel": "Partita IVA (facoltativa)",
|
||||
"vatNumberHint": "Per clienti svizzeri: CHE-XXX.XXX.XXX IVA. Clienti UE con partita IVA ricevono fattura in reverse charge (0% IVA).",
|
||||
"billingEmailLabel": "E-mail di fatturazione",
|
||||
"billingEmailHint": "Le fatture e i solleciti vengono inviati a questo indirizzo. Può differire dall'e-mail dell'account.",
|
||||
"notesLabel": "Note (facoltative)",
|
||||
"notesHint": "Numeri di riferimento, ordini d'acquisto o altre informazioni da riportare in fattura.",
|
||||
"saveChanges": "Salva modifiche",
|
||||
"createBilling": "Salva dati di fatturazione",
|
||||
"saving": "Salvataggio…",
|
||||
"saved": "Salvato.",
|
||||
"saveFailed": "Impossibile salvare. Riprova.",
|
||||
"lastUpdated": "Ultimo aggiornamento {when}",
|
||||
"fullName": "Nome completo",
|
||||
"notesPlaceholderPersonal": "Qualsiasi cosa dovremmo sapere — metodo di pagamento preferito, riferimento per fatturazione, ecc."
|
||||
"missingRequired": "Compila tutti i campi obbligatori.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -742,7 +751,9 @@
|
||||
"payWithCard": "Paga con carta",
|
||||
"redirectingToStripe": "Reindirizzamento…",
|
||||
"paymentReceived": "Pagamento ricevuto — grazie!",
|
||||
"paymentCancelled": "Pagamento annullato."
|
||||
"paymentCancelled": "Pagamento annullato.",
|
||||
"configureBillingCta": "Configura dati di fatturazione",
|
||||
"noBillingConfigNonOwner": "Solo il proprietario dell'organizzazione può configurare i dati di fatturazione. Contattalo per completare questo passaggio."
|
||||
},
|
||||
"adminCron": {
|
||||
"title": "Automazione fatturazione",
|
||||
@@ -772,6 +783,23 @@
|
||||
"kind": {
|
||||
"monthly_issue": "Emissione",
|
||||
"reminders": "Solleciti"
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user