148 lines
5.9 KiB
TypeScript
148 lines
5.9 KiB
TypeScript
/**
|
|
* Personal-account helpers.
|
|
*
|
|
* Two ZITADEL org-name formats may identify a personal account:
|
|
*
|
|
* 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`.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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 {
|
|
if (!orgName) return false;
|
|
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);
|
|
}
|