Compare commits

...

2 Commits

Author SHA1 Message Date
9c50c9f054 Group C+ fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-29 21:34:52 +02:00
49d81190d4 Group C fixes
All checks were successful
Build and Push / build (push) Successful in 1m47s
2026-04-29 17:20:58 +02:00
15 changed files with 1303 additions and 292 deletions

View File

@@ -6,37 +6,59 @@ import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
type AccountType = "personal" | "company";
/**
* Slice 4 + Bug 9: a "Register as individual" toggle distinguishes
* personal accounts from company registrations. When the toggle is on:
* - the company name field is hidden (and not sent)
* - the server skips the duplicate-domain check
* - the ZITADEL org is named `personal-{8hex}` (opaque, collision-free)
* - the user's display name lives only on the user record; the GUI
* shows it instead of the opaque org name everywhere
* Registration entry — Bug 1 redesign.
*
* Previously a hidden checkbox ("Register as an individual") sat on top
* of the company-flavoured form, which buried personal accounts under a
* single click that most users miss. The new layout puts a primary
* account-type chooser at the top: two large cards, one for Personal,
* one for Company. Selection is required before the form below
* appears, so the rest of the layout adapts cleanly without a
* collapsing-checkbox feel.
*
* Bug 12: per-field validation runs on submit. The native HTML required
* attribute already blocks empty submits at the browser level; the
* server-side Zod schema in `/api/register` is the authoritative
* second line of defence.
*
* Behaviour:
* - "Personal account": company-name field is hidden; on submit, the
* server generates an opaque `personal-{8hex}` org name (Bug 9).
* - "Company account": company-name field is required; the server
* additionally runs the duplicate-domain check.
* - Returning users (those who arrive here by accident) can switch
* types after picking — the choice cards stay clickable above the
* form. Field state is preserved across switches so they don't
* have to re-type their name.
*/
export default function RegisterPage() {
const t = useTranslations("register");
const tCommon = useTranslations("common");
const router = useRouter();
const [accountType, setAccountType] = useState<AccountType | null>(null);
const [form, setForm] = useState({
companyName: "",
givenName: "",
familyName: "",
email: "",
});
const [isPersonal, setIsPersonal] = useState(false);
const [state, setState] = useState<FormState>("idle");
const [error, setError] = useState("");
const isPersonal = accountType === "personal";
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!accountType) return; // Should be impossible — submit button is gated
setError("");
setState("submitting");
@@ -62,9 +84,6 @@ export default function RegisterPage() {
if (!res.ok) {
const data = await res.json();
// Localize known structured codes; fall back to server-supplied
// English message for everything else (validation, ZITADEL errors,
// generic 500s).
if (data.code === "duplicate_domain" && data.domain) {
throw new Error(t("duplicateDomain", { domain: data.domain }));
}
@@ -120,120 +139,212 @@ export default function RegisterPage() {
<p className="text-sm text-text-secondary">{t("subtitle")}</p>
</div>
<Card className="animate-in animate-in-delay-1">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Personal-account toggle */}
<label className="flex items-start gap-3 cursor-pointer select-none p-3 rounded-lg border border-border bg-surface-2 hover:border-accent/40 transition-colors">
<input
type="checkbox"
checked={isPersonal}
onChange={(e) => setIsPersonal(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border bg-surface-1 text-accent focus:ring-1 focus:ring-accent focus:ring-offset-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">
{t("individualToggle")}
{/* Account type chooser — required first step */}
<div
role="radiogroup"
aria-label={t("accountTypeLabel")}
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
>
<AccountTypeCard
selected={accountType === "personal"}
onClick={() => setAccountType("personal")}
label={t("personalCardTitle")}
description={t("personalCardDescription")}
icon={
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
}
/>
<AccountTypeCard
selected={accountType === "company"}
onClick={() => setAccountType("company")}
label={t("companyCardTitle")}
description={t("companyCardDescription")}
icon={
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 21V7l9-4 9 4v14M9 21V11h6v10M5 21h14"
/>
</svg>
}
/>
</div>
{/* Form — only shown after a choice is made. Animation
delay-2 lines up with the cards animating in first, so
the form feels like it appears in response to selection. */}
{accountType && (
<Card className="animate-in animate-in-delay-2">
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
{/* Company name — only for company accounts (Bug 2 mirror) */}
{!isPersonal && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("companyName")}
</label>
<input
name="companyName"
type="text"
required
value={form.companyName}
onChange={handleChange}
placeholder={t("companyNamePlaceholder")}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
<div className="text-xs text-text-muted mt-0.5">
{t("individualHint")}
)}
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("givenName")}
</label>
<input
name="givenName"
type="text"
required
value={form.givenName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("familyName")}
</label>
<input
name="familyName"
type="text"
required
value={form.familyName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
</div>
</label>
{/* Company name — hidden for personal */}
{!isPersonal && (
{/* Email */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("companyName")}
{t("email")}
</label>
<input
name="companyName"
type="text"
name="email"
type="email"
required
value={form.companyName}
value={form.email}
onChange={handleChange}
placeholder={t("companyNamePlaceholder")}
placeholder={isPersonal ? "you@example.ch" : "you@company.ch"}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
)}
{/* Name row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("givenName")}
</label>
<input
name="givenName"
type="text"
required
value={form.givenName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("familyName")}
</label>
<input
name="familyName"
type="text"
required
value={form.familyName}
onChange={handleChange}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
</div>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
{/* Email */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("email")}
</label>
<input
name="email"
type="email"
required
value={form.email}
onChange={handleChange}
placeholder={isPersonal ? "you@example.ch" : "you@company.ch"}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
<button
type="submit"
disabled={state === "submitting"}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === "submitting" ? tCommon("loading") : t("submit")}
</button>
</form>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2">
{error}
</div>
)}
<p className="text-xs text-text-muted text-center mt-4">
{t("hasAccount")}{" "}
<a
href="/login"
className="text-accent hover:text-accent-dim transition-colors"
>
{tCommon("login")}
</a>
</p>
</Card>
)}
<button
type="submit"
disabled={state === "submitting"}
className="w-full py-2.5 px-4 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === "submitting" ? tCommon("loading") : t("submit")}
</button>
</form>
<p className="text-xs text-text-muted text-center mt-4">
{t("hasAccount")}{" "}
<a
href="/login"
className="text-accent hover:text-accent-dim transition-colors"
>
{tCommon("login")}
</a>
</p>
</Card>
<p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-2">
<p className="text-xs text-text-muted text-center mt-6 animate-in animate-in-delay-3">
{t("footer")}
</p>
</div>
</div>
);
}
/**
* Account-type radio card. Visually a card, semantically a radio: arrow
* keys move between cards, Space/Enter selects.
*
* Selected state is rendered with the accent ring + tinted background;
* unselected is the standard surface-2 with hover affordance. The icon
* and text colours intensify when selected to give a clear "this one
* is on" signal beyond just the border colour.
*/
function AccountTypeCard({
selected,
onClick,
label,
description,
icon,
}: {
selected: boolean;
onClick: () => void;
label: string;
description: string;
icon: React.ReactNode;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onClick}
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
selected
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
}`}
>
<div
className={`mb-2 ${
selected ? "text-accent" : "text-text-muted"
}`}
>
{icon}
</div>
<div
className={`text-sm font-semibold mb-0.5 ${
selected ? "text-text-primary" : "text-text-primary"
}`}
>
{label}
</div>
<div className="text-xs text-text-muted leading-snug">{description}</div>
</button>
);
}

View File

@@ -9,6 +9,7 @@ import { PackageList } from "@/components/packages/package-list";
import { WorkspaceEditor } from "@/components/packages/workspace-editor";
import { ChannelUsers } from "@/components/channel-users/channel-users";
import { AssignedUsersPanel } from "@/components/tenants/assigned-users-panel";
import { SubscriptionToggle } from "@/components/tenants/subscription-toggle";
import { formatDateTime, formatRelative } from "@/lib/format";
const CHANNEL_PACKAGES = ["telegram", "discord", "email"];
@@ -40,6 +41,11 @@ export default async function TenantDetailPage({
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
// Bug 31: customer-side cancel/resume control. Same gate as canEdit
// — only owners (or platform staff) may toggle the subscription.
// The current state comes from spec.suspend on the CR.
const isSuspended = Boolean(tenant.spec.suspend);
// Bug 7: assigned-users panel is meaningless for personal tenants
// (sole-owner by definition; the only "assignee" is the owner
// themselves). We hide the panel when EITHER the CR carries the
@@ -102,6 +108,41 @@ export default async function TenantDetailPage({
)}
</div>
{/* Bug 31: prominent banner when the subscription is cancelled.
Sits between header and content so it's the first thing the
owner sees. Says clearly what state means, and that data is
preserved. The Resume action lives in the SubscriptionToggle
at the bottom — duplicating it here would clutter the banner
for the much-more-common active case. */}
{isSuspended && (
<div className="mb-8 animate-in animate-in-delay-1 bg-amber-500/10 border border-amber-500/30 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg
className="h-5 w-5 text-amber-400 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zM12 15.75h.008v.008H12v-.008z"
/>
</svg>
<div className="min-w-0">
<div className="text-sm font-semibold text-amber-300">
{t("suspendedTitle")}
</div>
<div className="text-xs text-text-secondary mt-1">
{t("suspendedDescription")}
</div>
</div>
</div>
</div>
)}
{/* Usage */}
<section className="mb-8 animate-in animate-in-delay-1">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
@@ -155,6 +196,25 @@ export default async function TenantDetailPage({
<AssignedUsersPanel tenantName={name} canEdit={canEdit} />
</section>
)}
{/* Bug 31: subscription cancel/resume — owners + platform staff
only. Lives at the bottom of the page (rather than near the
status badge) to add deliberate friction; mis-clicking
"Cancel subscription" from the top would be too easy. The
control itself opens a confirmation modal before sending. */}
{canEdit && (
<section className="mt-12 pt-8 border-t border-border animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("subscriptionTitle")}
</h2>
<p className="text-sm text-text-secondary mb-4">
{isSuspended
? t("subscriptionDescriptionSuspended")
: t("subscriptionDescriptionActive")}
</p>
<SubscriptionToggle tenantName={name} suspended={isSuspended} />
</section>
)}
</div>
);
}

View File

@@ -16,34 +16,10 @@ import {
import { sendAdminNotificationEmail } from "@/lib/email";
import { encryptSecrets } from "@/lib/crypto";
import { isPersonalOrgName } from "@/lib/personal-org";
import { onboardingSchema } from "@/lib/validation";
import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types";
import { z } from "zod";
const onboardingSchema = z.object({
instanceName: z
.string()
.trim()
.max(80)
.optional()
// Empty string from a form input → drop to undefined so the DB stores NULL
.transform((v) => (v && v.length > 0 ? v : undefined)),
agentName: z.string().min(1).max(50),
soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(),
packages: z.array(z.string()).optional(),
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
.optional(),
billingAddress: z.object({
company: z.string().optional(),
street: z.string().optional(),
city: z.string().optional(),
postalCode: z.string().optional(),
country: z.string().optional(),
}),
billingNotes: z.string().max(2_000).optional(),
});
/**
* Helper: shape a TenantRequest row for client consumption.
* Hides server-only fields (encryptedSecrets, internal db ids).

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getSessionUser, canMutate } from "@/lib/session";
import { getTenant, patchTenantSpec } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { safeError } from "@/lib/errors";
const patchSchema = z.object({
suspend: z.boolean(),
});
/**
* PATCH /api/tenants/[name]/suspend
*
* Customer-side "Cancel subscription" / "Resume" toggle (Bug 31).
*
* Sets `spec.suspend` on the PiecedTenant CR. The operator interprets
* this flag as "stop reconciling this tenant" — workloads, packages,
* and channel-user changes are no longer applied. Existing data is
* preserved (namespace, ConfigMaps, OpenBao secrets, CNPG database,
* billing records). Resuming sets the flag back to false and the
* operator picks up reconciliation on the next loop.
*
* Authorization
* -------------
* - Customer-side: only an `owner` of the tenant's org may call this.
* `canMutate` is the right gate (mirrors the rest of the customer
* API surface). User-role members cannot cancel a subscription.
* - Platform staff: allowed via `canMutate`'s isPlatform branch, but
* in practice they should use admin tooling for this — the action
* is exposed here for the customer's benefit.
*
* Visibility check is via `canUserSeeTenant` — same notFound() trick
* as the detail page, so we don't leak existence of tenants the
* caller can't see.
*
* Note on workload teardown
* -------------------------
* As of this writing, the operator's `suspend` handling is "skip
* reconciliation and set status.phase to Suspended". The underlying
* StatefulSet keeps running until next reconciliation, which won't
* happen while suspended. Group D will add scale-to-zero so cancelled
* subscriptions actually stop incurring compute. Until then, an
* operator following up with a `kubectl scale` is the workaround.
* Customer data is preserved either way.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { name } = await params;
const tenant = await getTenant(name);
if (!tenant) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Identical pattern to the detail page — don't leak existence.
if (!(await canUserSeeTenant(user, tenant))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json().catch(() => null);
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { suspend } = parsed.data;
// No-op early exit. Avoids a needless K8s patch + status churn when
// the user double-clicks the button or the UI is briefly out of sync.
if (Boolean(tenant.spec.suspend) === suspend) {
return NextResponse.json(
{ message: "No change.", suspend },
{ status: 200 }
);
}
try {
await patchTenantSpec(name, { suspend });
return NextResponse.json(
{
message: suspend
? "Subscription cancelled. Your data is preserved."
: "Subscription resumed.",
suspend,
},
{ status: 200 }
);
} catch (e: any) {
console.error("Suspend toggle failed:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to update subscription") },
{ status: e.statusCode || 500 }
);
}
}

View File

@@ -199,7 +199,22 @@ export function AdminPanel({ initialTenants }: AdminPanelProps) {
throw new Error(data.error || "Delete failed");
}
setDeleteModal(null);
await fetchTenants();
// Bug 32: K8s deletion is asynchronous — the resource enters a
// Terminating phase with a deletionTimestamp set, finalizers run,
// then the resource is fully removed. fetchTenants() right
// after the API call would race the K8s store and often still
// include the just-deleted row. Two complementary fixes:
// 1. Optimistically drop the row from local state so the UI
// reflects the user's intent immediately.
// 2. Schedule a delayed refetch (1.5s) to pick up any side
// effects (cascaded request rows, freshly-released names).
// The immediate fetchTenants() is kept as a "best chance" — if
// K8s does report the deletion synchronously (rare), we get the
// freshest data. If it doesn't, the optimistic update has us
// covered until the delayed refetch lands.
setTenants((prev) => prev.filter((t) => t.metadata.name !== name));
fetchTenants();
setTimeout(() => fetchTenants(), 1500);
} catch (e: any) {
setError(e.message);
} finally {

View File

@@ -13,8 +13,13 @@ function NavBar() {
const pathname = usePathname();
const user = (session as any)?.platformUser;
const isLogin = pathname === "/login";
if (isLogin) return null;
// Hide the nav entirely on auth-only routes. These pages have no
// session yet — showing "Dashboard" / "Sign Out" is misleading at
// best (the buttons would 401 or redirect-loop). Keep this list
// narrow and route-exact: anything else we add to the auth flow
// (e.g. password reset) needs to be added here too.
const isAuthRoute = pathname === "/login" || pathname === "/register";
if (isAuthRoute) return null;
return (
<header className="sticky top-0 z-50 border-b border-border bg-surface-1/80 backdrop-blur-md">

View File

@@ -5,6 +5,14 @@ import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, displayOrgNameFor } from "@/lib/personal-org";
import {
configureStepSchema,
billingStepSchema,
onboardingSchema,
fieldErrors,
SUPPORTED_COUNTRIES,
type SupportedCountry,
} from "@/lib/validation";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -68,6 +76,7 @@ export function OnboardingWizard({
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
const tCountries = useTranslations("countries");
// Personal accounts have an org name that is either the legacy
// "{givenName} {familyName} (Personal)" or the current opaque
@@ -159,11 +168,70 @@ export function OnboardingWizard({
const stepIndex = STEPS.indexOf(step);
// Bug 12 — per-step validation. `errors` holds field-path → message
// for the inline labels under each input. We only populate it on
// attempted advancement; touching a field clears its own error so
// valid input doesn't keep showing stale messages.
const [errors, setErrors] = useState<Record<string, string>>({});
const clearError = useCallback((path: string) => {
setErrors((prev) => {
if (!prev[path]) return prev;
const next = { ...prev };
delete next[path];
return next;
});
}, []);
/**
* Validate the current step against its schema. On success: clear
* errors and return true. On failure: populate errors and return
* false so the caller can refuse to advance.
*
* Welcome and configure-step have no schema interaction with billing
* fields — keeping the schemas narrow means we don't surface a
* billing error when the user is still typing on the configure step.
*/
const validateStep = (s: Step): boolean => {
if (s === "welcome") return true;
if (s === "configure") {
const r = configureStepSchema.safeParse({ agentName: config.agentName });
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
if (s === "billing") {
const r = billingStepSchema.safeParse({
billingAddress: config.billingAddress,
});
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
}
// confirm: validate the union (defence in depth — submit handler
// also runs onboardingSchema before POST).
const r = onboardingSchema.safeParse(config);
if (r.success) {
setErrors({});
return true;
}
setErrors(fieldErrors(r.error));
return false;
};
const goNext = () => {
if (!validateStep(step)) return;
if (stepIndex < STEPS.length - 1) setStep(STEPS[stepIndex + 1]);
};
const goBack = () => {
// Going back never re-validates; the user's existing errors stay
// pinned to fields so they can fix them after navigating back.
if (stepIndex > 0) setStep(STEPS[stepIndex - 1]);
};
@@ -216,6 +284,17 @@ export function OnboardingWizard({
};
const handleSubmit = async () => {
// Defence in depth: re-run the full schema before sending. The
// server schema is the authoritative gate but we save a round trip
// by catching any client-side gaps here. In practice this should
// never fail at this point — the per-step validators have already
// caught everything — but a future regression in the per-step
// schemas would otherwise let the bad payload through.
if (!validateStep("confirm")) {
setError(t("validationError"));
return;
}
setSubmitting(true);
setError("");
@@ -356,19 +435,21 @@ export function OnboardingWizard({
</p>
</div>
<div>
<FieldWithError error={errors.agentName}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("agentName")}
{t("agentName")} <RequiredMark />
</label>
<input
type="text"
required
value={config.agentName}
onChange={(e) =>
setConfig((prev) => ({ ...prev, agentName: e.target.value }))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
onChange={(e) => {
clearError("agentName");
setConfig((prev) => ({ ...prev, agentName: e.target.value }));
}}
className={inputClass(errors.agentName)}
/>
</div>
</FieldWithError>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
@@ -635,106 +716,131 @@ export function OnboardingWizard({
</p>
<div className="space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
{/* Bug 2: company line is meaningless for personal accounts.
Hide entirely rather than render an empty disabled field
— the latter would just suggest the customer should
fill it in. */}
{!isPersonal && (
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCompany")}
</label>
<input
type="text"
value={config.billingAddress.company}
onChange={(e) => {
clearError("billingAddress.company");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
company: e.target.value,
},
}));
}}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
)}
<div>
<FieldWithError error={errors["billingAddress.street"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingStreet")}
{t("billingStreet")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.street}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.street");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
street: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.street"])}
/>
</div>
</FieldWithError>
<div className="grid grid-cols-3 gap-3">
<div>
<FieldWithError error={errors["billingAddress.postalCode"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingPostalCode")}
{t("billingPostalCode")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.postalCode}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.postalCode");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
postalCode: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
}));
}}
className={inputClass(errors["billingAddress.postalCode"])}
/>
</div>
</FieldWithError>
<div className="col-span-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")}
</label>
<input
type="text"
value={config.billingAddress.city}
onChange={(e) =>
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
<FieldWithError error={errors["billingAddress.city"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.city}
onChange={(e) => {
clearError("billingAddress.city");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
city: e.target.value,
},
}));
}}
className={inputClass(errors["billingAddress.city"])}
/>
</FieldWithError>
</div>
</div>
<div>
{/* Bug 3: country was a free-text field — typos broke
invoicing. Now a fixed list of DACH+ neighbours. Add
more codes to SUPPORTED_COUNTRIES in lib/validation.ts
when expanding markets. */}
<FieldWithError error={errors["billingAddress.country"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCountry")}
{t("billingCountry")} <RequiredMark />
</label>
<input
type="text"
<select
value={config.billingAddress.country}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.country");
setConfig((prev) => ({
...prev,
billingAddress: {
...prev.billingAddress,
country: e.target.value,
country: e.target.value as SupportedCountry,
},
}))
}
className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors"
/>
</div>
}));
}}
className={inputClass(errors["billingAddress.country"])}
>
{SUPPORTED_COUNTRIES.map((code) => (
<option key={code} value={code}>
{tCountries(code)}
</option>
))}
</select>
</FieldWithError>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
@@ -782,67 +888,92 @@ export function OnboardingWizard({
{t("confirmDescription")}
</p>
{/* Bug 4 redesign: previously this step only showed agentName
and city — useless for actually reviewing what's about to
be submitted. Now it shows the real config: instance
name, agent name, packages, billing one-liner, contact
email, and notes. Each row uses two columns rather than
flex-justify-between so long values wrap underneath the
label rather than being squashed onto one line. */}
<div className="space-y-4">
<div className="bg-surface-2 border border-border rounded-lg p-4 space-y-3">
{config.instanceName.trim() && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("instanceName")}</span>
<span className="text-text-primary font-mono">
{config.instanceName.trim()}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("agentName")}</span>
<span className="text-text-primary font-mono">
{config.agentName}
</span>
</div>
{config.packages.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("packages")}</span>
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
<div className="bg-surface-2 border border-border rounded-lg p-4 divide-y divide-border">
<ReviewRow
label={t("instanceName")}
value={
config.instanceName.trim() || (
<span className="text-text-muted italic">
{t("reviewInstanceDefault")}
</span>
)
}
mono
/>
<ReviewRow
label={t("agentName")}
value={config.agentName}
mono
/>
<ReviewRow
label={t("packages")}
value={
config.packages.length === 0 ? (
<span className="text-text-muted italic">
{t("reviewNoPackages")}
</span>
) : (
<div className="flex flex-wrap gap-1 justify-end">
{config.packages.map((pkg) => (
<span
key={pkg}
className="text-xs font-mono bg-accent/10 text-accent border border-accent/20 rounded-full px-2 py-0.5"
>
{pkg}
</span>
))}
</div>
)
}
/>
<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>
</div>
)}
{config.packages.some((id) =>
PACKAGE_CATALOG.find((p) => p.id === id)?.requiresSecrets
) && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("credentialsProvided")}
</span>
<span className="text-emerald-400 text-xs font-medium">
</span>
</div>
)}
{config.billingAddress.company && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">
{t("billingCompany")}
</span>
<span className="text-text-primary">
{config.billingAddress.company}
</span>
</div>
)}
{config.billingAddress.city && (
<div className="flex justify-between text-sm">
<span className="text-text-muted">{t("billingCity")}</span>
<span className="text-text-primary">
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</span>
</div>
}
/>
<ReviewRow
label={t("reviewContactEmail")}
value={userEmail || ""}
mono
/>
{config.billingNotes.trim().length > 0 && (
<ReviewRow
label={t("billingNotes")}
value={
<span className="text-text-primary whitespace-pre-wrap text-right">
{config.billingNotes}
</span>
}
/>
)}
</div>
@@ -855,6 +986,25 @@ export function OnboardingWizard({
</div>
)}
{/* Aggregate validation errors — if any per-step schema check
missed something (it shouldn't, but defence in depth),
the user sees a consolidated list here rather than a
silent submit failure. */}
{Object.keys(errors).length > 0 && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mt-4">
<div className="font-semibold mb-1">
{t("validationErrorsTitle")}
</div>
<ul className="list-disc list-inside space-y-0.5">
{Object.entries(errors).map(([path, msg]) => (
<li key={path}>
<span className="font-mono">{path}</span>: {msg}
</li>
))}
</ul>
</div>
)}
<div className="flex justify-between mt-6">
<button
onClick={goBack}
@@ -875,3 +1025,74 @@ export function OnboardingWizard({
</div>
);
}
/**
* Two-column review row used by the confirm step. Right-aligned value
* with the label as a muted prefix on the left.
*/
function ReviewRow({
label,
value,
mono,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div className="flex justify-between gap-4 text-sm py-2 first:pt-0 last:pb-0">
<span className="text-text-muted shrink-0">{label}</span>
<span
className={`text-text-primary text-right min-w-0 break-words ${
mono ? "font-mono" : ""
}`}
>
{value}
</span>
</div>
);
}
/**
* Renders children + an inline error message if present. Children
* supply the label and input; this wrapper just appends the message.
*/
function FieldWithError({
error,
children,
}: {
error?: string;
children: React.ReactNode;
}) {
return (
<div>
{children}
{error && (
<p className="text-xs text-red-400 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}
function RequiredMark() {
return (
<span aria-hidden="true" className="text-accent">
*
</span>
);
}
/**
* Tailwind class for input/select with optional error-state ring.
* Centralised here to keep the wizard's many fields visually
* consistent without repeating the long class string.
*/
function inputClass(error?: string): string {
return `w-full px-3 py-2 bg-surface-2 border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 transition-colors ${
error
? "border-red-400/60 focus:ring-red-400 focus:border-red-400"
: "border-border focus:ring-accent focus:border-accent"
}`;
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
interface Props {
tenantName: string;
/**
* Current suspend state — server-derived. The control toggles this
* via PATCH /api/tenants/[name]/suspend, then refreshes the route
* so server-component-side data (status badge, suspended notice)
* re-renders.
*/
suspended: boolean;
}
/**
* SubscriptionToggle — owner-side cancel/resume control (Bug 31).
*
* Renders a single button that toggles between "Cancel subscription"
* (when active) and "Resume subscription" (when suspended). Cancellation
* is gated behind a confirmation modal because it's destructive
* looking from the user's POV — even though no data is lost, the
* AI assistant becomes unavailable until they resume. Resume has no
* modal; it's a strict subset of cancellation in terms of risk.
*
* The control intentionally lives at the bottom of the tenant detail
* page rather than next to the status badge — putting it near the
* top would invite mis-clicks. Customers who want to cancel scroll
* past the running configuration, billing-relevant info, and assigned
* users first; that's the right friction level.
*
* Suspended tenants render a top-of-page banner separately (see the
* detail page); this component focuses on the action itself.
*/
export function SubscriptionToggle({ tenantName, suspended }: Props) {
const t = useTranslations("tenantDetail");
const tCommon = useTranslations("common");
const router = useRouter();
const [confirmOpen, setConfirmOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const toggleSuspend = async (next: boolean) => {
setSubmitting(true);
setError("");
try {
const res = await fetch(
`/api/tenants/${encodeURIComponent(tenantName)}/suspend`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suspend: next }),
}
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || t("subscriptionUpdateFailed"));
}
setConfirmOpen(false);
// The status badge + suspended banner are server-rendered, so
// a route refresh is the simplest way to reflect the new state.
// Optimistic local toggle would diverge from the actual CR if
// the operator hasn't observed the patch yet.
router.refresh();
} catch (e: any) {
setError(e.message);
} finally {
setSubmitting(false);
}
};
if (suspended) {
return (
<div>
<button
type="button"
onClick={() => toggleSuspend(false)}
disabled={submitting}
className="text-sm font-medium px-4 py-2 rounded-lg border border-success/30 text-success hover:bg-success/10 transition-colors disabled:opacity-50"
>
{submitting ? tCommon("loading") : t("resumeSubscription")}
</button>
{error && <p className="text-xs text-red-400 mt-2">{error}</p>}
</div>
);
}
return (
<div>
<button
type="button"
onClick={() => setConfirmOpen(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary hover:border-text-secondary transition-colors"
>
{t("cancelSubscription")}
</button>
{error && !confirmOpen && (
<p className="text-xs text-red-400 mt-2">{error}</p>
)}
{confirmOpen && (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) setConfirmOpen(false);
}}
>
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelConfirmTitle")}
</h3>
<p className="text-sm text-text-secondary mb-3">
{t("cancelConfirmDescription")}
</p>
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
<li>{t("cancelConfirmBullet1")}</li>
<li>{t("cancelConfirmBullet2")}</li>
<li>{t("cancelConfirmBullet3")}</li>
</ul>
{error && (
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmOpen(false)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
>
{tCommon("cancel")}
</button>
<button
type="button"
onClick={() => toggleSuspend(true)}
disabled={submitting}
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
>
{submitting
? tCommon("loading")
: t("cancelSubscriptionConfirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,18 +1,39 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Visual treatment per phase. Each entry is a Tailwind class string
* applied to the badge. The `Pending` style is also used as a fallback
* for unknown phases — it's the most neutral colour.
*
* Slice 7 / Bug 31 added `Suspended`. It uses an amber-on-muted scheme
* to read as "intentionally paused" — distinct from `Error` (red) and
* `Deleting` (mute grey).
*/
const phaseStyles: Record<string, string> = {
Running:
"bg-success/10 text-success border-success/20",
Provisioning:
"bg-warning/10 text-warning border-warning/20",
Pending:
"bg-text-muted/10 text-text-secondary border-border",
Error:
"bg-error/10 text-error border-error/20",
Deleting:
"bg-text-muted/10 text-text-muted border-border",
Running: "bg-success/10 text-success border-success/20",
Ready: "bg-success/10 text-success border-success/20",
Provisioning: "bg-warning/10 text-warning border-warning/20",
Pending: "bg-text-muted/10 text-text-secondary border-border",
Suspended: "bg-amber-500/10 text-amber-400 border-amber-500/30",
Error: "bg-error/10 text-error border-error/20",
Deleting: "bg-text-muted/10 text-text-muted border-border",
};
export function StatusBadge({ phase }: { phase: string }) {
const t = useTranslations("phase");
const style = phaseStyles[phase] ?? phaseStyles.Pending;
// Translation lookup with fallback to the raw phase. Keeps things
// working if a new operator-side phase ships before the portal has
// a label for it.
const label = (() => {
try {
return t(phase);
} catch {
return phase;
}
})();
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${style}`}
@@ -23,7 +44,7 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{phase}
{label}
</span>
);
}

