190 lines
6.1 KiB
TypeScript
190 lines
6.1 KiB
TypeScript
/**
|
|
* 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<OrgMember[]> {
|
|
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<string, Set<string>>();
|
|
const authIdByUser = new Map<string, string>();
|
|
for (const a of auths) {
|
|
const set = rolesByUser.get(a.userId) ?? new Set<string>();
|
|
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<OrgMember | null> {
|
|
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<InviteResult> {
|
|
// 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 };
|