diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx new file mode 100644 index 00000000000..204b76587a3 --- /dev/null +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { AnchorHTMLAttributes } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CompanyAccess } from "./CompanyAccess"; + +const listMembersMock = vi.hoisted(() => vi.fn()); +const listJoinRequestsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/access", () => ({ + accessApi: { + listMembers: (companyId: string) => listMembersMock(companyId), + listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status), + updateMember: vi.fn(), + updateMemberPermissions: vi.fn(), + approveJoinRequest: vi.fn(), + rejectJoinRequest: vi.fn(), + }, +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, ...props }: AnchorHTMLAttributes) => {children}, +})); + +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + selectedCompanyId: "company-1", + selectedCompany: { id: "company-1", name: "Paperclip" }, + }), +})); + +vi.mock("@/context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("@/context/ToastContext", () => ({ + useToast: () => ({ pushToast: vi.fn() }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CompanyAccess", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + listMembersMock.mockResolvedValue({ + members: [ + { + id: "member-1", + companyId: "company-1", + principalType: "user", + principalId: "user-1", + status: "active", + membershipRole: "owner", + createdAt: "2026-04-10T00:00:00.000Z", + updatedAt: "2026-04-10T00:00:00.000Z", + user: { + id: "user-1", + email: "codexcoder@paperclip.local", + name: "Codex Coder", + image: null, + }, + grants: [], + }, + ], + access: { + currentUserRole: "owner", + canManageMembers: true, + canInviteUsers: true, + canApproveJoinRequests: true, + }, + }); + listJoinRequestsMock.mockResolvedValue([ + { + id: "join-1", + requestType: "human", + createdAt: "2026-04-10T00:00:00.000Z", + requesterUser: { + id: "user-2", + email: "board@paperclip.local", + name: "Board User", + image: null, + }, + requestEmailSnapshot: "board@paperclip.local", + requestingUserId: "user-2", + invite: { + allowedJoinTypes: "human", + humanRole: "operator", + }, + }, + { + id: "join-2", + requestType: "agent", + createdAt: "2026-04-10T00:00:00.000Z", + agentName: "Codex Worker", + adapterType: "codex_local", + capabilities: "Implements code changes", + invite: { + allowedJoinTypes: "agent", + humanRole: null, + }, + }, + ]); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("separates humans from agents and removes the summary cards", async () => { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Manage company user memberships"); + expect(container.textContent).toContain("Humans"); + expect(container.textContent).toContain("Agents"); + expect(container.textContent).toContain("Pending human joins"); + expect(container.textContent).toContain("Pending agent joins"); + expect(container.textContent).toContain("User account"); + expect(container.textContent).not.toContain("Active user accounts"); + expect(container.textContent).not.toContain("Suspended user accounts"); + expect(container.textContent).not.toContain("Pending user joins"); + expect((container.textContent ?? "").indexOf("Humans")).toBeLessThan((container.textContent ?? "").indexOf("Agents")); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx new file mode 100644 index 00000000000..8577209b6bb --- /dev/null +++ b/ui/src/pages/CompanyAccess.tsx @@ -0,0 +1,510 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS, PERMISSION_KEYS, type PermissionKey } from "@paperclipai/shared"; +import { Bot, ShieldCheck, Users } from "lucide-react"; +import { accessApi, type CompanyMember } from "@/api/access"; +import { ApiError } from "@/api/client"; +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 { Link } from "@/lib/router"; +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(", "); +} + +export function CompanyAccess() { + const { selectedCompany, selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToast(); + const queryClient = useQueryClient(); + const [editingMemberId, setEditingMemberId] = useState(null); + 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 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.joinRequests(selectedCompanyId, "pending_approval") }); + }; + + const updateMemberMutation = useMutation({ + mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: CompanyMember["status"]; grants: PermissionKey[] }) => { + await accessApi.updateMember(selectedCompanyId!, input.memberId, { + membershipRole: input.membershipRole, + status: input.status, + }); + return accessApi.updateMemberPermissions(selectedCompanyId!, input.memberId, { + 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], + ); + + useEffect(() => { + if (!editingMember) return; + setDraftRole(editingMember.membershipRole); + setDraftStatus(editingMember.status); + setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey))); + }, [editingMember]); + + 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 pendingAgentJoinRequests = + joinRequestsQuery.data?.filter((request) => request.requestType === "agent") ?? []; + const joinRequestActionPending = + approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending; + + 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 first. Roles, status, and explicit grants all live here. +

+
+ {access?.canApproveJoinRequests ? ( + + Open join request queue + + ) : null} +
+ + {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) => ( +
+
+
{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)}
+
+ +
+
+ )) + )} +
+
+ +
+
+
+
+ +

Agents

+
+

+ Agent access is handled through invite-driven join requests instead of human memberships. +

+
+ + Manage invites + +
+ + {!access?.canApproveJoinRequests ? ( +
+ Join request review requires company approval permissions. +
+ ) : pendingAgentJoinRequests.length === 0 ? ( +
+ No pending agent join requests right now. +
+ ) : ( +
+
+
+

Pending agent joins

+

+ Review pending agents here, then use the full queue for broader history. +

+
+ {pendingAgentJoinRequests.length} pending +
+
+ {pendingAgentJoinRequests.map((request) => ( + approveJoinRequestMutation.mutate(request.id)} + onReject={() => rejectJoinRequestMutation.mutate(request.id)} + /> + ))} +
+
+ )} +
+ + !open && setEditingMemberId(null)}> + + + Edit member + + Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}. + + + {editingMember && ( +
+
+ + +
+ +
+
+

Explicit grants

+

+ These permissions are stored separately from the role label. Use them when the member needs exceptions to the default role. +

+
+
+ {PERMISSION_KEYS.map((permissionKey) => ( + + ))} +
+
+
+ )} + + + + +
+
+
+ ); +} + +function PendingJoinRequestCard({ + title, + subtitle, + context, + detail, + detailSecondary, + approveLabel, + rejectLabel, + disabled, + onApprove, + onReject, +}: { + requestId: string; + 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} +
+
+ + +
+
+
+ ); +}