Compare commits

..

7 Commits

Author SHA1 Message Date
2cf5b56441 OCI Warning status
All checks were successful
Build and Push / build (push) Successful in 2m12s
2026-05-01 10:25:50 +02:00
f84516a65b Group D fixes
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-04-29 22:16:48 +02:00
219b4c8365 Group D fixes
Some checks failed
Build and Push / build (push) Failing after 37s
2026-04-29 22:13:08 +02:00
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
eeef108f7e Group B fixes
All checks were successful
Build and Push / build (push) Successful in 1m24s
2026-04-29 15:43:12 +02:00
c7df5c83a4 Fix user view tenant
All checks were successful
Build and Push / build (push) Successful in 1m32s
2026-04-29 12:33:04 +02:00
33 changed files with 2763 additions and 417 deletions

View File

@@ -0,0 +1,87 @@
import { getSessionUser, canMutate } from "@/lib/session";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { getTenantRequestById } from "@/lib/db";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
/**
* /dashboard/edit/[id] — re-opens the onboarding wizard with the
* fields of a still-pending request pre-filled (Bug 6). On submit,
* the wizard PATCHes /api/onboarding/[id] instead of POSTing to
* /api/onboarding.
*
* Hard guards
* -----------
* - Logged-in customer owner (or platform user) only — same as the
* /dashboard/new page.
* - Request must exist, belong to the caller's org, and be in 'pending'
* status. Editing approved/provisioning rows would race against the
* operator; we redirect such cases back to the dashboard rather than
* render an invalid wizard.
*
* Pre-fill
* --------
* The wizard takes a single `editingRequest` prop — when present, it
* (a) pre-populates state from those values and (b) targets the PATCH
* endpoint on submit. When absent, it behaves exactly as today (POST
* to /api/onboarding).
*
* Note on encrypted secrets
* -------------------------
* Per-package secrets are NEVER decrypted server-side and exposed to
* the client (would be a clear security regression). When editing,
* the wizard opens with empty secret fields and the user re-enters
* any they want to change. If they don't touch the package-secrets
* UI, the existing encrypted blob in the DB is preserved by the
* PATCH endpoint (it only re-encrypts when the wizard sends a
* non-empty secrets payload).
*/
export default async function EditRequestPage({
params,
}: {
params: Promise<{ id: string; locale: string }>;
}) {
const { id } = await params;
const user = await getSessionUser();
if (!user) redirect("/login");
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
const tr = await getTenantRequestById(id);
if (!tr) redirect("/dashboard");
if (tr.zitadelOrgId !== user.orgId) redirect("/dashboard");
if (tr.status !== "pending") redirect("/dashboard");
const t = await getTranslations("dashboard");
const tOnboarding = await getTranslations("onboarding");
return (
<div className="container max-w-3xl mx-auto px-4 py-8">
<div className="mb-8 animate-in">
<BackLink href="/dashboard" label={t("title")} />
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
{tOnboarding("editRequestTitle")}
</h1>
<p className="text-sm text-text-secondary">
{tOnboarding("editRequestDescription")}
</p>
</div>
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
editingRequest={{
id: tr.id,
instanceName: tr.instanceName ?? "",
agentName: tr.agentName,
soulMd: tr.soulMd ?? "",
agentsMd: tr.agentsMd ?? "",
packages: tr.packages,
billingAddress: tr.billingAddress,
billingNotes: tr.billingNotes ?? "",
}}
/>
</div>
);
}

View File

