import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type Agent, type PermissionKey, } from "@paperclipai/shared"; import { ShieldCheck, Trash2, Users } from "lucide-react"; import { accessApi, type CompanyMember } from "@/api/access"; import { agentsApi } from "@/api/agents"; import { ApiError } from "@/api/client"; import { issuesApi } from "@/api/issues"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "@/context/CompanyContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; const permissionLabels: Record = { "agents:create": "Create agents", "users:invite": "Invite humans and agents", "users:manage_permissions": "Manage members and grants", "tasks:assign": "Assign tasks", "tasks:assign_scope": "Assign scoped tasks", "joins:approve": "Approve join requests", }; function formatGrantSummary(member: CompanyMember) { if (member.grants.length === 0) return "No explicit grants"; return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", "); } const implicitRoleGrantMap: Record, PermissionKey[]> = { owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"], admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"], operator: ["tasks:assign"], viewer: [], }; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; type EditableMemberStatus = "pending" | "active" | "suspended"; function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) { return role ? implicitRoleGrantMap[role] : []; } export function CompanyAccess() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToast(); const queryClient = useQueryClient(); const [editingMemberId, setEditingMemberId] = useState(null); const [removingMemberId, setRemovingMemberId] = useState(null); const [reassignmentTarget, setReassignmentTarget] = useState("__unassigned"); const [draftRole, setDraftRole] = useState(null); const [draftStatus, setDraftStatus] = useState("active"); const [draftGrants, setDraftGrants] = useState>(new Set()); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/company/settings" }, { label: "Access" }, ]); }, [selectedCompany?.name, setBreadcrumbs]); const membersQuery = useQuery({ queryKey: queryKeys.access.companyMembers(selectedCompanyId ?? ""), queryFn: () => accessApi.listMembers(selectedCompanyId!), enabled: !!selectedCompanyId, }); const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId ?? ""), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId, }); const joinRequestsQuery = useQuery({ queryKey: queryKeys.access.joinRequests(selectedCompanyId ?? "", "pending_approval"), queryFn: () => accessApi.listJoinRequests(selectedCompanyId!, "pending_approval"), enabled: !!selectedCompanyId && !!membersQuery.data?.access.canApproveJoinRequests, }); const refreshAccessData = async () => { if (!selectedCompanyId) return; await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyMembers(selectedCompanyId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(selectedCompanyId, "pending_approval") }); }; const updateMemberMutation = useMutation({ mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => { return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, { membershipRole: input.membershipRole, status: input.status, grants: input.grants.map((permissionKey) => ({ permissionKey })), }); }, onSuccess: async () => { setEditingMemberId(null); await refreshAccessData(); pushToast({ title: "Member updated", tone: "success", }); }, onError: (error) => { pushToast({ title: "Failed to update member", body: error instanceof Error ? error.message : "Unknown error", tone: "error", }); }, }); const approveJoinRequestMutation = useMutation({ mutationFn: (requestId: string) => accessApi.approveJoinRequest(selectedCompanyId!, requestId), onSuccess: async () => { await refreshAccessData(); pushToast({ title: "Join request approved", tone: "success", }); }, onError: (error) => { pushToast({ title: "Failed to approve join request", body: error instanceof Error ? error.message : "Unknown error", tone: "error", }); }, }); const rejectJoinRequestMutation = useMutation({ mutationFn: (requestId: string) => accessApi.rejectJoinRequest(selectedCompanyId!, requestId), onSuccess: async () => { await refreshAccessData(); pushToast({ title: "Join request rejected", tone: "success", }); }, onError: (error) => { pushToast({ title: "Failed to reject join request", body: error instanceof Error ? error.message : "Unknown error", tone: "error", }); }, }); const editingMember = useMemo( () => membersQuery.data?.members.find((member) => member.id === editingMemberId) ?? null, [editingMemberId, membersQuery.data?.members], ); const removingMember = useMemo( () => membersQuery.data?.members.find((member) => member.id === removingMemberId) ?? null, [removingMemberId, membersQuery.data?.members], ); const assignedIssuesQuery = useQuery({ queryKey: ["access", "member-assigned-issues", selectedCompanyId ?? "", removingMember?.principalId ?? ""], queryFn: () => issuesApi.list(selectedCompanyId!, { assigneeUserId: removingMember!.principalId, status: reassignmentIssueStatuses, }), enabled: !!selectedCompanyId && !!removingMember, }); const archiveMemberMutation = useMutation({ mutationFn: async (input: { memberId: string; target: string }) => { const reassignment = input.target.startsWith("agent:") ? { assigneeAgentId: input.target.slice("agent:".length), assigneeUserId: null } : input.target.startsWith("user:") ? { assigneeAgentId: null, assigneeUserId: input.target.slice("user:".length) } : null; return accessApi.archiveMember(selectedCompanyId!, input.memberId, { reassignment }); }, onSuccess: async (result) => { setRemovingMemberId(null); setReassignmentTarget("__unassigned"); await refreshAccessData(); if (selectedCompanyId) { await queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listAssignedToMe(selectedCompanyId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) }); } pushToast({ title: "Member removed", body: result.reassignedIssueCount > 0 ? `${result.reassignedIssueCount} assigned issue${result.reassignedIssueCount === 1 ? "" : "s"} cleaned up.` : undefined, tone: "success", }); }, onError: (error) => { pushToast({ title: "Failed to remove member", body: error instanceof Error ? error.message : "Unknown error", tone: "error", }); }, }); useEffect(() => { if (!editingMember) return; setDraftRole(editingMember.membershipRole); setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended"); setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey))); }, [editingMember]); useEffect(() => { if (!removingMember) return; setReassignmentTarget("__unassigned"); }, [removingMember]); if (!selectedCompanyId) { return
Select a company to manage access.
; } if (membersQuery.isLoading) { return
Loading company access…
; } if (membersQuery.error) { const message = membersQuery.error instanceof ApiError && membersQuery.error.status === 403 ? "You do not have permission to manage company members." : membersQuery.error instanceof Error ? membersQuery.error.message : "Failed to load company members."; return
{message}
; } const members = membersQuery.data?.members ?? []; const access = membersQuery.data?.access; const pendingHumanJoinRequests = joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? []; const joinRequestActionPending = approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; const implicitGrantKeys = getImplicitGrantKeys(draftRole); const implicitGrantSet = new Set(implicitGrantKeys); const activeReassignmentUsers = members.filter( (member) => member.status === "active" && member.principalType === "user" && member.id !== removingMemberId, ); const activeReassignmentAgents = (agentsQuery.data ?? []).filter(isAssignableAgent); const assignedIssues = assignedIssuesQuery.data ?? []; return (

Company Access

Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.

{access && !access.currentUserRole && (
This account can manage access here through instance-admin privileges, but it does not currently hold an active company membership.
)}

Humans

Manage human company memberships, status, and grants here.

{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? (

Pending human joins

Review human join requests before they become active company members.

{pendingHumanJoinRequests.length} pending
{pendingHumanJoinRequests.map((request) => ( approveJoinRequestMutation.mutate(request.id)} onReject={() => rejectJoinRequestMutation.mutate(request.id)} /> ))}
) : null}
User account
Role
Status
Grants
Action
{members.length === 0 ? (
No user memberships found for this company yet.
) : ( members.map((member) => { const removalReason = member.removal?.reason ?? null; const canArchive = member.removal?.canArchive ?? true; return (
{member.user?.name?.trim() || member.user?.email || member.principalId}
{member.user?.email || member.principalId}
{member.membershipRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[member.membershipRole] : "Unset"}
{member.status.replace("_", " ")}
{formatGrantSummary(member)}
{removalReason ? (
{removalReason}
) : null}
); }) )}
!open && setEditingMemberId(null)}> Edit member Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}. {editingMember && (

Grants

Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.

Implicit grants from role

{draftRole ? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.` : "No role is selected, so this member has no implicit grants right now."}

{implicitGrantKeys.length > 0 ? (
{implicitGrantKeys.map((permissionKey) => ( {permissionLabels[permissionKey]} ))}
) : null}
{PERMISSION_KEYS.map((permissionKey) => ( ))}
)}
!open && setRemovingMemberId(null)}> Remove member Archive {memberDisplayName(removingMember)} and move active assignments before hiding this user from assignment fields. {removingMember && (
{memberDisplayName(removingMember)}
{removingMember.user?.email || removingMember.principalId}
{assignedIssuesQuery.isLoading ? "Checking assigned issues..." : `${assignedIssues.length} open assigned issue${assignedIssues.length === 1 ? "" : "s"}`}
{assignedIssues.length > 0 ? (
Issue reassignment
{assignedIssues.slice(0, 6).map((issue) => (
{issue.identifier ?? issue.id.slice(0, 8)}
{issue.title}
))} {assignedIssues.length > 6 ? (
{assignedIssues.length - 6} more issue{assignedIssues.length - 6 === 1 ? "" : "s"}
) : null}
) : null}
)}
); } function memberDisplayName(member: CompanyMember | null) { if (!member) return "this member"; return member.user?.name?.trim() || member.user?.email || member.principalId; } function isAssignableAgent(agent: Agent) { return agent.status !== "terminated" && agent.status !== "pending_approval"; } function isEditableMemberStatus(status: CompanyMember["status"]): status is EditableMemberStatus { return status === "pending" || status === "active" || status === "suspended"; } function PendingJoinRequestCard({ title, subtitle, context, detail, detailSecondary, approveLabel, rejectLabel, disabled, onApprove, onReject, }: { title: string; subtitle: string; context: string; detail: string; detailSecondary?: string; approveLabel: string; rejectLabel: string; disabled: boolean; onApprove: () => void; onReject: () => void; }) { return (
{title}
{subtitle}
{context}
{detail}
{detailSecondary ?
{detailSecondary}
: null}
); }