164
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,164 @@
import { z } from "zod";
/**
* Shared validation schemas for the onboarding wizard and the
* registration form. Both client and server import from here so the
* rules can't drift apart.
*
* Bug 12 motivation: until now, all wizard fields could be empty and
* still submit — the server schema in `/api/onboarding` had every
* billing field optional, and the client did no validation at all.
* Required fields are now declared once, here, and used in three
* places:
* 1. The wizard's per-step `validateStep()` to gate `goNext()`.
* 2. The wizard's submit handler to render inline errors.
* 3. The server route's `safeParse()` so the rules are also
* enforced on direct API calls.
*
* Don't mix UX-only state (e.g. "did the user touch this field yet")
* into these schemas — that belongs in the wizard's render layer.
* These schemas describe what the data has to look like, not the
* progressive-disclosure rules.
*/
// ISO-3166-1 alpha-2 codes accepted in the country dropdown. DACH+
// neighbours: Switzerland, Germany, Austria, France, Italy, plus
// Liechtenstein (Swiss customers with LI billing addresses are common
// enough to include without inflating the list). Add to this set when
// expanding into new markets.
export const SUPPORTED_COUNTRIES = ["CH", "DE", "AT", "FR", "IT", "LI"] as const;
export type SupportedCountry = (typeof SUPPORTED_COUNTRIES)[number];
/**
* Country-specific postal-code patterns. Bug 33: previously a postal
* code could be anything (e.g. "abc"), which broke invoicing.
*
* Patterns are deliberately conservative — they reject obviously wrong
* input but don't try to be exhaustive valid-range checkers (e.g. CH
* codes are 1000-9999 in practice but \d{4} accepts 0000; the post
* office will reject downstream if it matters). If a future country
* has multi-format codes (e.g. UK postcodes with the inner-outer
* structure), add it as a regex here rather than trying to fit
* every country into the same shape.
*/
const POSTAL_CODE_PATTERNS: Record<SupportedCountry, RegExp> = {
CH: /^\d{4}$/,
DE: /^\d{5}$/,
AT: /^\d{4}$/,
FR: /^\d{5}$/,
IT: /^\d{5}$/,
LI: /^\d{4}$/,
};
/**
* Postal-code expectation in human terms — used in error messages so
* the user gets a useful hint ("expected 4 digits") rather than just
* a regex failure. Keep in sync with POSTAL_CODE_PATTERNS.
*/
const POSTAL_CODE_HINTS: Record<SupportedCountry, string> = {
CH: "4 digits",
DE: "5 digits",
AT: "4 digits",
FR: "5 digits",
IT: "5 digits",
LI: "4 digits",
};
/**
* Billing address — every field required at minimum non-empty length.
* Postal code is validated against the chosen country (Bug 33). Country
* is a fixed enum to prevent free-text typos that break invoicing.
*
* `superRefine` is the right hook here because we need to look at two
* fields (country + postalCode) together. The error path is set on
* `postalCode` so the wizard renders the inline error under the right
* input rather than at the form root.
*/
export const billingAddressSchema = z
.object({
// Company line is structurally optional — personal accounts leave it
// empty by design (Bug 2). Server-side, the wizard's UI hides the
// field for personals; the schema just doesn't require it.
company: z.string().trim().max(100).optional().default(""),
street: z.string().trim().min(1, "required").max(200),
postalCode: z.string().trim().min(1, "required").max(12),
city: z.string().trim().min(1, "required").max(100),
country: z.enum(SUPPORTED_COUNTRIES, {
message: "Please choose a country from the list",
}),
})
.superRefine((data, ctx) => {
const pattern = POSTAL_CODE_PATTERNS[data.country];
if (!pattern.test(data.postalCode)) {
ctx.addIssue({
code: "custom",
path: ["postalCode"],
message: `Invalid postal code (expected ${POSTAL_CODE_HINTS[data.country]})`,
});
}
});
export type BillingAddressInput = z.infer<typeof billingAddressSchema>;
/**
* Per-step schemas for progressive validation. Each step validates only
* the fields visible up to that point, so the user gets feedback at the
* step they're on rather than at the end.
*
* The `welcome` step has nothing to validate.
* The `configure` step requires a non-empty agentName.
* The `billing` step requires a complete billing address (with the
* optional company line).
* The `confirm` step is the final submission and validates the union.
*/
export const configureStepSchema = z.object({
agentName: z.string().trim().min(1, "required").max(50),
});
export const billingStepSchema = z.object({
billingAddress: billingAddressSchema,
});
/**
* Full onboarding payload. Used by the API route and by the wizard's
* submit handler. `packageSecrets` is a free-shape map that gets
* encrypted by the server before it touches the DB.
*/
export const onboardingSchema = z.object({
instanceName: z
.string()
.trim()
.max(80)
.optional()
// Empty string from a form input → undefined so the DB stores NULL.
.transform((v) => (v && v.length > 0 ? v : undefined)),
agentName: z.string().trim().min(1, "required").max(50),
soulMd: z.string().max(10_000).optional(),
agentsMd: z.string().max(10_000).optional(),
packages: z.array(z.string()).optional(),
packageSecrets: z
.record(z.string(), z.record(z.string(), z.string()))
.optional(),
billingAddress: billingAddressSchema,
billingNotes: z.string().max(2_000).optional(),
});
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
/**
* Helper: flatten a Zod error into a flat field-path → message map.
* The wizard uses this to look up errors per input by their path.
*
* Returns `{}` on success (i.e. caller shouldn't call this on a parsed
* value; only on `safeParse(...).error`). Kept here rather than inline
* so both the wizard and any future field-level form (e.g. settings
* page reusing billingAddressSchema) can share it.
*/
export function fieldErrors(err: z.ZodError): Record<string, string> {
const out: Record<string, string> = {};
for (const issue of err.issues) {
const key = issue.path.join(".");
if (!(key in out)) out[key] = issue.message;
}
return out;
}