@@ -3,6 +3,9 @@ import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { BackLink } from "@/components/ui/back-link";
import { listTenants } from "@/lib/k8s";
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
import { personalAccountAtCapacity } from "@/lib/personal-org";
/**
* /dashboard/new — wizard for creating an additional instance for an
@@ -21,6 +24,10 @@ import { BackLink } from "@/components/ui/back-link";
* may create new instances. The server-side POST handler enforces the
* same; this redirect is purely UX so /user-role members don't land on
* a wizard that will 403 on submit.
*
* Bug 5: personal accounts that already hold a tenant or have one
* in-flight are sent back to the dashboard with the same UX rationale.
* Matching API guard lives in `/api/onboarding`.
*/
export default async function NewInstancePage() {
const user = await getSessionUser();
@@ -28,6 +35,25 @@ export default async function NewInstancePage() {
if (user.isPlatform) redirect("/dashboard");
if (!canMutate(user)) redirect("/dashboard");
if (user.isPersonal) {
const [allTenants, activeRequests] = await Promise.all([
listTenants(),
listActiveTenantRequestsByOrgId(user.orgId),
]);
const ownTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (
personalAccountAtCapacity(
user.isPersonal,
ownTenants.length,
activeRequests.length
)
) {
redirect("/dashboard");
}
}
const t = await getTranslations("dashboard");
return (
@@ -43,7 +69,11 @@ export default async function NewInstancePage() {
</div>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow orgName={user.orgName} />
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</div>
</div>
);

View File

@@ -8,8 +8,10 @@ import {
canSeeInflightRequests,
isUserScoped,
} from "@/lib/visibility";
import { personalAccountAtCapacity } from "@/lib/personal-org";
import { Card, CardHeader } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
import { ProvisioningStatus } from "@/components/onboarding/provisioning-status";
import { formatDateTime } from "@/lib/format";
@@ -179,7 +181,17 @@ export default async function DashboardPage() {
// the admin panel anyway) see the "Create new instance" link. A
// `user`-role member sees the dashboard but not the create flow —
// they need to ask an owner.
const canCreate = canMutate(user);
//
// Bug 5: personal accounts are 1-instance by design. Once a personal
// account has either an active tenant OR an in-flight request, the
// create button must disappear. The matching server-side guard is
// in `/api/onboarding` so direct POSTs are also rejected.
const personalAtCapacity = personalAccountAtCapacity(
user.isPersonal,
orgScopedTenants.length,
inflightRequests.length
);
const canCreate = canMutate(user) && !personalAtCapacity;
// First-time / no-visibility branch.
//
@@ -262,7 +274,11 @@ export default async function DashboardPage() {
</div>
<div className="animate-in animate-in-delay-1">
<OnboardingFlow orgName={user.orgName} />
<OnboardingFlow
orgName={user.orgName}
userName={user.name}
userEmail={user.email}
/>
</div>
</div>
);
@@ -300,7 +316,11 @@ export default async function DashboardPage() {
</h2>
<div className="space-y-3">
{inflightRequests.map((r) => (
<ProvisioningStatus key={r.id} requestId={r.id} />
<ProvisioningStatus
key={r.id}
requestId={r.id}
canAct={canMutate(user)}
/>
))}
</div>
</div>
@@ -329,7 +349,10 @@ export default async function DashboardPage() {
{tenant.metadata.name}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div>
</div>
{tenant.spec.agentName && (

View File

@@ -6,42 +6,66 @@ import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
type AccountType = "personal" | "company";
/**
* Slice 4: 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 "{givenName} {familyName} (Personal)"
* 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");
try {
// Build the request body explicitly. For personals we omit
// companyName so the server knows to derive the org name from
// the user's full name. The Zod schema accepts the omission.
// companyName so the server generates an opaque ZITADEL org name
// (`personal-{8hex}`); the Zod schema accepts the omission.
const body: Record<string, unknown> = {
givenName: form.givenName,
familyName: form.familyName,
@@ -60,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 }));
}
@@ -118,27 +139,65 @@ 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"
{/* 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 className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary">
{t("individualToggle")}
</div>
<div className="text-xs text-text-muted mt-0.5">
{t("individualHint")}
</div>
</div>
</label>
{/* Company name — hidden for personal */}
{/* 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">
@@ -227,11 +286,65 @@ export default function RegisterPage() {
</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

@@ -21,6 +21,12 @@ export default async function TeamPage() {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!canMutate(user)) redirect("/dashboard");
// Bug 8: personal accounts have no team to manage. The page is
// structurally meaningless and the invite form would create extra
// ZITADEL users in a single-user org. Redirect cleanly. The matching
// API guards in `/api/team` and `/api/team/invite` enforce the same
// rule on direct calls.
if (user.isPersonal) redirect("/dashboard");
const t = await getTranslations("team");
const tDashboard = await getTranslations("dashboard");

View File

@@ -4,11 +4,13 @@ import { redirect, notFound } from "next/navigation";
import { getTenant } from "@/lib/k8s";
import { canUserSeeTenant } from "@/lib/visibility";
import { StatusBadge } from "@/components/ui/status-badge";
import { WarningBadge } from "@/components/ui/warning-badge";
import { UsageDisplay } from "@/components/dashboard/usage-display";
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 +42,24 @@ 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
// `pieced.ch/personal=true` label (set at approve time for new
// personal tenants) OR the viewer is on a personal account (covers
// legacy tenants approved before the label was added; the customer
// sees their own personal tenant). Platform admins viewing a legacy
// unlabeled personal tenant are the only case where this falls
// through to "show panel" — operators can `kubectl label` to fix.
const isPersonalTenant =
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
user.isPersonal;
const enabledPackages = tenant.spec.packages || [];
const workspaceFiles = tenant.spec.workspaceFiles || {};
const enabledChannels = enabledPackages.filter((pkg) =>
@@ -69,6 +89,7 @@ export default async function TenantDetailPage({
{tenant.spec.displayName || name}
</h1>
<StatusBadge phase={tenant.status?.phase ?? "Pending"} />
<WarningBadge warnings={tenant.status?.warnings ?? []} />
</div>
{tenant.spec.agentName && (
<p className="text-sm text-text-secondary mt-3">
@@ -89,6 +110,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">
@@ -132,13 +188,35 @@ export default async function TenantDetailPage({
{/* Slice 7: Assigned users — visible to anyone who can see the
tenant, editable only by owners/platform users. The component
fetches its own data so the page doesn't need to await. */}
fetches its own data so the page doesn't need to await.
Bug 7: hidden entirely for personal tenants. */}
{!isPersonalTenant && (
<section className="mt-8 animate-in animate-in-delay-4">
<h2 className="text-xs font-semibold uppercase tracking-wider text-text-muted mb-3">
{t("assignedUsers")}
</h2>
<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

@@ -123,6 +123,15 @@ export async function POST(
},
{
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
// Bug 7: stamp the personal flag on the CR so callers (notably
// the tenant detail page) can hide assignment-related UI
// without an extra DB join. Slice 4 already tracks this on the
// request row; the CR label is the same fact at the K8s layer.
// Legacy tenants approved before this change won't carry the
// label — operators can backfill with `kubectl label`.
...(tenantRequest.isPersonal
? { "pieced.ch/personal": "true" }
: {}),
}
);

View File

@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import { dismissTenantRequest, getTenantRequestById } from "@/lib/db";
import { safeError } from "@/lib/errors";
/**
* POST /api/onboarding/[id]/dismiss
*
* Customer-side acknowledgement of a rejected or cancelled request
* (Bug 13). Sets `dismissed_at = now()` so the row stops appearing
* in the dashboard's `listActiveTenantRequestsByOrgId` query. The
* row itself is preserved for audit.
*
* Authorization mirrors the GET / DELETE / PATCH endpoints on this
* resource: customer owners (or platform staff) of the row's org.
*
* Idempotent: dismissing an already-dismissed request returns 200
* with no change. We refuse to dismiss non-terminal rows (pending,
* approved, provisioning, active) — those are still actionable, and
* "hiding" them would stash live state from the customer.
*/
export async function POST(
_req: NextRequest,
{ params }: { params: Promise<{ id: 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 { id } = await params;
const tr = await getTenantRequestById(id);
if (!tr) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (tr.status !== "rejected" && tr.status !== "cancelled") {
return NextResponse.json(
{
error:
"Only rejected or cancelled requests can be dismissed. Active requests stay visible.",
code: "not_dismissable",
currentStatus: tr.status,
},
{ status: 409 }
);
}
try {
await dismissTenantRequest(id);
return NextResponse.json({ message: "Dismissed.", id });
} catch (e: any) {
console.error("Failed to dismiss request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to dismiss request") },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,207 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionUser, canMutate } from "@/lib/session";
import {
getTenantRequestById,
updateTenantRequestStatus,
updateTenantRequestEditableFields,
} from "@/lib/db";
import { encryptSecrets } from "@/lib/crypto";
import { onboardingSchema } from "@/lib/validation";
import { safeError } from "@/lib/errors";
/**
* Customer-side controls for a single tenant_request row.
*
* - DELETE /api/onboarding/[id] → cancel a still-pending request
* - PATCH /api/onboarding/[id] → edit fields of a still-pending
* request (Bug 6)
*
* Both endpoints share the same authorization check: the caller must
* be a customer owner (or platform staff) of the request's org. We
* also enforce status === 'pending' on the row — once an admin has
* acted on it, the customer can no longer mutate it from the portal.
*
* Reading these is via the existing GET /api/onboarding?id=... handler.
*/
async function loadAuthorized(
id: string
): Promise<
| { error: NextResponse }
| { req: Awaited<ReturnType<typeof getTenantRequestById>>; }
> {
const user = await getSessionUser();
if (!user) {
return {
error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
};
}
if (!canMutate(user)) {
return {
error: NextResponse.json({ error: "Forbidden" }, { status: 403 }),
};
}
const tr = await getTenantRequestById(id);
if (!tr) {
return {
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
// Customers may only read their own org's requests; platform users
// may read any. Same scope as `GET /api/onboarding?id=...`.
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
return {
error: NextResponse.json({ error: "Not found" }, { status: 404 }),
};
}
return { req: tr };
}
/**
* DELETE /api/onboarding/[id]
*
* Customer cancels a still-pending request. Status flips to 'cancelled';
* the row is preserved for audit. The customer can dismiss the
* cancelled card afterwards (Bug 13 reuse — same dismissal mechanism).
*
* Once admin has approved/provisioned/rejected, this endpoint refuses
* (409). Cancelling a tenant that's already running goes through the
* subscription-suspend flow on the tenant detail page, not here.
*/
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const loaded = await loadAuthorized(id);
if ("error" in loaded) return loaded.error;
const tr = loaded.req!;
if (tr.status !== "pending") {
return NextResponse.json(
{
error:
"Only pending requests can be cancelled. Approved or provisioning instances must be managed from the tenant page.",
code: "not_pending",
currentStatus: tr.status,
},
{ status: 409 }
);
}
try {
await updateTenantRequestStatus(id, "cancelled");
return NextResponse.json({ message: "Request cancelled.", id });
} catch (e: any) {
console.error("Failed to cancel request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to cancel request") },
{ status: 500 }
);
}
}
/**
* PATCH /api/onboarding/[id]
*
* Customer edits a still-pending request. Validation is the same as on
* POST /api/onboarding (shared schema). Only customer-input fields are
* editable; status/tenant_name/admin_notes/etc. are server-managed.
*
* Note on company-level fields
* ----------------------------
* For a follow-up instance (org has prior approved rows), the POST
* handler intentionally ignores the wizard's billingAddress and uses
* the on-file value instead. We mirror that here: company-level fields
* (companyName, contactName, contactEmail, billingAddress) on a
* follow-up edit are NOT updated through this endpoint. The customer
* should use a future settings page (Bug 11) for those. For now,
* editing only mutates per-instance fields — agent name, instance
* name, packages, soulMd, agentsMd, billingNotes, packageSecrets.
*
* For the FIRST instance (no prior approved rows), billingAddress IS
* editable here, since the customer is still defining their company's
* billing data.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const loaded = await loadAuthorized(id);
if ("error" in loaded) return loaded.error;
const tr = loaded.req!;
if (tr.status !== "pending") {
return NextResponse.json(
{
error: "Only pending requests can be edited.",
code: "not_pending",
currentStatus: tr.status,
},
{ status: 409 }
);
}
const body = await req.json().catch(() => null);
const parsed = onboardingSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
);
}
const input = parsed.data;
// Re-encrypt package secrets if present in the patch body. When the
// user re-opens the wizard to edit, the secrets array is populated
// afresh from the wizard (we never decrypt and return existing
// secrets — that'd be a security regression). If the user didn't
// touch any secret-bearing package, the wizard sends no
// packageSecrets and we leave the existing encrypted blob alone.
let encryptedSecrets: Buffer | null | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {
try {
encryptedSecrets = await encryptSecrets(input.packageSecrets);
} catch (e: any) {
console.error("Failed to encrypt package secrets:", e);
return NextResponse.json(
{ error: "Failed to secure credentials. Please try again." },
{ status: 500 }
);
}
}
// Only first-instance edits get billingAddress; follow-ups inherit
// company billing from the on-file approved row.
const isFirstInstance = !tr.tenantName; // approximation; covers the
// "no prior approved row for this org" case the POST handler treats
// identically. A more rigorous check would call
// getMostRecentApprovedRequestForOrg, but in practice an org with
// an approved row for some other tenant has a tenantName on those
// rows, not on the pending one being edited — so the simple check
// here is fine for the only state the endpoint accepts (pending).
try {
const updated = await updateTenantRequestEditableFields(id, {
instanceName: input.instanceName,
agentName: input.agentName,
soulMd: input.soulMd,
agentsMd: input.agentsMd,
packages: input.packages ?? [],
billingAddress: isFirstInstance ? input.billingAddress : undefined,
billingNotes: input.billingNotes,
encryptedSecrets,
});
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ message: "Request updated.", id });
} catch (e: any) {
console.error("Failed to edit request:", e);
return NextResponse.json(
{ error: safeError(e, "Failed to edit request") },
{ status: 500 }
);
}
}

View File

@@ -16,47 +16,41 @@ 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).
*/
/**
* Helper: shape a TenantRequest row for client consumption.
* Hides server-only fields (encryptedSecrets, internal db ids).
*
* Slice 7 / Bug 6: surfaces enough fields for the customer-side edit
* flow to pre-fill the wizard. soulMd, agentsMd, billingAddress,
* billingNotes were previously kept off the public shape because the
* pre-Slice-3 dashboard didn't render them. Edit needs them.
*
* Bug 13: surfaces dismissedAt so the dashboard can distinguish
* "freshly rejected, show prominently" from "rejected and acknowledged,
* keep hidden" without an extra API call.
*/
function publicRequestShape(r: TenantRequest) {
return {
id: r.id,
instanceName: r.instanceName,
agentName: r.agentName,
soulMd: r.soulMd,
agentsMd: r.agentsMd,
packages: r.packages,
billingAddress: r.billingAddress,
billingNotes: r.billingNotes,
status: r.status,
adminNotes: r.adminNotes,
tenantName: r.tenantName,
dismissedAt: r.dismissedAt ?? null,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
};
@@ -217,6 +211,31 @@ export async function POST(request: Request) {
// the org-name check should agree.)
const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName);
// Bug 5: personal accounts are 1-instance by design. If there's
// already an active tenant or an in-flight request for this user's
// org, reject the submission outright. Server-side only check;
// matching UI guards live on /dashboard (button hidden) and
// /dashboard/new (server-redirect to /dashboard).
if (isPersonal) {
const [allTenants, activeRequests] = await Promise.all([
listTenants(),
listActiveTenantRequestsByOrgId(user.orgId),
]);
const ownTenants = allTenants.filter(
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
);
if (ownTenants.length > 0 || activeRequests.length > 0) {
return NextResponse.json(
{
error:
"Personal accounts are limited to one instance. Cancel your existing request or contact support to change plan.",
code: "personal_account_at_capacity",
},
{ status: 403 }
);
}
}
// Encrypt package secrets if provided
let encryptedSecrets: Buffer | undefined;
if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) {

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { registerCustomer } from "@/lib/zitadel";
import { rateLimit } from "@/lib/rate-limit";
import { checkDuplicateDomain } from "@/lib/db";
import { generatePersonalOrgName } from "@/lib/personal-org";
import type { RegistrationInput } from "@/types";
import { z } from "zod";
@@ -13,11 +14,10 @@ import { z } from "zod";
* - `companyName` is no longer always required. It's required when
* `isPersonal` is false/absent, ignored when `isPersonal` is true.
* - `isPersonal` flag distinguishes personal accounts. The server
* derives the ZITADEL org name from `${givenName} ${familyName}
* (Personal)` for personals — the suffix is the canonical marker
* that downstream code (onboarding POST, admin views) uses to
* distinguish personal orgs from companies. Customers cannot rename
* their own org, so the suffix is stable.
* derives the ZITADEL org name from a generated opaque ID
* (`personal-{8hex}`) — see `lib/personal-org.ts` for the format
* spec. Customers cannot rename their own org, so the marker is
* stable.
* - Personal accounts skip the duplicate-domain check entirely. Their
* row is also excluded from future domain checks (see
* `lib/domain-check.ts::findDuplicateInDb`).
@@ -44,15 +44,6 @@ const registrationSchema = z
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 3_600_000; // 1 hour
/**
* Suffix appended to personal-account ZITADEL org names. Used here to
* build the org name and elsewhere (session.orgName check) to detect
* whether the current user is on a personal org.
*
* Keep this in sync with `isPersonalOrgName()` in `lib/personal-org.ts`.
*/
const PERSONAL_ORG_SUFFIX = " (Personal)";
export async function POST(request: NextRequest) {
// --- Rate limiting ---
const ip =
@@ -116,14 +107,13 @@ export async function POST(request: NextRequest) {
//
// For company: use the customer-supplied companyName (already
// validated to be present + ≥2 chars by the schema refinement).
// For personal: synthesise from full name + " (Personal)" suffix.
// The suffix is the canonical marker for personal orgs.
//
// ZITADEL does NOT enforce org-name uniqueness, so two "Hans Müller
// (Personal)" orgs can coexist; the org id is what matters for our
// labelling and lookups, the name is human-readable only.
// For personal: a fresh opaque ID like "personal-3f2a8b1c". The
// user's actual display name is per-user (`session.user.name`),
// so the GUI shows that instead — see `displayOrgNameFor()`.
// This keeps personal orgs collision-free (Bug 9: two people
// named "Eva Müller" both being able to register).
const orgName = isPersonal
? `${input.givenName.trim()} ${input.familyName.trim()}${PERSONAL_ORG_SUFFIX}`
? generatePersonalOrgName()
: input.companyName!.trim();
const result = await registerCustomer({

View File

@@ -53,6 +53,12 @@ export async function PATCH(
if (!isCustomerOwner(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts have no team roles to change." },
{ status: 403 }
);
}
const { userId } = await params;

View File

@@ -35,6 +35,16 @@ export async function POST(req: Request) {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{
error:
"Personal accounts cannot invite additional members. Upgrade to a company account to add a team.",
code: "personal_account",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = inviteSchema.safeParse(body);

View File

@@ -24,6 +24,12 @@ export async function GET() {
if (!canMutate(user)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (user.isPersonal) {
return NextResponse.json(
{ error: "Personal accounts do not have a team." },
{ status: 403 }
);
}
try {
const members = await getOrgMembers(user.orgId);

View File

@@ -128,6 +128,23 @@ export async function POST(
{ status: 500 }
);
}
// Bug 7 server-side counterpart: personal tenants are sole-owner
// by definition. Reject any assignment attempt — this matches the
// hidden panel on the detail page and stops a determined client
// (or platform user with a legacy unlabeled personal tenant) from
// creating spurious rows.
if (
tenant.metadata.labels?.["pieced.ch/personal"] === "true" ||
(!user.isPlatform && user.isPersonal)
) {
return NextResponse.json(
{
error: "Personal tenants do not support additional assignments.",
code: "personal_tenant",
},
{ status: 403 }
);
}
const body = await req.json().catch(() => null);
const parsed = assignSchema.safeParse(body);

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">
@@ -40,11 +45,14 @@ function NavBar() {
<NavLink href="/dashboard" active={pathname === "/dashboard"}>
{t("dashboard")}
</NavLink>
{/* Slice 7: /team is owner+platform only. Match server-side
gate (canMutate). The roles array carries either "owner"
or "user" for customer sessions; isPlatform covers the
platform side. */}
{/* Slice 7: /team is owner+platform only AND personal
accounts are excluded — they have no team to manage
(Bug 8). Match server-side gates (`canMutate`,
`user.isPersonal === false`). The roles array carries
either "owner" or "user" for customer sessions;
isPlatform covers the platform side. */}
{user &&
!user.isPersonal &&
(user.isPlatform ||
(Array.isArray(user.roles) && user.roles.includes("owner"))) && (
<NavLink href="/team" active={pathname === "/team"}>
@@ -62,8 +70,17 @@ function NavBar() {
{/* Right side */}
<div className="flex items-center gap-4">
{user && (
// For personal accounts the orgName is opaque
// ("personal-3f2a8b1c") or a synthetic legacy
// "Name (Personal)" — neither is what we want in the nav.
// Show the user's display name instead. The detection logic
// and fallback chain live in `lib/personal-org.ts`; keeping
// a thin inline branch here avoids importing a server-only
// helper into a client component.
<span className="hidden md:inline text-xs text-text-secondary font-mono">
{user.orgName}
{user.isPersonal
? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
: user.orgName}
</span>
)}
<LanguageSwitcher />

View File

@@ -5,6 +5,21 @@ import { OnboardingWizard } from "./wizard";
interface OnboardingFlowProps {
orgName: string;
/**
* The user's display name. Forwarded to the wizard so personal
* accounts can show the user's own name where they would otherwise
* see an opaque org name. Ignored for company accounts.
*/
userName?: string;
userEmail?: string;
/**
* Bug 6: when present, the wizard is rendered in edit mode against
* the given pending request. See `OnboardingWizard` for the full
* shape and behavioural contract.
*/
editingRequest?: React.ComponentProps<
typeof OnboardingWizard
>["editingRequest"];
}
/**
@@ -18,12 +33,20 @@ interface OnboardingFlowProps {
* level (which renders one `<ProvisioningStatus>` per pending request),
* so this wrapper does just one thing: show the wizard, then navigate.
*/
export function OnboardingFlow({ orgName }: OnboardingFlowProps) {
export function OnboardingFlow({
orgName,
userName,
userEmail,
editingRequest,
}: OnboardingFlowProps) {
const router = useRouter();
return (
<OnboardingWizard
orgName={orgName}
userName={userName}
userEmail={userEmail}
editingRequest={editingRequest}
onComplete={() => {
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and

View File

@@ -1,6 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations, useFormatter } from "next-intl";
import { Card } from "@/components/ui/card";
import { StatusBadge } from "@/components/ui/status-badge";
@@ -14,6 +16,7 @@ interface RequestSummary {
status: string;
adminNotes?: string;
tenantName?: string;
dismissedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
@@ -36,21 +39,42 @@ interface SingleRequestState {
tenant: TenantSummary | null;
}
interface Props {
requestId: string;
/**
* Whether the viewer can act on this request — cancel a pending one,
* dismiss a rejected one, etc. True for owner + platform; false for
* `user`-role customers (who shouldn't see in-flight requests at all,
* but defence in depth — `canSeeInflightRequests` already gates the
* dashboard side).
*/
canAct: boolean;
}
/**
* ProvisioningStatus
*
* Polls /api/onboarding?id=<requestId> every 5s until the request reaches
* a terminal state. Slice 3: takes a `requestId` prop so multiple of these
* can render on the same dashboard for different in-flight requests.
* a terminal state. Slice 3: takes a `requestId` prop so multiple of
* these can render on the same dashboard for different in-flight
* requests.
*
* The pre-Slice-3 version polled /api/onboarding with no params and
* assumed one-request-per-org — that endpoint shape is gone now.
* Slice 7 / Bug 6 + 13:
* - pending → cancel + edit buttons
* - rejected → admin notes block + dismiss button
* - cancelled → small acknowledgement card + dismiss button
* - terminal Ready/Active states unchanged
*/
export function ProvisioningStatus({ requestId }: { requestId: string }) {
export function ProvisioningStatus({ requestId, canAct }: Props) {
const t = useTranslations("onboarding");
const tCommon = useTranslations("common");
const f = useFormatter();
const router = useRouter();
const [data, setData] = useState<SingleRequestState | null>(null);
const [error, setError] = useState("");
const [actionPending, setActionPending] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const poll = useCallback(async () => {
try {
@@ -67,11 +91,11 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
useEffect(() => {
poll();
const status = data?.request?.status;
const phase = data?.tenant?.phase;
const terminal =
status === "rejected" ||
status === "cancelled" ||
status === "active" ||
phase === "Ready" ||
phase === "Running";
@@ -82,7 +106,54 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
return () => clearInterval(interval);
}, [poll, data?.request?.status, data?.tenant?.phase]);
if (error) {
const handleCancel = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("cancelFailed"));
}
setConfirmCancel(false);
// Re-poll so the card transitions to "cancelled" state without a
// full route refresh — the dashboard's surrounding tenant cards
// are unaffected.
await poll();
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
const handleDismiss = async () => {
setActionPending(true);
setError("");
try {
const res = await fetch(
`/api/onboarding/${encodeURIComponent(requestId)}/dismiss`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || t("dismissFailed"));
}
// Server-rendered list query (`listActiveTenantRequestsByOrgId`)
// filters out dismissed rows — refresh to drop this card.
router.refresh();
} catch (err: any) {
setError(err.message);
} finally {
setActionPending(false);
}
};
if (error && !data) {
return (
<Card>
<div className="text-xs text-red-400">{error}</div>
@@ -107,7 +178,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
data.request.tenantName ||
data.request.agentName;
// Pending admin approval
// ─── Pending: awaiting admin approval ───────────────────────────────
if (status === "pending") {
return (
<Card className="animate-in">
@@ -131,7 +202,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("pendingTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("pendingDescription")}
@@ -150,12 +223,76 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
</span>
</p>
)}
{/* Bug 6 — owner-only edit + cancel actions while still
pending. Once admin acts, both buttons disappear (the
status branch changes). */}
{canAct && (
<div className="flex justify-center gap-2 mt-5">
<Link
href={`/dashboard/edit/${encodeURIComponent(requestId)}`}
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("editRequest")}
</Link>
<button
type="button"
onClick={() => setConfirmCancel(true)}
className="text-sm font-medium px-4 py-2 rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
{t("cancelRequest")}
</button>
</div>
)}
{error && (
<p className="text-xs text-red-400 mt-3">{error}</p>
)}
</div>
{confirmCancel && (
<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) setConfirmCancel(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("cancelConfirmRequestTitle")}
</h3>
<p className="text-sm text-text-secondary mb-5">
{t("cancelConfirmRequestDescription")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmCancel(false)}
disabled={actionPending}
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={handleCancel}
disabled={actionPending}
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
>
{actionPending
? tCommon("loading")
: t("cancelRequestConfirm")}
</button>
</div>
</div>
</div>
)}
</Card>
);
}
// Rejected
// ─── Rejected: admin declined ───────────────────────────────────────
if (status === "rejected") {
return (
<Card className="animate-in">
@@ -179,22 +316,94 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("rejectedTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("rejectedDescription")}
</p>
{data.request.adminNotes && (
<p className="text-xs text-text-muted mt-3 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
<div className="text-left text-xs text-text-secondary mt-4 bg-surface-2 border border-border rounded-lg p-3 max-w-sm mx-auto">
<div className="font-semibold uppercase tracking-wider text-text-muted text-[10px] mb-1.5">
{t("rejectionReason")}
</div>
<div className="whitespace-pre-wrap">
{data.request.adminNotes}
</p>
</div>
</div>
)}
{/* Bug 13: dismiss removes this card from the dashboard but
keeps the row in the DB for audit. The customer can also
just resubmit via the wizard — both paths are valid. */}
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
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 disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// Provisioning in progress (status approved/provisioning, optionally with tenant phase < Ready)
// ─── Cancelled: customer cancelled before admin acted (Bug 6) ──────
if (status === "cancelled") {
return (
<Card className="animate-in">
<div className="text-center py-6">
<div className="h-14 w-14 rounded-xl bg-text-muted/15 flex items-center justify-center mx-auto mb-4">
<svg
className="h-7 w-7 text-text-muted"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="font-display text-lg font-semibold text-text-primary mb-2">
{t("cancelledTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto">
{t("cancelledDescription")}
</p>
{canAct && (
<div className="flex justify-center mt-5">
<button
type="button"
onClick={handleDismiss}
disabled={actionPending}
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 disabled:opacity-50"
>
{actionPending ? tCommon("loading") : t("dismiss")}
</button>
</div>
)}
{error && <p className="text-xs text-red-400 mt-3">{error}</p>}
</div>
</Card>
);
}
// ─── Provisioning: approved, operator working ──────────────────────
if (
status === "approved" ||
status === "provisioning" ||
@@ -213,7 +422,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("provisioningTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary">
{t("provisioningDescription")}
@@ -249,7 +460,7 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
);
}
// Active / Ready
// ─── Active / Ready ─────────────────────────────────────────────────
if (status === "active") {
return (
<Card className="animate-in">
@@ -273,7 +484,9 @@ export function ProvisioningStatus({ requestId }: { requestId: string }) {
{t("readyTitle")}
</h2>
{label && (
<p className="text-xs font-mono text-text-secondary mb-2">{label}</p>
<p className="text-xs font-mono text-text-secondary mb-2">
{label}
</p>
)}
<p className="text-sm text-text-secondary max-w-sm mx-auto mb-4">
{t("readyDescription")}

View File

@@ -4,7 +4,15 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Card } from "@/components/ui/card";
import { PACKAGE_CATALOG, type PackageDef } from "@/lib/packages";
import { isPersonalOrgName, PERSONAL_ORG_SUFFIX } from "@/lib/personal-org";
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";
@@ -48,31 +56,103 @@ const CATEGORIES = [
interface WizardProps {
orgName: string;
/**
* The user's display name. Used as the visible label for personal
* accounts (where `orgName` is an opaque ID like "personal-3f2a8b1c"
* or a synthetic legacy "{name} (Personal)" string). Ignored for
* company accounts.
*/
userName?: string;
userEmail?: string;
/**
* Bug 6: when present, the wizard renders in "edit" mode — fields
* are pre-populated from the request, the SOUL.md auto-fetch is
* skipped (we trust the existing values), and the submit button
* PATCHes /api/onboarding/[id] instead of POSTing /api/onboarding.
*
* Per-package secrets are deliberately NOT pre-filled, even if the
* customer originally supplied them — server-side decryption to
* the client would be a security regression. The user re-enters
* any secrets they want to change; if they leave them blank, the
* existing encrypted blob in the DB is preserved by the PATCH
* endpoint.
*/
editingRequest?: {
id: string;
instanceName: string;
agentName: string;
soulMd: string;
agentsMd: string;
packages: string[];
billingAddress: {
company?: string;
street?: string;
city?: string;
postalCode?: string;
country?: string;
};
billingNotes: string;
};
onComplete: () => void;
}
export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
export function OnboardingWizard({
orgName,
userName,
userEmail,
editingRequest,
onComplete,
}: WizardProps) {
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
const tCountries = useTranslations("countries");
// Slice 4: personal accounts have an org name of the form
// "{givenName} {familyName} (Personal)". For SOUL.md and the billing
// company line, strip the suffix so the visible string is the user's
// actual name (no stray "(Personal)" leaking onto invoices or into
// the assistant's prompt).
// Personal accounts have an org name that is either the legacy
// "{givenName} {familyName} (Personal)" or the current opaque
// "personal-{8hex}" form. Either way, the customer-facing display
// should be the user's own name — never the org name. SOUL.md
// interpolation and the billing form follow the same rule so
// invoices and prompts don't leak "(Personal)" or "personal-3f2a..".
const isPersonal = isPersonalOrgName(orgName);
const displayOrgName = isPersonal
? orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim()
: orgName;
const displayOrgName = displayOrgNameFor({
name: userName,
email: userEmail,
orgName,
isPersonal,
});
const isEditing = Boolean(editingRequest);
const [step, setStep] = useState<Step>("welcome");
// Edit mode jumps straight to the configure step — the welcome step
// is a first-time onboarding affordance and only adds friction when
// the customer is fixing a typo.
const [step, setStep] = useState<Step>(isEditing ? "configure" : "welcome");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
// In edit mode we already have soulMd/agentsMd from the request;
// skip the workspace-defaults round trip that would overwrite them.
const [defaultsLoaded, setDefaultsLoaded] = useState(isEditing);
const [config, setConfig] = useState({
const [config, setConfig] = useState(() => {
if (editingRequest) {
return {
instanceName: editingRequest.instanceName,
agentName: editingRequest.agentName,
soulMd: editingRequest.soulMd,
agentsMd: editingRequest.agentsMd,
packages: editingRequest.packages,
billingAddress: {
company: editingRequest.billingAddress.company ?? "",
street: editingRequest.billingAddress.street ?? "",
city: editingRequest.billingAddress.city ?? "",
postalCode: editingRequest.billingAddress.postalCode ?? "",
country: editingRequest.billingAddress.country ?? "CH",
},
billingNotes: editingRequest.billingNotes,
};
}
return {
instanceName: "",
agentName: "Assistant",
soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName),
@@ -89,6 +169,7 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
country: "CH",
},
billingNotes: "",
};
});
// TOOLS.md preview — readonly, auto-generated
@@ -142,11 +223,70 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
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]);
};
@@ -199,6 +339,17 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
};
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("");
@@ -212,8 +363,17 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
}
}
const res = await fetch("/api/onboarding", {
method: "POST",
// Bug 6: edit mode targets the per-row endpoint with PATCH;
// create mode targets the collection endpoint with POST. Body
// shape is the same — both routes parse it through
// onboardingSchema.
const url = editingRequest
? `/api/onboarding/${encodeURIComponent(editingRequest.id)}`
: "/api/onboarding";
const method = editingRequest ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...config,
@@ -339,19 +499,21 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</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">
@@ -618,6 +780,11 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</p>
<div className="space-y-4">
{/* 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")}
@@ -625,99 +792,119 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
<input
type="text"
value={config.billingAddress.company}
onChange={(e) =>
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">
<FieldWithError error={errors["billingAddress.city"]}>
<label className="block text-xs font-semibold uppercase tracking-wider text-text-muted mb-1.5">
{t("billingCity")}
{t("billingCity")} <RequiredMark />
</label>
<input
type="text"
required
value={config.billingAddress.city}
onChange={(e) =>
onChange={(e) => {
clearError("billingAddress.city");
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"
}));
}}
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">
@@ -765,25 +952,39 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
{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()}
<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>
</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}
)
}
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>
{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
@@ -794,38 +995,49 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</span>
))}
</div>
</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>
)}
{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">
<div>{config.billingAddress.street}</div>
<div>
{config.billingAddress.postalCode}{" "}
{config.billingAddress.city}
</span>
</div>
<div className="text-text-muted">
{tCountries(
config.billingAddress.country as SupportedCountry
)}
</div>
</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>
@@ -838,6 +1050,25 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</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}
@@ -850,7 +1081,11 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
disabled={submitting}
className="py-2.5 px-6 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? tCommon("loading") : t("submitRequest")}
{submitting
? tCommon("loading")
: isEditing
? t("saveChanges")
: t("submitRequest")}
</button>
</div>
</Card>
@@ -858,3 +1093,74 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
</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,44 @@
"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",
// Reconfiguring shares the warning palette (yellow pulse) but renders
// a distinct label, so customers see it differently from first-time
// provisioning. Useful when packages or channel-users change and the
// pod restarts mid-life.
Reconfiguring: "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 +49,10 @@ export function StatusBadge({ phase }: { phase: string }) {
{phase === "Provisioning" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{phase}
{phase === "Reconfiguring" && (
<span className="status-pulse h-1.5 w-1.5 rounded-full bg-warning" />
)}
{label}
</span>
);
}

View File

@@ -0,0 +1,118 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Tenant warning shape received from the operator's status.warnings.
* Mirror of the operator's `TenantWarning` type. See
* pieced-operator/api/v1alpha1/piecedtenant_types.go.
*/
export interface TenantWarning {
source: string;
reason?: string;
message?: string;
since?: string;
}
interface Props {
warnings: TenantWarning[];
}
/**
* Renders a small amber warning badge if there are any non-fatal
* warnings on the tenant. The badge sits visually next to the phase
* StatusBadge — they're separate concepts (phase = lifecycle, warnings
* = observed sub-issues) and may both be present at once (e.g. tenant
* is `Ready` but has a SkillPacksReady=False warning).
*
* Hover/focus reveals the warning detail. We don't truncate the message
* inside the tooltip; OCI/CRD condition messages tend to be short and
* include the actionable detail (which skill, which secret, which
* resolver). If a future warning source has a 5-line stacktrace as a
* message we'll need a different treatment; cross that bridge then.
*
* Returns null when there are no warnings — keep render-call sites
* simple, they don't have to gate on length themselves.
*/
export function WarningBadge({ warnings }: Props) {
const t = useTranslations("warnings");
if (!warnings || warnings.length === 0) return null;
const tooltipLabel = (() => {
try {
return warnings.length === 1
? t("oneTooltip")
: t("manyTooltip", { count: warnings.length });
} catch {
return warnings.length === 1
? "1 warning"
: `${warnings.length} warnings`;
}
})();
return (
<span className="relative group inline-flex">
<button
type="button"
// Button is non-actionable in itself — it exists purely to get
// keyboard focus for screen readers and keyboard users, so the
// tooltip isn't pointer-only. `aria-label` carries the summary;
// the full content is in the tooltip below for sighted users.
aria-label={tooltipLabel}
className="inline-flex items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-400 hover:bg-amber-500/20 focus:outline-none focus:ring-1 focus:ring-amber-400 cursor-help"
// No onClick — this is informational, not actionable. Pure
// hover/focus widget. tabIndex defaults to 0 for buttons.
>
<svg
viewBox="0 0 24 24"
width={12}
height={12}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
</svg>
<span>{warnings.length}</span>
</button>
{/*
Tooltip. Hidden by default; shown on hover OR focus of the
sibling button. Positioned below-right so it doesn't collide with
the StatusBadge that typically sits left of this. Constrained
width so long messages wrap.
z-50 keeps it above table rows / cards.
*/}
<div
role="tooltip"
className="invisible group-hover:visible group-focus-within:visible absolute left-0 top-full mt-1 z-50 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-lg text-left"
>
<div className="text-[10px] uppercase tracking-wider text-text-muted mb-2">
{tooltipLabel}
</div>
<ul className="space-y-2">
{warnings.map((w, i) => (
<li key={i} className="text-xs">
<div className="font-mono text-amber-400 break-all">
{w.source}
</div>
{w.reason && (
<div className="text-text-secondary">{w.reason}</div>
)}
{w.message && (
<div className="text-text-secondary mt-0.5 break-words">
{w.message}
</div>
)}
</li>
))}
</ul>
</div>
</span>
);
}

View File

@@ -1,6 +1,7 @@
import NextAuth from "next-auth";
import type { NextAuthConfig } from "next-auth";
import type { PlatformRole, Role, SessionUser, ZitadelClaims } from "@/types";
import { isPersonalOrgName } from "@/lib/personal-org";
const PLATFORM_ROLES: PlatformRole[] = ["platform_admin", "platform_operator"];
@@ -57,21 +58,42 @@ export const authConfig: NextAuthConfig = {
claims["urn:zitadel:iam:org:project:roles"]
);
token.accessToken = account.access_token;
// 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
// becomes session.user.id everywhere downstream — including
// `tenant_user_assignments.assigned_by` and (more importantly)
// the WHERE clause used to look up the invited user's
// assignments on the dashboard. With a UUID in the session and
// a ZITADEL snowflake in the DB, the lookup matches nothing
// and assigned tenants never appear (Bug 27).
//
// Reference: https://github.com/nextauthjs/next-auth/issues/11174
// Auth.js respects an explicit token.sub assignment; the
// override below is preserved across subsequent jwt() calls.
if (typeof profile.sub === "string") {
token.sub = profile.sub;
}
}
return token;
},
async session({ session, token }) {
const roles = (token.roles as Role[]) ?? [];
const orgName = (token.orgName as string) ?? "";
const sessionUser: SessionUser = {
id: token.sub!,
name: session.user?.name ?? "",
email: session.user?.email ?? "",
orgId: token.orgId as string,
orgName: token.orgName as string,
orgName,
roles,
isPlatform: roles.some((r) =>
PLATFORM_ROLES.includes(r as PlatformRole)
),
// Derived from orgName — see lib/personal-org.ts. Recognises
// both legacy " (Personal)" suffix and current "personal-{8hex}"
// opaque names.
isPersonal: isPersonalOrgName(orgName),
};
(session as any).platformUser = sessionUser;
return session;

View File

@@ -1,5 +1,5 @@
import { Pool } from "pg";
import type { TenantRequest, TenantRequestStatus } from "@/types";
import type { BillingAddress, TenantRequest, TenantRequestStatus } from "@/types";
import { listTenants, getTenant } from "./k8s";
// ---------------------------------------------------------------------------
@@ -72,6 +72,11 @@ const MIGRATION_SQL = `
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE;
-- Bug 13: customer-side dismissal of rejected requests. NULL means "still
-- visible on the dashboard"; non-null means "customer clicked Dismiss".
-- Pending/approved/active rows keep this NULL by definition — the field
-- is only meaningful for rejected and cancelled rows.
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS dismissed_at TIMESTAMPTZ;
-- Slice 3: drop the legacy 1-org-1-request constraint if it exists
ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key;
@@ -250,10 +255,21 @@ export async function listTenantRequestsByOrgId(
}
/**
* As {@link listTenantRequestsByOrgId} but excludes terminal-failed states
* (rejected, deleted). Useful for the dashboard which wants to show
* pending/approved/provisioning/active tenants and pending requests, not
* historical rejections.
* As {@link listTenantRequestsByOrgId} but tuned for the customer's
* dashboard view.
*
* Returns:
* - All non-terminal rows (pending, approved, provisioning, active),
* because the customer needs to see what's in flight.
* - Terminal-failed rows (rejected, cancelled) that the customer
* hasn't dismissed yet (Bug 13). Without this, a rejection that
* happens while the customer isn't online would only be
* communicated by email — easy to miss.
*
* Excludes:
* - `deleted` rows (admin tore down the tenant — historical, not
* actionable).
* - Dismissed rejected/cancelled rows.
*/
export async function listActiveTenantRequestsByOrgId(
orgId: string
@@ -262,7 +278,8 @@ export async function listActiveTenantRequestsByOrgId(
const result = await getPool().query<TenantRequest>(
`SELECT * FROM tenant_requests
WHERE zitadel_org_id = $1
AND status NOT IN ('deleted', 'rejected')
AND status <> 'deleted'
AND (status NOT IN ('rejected', 'cancelled') OR dismissed_at IS NULL)
ORDER BY created_at DESC`,
[orgId]
);
@@ -354,6 +371,96 @@ export async function clearEncryptedSecrets(requestId: string): Promise<void> {
);
}
/**
* Set dismissed_at = now() on a request row. Used when a customer
* clicks "Dismiss" on a rejected/cancelled card on their dashboard
* (Bug 13). The row stays in the database for history/audit but
* stops appearing in `listActiveTenantRequestsByOrgId`.
*
* Idempotent: dismissing an already-dismissed row is a no-op.
* Caller is responsible for verifying the row belongs to the user's
* org before calling.
*/
export async function dismissTenantRequest(id: string): Promise<void> {
await ensureSchema();
await getPool().query(
`UPDATE tenant_requests
SET dismissed_at = COALESCE(dismissed_at, now()),
updated_at = now()
WHERE id = $1`,
[id]
);
}
/**
* Update editable fields of a still-pending tenant request. Bug 6 — a
* customer who notices a typo or wants to add a package after submitting
* the wizard should be able to fix it without admin involvement.
*
* Only the customer-input fields are updateable. `status`, `tenant_name`,
* `admin_notes`, `encrypted_secrets`, `is_personal`, `zitadel_*` and
* timestamps are managed elsewhere and intentionally not here.
*
* The caller is responsible for:
* - verifying the row belongs to the user's org
* - verifying status === 'pending' (editing approved/provisioning rows
* would race against the operator)
*
* Returns the updated row, or null if the id didn't match anything.
*/
export async function updateTenantRequestEditableFields(
id: string,
fields: {
instanceName?: string | null;
agentName?: string;
soulMd?: string;
agentsMd?: string | null;
packages?: string[];
billingAddress?: BillingAddress;
billingNotes?: string;
encryptedSecrets?: Buffer | null;
}
): Promise<TenantRequest | null> {
await ensureSchema();
const sets: string[] = ["updated_at = now()"];
const values: any[] = [id];
let idx = 2;
// Map JS field names to SQL columns. Each entry is gated on
// `!== undefined` so passing only some fields just updates those.
const colMap: Array<[keyof typeof fields, string]> = [
["instanceName", "instance_name"],
["agentName", "agent_name"],
["soulMd", "soul_md"],
["agentsMd", "agents_md"],
["packages", "packages"],
["billingAddress", "billing_address"],
["billingNotes", "billing_notes"],
["encryptedSecrets", "encrypted_secrets"],
];
for (const [jsField, sqlCol] of colMap) {
const v = fields[jsField];
if (v === undefined) continue;
sets.push(`${sqlCol} = $${idx}`);
values.push(v);
idx++;
}
if (sets.length === 1) {
// No editable fields supplied — return the row unchanged rather
// than running a useless UPDATE that just bumps updated_at.
const cur = await getTenantRequestById(id);
return cur;
}
const result = await getPool().query<TenantRequest>(
`UPDATE tenant_requests SET ${sets.join(", ")} WHERE id = $1 RETURNING *`,
values
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
/**
* Wrapper around domain-check.ts that injects the portal's connection pool.
* Kept here so route handlers don't need direct access to the pool.
@@ -446,6 +553,8 @@ function mapRow(row: any): TenantRequest {
tenantName: row.tenant_name,
encryptedSecrets: row.encrypted_secrets ?? null,
isPersonal: row.is_personal ?? false,
dismissedAt:
row.dismissed_at?.toISOString?.() ?? row.dismissed_at ?? null,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};

View File

@@ -1,40 +1,147 @@
/**
* Personal-account helpers.
*
* Slice 4 establishes the convention that ZITADEL org names for personal
* accounts end with the literal " (Personal)" suffix. This file
* centralises the suffix and the predicate so both registration (which
* sets the suffix) and onboarding (which reads it from the session) use
* the same canonical form.
* Two ZITADEL org-name formats may identify a personal account:
*
* Why a name suffix and not ZITADEL org metadata?
* -----------------------------------------------
* 1. The suffix is visible in ZITADEL Console, admin tools, JWT claims,
* etc. — useful debugging signal at zero cost.
* 2. Customers cannot rename their own org (requires IAM_OWNER, which
* only the SA holds), so the suffix is stable for the lifetime of
* the org.
* 3. No extra ZITADEL API calls at onboarding time to fetch metadata.
* 4. No extra portal DB tables.
* 1. Legacy (Slice 4 .. 7-pre-Bug9):
* "{givenName} {familyName} (Personal)"
* Embedded the user's name in the org name. Hit a uniqueness
* collision on common Swiss names (Bug 9: two people named "Eva
* Müller" can't both register). Suffix is detected via
* `PERSONAL_ORG_SUFFIX`.
*
* The trade-off: an admin who manually renames a personal org via
* ZITADEL Console could remove the suffix, after which onboarding
* would treat that org as a company. That's a deliberate destructive
* action and the worst outcome is a misnamed K8s CR; nothing breaks.
* 2. Current (Slice 7+):
* "personal-{8 hex chars}"
* Opaque, structurally collision-free, no PII. The user's display
* name lives only in the per-user fields (`session.user.name`),
* which is what the GUI shows wherever it would otherwise have
* shown the org name. See `displayOrgNameFor()` below.
*
* Both formats are recognised as personal by `isPersonalOrgName()`.
* Existing legacy orgs continue to work; new orgs are created in the
* opaque format.
*
* Why a name pattern and not ZITADEL org metadata?
* ------------------------------------------------
* - Visible in ZITADEL Console, JWT claims, admin tools — useful debug
* signal at zero cost.
* - Customers cannot rename their own org (requires IAM_OWNER, which
* only the SA holds), so the marker is stable for the life of the
* org.
* - No extra ZITADEL API calls at onboarding time.
* - No extra portal DB tables.
*
* Trade-off: an admin who manually renames a personal org via Console
* could remove the marker. That's a deliberate destructive action; the
* worst outcome is a misnamed K8s CR. Nothing breaks.
*/
/** Suffix used by the legacy " (Personal)" naming scheme. */
export const PERSONAL_ORG_SUFFIX = " (Personal)";
/**
* Pattern for the current opaque-id naming scheme. The hex chunk is
* generated from `crypto.randomUUID()` — eight hex digits give 4 billion
* distinct values, far more than the pilot will ever need, while
* keeping the org name short and copy-pasteable.
*/
const PERSONAL_ORG_OPAQUE_RE = /^personal-[0-9a-f]{8}$/;
/**
* Generate a fresh opaque org name for a personal account.
*
* The result is uniformly random in the form "personal-XXXXXXXX". Caller
* doesn't need a duplicate check — at 4e9 cardinality the birthday
* collision probability is negligible at pilot scale, and ZITADEL would
* reject a duplicate creation with a clean error which we let surface.
*
* `crypto.randomUUID()` is used because it's available natively in
* Node 20+ and edge runtimes. We slice the hex digits we need from
* the UUID rather than calling a separate randomBytes API; the result
* is the same.
*/
export function generatePersonalOrgName(): string {
const uuid = crypto.randomUUID(); // 8-4-4-4-12 hex digits
const hex = uuid.replace(/-/g, "").slice(0, 8);
return `personal-${hex}`;
}
/**
* Returns true when the given ZITADEL org name marks a personal account.
*
* The check is exact-suffix match (after trimming). Whitespace inside
* the suffix is significant — `" (personal)"` lowercase or `"(Personal)"`
* without the leading space are not matches and not personal orgs.
* Recognises both the legacy " (Personal)" suffix and the current
* "personal-{8hex}" opaque form. Whitespace inside the legacy suffix is
* significant — `" (personal)"` lowercase or `"(Personal)"` without the
* leading space are NOT matches and are treated as company orgs.
*
* Pass `session.orgName` from the SessionUser at the call site.
*/
export function isPersonalOrgName(orgName: string | null | undefined): boolean {
export function isPersonalOrgName(
orgName: string | null | undefined
): boolean {
if (!orgName) return false;
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
const trimmed = orgName.trimEnd();
if (PERSONAL_ORG_OPAQUE_RE.test(trimmed)) return true;
if (trimmed.endsWith(PERSONAL_ORG_SUFFIX)) return true;
return false;
}
/**
* The label to show wherever the GUI would otherwise show the user's
* org name. For company accounts this is the org name; for personal
* accounts the org name itself is opaque (or a synthetic legacy
* "Name (Personal)" string), so we substitute the user's display name.
*
* Use this anywhere a customer-facing string would render the
* organisation: nav header, billing forms, SOUL.md interpolation, etc.
*/
export function displayOrgNameFor(user: {
name?: string | null;
email?: string | null;
orgName?: string | null;
isPersonal?: boolean;
}): string {
const orgName = user.orgName ?? "";
// Defensive: if `isPersonal` wasn't set on the session (older sessions
// pre-Slice-7-Bug-9), fall back to detecting from the name itself.
const personal = user.isPersonal ?? isPersonalOrgName(orgName);
if (!personal) return orgName;
// Legacy legacy "Name (Personal)" — strip the suffix and use what's
// left as a sensible display, since it's already the user's name.
if (orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX)) {
return orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim();
}
// New opaque form — show the user's display name. Fall back to email
// local-part if no display name is available, which is rare but
// possible during the brief window between user creation and the
// user setting their profile.
if (user.name && user.name.trim().length > 0) return user.name.trim();
if (user.email) return user.email.split("@")[0];
return orgName;
}
/**
* One-instance-per-account rule for personal accounts (Bug 5).
*
* Personal accounts are 1-instance by design: a single user, a single
* tenant. After the first tenant or in-flight request exists, the
* customer is over quota and any further onboarding submission must
* be blocked. Company accounts are unaffected.
*
* `tenantCount` and `requestCount` are measured against the customer's
* own org — caller is responsible for filtering before passing them
* in. Both values are non-negative integers; the predicate is true
* iff at least one of them is > 0.
*
* Used by the dashboard (hide the "+ Create new instance" button),
* /dashboard/new (server-redirect), and /api/onboarding (return 403).
* Keeping the rule in one place avoids three separate copies of the
* same boolean drifting apart.
*/
export function personalAccountAtCapacity(
isPersonal: boolean,
tenantCount: number,
requestCount: number
): boolean {
return isPersonal && (tenantCount > 0 || requestCount > 0);
}

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,27 @@
"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",
"editRequestTitle": "Anfrage bearbeiten",
"editRequestDescription": "Passen Sie die Konfiguration an, bevor unser Team sie prüft.",
"editRequest": "Bearbeiten",
"cancelRequest": "Anfrage stornieren",
"cancelRequestConfirm": "Ja, Anfrage stornieren",
"cancelConfirmRequestTitle": "Diese Anfrage stornieren?",
"cancelConfirmRequestDescription": "Ihre ausstehende Anfrage wird als storniert markiert und aus der Warteschlange entfernt. Sie können jederzeit eine neue Anfrage einreichen.",
"cancelFailed": "Anfrage konnte nicht storniert werden.",
"cancelledTitle": "Anfrage storniert",
"cancelledDescription": "Sie haben diese Anfrage vor der Bearbeitung storniert. Es wurde keine Instanz erstellt.",
"dismiss": "Ausblenden",
"dismissFailed": "Konnte nicht ausgeblendet werden.",
"rejectionReason": "Angegebener Grund",
"saveChanges": "Änderungen speichern"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +143,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 +343,27 @@
"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",
"Reconfiguring": "Wird neu konfiguriert"
},
"warnings": {
"oneTooltip": "1 Warnung",
"manyTooltip": "{count} Warnungen"
}
}

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,27 @@
"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",
"editRequestTitle": "Edit your request",
"editRequestDescription": "Adjust the configuration before our team reviews it.",
"editRequest": "Edit",
"cancelRequest": "Cancel request",
"cancelRequestConfirm": "Yes, cancel request",
"cancelConfirmRequestTitle": "Cancel this request?",
"cancelConfirmRequestDescription": "Your pending request will be marked as cancelled and removed from the review queue. You can submit a new request at any time.",
"cancelFailed": "Could not cancel request.",
"cancelledTitle": "Request cancelled",
"cancelledDescription": "You cancelled this request before it was processed. No instance was created.",
"dismiss": "Dismiss",
"dismissFailed": "Could not dismiss.",
"rejectionReason": "Reason given",
"saveChanges": "Save changes"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +143,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 +343,27 @@
"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",
"Reconfiguring": "Reconfiguring"
},
"warnings": {
"oneTooltip": "1 warning",
"manyTooltip": "{count} warnings"
}
}

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,27 @@
"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",
"editRequestTitle": "Modifier votre demande",
"editRequestDescription": "Ajustez la configuration avant que notre équipe ne l'examine.",
"editRequest": "Modifier",
"cancelRequest": "Annuler la demande",
"cancelRequestConfirm": "Oui, annuler la demande",
"cancelConfirmRequestTitle": "Annuler cette demande ?",
"cancelConfirmRequestDescription": "Votre demande en attente sera marquée comme annulée et retirée de la file. Vous pouvez soumettre une nouvelle demande à tout moment.",
"cancelFailed": "Impossible d'annuler la demande.",
"cancelledTitle": "Demande annulée",
"cancelledDescription": "Vous avez annulé cette demande avant son traitement. Aucune instance n'a été créée.",
"dismiss": "Masquer",
"dismissFailed": "Impossible de masquer.",
"rejectionReason": "Motif indiqué",
"saveChanges": "Enregistrer les modifications"
},
"dashboard": {
"title": "Tableau de bord",
@@ -118,7 +143,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 +343,27 @@
"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",
"Reconfiguring": "Reconfiguration"
},
"warnings": {
"oneTooltip": "1 avertissement",
"manyTooltip": "{count} avertissements"
}
}

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,27 @@
"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",
"editRequestTitle": "Modifica la sua richiesta",
"editRequestDescription": "Modifichi la configurazione prima che il nostro team la esamini.",
"editRequest": "Modifica",
"cancelRequest": "Annulla richiesta",
"cancelRequestConfirm": "Sì, annulla la richiesta",
"cancelConfirmRequestTitle": "Annullare questa richiesta?",
"cancelConfirmRequestDescription": "La sua richiesta in attesa sarà contrassegnata come annullata e rimossa dalla coda di revisione. Può inviare una nuova richiesta in qualsiasi momento.",
"cancelFailed": "Impossibile annullare la richiesta.",
"cancelledTitle": "Richiesta annullata",
"cancelledDescription": "Lei ha annullato questa richiesta prima dell'elaborazione. Nessuna istanza è stata creata.",
"dismiss": "Nascondi",
"dismissFailed": "Impossibile nascondere.",
"rejectionReason": "Motivo indicato",
"saveChanges": "Salva modifiche"
},
"dashboard": {
"title": "Dashboard",
@@ -118,7 +143,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 +343,27 @@
"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",
"Reconfiguring": "Riconfigurazione"
},
"warnings": {
"oneTooltip": "1 avviso",
"manyTooltip": "{count} avvisi"
}
}

View File

@@ -47,6 +47,23 @@ export interface SessionUser {
orgName: string;
roles: Role[];
isPlatform: boolean;
/**
* True when the user's ZITADEL org is a personal account — i.e. a
* single-user org provisioned by the registration flow with
* `isPersonal: true`. Derived from `orgName` in the session callback;
* see `lib/personal-org.ts::isPersonalOrgName` for the detection
* rules (recognises both the legacy " (Personal)" suffix and the
* current "personal-{8hex}" opaque form).
*
* Drives several customer-facing behaviours:
* - /team page is hidden (Bug 8): there's no team to manage.
* - "Create new instance" is gated to a single tenant + request
* (Bug 5): personal accounts are 1-instance by design.
* - The assigned-users panel on /tenants/[name] is hidden (Bug 7).
* - Wherever the GUI would otherwise show `orgName`, it shows the
* user's display name instead (Bug 9 — the org name is opaque).
*/
isPersonal: boolean;
}
// PiecedTenant CR (pieced.ch/v1alpha1)
@@ -61,7 +78,15 @@ export interface PiecedTenantSpec {
}
export interface PiecedTenantStatus {
phase: "Pending" | "Provisioning" | "Running" | "Ready" | "Error" | "Deleting";
phase:
| "Pending"
| "Provisioning"
| "Running"
| "Ready"
| "Reconfiguring"
| "Suspended"
| "Error"
| "Deleting";
message?: string;
observedGeneration?: number;
/**
@@ -78,6 +103,21 @@ export interface PiecedTenantStatus {
litellmKeyAlias?: string;
tenantNamespace?: string;
enabledPackages?: string[];
/**
* Non-fatal issues from downstream resources surfaced by the operator
* (e.g. an OpenClawInstance sub-condition reporting failure). The
* tenant is still usable — these are informational, rendered as a
* warning badge alongside the phase.
*
* `source` is "<Kind>/<ConditionType>" e.g. "OpenClawInstance/SkillPacksReady".
* `message` is shown in the tooltip when the user hovers the badge.
*/
warnings?: Array<{
source: string;
reason?: string;
message?: string;
since?: string;
}>;
conditions?: Array<{
type: string;
status: string;
@@ -112,8 +152,8 @@ export interface UsageSummary {
export interface RegistrationInput {
/**
* Required for company registrations. Ignored when `isPersonal` is true —
* the server then derives the ZITADEL org name from the user's full name
* with a "(Personal)" suffix.
* the server then generates an opaque ZITADEL org name of the form
* `personal-{8hex}` (see `lib/personal-org.ts::generatePersonalOrgName`).
*/
companyName?: string;
givenName: string;
@@ -121,10 +161,11 @@ export interface RegistrationInput {
email: string;
preferredLanguage?: string;
/**
* Slice 4: when true, registration creates a personal account (one
* person, no company). Domain-uniqueness check is skipped, ZITADEL org
* is named "{givenName} {familyName} (Personal)", subsequent tenants
* are named with the `p-{requestId[:8]}` convention.
* Slice 4 + Bug 9: when true, registration creates a personal account
* (one person, no company). Domain-uniqueness check is skipped, the
* ZITADEL org is named `personal-{8hex}` (opaque, collision-free),
* the user's display name lives only on the user record, and
* subsequent tenants are named with the `p-{requestId[:8]}` convention.
*/
isPersonal?: boolean;
}
@@ -144,6 +185,7 @@ export type TenantRequestStatus =
| "provisioning" // PiecedTenant CR created, operator reconciling
| "active" // Tenant running
| "rejected" // Admin rejected
| "cancelled" // Customer cancelled before admin acted on it (Bug 6)
| "deleted"; // Tenant was deleted by admin
export interface TenantRequest {
@@ -177,6 +219,14 @@ export interface TenantRequest {
* domain-uniqueness check on subsequent registrations.
*/
isPersonal?: boolean;
/**
* Bug 13: when set, the customer has explicitly dismissed a rejected
* request from their dashboard. Used by `listActiveTenantRequestsByOrgId`
* to keep showing rejected rows until they're dismissed (so a customer
* who wasn't online when the rejection happened still sees it on next
* login). Always null for non-rejected statuses.
*/
dismissedAt?: string | null;
createdAt: string;
updatedAt: string;
}