mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
Clarify implicit grants in company access
This commit is contained in:
1 parent
1a69dc8d16
commit
162eef9f81
2 files changed
+71
-92
No files matched your search
@@ -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<HTMLAnchorElement>) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<PermissionKey, string> = {
|
||||
@@ -35,6 +34,17 @@ function formatGrantSummary(member: CompanyMember) {
|
||||
return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
|
||||
}
|
||||
|
||||
const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>, 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 (
|
||||
<div className="max-w-6xl space-y-8">
|
||||
@@ -192,21 +202,14 @@ export function CompanyAccess() {
|
||||
)}
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships first. Roles, status, and explicit grants all live here.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
{access?.canApproveJoinRequests ? (
|
||||
<Link to="/inbox/requests" className="text-sm text-muted-foreground underline underline-offset-4">
|
||||
Open join request queue
|
||||
</Link>
|
||||
) : null}
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships, status, and grants here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{access?.canApproveJoinRequests && pendingHumanJoinRequests.length > 0 ? (
|
||||
@@ -224,7 +227,6 @@ export function CompanyAccess() {
|
||||
{pendingHumanJoinRequests.map((request) => (
|
||||
<PendingJoinRequestCard
|
||||
key={request.id}
|
||||
requestId={request.id}
|
||||
title={
|
||||
request.requesterUser?.name ||
|
||||
request.requestEmailSnapshot ||
|
||||
@@ -296,67 +298,6 @@ export function CompanyAccess() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-base font-semibold">Agents</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Agent access is handled through invite-driven join requests instead of human memberships.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/company/settings/invites" className="text-sm text-muted-foreground underline underline-offset-4">
|
||||
Manage invites
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!access?.canApproveJoinRequests ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
Join request review requires company approval permissions.
|
||||
</div>
|
||||
) : pendingAgentJoinRequests.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
No pending agent join requests right now.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-xl border border-border px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Pending agent joins</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review pending agents here, then use the full queue for broader history.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{pendingAgentJoinRequests.length} pending</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingAgentJoinRequests.map((request) => (
|
||||
<PendingJoinRequestCard
|
||||
key={request.id}
|
||||
requestId={request.id}
|
||||
title={request.agentName || "Unknown agent requester"}
|
||||
subtitle={request.adapterType || "Adapter not provided"}
|
||||
context={
|
||||
request.invite
|
||||
? `${request.invite.allowedJoinTypes} join invite${request.invite.humanRole ? ` • human role ${request.invite.humanRole}` : ""}`
|
||||
: "Invite metadata unavailable"
|
||||
}
|
||||
detail={request.capabilities || `Submitted ${new Date(request.createdAt).toLocaleString()}`}
|
||||
detailSecondary={`Submitted ${new Date(request.createdAt).toLocaleString()}`}
|
||||
approveLabel="Approve agent"
|
||||
rejectLabel="Reject agent"
|
||||
disabled={joinRequestActionPending}
|
||||
onApprove={() => approveJoinRequestMutation.mutate(request.id)}
|
||||
onReject={() => rejectJoinRequestMutation.mutate(request.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Dialog open={!!editingMember} onOpenChange={(open) => !open && setEditingMemberId(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
@@ -403,11 +344,28 @@ export function CompanyAccess() {
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Explicit grants</h3>
|
||||
<h3 className="text-sm font-medium">Grants</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border px-3 py-3">
|
||||
<div className="text-sm font-medium">Implicit grants from role</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{implicitGrantKeys.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{implicitGrantKeys.map((permissionKey) => (
|
||||
<Badge key={permissionKey} variant="outline">
|
||||
{permissionLabels[permissionKey]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{PERMISSION_KEYS.map((permissionKey) => (
|
||||
<label
|
||||
@@ -428,6 +386,16 @@ export function CompanyAccess() {
|
||||
<span className="space-y-1">
|
||||
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
|
||||
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
|
||||
{implicitGrantSet.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
|
||||
</span>
|
||||
) : null}
|
||||
{draftGrants.has(permissionKey) ? (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Stored explicitly for this member.
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -472,7 +440,6 @@ function PendingJoinRequestCard({
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
requestId: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
context: string;
|
||||
|
||||
Reference in new issue
Block a user