View File

@@ -20,11 +20,11 @@
"button": "Weiter mit ZITADEL",
"footer": "On-Premises gehostet in der Schweiz",
"noAccount": "Noch kein Konto?",
"register": "Firma registrieren"
"register": "Konto erstellen"
},
"register": {
"title": "Konto erstellen",
"subtitle": "Registrieren Sie Ihre Firma für einen in der Schweiz gehosteten KI-Assistenten",
"subtitle": "Richten Sie Ihren Schweizer KI-Assistenten ein",
"companyName": "Firmenname",
"companyNamePlaceholder": "Muster GmbH",
"givenName": "Vorname",
@@ -38,7 +38,12 @@
"goToLogin": "Zur Anmeldung",
"duplicateDomain": "Für die E-Mail-Domain {domain} ist bereits ein Konto registriert. Bitte wenden Sie sich an Ihren Firmenadministrator, um eingeladen zu werden, oder kontaktieren Sie den PieCed-IT-Support, falls dies ein Fehler ist.",
"individualToggle": "Als Privatperson registrieren",
"individualHint": "Aktivieren Sie diese Option, wenn Sie sich nicht im Namen eines Unternehmens registrieren. Ihr Konto wird als persönlicher Arbeitsbereich eingerichtet."
"individualHint": "Aktivieren Sie diese Option, wenn Sie sich nicht im Namen eines Unternehmens registrieren. Ihr Konto wird als persönlicher Arbeitsbereich eingerichtet.",
"accountTypeLabel": "Kontotyp",
"personalCardTitle": "Privat",
"personalCardDescription": "Für Sie persönlich.",
"companyCardTitle": "Unternehmen",
"companyCardDescription": "Für Ihr Unternehmen oder Team."
},
"onboarding": {
"loading": "Status wird geladen…",
@@ -89,7 +94,13 @@
"submittedAt": "Eingereicht",
"instanceName": "Instanzname",
"instanceNamePlaceholder": "z.B. Produktion, Dev, Vertrieb",
"instanceNameHint": "Optionaler lesbarer Name, um diese Instanz von anderen in Ihrem Dashboard zu unterscheiden. Leer lassen, um den Firmennamen zu verwenden."
"instanceNameHint": "Optionaler lesbarer Name, um diese Instanz von anderen in Ihrem Dashboard zu unterscheiden. Leer lassen, um den Firmennamen zu verwenden.",
"validationError": "Bitte korrigieren Sie die Fehler vor dem Absenden.",
"validationErrorsTitle": "Einige Pflichtfelder fehlen oder sind ungültig:",
"reviewInstanceDefault": "(Standard — verwendet Firmenname)",
"reviewNoPackages": "Keine ausgewählt",
"reviewBillingTo": "Rechnungsempfänger",
"reviewContactEmail": "Kontakt-E-Mail"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +129,21 @@
"notFound": "Tenant nicht gefunden.",
"usage": "Nutzung & Kosten",
"provisioned": "Bereitgestellt",
"assignedUsers": "Zugewiesene Benutzer"
"assignedUsers": "Zugewiesene Benutzer",
"subscriptionTitle": "Abonnement",
"subscriptionDescriptionActive": "Kündigen Sie Ihr Abonnement, wenn Sie diesen Assistenten nicht mehr benötigen. Ihre Daten bleiben erhalten und Sie können jederzeit wieder aktivieren.",
"subscriptionDescriptionSuspended": "Ihr Abonnement ist gekündigt. Aktivieren Sie es wieder, um den Assistenten online zu bringen.",
"cancelSubscription": "Abonnement kündigen",
"cancelSubscriptionConfirm": "Ja, kündigen",
"resumeSubscription": "Abonnement reaktivieren",
"cancelConfirmTitle": "Dieses Abonnement kündigen?",
"cancelConfirmDescription": "Ihr Assistent wird nicht mehr verfügbar sein. Sie können jederzeit reaktivieren — Ihre Daten bleiben erhalten.",
"cancelConfirmBullet1": "Workspace-Dateien (SOUL.md, AGENTS.md) bleiben erhalten",
"cancelConfirmBullet2": "Paket-Anmeldedaten bleiben gespeichert",
"cancelConfirmBullet3": "Rechnungsdaten bleiben gespeichert",
"subscriptionUpdateFailed": "Abonnement konnte nicht aktualisiert werden.",
"suspendedTitle": "Abonnement gekündigt",
"suspendedDescription": "Ihr Assistent ist pausiert. Konfiguration und Daten bleiben erhalten. Verwenden Sie die Reaktivierungs-Schaltfläche unten auf dieser Seite, um ihn wieder online zu bringen."
},
"usage": {
"inputTokens": "Input-Tokens",
@@ -304,5 +329,22 @@
"pickUser": "Benutzer auswählen…",
"assign": "Zuweisen",
"revoke": "Entfernen"
},
"countries": {
"CH": "Schweiz",
"DE": "Deutschland",
"AT": "Österreich",
"FR": "Frankreich",
"IT": "Italien",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "Ausstehend",
"Provisioning": "Wird bereitgestellt",
"Running": "Aktiv",
"Ready": "Bereit",
"Suspended": "Pausiert",
"Error": "Fehler",
"Deleting": "Wird gelöscht"
}
}

