mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
Simplify core company access UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cdee5ea706
commit
776ad609c5
13 files changed
+218
-164
No files matched your search
@@ -905,6 +905,7 @@ export const PLUGIN_RESERVED_COMPANY_SETTINGS_ROUTE_SEGMENTS = [
|
||||
"general",
|
||||
"environments",
|
||||
"access",
|
||||
"members",
|
||||
"invites",
|
||||
"secrets",
|
||||
] as const;
|
||||
|
||||
+3
-2
@@ -31,7 +31,7 @@ import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess } from "./pages/CompanyAccess";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
@@ -70,7 +70,8 @@ function boardRoutes() {
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
<Route path="company/export/*" element={<CompanyExport />} />
|
||||
<Route path="company/import" element={<CompanyImport />} />
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).toContain("Access");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
expect(container.textContent).toContain("Secrets");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
@@ -139,8 +139,8 @@ describe("CompanySettingsSidebar", () => {
|
||||
);
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/access",
|
||||
label: "Access",
|
||||
to: "/company/settings/members",
|
||||
label: "Members",
|
||||
badge: 2,
|
||||
end: true,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, Shield, SlidersHorizontal } from "lucide-react";
|
||||
import { ChevronLeft, KeyRound, MailPlus, MonitorCog, Puzzle, Settings, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { sidebarBadgesApi } from "@/api/sidebarBadges";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Link } from "@/lib/router";
|
||||
@@ -67,9 +67,9 @@ export function CompanySettingsSidebar() {
|
||||
end
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/company/settings/access"
|
||||
label="Access"
|
||||
icon={Shield}
|
||||
to="/company/settings/members"
|
||||
label="Members"
|
||||
icon={Users}
|
||||
badge={badges?.joinRequests ?? 0}
|
||||
end
|
||||
/>
|
||||
|
||||
@@ -60,27 +60,29 @@ describe("CompanySettingsNav", () => {
|
||||
expect(getCompanySettingsTab("/PAP/company/settings")).toBe("general");
|
||||
expect(getCompanySettingsTab("/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/environments")).toBe("environments");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("access");
|
||||
expect(getCompanySettingsTab("/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/members")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/PAP/company/settings/access")).toBe("members");
|
||||
expect(getCompanySettingsTab("/company/settings/invites")).toBe("invites");
|
||||
});
|
||||
|
||||
it("renders the active tab and navigates when a different tab is selected", async () => {
|
||||
currentPathname = "/PAP/company/settings/access";
|
||||
currentPathname = "/PAP/company/settings/members";
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CompanySettingsNav />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("access");
|
||||
expect(container.textContent).toContain("members");
|
||||
expect(pageTabBarMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: "access",
|
||||
value: "members",
|
||||
items: [
|
||||
{ value: "general", label: "General" },
|
||||
{ value: "environments", label: "Environments" },
|
||||
{ value: "access", label: "Access" },
|
||||
{ value: "members", label: "Members" },
|
||||
{ value: "invites", label: "Invites" },
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "@/lib/router";
|
||||
const items = [
|
||||
{ value: "general", label: "General", href: "/company/settings" },
|
||||
{ value: "environments", label: "Environments", href: "/company/settings/environments" },
|
||||
{ value: "access", label: "Access", href: "/company/settings/access" },
|
||||
{ value: "members", label: "Members", href: "/company/settings/members" },
|
||||
{ value: "invites", label: "Invites", href: "/company/settings/invites" },
|
||||
] as const;
|
||||
|
||||
@@ -16,8 +16,8 @@ export function getCompanySettingsTab(pathname: string): CompanySettingsTab {
|
||||
return "environments";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/access")) {
|
||||
return "access";
|
||||
if (pathname.includes("/company/settings/members") || pathname.includes("/company/settings/access")) {
|
||||
return "members";
|
||||
}
|
||||
|
||||
if (pathname.includes("/company/settings/invites")) {
|
||||
|
||||
@@ -4,23 +4,25 @@ import { act } 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";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
|
||||
|
||||
const listMembersMock = vi.hoisted(() => vi.fn());
|
||||
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
|
||||
const updateMemberMock = vi.hoisted(() => vi.fn());
|
||||
const archiveMemberMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listIssuesMock = vi.hoisted(() => vi.fn());
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = 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(),
|
||||
updateMember: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberMock(companyId, memberId, input),
|
||||
updateMemberPermissions: vi.fn(),
|
||||
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
|
||||
updateMemberAccessMock(companyId, memberId, input),
|
||||
updateMemberAccess: vi.fn(),
|
||||
archiveMember: (companyId: string, memberId: string, input: unknown) =>
|
||||
archiveMemberMock(companyId, memberId, input),
|
||||
approveJoinRequest: vi.fn(),
|
||||
@@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
|
||||
mockNavigate(to, replace);
|
||||
return <div data-testid="navigate">{to}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
selectedCompanyId: "company-1",
|
||||
@@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
updateMemberAccessMock.mockResolvedValue({});
|
||||
updateMemberMock.mockResolvedValue({});
|
||||
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
|
||||
listAgentsMock.mockResolvedValue([
|
||||
{
|
||||
@@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
|
||||
status: "todo",
|
||||
},
|
||||
]);
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
|
||||
it("keeps the page human-focused and hides advanced permission controls", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Manage company user memberships");
|
||||
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
|
||||
expect(container.textContent).toContain("Members can collaborate across the company by default");
|
||||
expect(container.textContent).toContain("Advanced permission controls are available in Paperclip EE");
|
||||
expect(container.textContent).toContain("Humans");
|
||||
expect(container.textContent).toContain("Pending human joins");
|
||||
expect(container.textContent).toContain("User account");
|
||||
expect(container.textContent).not.toContain("Grants");
|
||||
expect(container.textContent).not.toContain("explicit grants");
|
||||
expect(container.textContent).not.toContain("Assign scoped tasks");
|
||||
expect(container.textContent).not.toContain("Agents");
|
||||
expect(container.textContent).not.toContain("Pending agent joins");
|
||||
expect(container.textContent).not.toContain("Open join request queue");
|
||||
@@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
|
||||
});
|
||||
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.",
|
||||
);
|
||||
expect(document.body.textContent).toContain("Update company role and membership status");
|
||||
expect(document.body.textContent).not.toContain("Implicit grants from role");
|
||||
expect(document.body.textContent).not.toContain("permissionKey");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves member role, status, and grants in one request", async () => {
|
||||
it("saves member role and status without touching grants", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
@@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
|
||||
await flushReact();
|
||||
|
||||
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Save access",
|
||||
(button) => button.textContent === "Save member",
|
||||
);
|
||||
expect(saveButton).toBeTruthy();
|
||||
|
||||
@@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
|
||||
membershipRole: "owner",
|
||||
status: "active",
|
||||
grants: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects legacy access deep links to the EE permissions route when installed", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
{
|
||||
type: "companySettingsPage",
|
||||
id: "permissions",
|
||||
displayName: "Permissions",
|
||||
routePath: "permissions",
|
||||
pluginKey: "paperclip-ee",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccessLegacyRoute />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true);
|
||||
expect(container.textContent).toContain("/company/settings/permissions");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a read-only EE unavailable fallback for legacy access deep links", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyAccessLegacyRoute />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Advanced Permissions");
|
||||
expect(container.textContent).toContain("Paperclip EE unavailable");
|
||||
expect(container.textContent).toContain("Open Members");
|
||||
expect(container.textContent).toContain("Open Invites");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
+76
-109
@@ -2,17 +2,14 @@ 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 { 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,38 +22,13 @@ 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";
|
||||
|
||||
const permissionLabels: Record<PermissionKey, string> = {
|
||||
"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",
|
||||
"tasks:manage_active_checkouts": "Manage active task checkouts",
|
||||
"joins:approve": "Approve join requests",
|
||||
"environments:manage": "Manage environments",
|
||||
};
|
||||
|
||||
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<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: [],
|
||||
};
|
||||
import { usePluginSlots } from "@/plugins/slots";
|
||||
|
||||
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();
|
||||
@@ -67,13 +39,12 @@ export function CompanyAccess() {
|
||||
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
|
||||
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
|
||||
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
|
||||
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
{ label: "Settings", href: "/company/settings" },
|
||||
{ label: "Access" },
|
||||
{ label: "Members" },
|
||||
]);
|
||||
}, [selectedCompany?.name, setBreadcrumbs]);
|
||||
|
||||
@@ -103,11 +74,10 @@ export function CompanyAccess() {
|
||||
};
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
|
||||
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
|
||||
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
|
||||
return accessApi.updateMember(selectedCompanyId!, input.memberId, {
|
||||
membershipRole: input.membershipRole,
|
||||
status: input.status,
|
||||
grants: input.grants.map((permissionKey) => ({ permissionKey })),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
@@ -223,7 +193,6 @@ export function CompanyAccess() {
|
||||
if (!editingMember) return;
|
||||
setDraftRole(editingMember.membershipRole);
|
||||
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
|
||||
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
|
||||
}, [editingMember]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -255,8 +224,6 @@ export function CompanyAccess() {
|
||||
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" &&
|
||||
@@ -271,11 +238,14 @@ export function CompanyAccess() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Company Access</h1>
|
||||
<h1 className="text-lg font-semibold">Company Members</h1>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
|
||||
Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Advanced permission controls are available in Paperclip EE. Core keeps this page focused on membership, invite approvals, and safe member removal.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access && !access.currentUserRole && (
|
||||
@@ -291,7 +261,7 @@ export function CompanyAccess() {
|
||||
<h2 className="text-base font-semibold">Humans</h2>
|
||||
</div>
|
||||
<p className="max-w-3xl text-sm text-muted-foreground">
|
||||
Manage human company memberships, status, and grants here.
|
||||
Manage human company memberships and status here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -340,11 +310,10 @@ export function CompanyAccess() {
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div>User account</div>
|
||||
<div>Role</div>
|
||||
<div>Status</div>
|
||||
<div>Grants</div>
|
||||
<div className="text-right">Action</div>
|
||||
</div>
|
||||
{members.length === 0 ? (
|
||||
@@ -356,7 +325,7 @@ export function CompanyAccess() {
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
|
||||
@@ -372,7 +341,6 @@ export function CompanyAccess() {
|
||||
{member.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
|
||||
<div className="space-y-1 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
|
||||
@@ -405,7 +373,7 @@ export function CompanyAccess() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
|
||||
Update company role and membership status for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingMember && (
|
||||
@@ -443,66 +411,6 @@ export function CompanyAccess() {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Grants</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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
|
||||
key={permissionKey}
|
||||
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={draftGrants.has(permissionKey)}
|
||||
onCheckedChange={(checked) => {
|
||||
setDraftGrants((current) => {
|
||||
const next = new Set(current);
|
||||
if (checked) next.add(permissionKey);
|
||||
else next.delete(permissionKey);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
@@ -516,12 +424,11 @@ export function CompanyAccess() {
|
||||
memberId: editingMember.id,
|
||||
membershipRole: draftRole,
|
||||
status: draftStatus,
|
||||
grants: [...draftGrants],
|
||||
});
|
||||
}}
|
||||
disabled={updateMemberMutation.isPending}
|
||||
>
|
||||
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
|
||||
{updateMemberMutation.isPending ? "Saving…" : "Save member"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -616,6 +523,66 @@ export function CompanyAccess() {
|
||||
);
|
||||
}
|
||||
|
||||
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 <Navigate to="/company/settings/permissions" replace />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Checking for Paperclip EE permissions...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Advanced Permissions</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Advanced access, scoped assignment, and explicit grant controls are now owned by the Paperclip EE Permissions plugin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border px-5 py-5">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-sm font-semibold">Paperclip EE unavailable</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires Paperclip EE.
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">Plugin extensions unavailable: {errorMessage}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to="/company/settings/members">Open Members</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/company/settings/invites">Open Invites</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function memberDisplayName(member: CompanyMember | null) {
|
||||
if (!member) return "this member";
|
||||
return member.user?.name?.trim() || member.user?.email || member.principalId;
|
||||
|
||||
@@ -152,7 +152,8 @@ describe("CompanyInvites", () => {
|
||||
expect(container.textContent).toContain("Choose a role");
|
||||
expect(container.textContent).toContain("Each invite link is single-use.");
|
||||
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
|
||||
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
|
||||
expect(container.textContent).toContain("Everything in Admin, plus managing members.");
|
||||
expect(container.textContent).not.toContain("permission grants");
|
||||
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
|
||||
|
||||
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
|
||||
|
||||
@@ -14,8 +14,8 @@ const inviteRoleOptions = [
|
||||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
description: "Can view company work and follow along.",
|
||||
gets: "View-only company membership.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
@@ -32,8 +32,8 @@ const inviteRoleOptions = [
|
||||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
description: "Full company access, including membership management.",
|
||||
gets: "Everything in Admin, plus managing members.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -403,16 +403,16 @@ describe("InviteLandingPage", () => {
|
||||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
expect(container.textContent).toContain("A company admin must approve your request to join.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
);
|
||||
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
|
||||
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
|
||||
|
||||
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
|
||||
(link) => link.textContent === "Company Settings → Access",
|
||||
(link) => link.textContent === "Company Settings → Members",
|
||||
);
|
||||
expect(approvalLinks).toHaveLength(2);
|
||||
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
|
||||
const expectedApprovalUrl = `${window.location.origin}/company/settings/members`;
|
||||
for (const link of approvalLinks) {
|
||||
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
|
||||
}
|
||||
@@ -471,7 +471,7 @@ describe("InviteLandingPage", () => {
|
||||
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Your request is still awaiting approval.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Access to approve your request.",
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
|
||||
claimApiKeyPath = null,
|
||||
onboardingTextUrl = null,
|
||||
}: AwaitingJoinApprovalPanelProps) {
|
||||
const approvalUrl = `${window.location.origin}/company/settings/access`;
|
||||
const approvalUrl = `${window.location.origin}/company/settings/members`;
|
||||
const approverLabel = invitedByUserName ?? "A company admin";
|
||||
|
||||
return (
|
||||
@@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
|
||||
href={approvalUrl}
|
||||
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
|
||||
>
|
||||
Company Settings → Access
|
||||
Company Settings → Members
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Access</a> to approve your request.
|
||||
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings → Members</a> to approve your request.
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Refresh this page after you've been approved — you'll be redirected automatically.
|
||||
|
||||
@@ -23,8 +23,8 @@ const inviteRoleOptions = [
|
||||
{
|
||||
value: "viewer",
|
||||
label: "Viewer",
|
||||
description: "Can view company work and follow along without operational permissions.",
|
||||
gets: "No built-in grants.",
|
||||
description: "Can view company work and follow along.",
|
||||
gets: "View-only company membership.",
|
||||
},
|
||||
{
|
||||
value: "operator",
|
||||
@@ -41,8 +41,8 @@ const inviteRoleOptions = [
|
||||
{
|
||||
value: "owner",
|
||||
label: "Owner",
|
||||
description: "Full company access, including membership and permission management.",
|
||||
gets: "Everything in Admin, plus managing members and permission grants.",
|
||||
description: "Full company access, including membership management.",
|
||||
gets: "Everything in Admin, plus managing members.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -423,8 +423,8 @@ function InviteResultPreview({
|
||||
<>
|
||||
<div className="border border-zinc-800 p-3">
|
||||
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access">
|
||||
Company Settings → Access
|
||||
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
|
||||
Company Settings → Members
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
@@ -897,7 +897,7 @@ export function InviteUxLab() {
|
||||
/>
|
||||
<InviteResultPreview
|
||||
title="Request to join Acme Robotics"
|
||||
description="Ask them to visit Company Settings → Access to approve your request."
|
||||
description="Ask them to visit Company Settings → Members to approve your request."
|
||||
/>
|
||||
</div>
|
||||
</LabSection>
|
||||
|
||||
Reference in new issue
Block a user