Files
pieced-portal/src/lib/team.ts
admin c46f27edef
All checks were successful
Build and Push / build (push) Successful in 1m30s
Fix bugs
2026-04-29 12:16:00 +02:00

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 };