diff --git a/src/app/[locale]/dashboard/new/page.tsx b/src/app/[locale]/dashboard/new/page.tsx
index 1f1fd46..77c4129 100644
--- a/src/app/[locale]/dashboard/new/page.tsx
+++ b/src/app/[locale]/dashboard/new/page.tsx
@@ -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() {
-
+
);
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx
index bbe866d..bc3105e 100644
--- a/src/app/[locale]/dashboard/page.tsx
+++ b/src/app/[locale]/dashboard/page.tsx
@@ -8,6 +8,7 @@ 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 { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
@@ -179,7 +180,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 +273,11 @@ export default async function DashboardPage() {
-
+
);
diff --git a/src/app/[locale]/register/page.tsx b/src/app/[locale]/register/page.tsx
index 01955d8..6c1024c 100644
--- a/src/app/[locale]/register/page.tsx
+++ b/src/app/[locale]/register/page.tsx
@@ -8,11 +8,13 @@ import { Card } from "@/components/ui/card";
type FormState = "idle" | "submitting" | "success" | "error";
/**
- * Slice 4: a "Register as individual" toggle distinguishes personal
- * accounts from company registrations. When the toggle is on:
+ * Slice 4 + Bug 9: a "Register as individual" toggle distinguishes
+ * personal accounts from company registrations. When the toggle is on:
* - the company name field is hidden (and not sent)
* - the server skips the duplicate-domain check
- * - the ZITADEL org is named "{givenName} {familyName} (Personal)"
+ * - the ZITADEL org is named `personal-{8hex}` (opaque, collision-free)
+ * - the user's display name lives only on the user record; the GUI
+ * shows it instead of the opaque org name everywhere
*/
export default function RegisterPage() {
const t = useTranslations("register");
@@ -40,8 +42,8 @@ export default function RegisterPage() {
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 = {
givenName: form.givenName,
familyName: form.familyName,
diff --git a/src/app/[locale]/team/page.tsx b/src/app/[locale]/team/page.tsx
index c8d4b41..0de3425 100644
--- a/src/app/[locale]/team/page.tsx
+++ b/src/app/[locale]/team/page.tsx
@@ -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");
diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index 3ed7415..ce2224e 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -40,6 +40,19 @@ export default async function TenantDetailPage({
// the same page but with edit controls hidden / fields read-only.
const canEdit = canMutate(user);
+ // 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) =>
@@ -132,13 +145,16 @@ 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. */}
-
-
- {t("assignedUsers")}
-
-
-
+ fetches its own data so the page doesn't need to await.
+ Bug 7: hidden entirely for personal tenants. */}
+ {!isPersonalTenant && (
+
+
+ {t("assignedUsers")}
+
+
+
+ )}
);
}
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts
index 29d42e0..7f52ebc 100644
--- a/src/app/api/admin/requests/[id]/approve/route.ts
+++ b/src/app/api/admin/requests/[id]/approve/route.ts
@@ -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" }
+ : {}),
}
);
diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts
index 717b683..4c5ee40 100644
--- a/src/app/api/onboarding/route.ts
+++ b/src/app/api/onboarding/route.ts
@@ -217,6 +217,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) {
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index e7c2af7..c1b6ba5 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -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({
diff --git a/src/app/api/team/[userId]/role/route.ts b/src/app/api/team/[userId]/role/route.ts
index 2372ac0..6b2c334 100644
--- a/src/app/api/team/[userId]/role/route.ts
+++ b/src/app/api/team/[userId]/role/route.ts
@@ -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;
diff --git a/src/app/api/team/invite/route.ts b/src/app/api/team/invite/route.ts
index 8e7504e..2a63bb7 100644
--- a/src/app/api/team/invite/route.ts
+++ b/src/app/api/team/invite/route.ts
@@ -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);
diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts
index da2d643..1300c15 100644
--- a/src/app/api/team/route.ts
+++ b/src/app/api/team/route.ts
@@ -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);
diff --git a/src/app/api/tenants/[name]/assignments/route.ts b/src/app/api/tenants/[name]/assignments/route.ts
index e6de5a4..0f3be21 100644
--- a/src/app/api/tenants/[name]/assignments/route.ts
+++ b/src/app/api/tenants/[name]/assignments/route.ts
@@ -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);
diff --git a/src/components/layout/nav-shell.tsx b/src/components/layout/nav-shell.tsx
index 79508a8..ba05c3c 100644
--- a/src/components/layout/nav-shell.tsx
+++ b/src/components/layout/nav-shell.tsx
@@ -40,11 +40,14 @@ function NavBar() {
{t("dashboard")}
- {/* 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"))) && (
@@ -62,8 +65,17 @@ function NavBar() {
{/* Right side */}
{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.
- {user.orgName}
+ {user.isPersonal
+ ? user.name || (user.email ? user.email.split("@")[0] : user.orgName)
+ : user.orgName}
)}
diff --git a/src/components/onboarding/onboarding-flow.tsx b/src/components/onboarding/onboarding-flow.tsx
index b5892a6..a086156 100644
--- a/src/components/onboarding/onboarding-flow.tsx
+++ b/src/components/onboarding/onboarding-flow.tsx
@@ -5,6 +5,13 @@ 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;
}
/**
@@ -18,12 +25,18 @@ interface OnboardingFlowProps {
* level (which renders one `
` 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,
+}: OnboardingFlowProps) {
const router = useRouter();
return (
{
// Navigate back to /dashboard and re-fetch on the server. The
// parent server component will see the new `pending` row and
diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx
index bcc9f5d..39d026f 100644
--- a/src/components/onboarding/wizard.tsx
+++ b/src/components/onboarding/wizard.tsx
@@ -4,7 +4,7 @@ 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";
type Step = "welcome" | "configure" | "billing" | "confirm";
@@ -48,23 +48,40 @@ 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;
onComplete: () => void;
}
-export function OnboardingWizard({ orgName, onComplete }: WizardProps) {
+export function OnboardingWizard({
+ orgName,
+ userName,
+ userEmail,
+ onComplete,
+}: WizardProps) {
const t = useTranslations("onboarding");
const tPkg = useTranslations("packages");
const tCommon = useTranslations("common");
- // 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 [step, setStep] = useState("welcome");
const [submitting, setSubmitting] = useState(false);
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 3fc2c3c..b80ec04 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -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"];
@@ -78,16 +79,21 @@ export const authConfig: NextAuthConfig = {
},
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;
diff --git a/src/lib/personal-org.ts b/src/lib/personal-org.ts
index 520b3af..09390e0 100644
--- a/src/lib/personal-org.ts
+++ b/src/lib/personal-org.ts
@@ -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);
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 4287dd6..b071be9 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -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)
@@ -112,8 +129,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 +138,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;
}