diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index 204b76587a3..6fe1e474442 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -1,7 +1,6 @@ // @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"; @@ -21,10 +20,6 @@ vi.mock("@/api/access", () => ({ }, })); -vi.mock("@/lib/router", () => ({ - Link: ({ children, ...props }: AnchorHTMLAttributes) => {children}, -})); - vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", @@ -122,7 +117,7 @@ describe("CompanyAccess", () => { vi.clearAllMocks(); }); - it("separates humans from agents and removes the summary cards", async () => { + it("keeps the page human-focused and explains implicit versus explicit grants", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -140,14 +135,31 @@ describe("CompanyAccess", () => { 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("Agents"); + expect(container.textContent).not.toContain("Pending agent joins"); + expect(container.textContent).not.toContain("Open join request queue"); + expect(container.textContent).not.toContain("Manage invites"); 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")); + + const editButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Edit", + ); + expect(editButton).toBeTruthy(); + + await act(async () => { + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(document.body.textContent).toContain("Implicit grants from role"); + expect(document.body.textContent).toContain("Owner currently includes these permissions automatically."); + expect(document.body.textContent).toContain( + "Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.", + ); await act(async () => { root.unmount(); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 8577209b6bb..eb6126bc5a1 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -1,7 +1,7 @@ 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 { ShieldCheck, Users } from "lucide-react"; import { accessApi, type CompanyMember } from "@/api/access"; import { ApiError } from "@/api/client"; import { Button } from "@/components/ui/button"; @@ -18,7 +18,6 @@ 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 = { @@ -35,6 +34,17 @@ function formatGrantSummary(member: CompanyMember) { 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: [], +}; + +function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) { + return role ? implicitRoleGrantMap[role] : []; +} + export function CompanyAccess() { const { selectedCompany, selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -168,10 +178,10 @@ export function CompanyAccess() { 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; + const implicitGrantKeys = getImplicitGrantKeys(draftRole); + const implicitGrantSet = new Set(implicitGrantKeys); return (
@@ -192,21 +202,14 @@ export function CompanyAccess() { )}
-
-
-
- -

Humans

-
-

- Manage human company memberships first. Roles, status, and explicit grants all live here. -

+
+
+ +

Humans

- {access?.canApproveJoinRequests ? ( - - Open join request queue - - ) : null} +

+ Manage human company memberships, status, and grants here. +

{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? ( @@ -224,7 +227,6 @@ export function CompanyAccess() { {pendingHumanJoinRequests.map((request) => (
-
-
-
-
- -

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)}> @@ -403,11 +344,28 @@ export function CompanyAccess() {
-

Explicit grants

+

Grants

- These permissions are stored separately from the role label. Use them when the member needs exceptions to the default role. + 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) => ( ))} @@ -472,7 +440,6 @@ function PendingJoinRequestCard({ onApprove, onReject, }: { - requestId: string; title: string; subtitle: string; context: string;