Team UI
All checks were successful
Build and Push / build (push) Successful in 1m26s

This commit is contained in:
2026-04-26 23:07:47 +02:00
parent 22fd5fb2cc
commit a31d05b7c2
17 changed files with 1459 additions and 8 deletions

168
src/lib/team.ts Normal file
View 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 };

View File

@@ -213,6 +213,143 @@ export async function deleteOrganization(orgId: string): Promise<void> {
await zitadelFetch(`/v2/organizations/${orgId}`, "DELETE");
}
// ---------------------------------------------------------------------------
// Slice 7: search/list APIs for team management
// ---------------------------------------------------------------------------
//
// Two endpoints used by the Team UI:
// - listOrgUsers → POST /v2/users (search with organizationIdQuery)
// - listOrgAuthorizations → Connect RPC to AuthorizationService.ListAuthorizations
//
// Caveats
// -------
// ZITADEL's v2 API surface evolves; the request/response shapes below were
// written against the v2 schema as documented at the time of authoring
// (organizationIdQuery filter on UserService.SearchUsers; ListAuthorizations
// with a ListQuery + filter pair). If your installed ZITADEL version uses
// slightly different field names, parsing here is intentionally tolerant —
// the helpers return [] rather than throwing on shape drift, log a warning,
// and the caller's UI shows an empty team list (which is recoverable).
//
// If you find a discrepancy, fix the request shape here and re-deploy; the
// rest of the team UI doesn't care about the on-the-wire format.
export interface OrgUser {
userId: string;
email: string;
givenName: string;
familyName: string;
displayName: string;
}
/**
* List all users belonging to a given ZITADEL organization. Paginated;
* we cap at 200 per call which is generous for the pilot scale.
*/
export async function listOrgUsers(orgId: string): Promise<OrgUser[]> {
try {
const data = await zitadelFetch<{ result?: any[] }>(
"/v2/users",
"POST",
{
queries: [{ organizationIdQuery: { organizationId: orgId } }],
// Sort by username so the team list is deterministic across reloads
sortingColumn: "USER_FIELD_NAME_USERNAME",
query: { limit: 200, asc: true },
}
);
if (!data?.result || !Array.isArray(data.result)) return [];
return data.result.flatMap((row: any) => {
// ZITADEL distinguishes human and machine users; we only want humans.
const human = row?.human;
if (!human) return [];
const profile = human.profile ?? {};
const email = human.email?.email ?? "";
const userId = row.userId ?? row.id ?? "";
if (!userId) return [];
return [
{
userId,
email,
givenName: profile.givenName ?? "",
familyName: profile.familyName ?? "",
displayName:
profile.displayName ??
`${profile.givenName ?? ""} ${profile.familyName ?? ""}`.trim() ??
email,
} as OrgUser,
];
});
} catch (err) {
console.warn(
`Failed to list users for org ${orgId} (returning empty):`,
err
);
return [];
}
}
export interface OrgAuthorization {
authorizationId: string;
userId: string;
organizationId: string;
projectId: string;
roleKeys: string[];
}
/**
* List authorizations for the OpenClaw Platform project, filtered to a
* single organization. Used by the team UI to render each member's
* effective role.
*
* Connect RPC: zitadel.authorization.v2.AuthorizationService/ListAuthorizations
*
* Returns [] on any error so the team page can render a degraded view
* (members visible, roles blank) rather than blowing up entirely.
*/
export async function listOrgAuthorizations(
orgId: string
): Promise<OrgAuthorization[]> {
try {
const data = await connectRpc<{ authorizations?: any[] }>(
"zitadel.authorization.v2.AuthorizationService",
"ListAuthorizations",
{
filters: [
{ organizationId: orgId },
{ projectId: ZITADEL_PROJECT_ID },
],
// Cap at 500 — far more than a pilot org should ever need
pagination: { limit: 500 },
}
);
if (!data?.authorizations || !Array.isArray(data.authorizations)) {
return [];
}
return data.authorizations.flatMap((row: any) => {
const userId = row?.userId ?? "";
if (!userId) return [];
return [
{
authorizationId: row.id ?? row.authorizationId ?? "",
userId,
organizationId: row.organizationId ?? orgId,
projectId: row.projectId ?? ZITADEL_PROJECT_ID,
roleKeys: Array.isArray(row.roleKeys) ? row.roleKeys : [],
} as OrgAuthorization,
];
});
} catch (err) {
console.warn(
`Failed to list authorizations for org ${orgId} (returning empty):`,
err
);
return [];
}
}
// ---------------------------------------------------------------------------
// Full registration flow
// ---------------------------------------------------------------------------