View File

@@ -20,11 +20,11 @@
"button": "Continue with ZITADEL",
"footer": "Hosted on-premises in Switzerland",
"noAccount": "No account yet?",
"register": "Register your company"
"register": "Create an account"
},
"register": {
"title": "Create your account",
"subtitle": "Register your company for a Swiss-hosted AI assistant",
"subtitle": "Set up your Swiss-hosted AI assistant",
"companyName": "Company Name",
"companyNamePlaceholder": "Acme GmbH",
"givenName": "First Name",
@@ -38,7 +38,12 @@
"goToLogin": "Go to Sign In",
"duplicateDomain": "An account for the email domain {domain} is already registered. Please contact your company administrator to be invited, or reach out to PieCed IT support if you believe this is in error.",
"individualToggle": "Register as an individual",
"individualHint": "Tick this if you're not registering on behalf of a company. Your account will be set up as a personal workspace."
"individualHint": "Tick this if you're not registering on behalf of a company. Your account will be set up as a personal workspace.",
"accountTypeLabel": "Account type",
"personalCardTitle": "Personal",
"personalCardDescription": "For yourself.",
"companyCardTitle": "Company",
"companyCardDescription": "For your business or team."
},
"onboarding": {
"loading": "Loading status…",
@@ -89,7 +94,13 @@
"submittedAt": "Submitted",
"instanceName": "Instance name",
"instanceNamePlaceholder": "e.g. Production, Dev, Sales",
"instanceNameHint": "Optional human-readable name to distinguish this instance from others on your dashboard. Leave blank to use your company name."
"instanceNameHint": "Optional human-readable name to distinguish this instance from others on your dashboard. Leave blank to use your company name.",
"validationError": "Please fix the errors before submitting.",
"validationErrorsTitle": "Some required fields are missing or invalid:",
"reviewInstanceDefault": "(default — uses company name)",
"reviewNoPackages": "None selected",
"reviewBillingTo": "Billing to",
"reviewContactEmail": "Contact email"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +129,21 @@
"notFound": "Tenant not found.",
"usage": "Usage & Spend",
"provisioned": "Provisioned",
"assignedUsers": "Assigned users"
"assignedUsers": "Assigned users",
"subscriptionTitle": "Subscription",
"subscriptionDescriptionActive": "Cancel your subscription if you no longer need this assistant. Your data will be preserved and you can resume anytime.",
"subscriptionDescriptionSuspended": "Your subscription is cancelled. Resume to bring the assistant back online.",
"cancelSubscription": "Cancel subscription",
"cancelSubscriptionConfirm": "Yes, cancel",
"resumeSubscription": "Resume subscription",
"cancelConfirmTitle": "Cancel this subscription?",
"cancelConfirmDescription": "Your assistant will become unavailable. You can resume anytime — your data is preserved.",
"cancelConfirmBullet1": "Workspace files (SOUL.md, AGENTS.md) are kept",
"cancelConfirmBullet2": "Package credentials remain stored",
"cancelConfirmBullet3": "Billing information is kept on file",
"subscriptionUpdateFailed": "Could not update subscription.",
"suspendedTitle": "Subscription cancelled",
"suspendedDescription": "Your assistant is paused. Configuration and data are preserved. Use the Resume control at the bottom of this page to bring it back online."
},
"usage": {
"inputTokens": "Input Tokens",
@@ -304,5 +329,22 @@
"pickUser": "Select a user…",
"assign": "Assign",
"revoke": "Remove"
},
"countries": {
"CH": "Switzerland",
"DE": "Germany",
"AT": "Austria",
"FR": "France",
"IT": "Italy",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "Pending",
"Provisioning": "Provisioning",
"Running": "Running",
"Ready": "Ready",
"Suspended": "Suspended",
"Error": "Error",
"Deleting": "Deleting"
}
}

