This commit is contained in:
168
src/lib/team.ts
Normal file
168
src/lib/team.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — one user could in principle hold
|
||||
// multiple authorization rows (one per role assigned at different
|
||||
// times). Flatten roleKeys.
|
||||
const rolesByUser = new Map<string, Set<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);
|
||||
}
|
||||
|
||||
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) ?? []),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user