diff --git a/scripts/verify-personal-org.mjs b/scripts/verify-personal-org.mjs new file mode 100644 index 0000000..288543c --- /dev/null +++ b/scripts/verify-personal-org.mjs @@ -0,0 +1,32 @@ +// Standalone JS port of `lib/personal-org.ts::isPersonalOrgName` +// for offline verification. + +const PERSONAL_ORG_SUFFIX = " (Personal)"; + +function isPersonalOrgName(orgName) { + if (!orgName) return false; + return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX); +} + +const cases = [ + ["Bob Müller (Personal)", true, "personal account"], + ["Acme GmbH", false, "company"], + ["Acme (Personal) Ltd", false, "suffix in middle does not count"], + ["Bob (Personal) ", true, "trailing whitespace tolerated"], + ["Bob (personal)", false, "case-sensitive — lowercase doesn't match"], + ["", false, "empty"], + [null, false, "null"], + [undefined, false, "undefined"], + ["Bob (Personal)x", false, "non-trailing suffix"], + [" (Personal)", true, "minimal — empty user name (degenerate but matches)"], +]; + +let pass = 0, fail = 0; +for (const [name, expected, note] of cases) { + const got = isPersonalOrgName(name); + const ok = got === expected; + console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}] input=${JSON.stringify(name)}`); + if (ok) pass++; else fail++; +} +console.log(`\n${pass} pass, ${fail} fail`); +process.exit(fail === 0 ? 0 : 1); diff --git a/src/app/[locale]/register/page.tsx b/src/app/[locale]/register/page.tsx index d05c6ec..01955d8 100644 --- a/src/app/[locale]/register/page.tsx +++ b/src/app/[locale]/register/page.tsx @@ -7,6 +7,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: + * - the company name field is hidden (and not sent) + * - the server skips the duplicate-domain check + * - the ZITADEL org is named "{givenName} {familyName} (Personal)" + */ export default function RegisterPage() { const t = useTranslations("register"); const tCommon = useTranslations("common"); @@ -18,6 +25,7 @@ export default function RegisterPage() { familyName: "", email: "", }); + const [isPersonal, setIsPersonal] = useState(false); const [state, setState] = useState("idle"); const [error, setError] = useState(""); @@ -31,15 +39,23 @@ export default function RegisterPage() { 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. + const body: Record = { + givenName: form.givenName, + familyName: form.familyName, + email: form.email, + isPersonal, + }; + if (!isPersonal) { + body.companyName = form.companyName; + } + const res = await fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - companyName: form.companyName, - givenName: form.givenName, - familyName: form.familyName, - email: form.email, - }), + body: JSON.stringify(body), }); if (!res.ok) { @@ -104,21 +120,41 @@ export default function RegisterPage() {
- {/* Company name */} -
- + {/* Personal-account toggle */} +
+
+
+ {t("individualToggle")} +
+
+ {t("individualHint")} +
+
+ + + {/* Company name — hidden for personal */} + {!isPersonal && ( +
+ + +
+ )} {/* Name row */}
@@ -161,7 +197,7 @@ export default function RegisterPage() { required value={form.email} onChange={handleChange} - placeholder="you@company.ch" + placeholder={isPersonal ? "you@example.ch" : "you@company.ch"} className="w-full px-3 py-2 bg-surface-2 border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors" />
diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index c6842f3..29d42e0 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -63,10 +63,10 @@ export async function POST( const isReApproval = tenantRequest.status === "rejected"; // Build the CR name: see `lib/tenant-naming.ts` for the format spec. - // For now all approvals are kind="company" — the personal branch is - // wired but unused until Slice 4 introduces the `is_personal` column. + // Slice 4: for personal accounts the slug is replaced by the literal + // "p-" prefix so no PII is embedded in the K8s namespace name. const tenantName = deriveTenantName( - "company", + tenantRequest.isPersonal ? "personal" : "company", tenantRequest.companyName, tenantRequest.id ); @@ -101,13 +101,17 @@ export async function POST( }; // Step 4: Create the PiecedTenant CR. - // displayName: prefer the customer-chosen instance name; fall back to - // the company name. With multi-tenant per org, instanceName is what - // distinguishes "Acme Production" from "Acme Dev" on the dashboard. + // displayName precedence: + // 1. customer-chosen instance name (Slice 3 multi-tenant) + // 2. for personal accounts, the contact name (avoids exposing the + // synthetic "{name} (Personal)" company name in the OpenClaw UI) + // 3. company name otherwise const displayName = tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0 ? tenantRequest.instanceName.trim() - : tenantRequest.companyName; + : tenantRequest.isPersonal + ? tenantRequest.contactName || "Assistant" + : tenantRequest.companyName; await createTenant( tenantName, diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts index 1b4671b..99896f1 100644 --- a/src/app/api/onboarding/route.ts +++ b/src/app/api/onboarding/route.ts @@ -10,6 +10,7 @@ import { import { getTenant, listTenants } from "@/lib/k8s"; import { sendAdminNotificationEmail } from "@/lib/email"; import { encryptSecrets } from "@/lib/crypto"; +import { isPersonalOrgName } from "@/lib/personal-org"; import type { OnboardingInput, PiecedTenant, TenantRequest } from "@/types"; import { z } from "zod"; @@ -176,6 +177,16 @@ export async function POST(request: Request) { // company line in favour of the recorded company name. const prior = await getMostRecentApprovedRequestForOrg(user.orgId); + // Slice 4: detect personal-account orgs by the canonical " (Personal)" + // suffix on the ZITADEL org name. Set at registration, stable for the + // lifetime of the org. Persisted on the row so admin views and the + // approve handler don't have to re-derive it. + // + // If any prior row has is_personal set, prefer that — it's the same + // org and the value can't change. (The prior-row check is defensive; + // the org-name check should agree.) + const isPersonal = prior?.isPersonal ?? isPersonalOrgName(user.orgName); + // Encrypt package secrets if provided let encryptedSecrets: Buffer | undefined; if (input.packageSecrets && Object.keys(input.packageSecrets).length > 0) { @@ -212,6 +223,7 @@ export async function POST(request: Request) { billingAddress, billingNotes, encryptedSecrets, + isPersonal, }); // Notify admin about the new request. For follow-up instances, include diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index aa34420..e7c2af7 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -5,18 +5,54 @@ import { checkDuplicateDomain } from "@/lib/db"; import type { RegistrationInput } from "@/types"; import { z } from "zod"; -const registrationSchema = z.object({ - companyName: z.string().min(2).max(100), - givenName: z.string().min(1).max(100), - familyName: z.string().min(1).max(100), - email: z.string().email(), - preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), -}); +/** + * Registration schema. + * + * Slice 4 changes + * --------------- + * - `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. + * - Personal accounts skip the duplicate-domain check entirely. Their + * row is also excluded from future domain checks (see + * `lib/domain-check.ts::findDuplicateInDb`). + */ +const registrationSchema = z + .object({ + companyName: z.string().min(2).max(100).optional(), + givenName: z.string().min(1).max(100), + familyName: z.string().min(1).max(100), + email: z.string().email(), + preferredLanguage: z.enum(["en", "de", "fr", "it"]).optional(), + isPersonal: z.boolean().optional().default(false), + }) + .refine( + (data) => + data.isPersonal || (data.companyName && data.companyName.trim().length >= 2), + { + message: "Company name is required for company registrations", + path: ["companyName"], + } + ); /** 3 registrations per IP per hour */ 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 = @@ -53,31 +89,45 @@ export async function POST(request: NextRequest) { } const input: RegistrationInput = parsed.data; + const isPersonal = input.isPersonal === true; - // --- Duplicate-domain check --- + // --- Duplicate-domain check (skipped for personal accounts) --- // - // Block if another active tenant_request or ZITADEL org already exists - // for this corporate email domain. Public domains (gmail, gmx, etc.) - // are exempted by checkDuplicateDomain. - // - // We return a structured `code: "duplicate_domain"` with the matched - // domain so the client can render the localized message via - // register.duplicateDomain (with {domain} interpolation). The fallback - // English string is included for non-i18n clients (curl, monitoring). - const dup = await checkDuplicateDomain(input.email); - if (dup.blocked && dup.domain) { - return NextResponse.json( - { - error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`, - code: "duplicate_domain", - domain: dup.domain, - }, - { status: 409 }, - ); + // Personal accounts are explicitly allowed to use any email domain + // (including corporate). Their tenant_request rows are excluded + // from this check by lib/domain-check.ts, so a personal account + // doesn't block a later real-company registration on the same + // domain. + if (!isPersonal) { + const dup = await checkDuplicateDomain(input.email); + if (dup.blocked && dup.domain) { + return NextResponse.json( + { + error: `An account for the email domain ${dup.domain} is already registered. Please contact your company administrator or PieCed IT support.`, + code: "duplicate_domain", + domain: dup.domain, + }, + { status: 409 }, + ); + } } + // --- Determine the ZITADEL org name --- + // + // 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. + const orgName = isPersonal + ? `${input.givenName.trim()} ${input.familyName.trim()}${PERSONAL_ORG_SUFFIX}` + : input.companyName!.trim(); + const result = await registerCustomer({ - companyName: input.companyName, + companyName: orgName, email: input.email, givenName: input.givenName, familyName: input.familyName, @@ -88,6 +138,7 @@ export async function POST(request: NextRequest) { { orgId: result.orgId, userId: result.userId, + isPersonal, message: "Registration successful. You will receive an invitation email to set your password.", }, diff --git a/src/components/onboarding/wizard.tsx b/src/components/onboarding/wizard.tsx index b132ef1..bcc9f5d 100644 --- a/src/components/onboarding/wizard.tsx +++ b/src/components/onboarding/wizard.tsx @@ -4,6 +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"; type Step = "welcome" | "configure" | "billing" | "confirm"; @@ -55,6 +56,16 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { 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). + const isPersonal = isPersonalOrgName(orgName); + const displayOrgName = isPersonal + ? orgName.slice(0, -PERSONAL_ORG_SUFFIX.length).trim() + : orgName; + const [step, setStep] = useState("welcome"); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); @@ -64,11 +75,14 @@ export function OnboardingWizard({ orgName, onComplete }: WizardProps) { const [config, setConfig] = useState({ instanceName: "", agentName: "Assistant", - soulMd: FALLBACK_SOUL.replace("{company}", orgName), + soulMd: FALLBACK_SOUL.replace("{company}", displayOrgName), agentsMd: FALLBACK_AGENTS, packages: [] as string[], billingAddress: { - company: orgName, + // For personal accounts, leave the company field empty — it'll + // appear on invoices. The user can still type something if they + // want to. + company: isPersonal ? "" : displayOrgName, street: "", city: "", postalCode: "", diff --git a/src/lib/db.ts b/src/lib/db.ts index 12fa53e..7813bbf 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -55,6 +55,7 @@ const MIGRATION_SQL = ` admin_notes TEXT, tenant_name TEXT, encrypted_secrets BYTEA, + is_personal BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -70,6 +71,7 @@ const MIGRATION_SQL = ` ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA; 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; -- 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; @@ -156,8 +158,9 @@ export async function createTenantRequest( `INSERT INTO tenant_requests (zitadel_org_id, zitadel_user_id, company_name, instance_name, contact_name, contact_email, agent_name, soul_md, agents_md, - packages, billing_address, billing_notes, encrypted_secrets) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + packages, billing_address, billing_notes, encrypted_secrets, + is_personal) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ params.zitadelOrgId, @@ -173,6 +176,7 @@ export async function createTenantRequest( JSON.stringify(params.billingAddress), params.billingNotes, params.encryptedSecrets ?? null, + params.isPersonal ?? false, ] ); return mapRow(result.rows[0]); @@ -408,6 +412,7 @@ function mapRow(row: any): TenantRequest { adminNotes: row.admin_notes, tenantName: row.tenant_name, encryptedSecrets: row.encrypted_secrets ?? null, + isPersonal: row.is_personal ?? false, createdAt: row.created_at?.toISOString?.() ?? row.created_at, updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at, }; diff --git a/src/lib/domain-check.ts b/src/lib/domain-check.ts index 2cccc4c..c23ae04 100644 --- a/src/lib/domain-check.ts +++ b/src/lib/domain-check.ts @@ -140,6 +140,12 @@ export function isPublicEmailDomain(domain: string): boolean { * Look up active tenant_requests whose contact_email shares the given domain. * Active = status NOT IN ('rejected', 'deleted'). * + * Slice 4: personal-account rows (is_personal = TRUE) are excluded. A + * person's personal account doesn't claim the domain on behalf of a + * company — alice@acme.ch registering as a personal account must not + * block the actual Acme GmbH from registering later. The personal flag + * lives on the row itself, set by /api/register at creation time. + * * Uses LOWER() on both sides to handle any historical case inconsistency in * stored emails. The pattern '%@' is anchored so 'acme.ch' does not * match 'notacme.ch' or 'acme.ch.evil.com'. @@ -151,7 +157,8 @@ async function findDuplicateInDb( const result = await pool.query<{ count: string }>( `SELECT COUNT(*) AS count FROM tenant_requests WHERE LOWER(contact_email) LIKE $1 - AND status NOT IN ('rejected', 'deleted')`, + AND status NOT IN ('rejected', 'deleted') + AND is_personal = FALSE`, [`%@${domain.toLowerCase()}`] ); return Number(result.rows[0]?.count ?? 0) > 0; diff --git a/src/lib/personal-org.ts b/src/lib/personal-org.ts new file mode 100644 index 0000000..520b3af --- /dev/null +++ b/src/lib/personal-org.ts @@ -0,0 +1,40 @@ +/** + * 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. + * + * 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. + * + * 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. + */ + +export const PERSONAL_ORG_SUFFIX = " (Personal)"; + +/** + * 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. + * + * Pass `session.orgName` from the SessionUser at the call site. + */ +export function isPersonalOrgName(orgName: string | null | undefined): boolean { + if (!orgName) return false; + return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX); +} diff --git a/src/messages/de.json b/src/messages/de.json index a226bac..00f47fc 100644 --- a/src/messages/de.json +++ b/src/messages/de.json @@ -35,7 +35,9 @@ "successTitle": "Registrierung eingegangen", "successDescription": "Sie erhalten eine Einladungs-E-Mail mit einem Link, um Ihr Passwort festzulegen und Ihre E-Mail-Adresse zu bestätigen. Danach können Sie sich anmelden und Ihren KI-Assistenten einrichten.", "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." + "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." }, "onboarding": { "loading": "Status wird geladen…", diff --git a/src/messages/en.json b/src/messages/en.json index e719f85..62d5bab 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -35,7 +35,9 @@ "successTitle": "Registration received", "successDescription": "You will receive an invitation email with a link to set your password and verify your email address. Once completed, you can sign in to set up your AI assistant.", "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." + "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." }, "onboarding": { "loading": "Loading status…", diff --git a/src/messages/fr.json b/src/messages/fr.json index d6ec265..bf556a3 100644 --- a/src/messages/fr.json +++ b/src/messages/fr.json @@ -35,7 +35,9 @@ "successTitle": "Inscription reçue", "successDescription": "Vous recevrez un e-mail d'invitation avec un lien pour définir votre mot de passe et vérifier votre adresse e-mail. Ensuite, vous pourrez vous connecter et configurer votre assistant IA.", "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." + "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." }, "onboarding": { "loading": "Chargement du statut…", diff --git a/src/messages/it.json b/src/messages/it.json index c0b12ea..70f6801 100644 --- a/src/messages/it.json +++ b/src/messages/it.json @@ -35,7 +35,9 @@ "successTitle": "Registrazione ricevuta", "successDescription": "Riceverai un'e-mail di invito con un link per impostare la password e verificare il tuo indirizzo e-mail. Dopodiché potrai accedere e configurare il tuo assistente IA.", "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." + "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." }, "onboarding": { "loading": "Caricamento stato…", diff --git a/src/types/index.ts b/src/types/index.ts index ff4a3aa..464f468 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -83,11 +83,23 @@ export interface UsageSummary { // Registration export interface RegistrationInput { - companyName: string; + /** + * 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. + */ + companyName?: string; givenName: string; familyName: string; 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. + */ + isPersonal?: boolean; } // Billing address @@ -131,6 +143,13 @@ export interface TenantRequest { adminNotes?: string; tenantName?: string; encryptedSecrets?: Buffer | null; + /** + * Slice 4: true for personal accounts. Drives CR-naming (`p-{suffix}` + * vs `{slug}-{suffix}` in `lib/tenant-naming.ts`), display-name + * fallback (contact name vs company name), and exclusion from the + * domain-uniqueness check on subsequent registrations. + */ + isPersonal?: boolean; createdAt: string; updatedAt: string; }