View File

@@ -20,11 +20,11 @@
"button": "Continuer avec ZITADEL",
"footer": "Hébergé on-premises en Suisse",
"noAccount": "Pas encore de compte ?",
"register": "Enregistrer votre entreprise"
"register": "Créer un compte"
},
"register": {
"title": "Créer votre compte",
"subtitle": "Enregistrez votre entreprise pour un assistant IA hébergé en Suisse",
"subtitle": "Configurez votre assistant IA hébergé en Suisse",
"companyName": "Nom de l'entreprise",
"companyNamePlaceholder": "Exemple SA",
"givenName": "Prénom",
@@ -38,7 +38,12 @@
"goToLogin": "Aller à la connexion",
"duplicateDomain": "Un compte pour le domaine de courriel {domain} est déjà enregistré. Veuillez contacter l'administrateur de votre entreprise pour être invité, ou contactez le support PieCed IT si vous pensez qu'il s'agit d'une erreur.",
"individualToggle": "S'inscrire en tant que particulier",
"individualHint": "Cochez cette case si vous ne vous inscrivez pas au nom d'une entreprise. Votre compte sera configuré comme espace de travail personnel."
"individualHint": "Cochez cette case si vous ne vous inscrivez pas au nom d'une entreprise. Votre compte sera configuré comme espace de travail personnel.",
"accountTypeLabel": "Type de compte",
"personalCardTitle": "Particulier",
"personalCardDescription": "Pour vous.",
"companyCardTitle": "Entreprise",
"companyCardDescription": "Pour votre entreprise ou équipe."
},
"onboarding": {
"loading": "Chargement du statut…",
@@ -89,7 +94,13 @@
"submittedAt": "Soumis",
"instanceName": "Nom de l'instance",
"instanceNamePlaceholder": "ex. Production, Dev, Ventes",
"instanceNameHint": "Nom lisible facultatif pour distinguer cette instance des autres sur votre tableau de bord. Laisser vide pour utiliser le nom de votre entreprise."
"instanceNameHint": "Nom lisible facultatif pour distinguer cette instance des autres sur votre tableau de bord. Laisser vide pour utiliser le nom de votre entreprise.",
"validationError": "Veuillez corriger les erreurs avant l'envoi.",
"validationErrorsTitle": "Certains champs obligatoires manquent ou sont invalides :",
"reviewInstanceDefault": "(par défaut — utilise le nom de l'entreprise)",
"reviewNoPackages": "Aucun sélectionné",
"reviewBillingTo": "Facturer à",
"reviewContactEmail": "E-mail de contact"
},
"dashboard": {
"title": "Tableau de bord",
@@ -118,7 +129,21 @@
"notFound": "Locataire non trouvé.",
"usage": "Utilisation et coûts",
"provisioned": "Provisionné",
"assignedUsers": "Utilisateurs attribués"
"assignedUsers": "Utilisateurs attribués",
"subscriptionTitle": "Abonnement",
"subscriptionDescriptionActive": "Annulez votre abonnement si vous n'avez plus besoin de cet assistant. Vos données seront conservées et vous pourrez reprendre à tout moment.",
"subscriptionDescriptionSuspended": "Votre abonnement est annulé. Reprenez pour remettre l'assistant en ligne.",
"cancelSubscription": "Annuler l'abonnement",
"cancelSubscriptionConfirm": "Oui, annuler",
"resumeSubscription": "Reprendre l'abonnement",
"cancelConfirmTitle": "Annuler cet abonnement ?",
"cancelConfirmDescription": "Votre assistant sera indisponible. Vous pouvez reprendre à tout moment — vos données sont préservées.",
"cancelConfirmBullet1": "Les fichiers de l'espace de travail (SOUL.md, AGENTS.md) sont conservés",
"cancelConfirmBullet2": "Les identifiants des packages restent stockés",
"cancelConfirmBullet3": "Les informations de facturation sont conservées",
"subscriptionUpdateFailed": "Impossible de mettre à jour l'abonnement.",
"suspendedTitle": "Abonnement annulé",
"suspendedDescription": "Votre assistant est en pause. La configuration et les données sont préservées. Utilisez le contrôle Reprendre en bas de cette page pour le remettre en ligne."
},
"usage": {
"inputTokens": "Tokens d'entrée",
@@ -304,5 +329,22 @@
"pickUser": "Sélectionner un utilisateur…",
"assign": "Attribuer",
"revoke": "Retirer"
},
"countries": {
"CH": "Suisse",
"DE": "Allemagne",
"AT": "Autriche",
"FR": "France",
"IT": "Italie",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "En attente",
"Provisioning": "Mise en service",
"Running": "Actif",
"Ready": "Prêt",
"Suspended": "Suspendu",
"Error": "Erreur",
"Deleting": "Suppression"
}
}

