/** * Team management — high-level operations on top of `lib/zitadel.ts`. * * Two responsibilities: * 1. Fetching the joined "members + roles" view for an org, used by * the /team page and the assigned-users panel. * 2. Inviting a new member end-to-end (create user + send invite + * assign role) with rollback on partial failure, mirroring * `registerCustomer` for new orgs. * * Allowed customer roles * ---------------------- * Slice 7 reduced scope: invitations may only set the customer roles * `owner` or `user`. Platform roles cannot be granted via the portal — * those are managed in ZITADEL Console with stricter access. The * `viewer` role is gone since Slice 5. */ import { listOrgUsers, listOrgAuthorizations, createHumanUser, createInviteCode, createAuthorization, type OrgUser, } from "./zitadel"; import type { CustomerRole } from "@/types"; const ALLOWED_INVITE_ROLES: CustomerRole[] = ["owner", "user"]; export function isValidInviteRole(role: string): role is CustomerRole { return (ALLOWED_INVITE_ROLES as string[]).includes(role); } export interface OrgMember { userId: string; email: string; displayName: string; givenName: string; familyName: string; /** * Roles held by this member on the org's project grant. Usually a * single-element array (one of "owner" / "user"). Could be empty * if the user exists in the org but has no project authorization * yet — appears as "no role" in the UI. */ roles: string[]; /** * The ZITADEL authorization ID backing the role assignment, if any. * Used by the team UI's role-change flow to call UpdateAuthorization. * Empty string if the member has no authorization (orphan / pre-Slice-7 * legacy / mid-invite race). * * If a member somehow holds multiple authorization rows (not expected * at our project-grant scope of [owner, user]), only the first is * surfaced here. The team page joins per-user, so the UI sees one * row per member; mutations target that authorization. */ authorizationId: string; } /** * Fetch the joined members-with-roles view for an org. Two ZITADEL * calls run in parallel (users + authorizations) then joined in memory. * * If either call fails, returns whatever the other one produced — * users without roles render as "no role" badges; missing users are * just absent. Better degraded than empty. */ export async function getOrgMembers(orgId: string): Promise { const [users, auths] = await Promise.all([ listOrgUsers(orgId), listOrgAuthorizations(orgId), ]); // Group authorizations by userId. We track BOTH the union of role // keys (for display) and the first authorizationId we see (for the // role-change flow). A user could in principle hold multiple // authorization rows, but at our project-grant scope of [owner, user] // each member ends up with exactly one. If a future config produces // multi-row members the UI surfaces the first; cleanup belongs in // ZITADEL Console. const rolesByUser = new Map>(); const authIdByUser = new Map(); for (const a of auths) { const set = rolesByUser.get(a.userId) ?? new Set(); for (const r of a.roleKeys) set.add(r); rolesByUser.set(a.userId, set); if (!authIdByUser.has(a.userId) && a.authorizationId) { authIdByUser.set(a.userId, a.authorizationId); } } return users.map((u) => ({ userId: u.userId, email: u.email, displayName: u.displayName, givenName: u.givenName, familyName: u.familyName, roles: Array.from(rolesByUser.get(u.userId) ?? []), authorizationId: authIdByUser.get(u.userId) ?? "", })); } /** * Look up a single org member by userId. Convenience wrapper used to * resolve a userId in an assignment row to a display name. Returns * null if the user no longer exists in the org (stale assignment row). */ export async function getOrgMember( orgId: string, userId: string ): Promise { const all = await getOrgMembers(orgId); return all.find((m) => m.userId === userId) ?? null; } export interface InviteResult { userId: string; emailAlreadyExists: boolean; } /** * Invite a new member into an existing customer org. * * Three steps: * 1. createHumanUser — create the ZITADEL human, no password. * 2. createInviteCode — send the invite email (set password + verify). * 3. createAuthorization — assign the chosen customer role. * * If any step after (1) fails, the user is NOT rolled back. Reasoning: * unlike registration where a half-created org is useless, a * half-invited user can be cleaned up manually in ZITADEL Console and * re-invited. The mid-failure cost of partial state is low; the cost of * a wrong rollback is double-creation on retry. So we surface the * error and let the operator decide. * * The invite-email step is best-effort — if SMTP is misconfigured the * user is created and authorized but no email goes out. Owner can * resend manually from ZITADEL Console. * * Note: ZITADEL rejects creating a user with an email that already * exists in the same instance. The error is surfaced as-is from the * `extractZitadelMessage`-aware caller. */ export async function inviteOrgMember(params: { orgId: string; email: string; givenName: string; familyName: string; role: CustomerRole; preferredLanguage?: string; }): Promise { // Step 1: create the user const user = await createHumanUser({ orgId: params.orgId, email: params.email, givenName: params.givenName, familyName: params.familyName, preferredLanguage: params.preferredLanguage, }); // Step 2: send invite — best-effort try { await createInviteCode(user.id); } catch (err) { console.warn( `Invite email could not be sent for user ${user.id} (SMTP may not be configured):`, err ); } // Step 3: assign role await createAuthorization({ userId: user.id, organizationId: params.orgId, roleKeys: [params.role], }); return { userId: user.id, emailAlreadyExists: false, }; } /** * Re-export for convenience. */ export type { OrgUser };