From eeef108f7ebdce7ebf33814985753e8c01621a43 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 29 Apr 2026 15:43:12 +0200 Subject: [PATCH] Group B fixes --- src/app/[locale]/dashboard/new/page.tsx | 32 +++- src/app/[locale]/dashboard/page.tsx | 19 ++- src/app/[locale]/register/page.tsx | 12 +- src/app/[locale]/team/page.tsx | 6 + src/app/[locale]/tenants/[name]/page.tsx | 30 +++- .../api/admin/requests/[id]/approve/route.ts | 9 ++ src/app/api/onboarding/route.ts | 25 +++ src/app/api/register/route.ts | 32 ++-- src/app/api/team/[userId]/role/route.ts | 6 + src/app/api/team/invite/route.ts | 10 ++ src/app/api/team/route.ts | 6 + .../api/tenants/[name]/assignments/route.ts | 17 ++ src/components/layout/nav-shell.tsx | 22 ++- src/components/onboarding/onboarding-flow.tsx | 15 +- src/components/onboarding/wizard.tsx | 37 +++-- src/lib/auth.ts | 8 +- src/lib/personal-org.ts | 153 +++++++++++++++--- src/types/index.ts | 30 +++- 18 files changed, 387 insertions(+), 82 deletions(-) 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; }