View File

@@ -20,11 +20,11 @@
"button": "Continua con ZITADEL",
"footer": "Ospitato on-premises in Svizzera",
"noAccount": "Non hai ancora un account?",
"register": "Registra la tua azienda"
"register": "Crea un account"
},
"register": {
"title": "Crea il tuo account",
"subtitle": "Registra la tua azienda per un assistente IA ospitato in Svizzera",
"subtitle": "Configuri il suo assistente IA ospitato in Svizzera",
"companyName": "Nome azienda",
"companyNamePlaceholder": "Esempio SA",
"givenName": "Nome",
@@ -38,7 +38,12 @@
"goToLogin": "Vai all'accesso",
"duplicateDomain": "Un account per il dominio e-mail {domain} è già registrato. Contatta l'amministratore della tua azienda per essere invitato, oppure contatta il supporto PieCed IT se ritieni che si tratti di un errore.",
"individualToggle": "Registrati come privato",
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale."
"individualHint": "Seleziona questa opzione se non ti stai registrando per conto di un'azienda. Il tuo account sarà configurato come area di lavoro personale.",
"accountTypeLabel": "Tipo di account",
"personalCardTitle": "Privato",
"personalCardDescription": "Per lei.",
"companyCardTitle": "Azienda",
"companyCardDescription": "Per la sua azienda o team."
},
"onboarding": {
"loading": "Caricamento stato…",
@@ -89,7 +94,13 @@
"submittedAt": "Inviato",
"instanceName": "Nome istanza",
"instanceNamePlaceholder": "es. Produzione, Dev, Vendite",
"instanceNameHint": "Nome leggibile facoltativo per distinguere questa istanza dalle altre nella dashboard. Lasciare vuoto per usare il nome dell'azienda."
"instanceNameHint": "Nome leggibile facoltativo per distinguere questa istanza dalle altre nella dashboard. Lasciare vuoto per usare il nome dell'azienda.",
"validationError": "Correggere gli errori prima di inviare.",
"validationErrorsTitle": "Alcuni campi obbligatori sono mancanti o non validi:",
"reviewInstanceDefault": "(predefinito — usa il nome dell'azienda)",
"reviewNoPackages": "Nessuno selezionato",
"reviewBillingTo": "Fatturare a",
"reviewContactEmail": "Email di contatto"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +129,21 @@
"notFound": "Tenant non trovato.",
"usage": "Utilizzo e costi",
"provisioned": "Attivato",
"assignedUsers": "Utenti assegnati"
"assignedUsers": "Utenti assegnati",
"subscriptionTitle": "Abbonamento",
"subscriptionDescriptionActive": "Annulli il suo abbonamento se non ha più bisogno di questo assistente. I suoi dati saranno preservati e potrà riprendere in qualsiasi momento.",
"subscriptionDescriptionSuspended": "Il suo abbonamento è annullato. Riprenda per riportare l'assistente online.",
"cancelSubscription": "Annulla abbonamento",
"cancelSubscriptionConfirm": "Sì, annulla",
"resumeSubscription": "Riprendi abbonamento",
"cancelConfirmTitle": "Annullare questo abbonamento?",
"cancelConfirmDescription": "Il suo assistente diventerà non disponibile. Può riprendere in qualsiasi momento — i suoi dati sono preservati.",
"cancelConfirmBullet1": "I file del workspace (SOUL.md, AGENTS.md) sono mantenuti",
"cancelConfirmBullet2": "Le credenziali dei pacchetti rimangono memorizzate",
"cancelConfirmBullet3": "Le informazioni di fatturazione sono mantenute",
"subscriptionUpdateFailed": "Impossibile aggiornare l'abbonamento.",
"suspendedTitle": "Abbonamento annullato",
"suspendedDescription": "Il suo assistente è in pausa. Configurazione e dati sono preservati. Usi il controllo Riprendi in fondo a questa pagina per riportarlo online."
},
"usage": {
"inputTokens": "Token di input",
@@ -304,5 +329,22 @@
"pickUser": "Seleziona un utente…",
"assign": "Assegna",
"revoke": "Rimuovi"
},
"countries": {
"CH": "Svizzera",
"DE": "Germania",
"AT": "Austria",
"FR": "Francia",
"IT": "Italia",
"LI": "Liechtenstein"
},
"phase": {
"Pending": "In attesa",
"Provisioning": "In provisioning",
"Running": "Attivo",
"Ready": "Pronto",
"Suspended": "Sospeso",
"Error": "Errore",
"Deleting": "Eliminazione"
}
}

View File

@@ -78,7 +78,14 @@ export interface PiecedTenantSpec {
}
export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting";
phase:
| "Pending"
| "Provisioning"
| "Running"
| "Ready"
| "Suspended"
| "Error"
| "Deleting";
message?: string;
observedGeneration?: number;
/**