import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, type Agent, } from "@paperclipai/shared"; import { Shield, 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 { 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 { Link, Navigate } from "@/lib/router"; import { queryKeys } from "@/lib/queryKeys"; import { usePluginSlots } from "@/plugins/slots"; const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out"; type EditableMemberStatus = "pending" | "active" | "suspended"; 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"); useEffect(() => { setBreadcrumbs([ { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, { label: "Settings", href: "/company/settings" }, { label: "Members" }, ]); }, [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 }) => { return accessApi.updateMember(selectedCompanyId!, input.memberId, { membershipRole: input.membershipRole, status: input.status, }); }, 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"); }, [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 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 Members

Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.

Core keeps this page focused on membership, invite approvals, and safe member removal.
{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 and status here.

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

Pending human joins

Review pending 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
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("_", " ")}
{removalReason ? (
{removalReason}
) : null}
); }) )}
!open && setEditingMemberId(null)}> Edit member Update company role and membership status for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}. {editingMember && (
)}
!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}
)}
); } export function CompanyAccessLegacyRoute() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { slots, isLoading, errorMessage } = usePluginSlots({ slotTypes: ["companySettingsPage"], companyId: selectedCompanyId, enabled: !!selectedCompanyId, }); useEffect(() => { setBreadcrumbs([ { label: "Settings", href: "/company/settings" }, { label: "Access" }, ]); }, [setBreadcrumbs]); const permissionsSlot = slots.find((slot) => slot.routePath === "permissions"); if (permissionsSlot) { return ; } if (isLoading) { return
Checking for advanced permission extensions...
; } return (

Advanced Permissions

Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.

Advanced permissions unavailable

Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension.

{errorMessage ? (

Plugin extensions unavailable: {errorMessage}

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