/* ---------------------------------------------------------------------------- AccessList - the one membership editor, used everywhere access is edited: Workspace Access, the person sheet, the workspace sheet and the invite dialog. Same rows, verbs and role definitions in all four places. Roles come from the server's role catalog (see RoleSelect). You and owners are always listed. Service accounts sit in their own subgroup. Variants: - "inline" (default): avatar, name and email, a role select that saves immediately, and a menu with Remove. - "text": quieter rows with the role as text; the whole row opens the person sheet, where the role is changed. - "matrix": people by workspaces, one role per cell. Only for a few workspaces. -------------------------------------------------------------------------- */ import { useId, type ReactNode } from "react"; import { BotIcon, ChevronRightIcon, MailIcon, MoreHorizontalIcon, RotateCcwIcon, SlidersHorizontalIcon, UserMinusIcon, XIcon, } from "lucide-react"; import { cn } from "../lib/cn"; import { Avatar, AvatarFallback } from "./avatar"; import { Button } from "./button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu"; import { MetaChip } from "./meta-chip"; import { failedLoadParts, type FailedLoad } from "./diff-view"; import { ErrorMessage } from "./error-message"; import { InlineDisabledReason } from "./select-menu"; import { Skeleton } from "./skeleton"; import { StatusBadge } from "./status-badge"; import { RoleSelect, roleLabel, type RoleOption, type RoleValue } from "./role-select"; export type AccessMemberStatus = "active" | "invited" | "invite_failed" | "suspended"; export interface AccessMember { id: string; name: string; /** Service accounts have none. */ email?: string | null | undefined; initials: string; kind?: "person" | "service" | undefined; isYou?: boolean | undefined; /** * The viewer administers this list from above (an organization owner or * admin on the organization's workspace page), so their own row stays * editable: they set and remove their own access like anyone else's. */ managesOwnAccess?: boolean | undefined; /** Owners are always listed and can't be removed here. */ isOwner?: boolean | undefined; /** A small tag after the name, for example "Only owner". */ tag?: string | undefined; status?: AccessMemberStatus | undefined; /** "Invited · expires in 5 days". Shown only when not active. */ statusLabel?: string | undefined; /** The role in this list's scope. */ role: RoleValue; /** For a legacy custom grant: the role "Reset to role" goes back to. */ resetRole?: R | undefined; /** Why this person's role can't be changed here. */ roleLockedReason?: string | undefined; /** Why this person can't be removed here. Owners and You never can. */ removeLockedReason?: string | undefined; /** Matrix variant: the role per scope id (null is no access). */ grants?: Record> | undefined; } export interface AccessScope { id: string; /** "Platform engineering". */ label: string; } export type AccessListVariant = "inline" | "text" | "matrix"; export interface AccessListProps { members: readonly AccessMember[]; /** From the server's role catalog, least to most access. */ roles: readonly RoleOption[]; variant?: AccessListVariant | undefined; /** Accessible name for the list: "People with access to Platform engineering". */ label: string; /** Matrix columns. */ scopes?: readonly AccessScope[] | undefined; /** Saves a role. Return a promise to show the saving state. */ onRoleChange?: ( member: AccessMember, role: R | null, scopeId?: string, ) => void | Promise; onRemove?: ((member: AccessMember) => void) | undefined; /** * Opens the hand-picked permission editor ("Custom permissions…" in the ⋯ * menu). Optional: most places only offer roles. */ onCustomize?: ((member: AccessMember) => void) | undefined; /** Replaces a legacy custom grant with `member.resetRole`. */ onResetToRole?: ((member: AccessMember) => void | Promise) | undefined; /** Text variant: opens the person sheet. */ onOpen?: ((member: AccessMember) => void) | undefined; onResendInvite?: ((member: AccessMember) => void) | undefined; onRevokeInvite?: ((member: AccessMember) => void) | undefined; /** When set, nothing can be changed and this line says why. */ readOnlyReason?: string | undefined; loading?: boolean | undefined; /** Skeleton rows while loading. Default 4. */ loadingRows?: number | undefined; /** What happened, what to do next, and an optional retry. Replaces the list. */ error?: FailedLoad | undefined; /** Members whose role is being saved elsewhere, shown with a spinner. */ savingIds?: readonly string[] | undefined; /** Shown when only you have access. */ emptyMessage?: ReactNode | undefined; /** "Remove from workspace" or "Remove from organization". */ removeLabel?: string | undefined; /** What the list sits on, so the matrix's sticky column matches. Default "bg". */ canvas?: "bg" | "surface" | undefined; /** Adds "No access" to role choices (matrix, person sheet). */ noAccessLabel?: string | undefined; className?: string | undefined; } /** Service accounts go in their own subgroup, after the people. */ export function groupAccessMembers( members: readonly AccessMember[], ): { people: AccessMember[]; services: AccessMember[] } { const people = members.filter((member) => member.kind !== "service"); const services = members.filter((member) => member.kind === "service"); // You first, then owners, then everyone else in the order the server sent. const rank = (member: AccessMember) => (member.isYou ? 0 : member.isOwner ? 1 : 2); return { people: people .map((member, index) => ({ member, index })) .sort((a, b) => rank(a.member) - rank(b.member) || a.index - b.index) .map(({ member }) => member), services, }; } /** Why a member can't be removed, or null when they can. */ export function removeBlockedReason(member: AccessMember): string | null { if (member.removeLockedReason) return member.removeLockedReason; if (member.isYou && !member.managesOwnAccess) return "You can't remove yourself here."; if (member.isOwner) return "Owners always have access."; return null; } function isInvite(member: AccessMember): boolean { return member.status === "invited" || member.status === "invite_failed"; } /* ---------------------------------------------------------------- pieces */ function MemberAvatar({ member }: { member: AccessMember }) { if (member.kind === "service") { return ( ); } // Someone who hasn't joined yet: a dashed outline, the same initials. const pending = isInvite(member); return ( ); } function MemberIdentity({ member, roles, onResetToRole, readOnly, }: { member: AccessMember; roles: readonly RoleOption[]; onResetToRole?: ((member: AccessMember) => void | Promise) | undefined; readOnly: boolean; }) { const showStatus = member.status && member.status !== "active" && member.statusLabel; const secondary = member.kind === "service" ? "Service account" : member.email; const resetTo = member.role === "custom" && member.resetRole ? roleLabel(roles, member.resetRole) : null; return (
{/* One line: the name truncates before the chip wraps. */}
{member.name} {member.isYou ? You : null}
{secondary || member.tag ? ( // Like a ListRow meta line: the separator trails its item, so a // wrapped tag ("Only owner") starts its own line cleanly.
{secondary ? ( {secondary} {member.tag ? ( ) : null} ) : null} {member.tag ? {member.tag} : null}
) : null} {showStatus ? (
{member.statusLabel}
) : null} {member.role === "custom" ? ( // The reset link carries its own icon, so it needs no separator (and no // dot is left dangling when it wraps under the text on narrow rows).
Hand-picked permissions {resetTo && onResetToRole && !readOnly ? ( ) : null}
) : null}
); } function RowMenu({ member, removeLabel, onRemove, onCustomize, onResetToRole, onResendInvite, onRevokeInvite, roles, }: { member: AccessMember; removeLabel: string; onRemove?: ((member: AccessMember) => void) | undefined; onCustomize?: ((member: AccessMember) => void) | undefined; onResetToRole?: ((member: AccessMember) => void | Promise) | undefined; onResendInvite?: ((member: AccessMember) => void) | undefined; onRevokeInvite?: ((member: AccessMember) => void) | undefined; roles: readonly RoleOption[]; }) { const invite = isInvite(member); const blocked = removeBlockedReason(member); const canReset = member.role === "custom" && member.resetRole && onResetToRole; // Custom permissions follow the same rule as the role: never your own. const canCustomize = Boolean(onCustomize) && (!member.isYou || member.managesOwnAccess) && !member.isOwner; const hasItems = invite ? Boolean(onResendInvite || onRevokeInvite) : Boolean((onRemove && !blocked) || canReset || canCustomize); if (!hasItems) return