From d764e42fea182d96e8a14246bfd88a3099923a92 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 19 May 2026 15:52:56 -0500 Subject: [PATCH] Move EE permissions plugin out of core PR --- Dockerfile | 1 - .../paperclip-ee-permissions/.gitignore | 3 - .../paperclip-ee-permissions/README.md | 67 - .../esbuild.config.mjs | 17 - .../paperclip-ee-permissions/package.json | 45 - .../rollup.config.mjs | 28 - .../paperclip-ee-permissions/src/manifest.ts | 57 - .../paperclip-ee-permissions/src/ui/app.tsx | 1227 ----------------- .../paperclip-ee-permissions/src/ui/index.tsx | 1 - .../paperclip-ee-permissions/src/worker.ts | 574 -------- .../tests/plugin.spec.ts | 394 ------ .../paperclip-ee-permissions/tsconfig.json | 27 - .../paperclip-ee-permissions/vitest.config.ts | 8 - .../__tests__/authorization-service.test.ts | 4 +- ...access-authorization-host-services.test.ts | 10 +- .../CompanySettingsSidebar.test.tsx | 4 +- ui/src/pages/CompanyAccess.test.tsx | 10 +- ui/src/pages/CompanyAccess.tsx | 10 +- .../pages/CompanySettingsPluginPage.test.tsx | 4 +- 19 files changed, 21 insertions(+), 2470 deletions(-) delete mode 100644 packages/plugins/paperclip-ee-permissions/.gitignore delete mode 100644 packages/plugins/paperclip-ee-permissions/README.md delete mode 100644 packages/plugins/paperclip-ee-permissions/esbuild.config.mjs delete mode 100644 packages/plugins/paperclip-ee-permissions/package.json delete mode 100644 packages/plugins/paperclip-ee-permissions/rollup.config.mjs delete mode 100644 packages/plugins/paperclip-ee-permissions/src/manifest.ts delete mode 100644 packages/plugins/paperclip-ee-permissions/src/ui/app.tsx delete mode 100644 packages/plugins/paperclip-ee-permissions/src/ui/index.tsx delete mode 100644 packages/plugins/paperclip-ee-permissions/src/worker.ts delete mode 100644 packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts delete mode 100644 packages/plugins/paperclip-ee-permissions/tsconfig.json delete mode 100644 packages/plugins/paperclip-ee-permissions/vitest.config.ts diff --git a/Dockerfile b/Dockerfile index 3ee0fb81282..03f26942b40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,6 @@ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-lo COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ COPY packages/plugins/sdk/package.json packages/plugins/sdk/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ -COPY packages/plugins/paperclip-ee-permissions/package.json packages/plugins/paperclip-ee-permissions/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/ diff --git a/packages/plugins/paperclip-ee-permissions/.gitignore b/packages/plugins/paperclip-ee-permissions/.gitignore deleted file mode 100644 index 84f3869a736..00000000000 --- a/packages/plugins/paperclip-ee-permissions/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -node_modules -*.tsbuildinfo diff --git a/packages/plugins/paperclip-ee-permissions/README.md b/packages/plugins/paperclip-ee-permissions/README.md deleted file mode 100644 index f3bfe5d700f..00000000000 --- a/packages/plugins/paperclip-ee-permissions/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# @paperclipai/plugin-paperclip-ee-permissions - -Closed-source Paperclip EE plugin that hosts the advanced company access, -grants, and authorization-policy UX. - -## Status - -Phase 5 implementation surface. The plugin registers a `companySettingsPage` -slot at `/permissions`, activates a per-company EE mode stub, and renders the -advanced authorization controls through typed plugin-SDK access and -authorization clients. - -## Capabilities - -- `access.members.read` / `access.members.write` -- `access.invites.read` / `access.invites.write` -- `authorization.grants.read` / `authorization.grants.write` -- `authorization.policies.read` / `authorization.policies.write` -- `authorization.audit.read` -- `companies.read`, `agents.read`, `issues.read` -- `plugin.state.read`, `plugin.state.write` -- `instance.settings.register`, `ui.page.register` - -The plugin never imports from `ui/src/api/access.ts` or any other host UI -internal — all data flows through `ctx.access.*` and `ctx.authorization.*`. - -## Deterministic missing-state UX - -The page renders one of three states: - -| Condition | UI | -| ------------------------------------------ | ----------------------------------------------------- | -| `companyId === null` | "No active company" card | -| License inactive for company | "Advanced permissions mode is not active" with Activate button | -| License active, host returns capability or worker error | Inline warning card; rest of the page still renders | - -## Advanced surfaces - -When licensed, the company settings page exposes: - -- Agent visibility policy editing (`discoverable` / `private`). -- Assignment policy editing with broad, scoped-to-agent, and clear grant modes. -- Protected-agent approval requirement fields. -- Permission preview and explanation panels that call the same core - authorization decision path used by enforcement. -- Authorization audit filters for actor, resource, action, and decision. - -The license check is a per-company plugin-state stub today; later phases -replace it with a core-owned `advanced_permissions_enabled` flag. - -## Scripts - -- `pnpm build` — bundle worker, manifest, and UI via esbuild -- `pnpm test` — run vitest harness tests for the worker - -## Layout - -``` -src/ - manifest.ts # Slot + capability declarations - worker.ts # getData / performAction handlers - ui/ - index.tsx # Re-exports the slot component - app.tsx # EePermissionsCompanySettingsPage -tests/ - plugin.spec.ts # Worker harness tests -``` diff --git a/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs b/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs deleted file mode 100644 index b5cfd36ed95..00000000000 --- a/packages/plugins/paperclip-ee-permissions/esbuild.config.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import esbuild from "esbuild"; -import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; - -const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); -const watch = process.argv.includes("--watch"); - -const workerCtx = await esbuild.context(presets.esbuild.worker); -const manifestCtx = await esbuild.context(presets.esbuild.manifest); -const uiCtx = await esbuild.context(presets.esbuild.ui); - -if (watch) { - await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); - console.log("esbuild watch mode enabled for worker, manifest, and ui"); -} else { - await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); - await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); -} diff --git a/packages/plugins/paperclip-ee-permissions/package.json b/packages/plugins/paperclip-ee-permissions/package.json deleted file mode 100644 index 29d7b4ff7f2..00000000000 --- a/packages/plugins/paperclip-ee-permissions/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@paperclipai/plugin-paperclip-ee-permissions", - "version": "0.1.0", - "type": "module", - "private": true, - "description": "Paperclip EE plugin that hosts advanced company access, grants, and authorization-policy UX.", - "scripts": { - "prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps", - "build": "node ./esbuild.config.mjs", - "build:rollup": "rollup -c", - "dev": "node ./esbuild.config.mjs --watch", - "test": "vitest run --config ./vitest.config.ts", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" - }, - "paperclipPlugin": { - "manifest": "./dist/manifest.js", - "worker": "./dist/worker.js", - "ui": "./dist/ui/" - }, - "keywords": [ - "paperclip", - "plugin", - "ee", - "permissions", - "authorization" - ], - "author": "Paperclip", - "license": "UNLICENSED", - "devDependencies": { - "@paperclipai/plugin-sdk": "workspace:*", - "@paperclipai/shared": "workspace:*", - "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-typescript": "^12.1.2", - "@types/node": "^24.6.0", - "@types/react": "^19.0.8", - "esbuild": "^0.27.3", - "rollup": "^4.38.0", - "tslib": "^2.8.1", - "typescript": "^5.7.3", - "vitest": "^3.0.5" - }, - "peerDependencies": { - "react": ">=18" - } -} diff --git a/packages/plugins/paperclip-ee-permissions/rollup.config.mjs b/packages/plugins/paperclip-ee-permissions/rollup.config.mjs deleted file mode 100644 index ccee40a7443..00000000000 --- a/packages/plugins/paperclip-ee-permissions/rollup.config.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { nodeResolve } from "@rollup/plugin-node-resolve"; -import typescript from "@rollup/plugin-typescript"; -import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; - -const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); - -function withPlugins(config) { - if (!config) return null; - return { - ...config, - plugins: [ - nodeResolve({ - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], - }), - typescript({ - tsconfig: "./tsconfig.json", - declaration: false, - declarationMap: false, - }), - ], - }; -} - -export default [ - withPlugins(presets.rollup.manifest), - withPlugins(presets.rollup.worker), - withPlugins(presets.rollup.ui), -].filter(Boolean); diff --git a/packages/plugins/paperclip-ee-permissions/src/manifest.ts b/packages/plugins/paperclip-ee-permissions/src/manifest.ts deleted file mode 100644 index e5ac1898040..00000000000 --- a/packages/plugins/paperclip-ee-permissions/src/manifest.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; - -export const PLUGIN_ID = "paperclipai.plugin-paperclip-ee-permissions"; - -export const SLOT_IDS = { - companySettingsPage: "ee-permissions-company-settings-page", -} as const; - -export const EXPORT_NAMES = { - companySettingsPage: "EePermissionsCompanySettingsPage", -} as const; - -const manifest: PaperclipPluginManifestV1 = { - id: PLUGIN_ID, - apiVersion: 1, - version: "0.1.0", - displayName: "Paperclip EE Permissions", - description: - "Advanced company access, grants, and authorization-policy UX for licensed Paperclip EE deployments.", - author: "Paperclip", - categories: ["ui"], - capabilities: [ - "companies.read", - "agents.read", - "issues.read", - "access.members.read", - "access.invites.read", - "authorization.grants.read", - "authorization.policies.read", - "authorization.audit.read", - "access.members.write", - "access.invites.write", - "authorization.grants.write", - "authorization.policies.write", - "plugin.state.read", - "plugin.state.write", - "instance.settings.register", - "ui.page.register", - ], - entrypoints: { - worker: "./dist/worker.js", - ui: "./dist/ui", - }, - ui: { - slots: [ - { - type: "companySettingsPage", - id: SLOT_IDS.companySettingsPage, - displayName: "Permissions", - exportName: EXPORT_NAMES.companySettingsPage, - routePath: "permissions", - }, - ], - }, -}; - -export default manifest; diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx deleted file mode 100644 index 08b20ecf106..00000000000 --- a/packages/plugins/paperclip-ee-permissions/src/ui/app.tsx +++ /dev/null @@ -1,1227 +0,0 @@ -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; -import { - AssigneePicker, - DataTable, - ErrorBoundary, - JsonTree, - KeyValueList, - MetricCard, - ProjectPicker, - Spinner, - StatusBadge, - useHostContext, - usePluginAction, - usePluginData, - type DataTableColumn, - type PluginCompanySettingsPageProps, - type StatusBadgeVariant, -} from "@paperclipai/plugin-sdk/ui"; - -type HumanCompanyMembershipRole = "owner" | "admin" | "operator" | "viewer"; -type MemberEditableStatus = "pending" | "active" | "suspended"; -type MemberPrincipalType = "user" | "agent"; -type PermissionKey = - | "agents:create" - | "environments:manage" - | "users:invite" - | "users:manage_permissions" - | "tasks:assign" - | "tasks:assign_scope" - | "tasks:manage_active_checkouts" - | "joins:approve"; - -const HUMAN_ROLE_LABELS: Record = { - owner: "Owner", - admin: "Admin", - operator: "Operator", - viewer: "Viewer", -}; - -const MEMBER_PERMISSION_KEYS: ReadonlyArray = [ - "agents:create", - "users:invite", - "users:manage_permissions", - "tasks:assign", - "tasks:manage_active_checkouts", - "joins:approve", - "environments:manage", -]; - -const PERMISSION_LABELS: 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", - "tasks:manage_active_checkouts": "Manage active task checkouts", - "joins:approve": "Approve join requests", - "environments:manage": "Manage environments", -}; - -const IMPLICIT_ROLE_GRANTS: Record = { - 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: [], -}; - -type MemberRecord = { - id: string; - companyId: string; - principalType: MemberPrincipalType; - principalId: string; - status: MemberEditableStatus | "archived"; - membershipRole: HumanCompanyMembershipRole | string | null; - grants: Array<{ permissionKey: string; scope: Record | null }>; -}; - -type AgentRecord = { - id: string; - name: string; - role?: string | null; - title?: string | null; - status?: string | null; -}; - -type MemberAccessData = { - companyId: string; - warnings: Array<{ code: string; message: string }>; - members: MemberRecord[]; - agents?: AgentRecord[]; -}; - -type LicenseState = { - status: "active" | "inactive"; - activatedAt?: string; - activatedByUserId?: string | null; - note?: string | null; -}; - -type PolicySummary = { - companyId: string; - permissionsMode: "simple"; - memberCount: number; - activeMemberCount: number; - grantCount: number; - advancedPolicyAvailable: false; -}; - -type IssueRecord = { - id: string; - title: string; - status: string; - projectId?: string | null; -}; - -type DecisionRecord = { - allowed: boolean; - action: string; - explanation: string; - reason: string; - grant?: { - permissionKey: string; - scope: Record | null; - }; -}; - -type Overview = { - companyId: string; - license: LicenseState; - policySummary: PolicySummary | null; - warnings: Array<{ code: string; message: string }>; -}; - -type AuditEntry = { - id: string; - actorType: string; - actorId: string; - action: string; - entityType: string; - entityId: string; - details: Record | null; - createdAt: string; -}; - -type AdvancedPolicyData = { - companyId: string; - summary: PolicySummary | null; - warnings: Overview["warnings"]; - agents: AgentRecord[]; - issues: IssueRecord[]; - selected: { - actorAgentId: string | null; - targetAgentId: string | null; - projectId: string | null; - issueId: string | null; - }; - agentPolicy: { - resourceType: string; - resourceId: string; - policy: Record | null; - updatedAt: string | null; - } | null; - actorGrants: Array<{ - permissionKey: string; - scope: Record | null; - }>; - preview: DecisionRecord | null; - explanation: DecisionRecord | null; - auditEntries: AuditEntry[]; -}; - -const layoutStack: CSSProperties = { - display: "grid", - gap: "16px", - padding: "24px 0", -}; - -const cardStyle: CSSProperties = { - border: "1px solid var(--border, #e2e8f0)", - borderRadius: "8px", - padding: "16px", - display: "grid", - gap: "12px", - background: "var(--card, #ffffff)", -}; - -const subtleCardStyle: CSSProperties = { - border: "1px solid var(--border, #e2e8f0)", - borderRadius: "8px", - padding: "12px", - display: "grid", - gap: "8px", - background: "var(--background, transparent)", -}; - -const mutedTextStyle: CSSProperties = { - color: "var(--muted-foreground, #64748b)", - fontSize: "0.9rem", - lineHeight: 1.5, -}; - -const sectionHeadingStyle: CSSProperties = { - fontSize: "0.75rem", - letterSpacing: "0.04em", - textTransform: "uppercase", - color: "var(--muted-foreground, #64748b)", -}; - -const rowStyle: CSSProperties = { - display: "flex", - alignItems: "center", - flexWrap: "wrap", - gap: "8px", -}; - -const gridStyle: CSSProperties = { - display: "grid", - gap: "12px", - gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", -}; - -const inputStyle: CSSProperties = { - width: "100%", - border: "1px solid var(--border, #cbd5e1)", - borderRadius: "6px", - padding: "8px 10px", - background: "var(--background, transparent)", - color: "inherit", - fontSize: "0.85rem", -}; - -const buttonStyle: CSSProperties = { - padding: "7px 12px", - borderRadius: "6px", - border: "1px solid var(--border, #cbd5e1)", - background: "var(--background, transparent)", - color: "inherit", - cursor: "pointer", -}; - -const primaryButtonStyle: CSSProperties = { - ...buttonStyle, - background: "var(--foreground, #0f172a)", - color: "var(--background, #ffffff)", -}; - -const warningStyle: CSSProperties = { - border: "1px solid var(--warning-border, #facc15)", - background: "var(--warning-muted, #fefce8)", - borderRadius: "8px", - padding: "10px 12px", - color: "var(--warning-foreground, #713f12)", -}; - -const fieldStyle: CSSProperties = { - display: "grid", - gap: "6px", -}; - -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function statusForDecision(allowed: boolean): StatusBadgeVariant { - return allowed ? "ok" : "error"; -} - -function decisionLabel(allowed: boolean): string { - return allowed ? "✓ Allowed" : "✕ Denied"; -} - -function membershipStatusVariant(status: string): StatusBadgeVariant { - if (status === "active") return "ok"; - if (status === "pending") return "pending"; - if (status === "suspended" || status === "archived") return "warning"; - return "info"; -} - -function formatPermission(permissionKey: string): string { - return PERMISSION_LABELS[permissionKey as PermissionKey] ?? permissionKey; -} - -function formatScope(scope: Record | null | undefined): string { - if (!scope || Object.keys(scope).length === 0) return "Any scope"; - const parts = Object.entries(scope).map(([key, value]) => `${key}: ${String(value)}`); - return parts.join(", "); -} - -function formatMode(value: string | null | undefined): string { - if (!value) return "Not set"; - return value.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); -} - -function formatDate(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); -} - -function getPolicySection(policy: Record | null | undefined, key: string): Record { - const value = policy?.[key]; - return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -} - -function getPolicyString(policy: Record | null | undefined, section: string, key: string, fallback: string) { - const value = getPolicySection(policy, section)[key]; - return typeof value === "string" ? value : fallback; -} - -function getPolicyBoolean(policy: Record | null | undefined, section: string, key: string, fallback: boolean) { - const value = getPolicySection(policy, section)[key]; - return typeof value === "boolean" ? value : fallback; -} - -function RawDisclosure({ label = "Raw response", data }: { label?: string; data: unknown }) { - return ( -
- {label} - -
- ); -} - -function CapabilityWarning({ warnings }: { warnings: Overview["warnings"] }) { - if (warnings.length === 0) return null; - const denied = warnings.some((warning) => warning.code === "CAPABILITY_DENIED"); - return ( -
- {denied ? "Some advanced data is unavailable." : "Some advanced data could not be loaded."} -
- {denied - ? "The plugin is missing one or more capability grants. Install the latest version or re-activate the plugin to restore the missing surfaces." - : "Existing restrictions remain enforced by core. Retry once the underlying service is reachable."} -
-
    - {warnings.map((warning, index) => ( -
  • - {warning.code}: {warning.message} -
  • - ))} -
-
- ); -} - -function LoadingState({ label }: { label: string }) { - return ( -
- - {label} -
- ); -} - -function MissingCompanyState() { - return ( -
-
-
Permissions
- No active company -
Switch into a company to manage advanced permissions.
-
-
- ); -} - -function UnlicensedState({ - companyId, - onActivate, - activating, -}: { - companyId: string; - onActivate: () => void; - activating: boolean; -}) { - return ( -
-
-
Paperclip EE Permissions
- Advanced permissions mode is not active -
- Members can collaborate across this company by default. Activate Paperclip EE permissions to unlock scoped grants, protected-agent controls, assignment previews, and audit filters. -
-
- -
- {companyId} }]} /> -
-
- ); -} - -type PrincipalProfile = { - label: string; - secondary: string; -}; - -function profileForPrincipal(member: MemberRecord, agents: AgentRecord[]): PrincipalProfile { - if (member.principalType === "agent") { - const agent = agents.find((entry) => entry.id === member.principalId); - if (agent) { - return { - label: agent.name, - secondary: [agent.title || agent.role, agent.status, agent.id].filter(Boolean).join(" / "), - }; - } - return { - label: "Agent", - secondary: member.principalId, - }; - } - if (member.principalId.includes("@")) { - const [localPart] = member.principalId.split("@"); - return { - label: localPart.split(/[._-]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") || member.principalId, - secondary: member.principalId, - }; - } - return { - label: "Board user", - secondary: member.principalId, - }; -} - -type MemberTableRow = { - id: string; - member: MemberRecord; - principal: ReactNode; - role: ReactNode; - status: ReactNode; - grants: ReactNode; - action: ReactNode; -}; - -function MembersPanel({ companyId }: { companyId: string }) { - const query = usePluginData("memberAccess", { companyId }); - const saveMemberAccess = usePluginAction("saveMemberAccess"); - const [editingMemberId, setEditingMemberId] = useState(null); - const [draftRole, setDraftRole] = useState(""); - const [draftStatus, setDraftStatus] = useState("active"); - const [draftGrants, setDraftGrants] = useState>(new Set()); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const members = query.data?.members ?? []; - const agents = query.data?.agents ?? []; - const editingMember = useMemo( - () => members.find((member) => member.id === editingMemberId) ?? null, - [members, editingMemberId], - ); - const editingProfile = editingMember ? profileForPrincipal(editingMember, agents) : null; - const implicitGrantKeys = useMemo( - () => (draftRole ? IMPLICIT_ROLE_GRANTS[draftRole] : []), - [draftRole], - ); - - useEffect(() => { - if (!editingMember) return; - const role = editingMember.membershipRole; - setDraftRole((role && role in HUMAN_ROLE_LABELS ? (role as HumanCompanyMembershipRole) : "")); - setDraftStatus( - editingMember.status === "active" || editingMember.status === "pending" || editingMember.status === "suspended" - ? editingMember.status - : "suspended", - ); - setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey as PermissionKey))); - setError(null); - }, [editingMember]); - - if (query.loading && !query.data) { - return ; - } - - if (query.error) { - return ( -
- Could not load company members. -
{query.error.message}
-
- ); - } - - const pendingHumans = members.filter((member) => member.principalType === "user" && member.status === "pending"); - const activeHumans = members.filter((member) => member.principalType === "user" && member.status !== "pending"); - const agentMembers = members.filter((member) => member.principalType === "agent"); - const closeEditor = () => { - setEditingMemberId(null); - setError(null); - }; - - async function save() { - if (!editingMember) return; - setBusy(true); - setError(null); - try { - await saveMemberAccess({ - companyId, - memberId: editingMember.id, - membershipRole: draftRole || null, - status: draftStatus, - grants: [...draftGrants], - }); - query.refresh(); - setEditingMemberId(null); - } catch (err) { - setError(getErrorMessage(err)); - } finally { - setBusy(false); - } - } - - return ( -
- - -
-
-
-
Members
- Company access -
- -
-
- Roles, membership status, and explicit permission grants for humans and agents in this company. -
- {members.length === 0 ? ( -
No company members yet.
- ) : null} - - - -
- - {editingMember && editingProfile ? ( -
-
-
Editing member
- -
- {editingProfile.label} -
{editingProfile.secondary}
- -
- - -
- -
-
- Implicit grants from role - -
-
- {draftRole - ? `${HUMAN_ROLE_LABELS[draftRole]} already includes these permissions automatically.` - : "No role selected, so this member has no implicit grants right now."} -
- {implicitGrantKeys.length > 0 ? ( -
- {implicitGrantKeys.map((permissionKey) => ( - - ))} -
- ) : null} -
- -
-
Explicit grants
-
- Explicit grants persist when the role changes. Scoped assignment grants are managed in the policy editor below. -
-
- {MEMBER_PERMISSION_KEYS.map((permissionKey) => { - const isImplicit = implicitGrantKeys.includes(permissionKey); - const isChecked = draftGrants.has(permissionKey); - return ( -
- - {isImplicit && !isChecked ? ( -
Included implicitly by the {draftRole ? HUMAN_ROLE_LABELS[draftRole] : "selected"} role.
- ) : null} - {isChecked ? ( -
Stored explicitly for this member.
- ) : null} -
- ); - })} -
-
- - {error ?
Could not save: {error}
: null} - -
- - -
-
- ) : null} -
- ); -} - -function MembersTable({ - members, - agents, - label, - emptyLabel, - onEdit, -}: { - members: MemberRecord[]; - agents: AgentRecord[]; - label: string; - emptyLabel: string; - onEdit: (memberId: string) => void; -}) { - const columns: DataTableColumn[] = [ - { key: "principal", header: "Principal", render: (_value, row) => row.principal }, - { key: "role", header: "Role", render: (_value, row) => row.role, width: "140px" }, - { key: "status", header: "Status", render: (_value, row) => row.status, width: "120px" }, - { key: "grants", header: "Grants", render: (_value, row) => row.grants }, - { key: "action", header: "", render: (_value, row) => row.action, width: "80px" }, - ]; - const rows: MemberTableRow[] = members.map((member) => { - const profile = profileForPrincipal(member, agents); - return { - id: member.id, - member, - principal: ( -
- {profile.label} - {profile.secondary} -
- ), - role: member.membershipRole ? formatMode(member.membershipRole) : "Unset", - status: , - grants: `${member.grants.length} explicit grant${member.grants.length === 1 ? "" : "s"}`, - action: , - }; - }); - return ( -
-
- {label} - 0 ? "info" : "pending"} /> -
- >[]} - rows={rows as unknown as Record[]} - emptyMessage={emptyLabel} - /> -
- ); -} - -function GrantRows({ grants }: { grants: AdvancedPolicyData["actorGrants"] }) { - if (grants.length === 0) { - return
No assignment grants for this actor yet.
; - } - return ( -
- {grants.map((grant, index) => ( -
- - {formatScope(grant.scope)} -
- ))} -
- ); -} - -function CurrentAgentPolicy({ policy }: { policy: AdvancedPolicyData["agentPolicy"] }) { - if (!policy?.policy) { - return
No saved policy. Saving below will create one.
; - } - const visibilityMode = getPolicyString(policy.policy, "agentVisibility", "mode", "discoverable"); - const assignmentMode = getPolicyString(policy.policy, "assignmentPolicy", "mode", "company_default"); - const requiresApproval = getPolicyBoolean(policy.policy, "protectedAgent", "requiresApproval", false); - const approvalReason = getPolicyString(policy.policy, "protectedAgent", "approvalReason", ""); - return ( - - {formatMode(visibilityMode)} - - ), - }, - { - label: "Assignment", - value: ( - - {formatMode(assignmentMode)} - - ), - }, - { - label: "Protected agent", - value: requiresApproval - ? `Requires approval${approvalReason ? `: "${approvalReason}"` : ""}` - : "No approval required", - }, - ]} - /> - ); -} - -function DecisionCard({ title, decision }: { title: string; decision: DecisionRecord | null }) { - if (!decision) return null; - return ( -
-
-
{title}
- -
-
{decision.explanation}
- - -
- ); -} - -function AuthorizationAudit({ - entries, - agents, -}: { - entries: AuditEntry[]; - agents: AgentRecord[]; -}) { - type AuditRow = { - id: string; - time: string; - actor: ReactNode; - action: string; - resource: string; - decision: ReactNode; - details: ReactNode; - }; - const columns: DataTableColumn[] = [ - { key: "time", header: "Time", render: (_value, row) => row.time, width: "170px" }, - { key: "actor", header: "Actor", render: (_value, row) => row.actor }, - { key: "action", header: "Action", render: (_value, row) => row.action }, - { key: "resource", header: "Resource", render: (_value, row) => row.resource }, - { key: "decision", header: "Decision", render: (_value, row) => row.decision, width: "120px" }, - { key: "details", header: "Details", render: (_value, row) => row.details }, - ]; - const rows: AuditRow[] = entries.map((entry) => { - const agent = entry.actorType === "agent" ? agents.find((candidate) => candidate.id === entry.actorId) : null; - const decision = entry.details?.decision === "deny" ? false : entry.details?.decision === "allow" ? true : null; - return { - id: entry.id, - time: formatDate(entry.createdAt), - actor: ( -
- {agent?.name ?? formatMode(entry.actorType)} - {entry.actorId} -
- ), - action: formatPermission(entry.action), - resource: `${entry.entityType} / ${entry.entityId}`, - decision: decision === null - ? - : , - details: , - }; - }); - return ( - >[]} - rows={rows as unknown as Record[]} - emptyMessage="No authorization decisions in this filter window yet. Adjust the filters above to broaden the audit search." - /> - ); -} - -function AdvancedPolicyEditor({ companyId }: { companyId: string }) { - const saveAgentPolicy = usePluginAction("saveAgentPolicy"); - const saveAssignmentGrant = usePluginAction("saveAssignmentGrant"); - const [actorAgentId, setActorAgentId] = useState(""); - const [targetAgentId, setTargetAgentId] = useState(""); - const [projectId, setProjectId] = useState(""); - const [issueId, setIssueId] = useState(""); - const [auditAction, setAuditAction] = useState(""); - const [auditActorType, setAuditActorType] = useState(""); - const [auditEntityType, setAuditEntityType] = useState(""); - const [auditEntityId, setAuditEntityId] = useState(""); - const [auditDecision, setAuditDecision] = useState(""); - const [visibilityMode, setVisibilityMode] = useState("discoverable"); - const [assignmentMode, setAssignmentMode] = useState("company_default"); - const [requiresApproval, setRequiresApproval] = useState(false); - const [approvalReason, setApprovalReason] = useState(""); - const [grantMode, setGrantMode] = useState("scoped_agent"); - const [busyAction, setBusyAction] = useState(null); - const [lastResult, setLastResult] = useState(null); - const params = useMemo(() => ({ - companyId, - actorAgentId, - targetAgentId, - projectId, - issueId, - auditAction, - auditActorType, - auditEntityType, - auditEntityId, - auditDecision, - }), [companyId, actorAgentId, targetAgentId, projectId, issueId, auditAction, auditActorType, auditEntityType, auditEntityId, auditDecision]); - const query = usePluginData("advancedPolicy", params); - const data = query.data; - - useEffect(() => { - if (!data) return; - if (!actorAgentId && data.selected.actorAgentId) setActorAgentId(data.selected.actorAgentId); - if (!targetAgentId && data.selected.targetAgentId) setTargetAgentId(data.selected.targetAgentId); - if (!projectId && data.selected.projectId) setProjectId(data.selected.projectId); - if (!issueId && data.selected.issueId) setIssueId(data.selected.issueId); - }, [actorAgentId, data, issueId, projectId, targetAgentId]); - - useEffect(() => { - const policy = data?.agentPolicy?.policy; - setVisibilityMode(getPolicyString(policy, "agentVisibility", "mode", "discoverable")); - setAssignmentMode(getPolicyString(policy, "assignmentPolicy", "mode", "company_default")); - setRequiresApproval(getPolicyBoolean(policy, "protectedAgent", "requiresApproval", false)); - setApprovalReason(getPolicyString(policy, "protectedAgent", "approvalReason", "")); - }, [data?.agentPolicy?.resourceId, data?.agentPolicy?.updatedAt]); - - const issueOptions = useMemo( - () => (data?.issues ?? []).filter((issue) => !projectId || issue.projectId === projectId), - [data?.issues, projectId], - ); - - async function run(label: string, action: () => Promise) { - setBusyAction(label); - try { - const result = await action(); - setLastResult(result); - query.refresh(); - } catch (error) { - setLastResult({ error: getErrorMessage(error) }); - } finally { - setBusyAction(null); - } - } - - if (query.loading && !data) { - return ; - } - - if (query.error) { - return ( -
- Advanced policy APIs unavailable. -
{query.error.message}
-
- ); - } - - const hasPreviewSelection = Boolean(actorAgentId && targetAgentId); - - return ( -
- - -
- - - -
- -
-
-
Policy preview
- Check assignment decisions before saving policy changes -
-
- - - - -
- {!hasPreviewSelection ? ( -
Select an actor and target agent to preview a policy decision.
- ) : ( -
- - -
- )} -
- -
-
-
Agent Visibility
- - - - - -
- -
-
Assignment Policy
- - - -
-
- -
-
Current Agent Policy
- -
- -
-
-
-
Authorization Audit
- Recent authorization decisions -
- -
-
- - - - - -
- -
- - {lastResult ? : null} -
- ); -} - -function EePermissionsCompanySettingsPageContent(_props: PluginCompanySettingsPageProps) { - const hostContext = useHostContext(); - const companyId = hostContext.companyId; - const overview = usePluginData("overview", companyId ? { companyId } : {}); - const activate = usePluginAction("activateLicense"); - const deactivate = usePluginAction("deactivateLicense"); - const [activationBusy, setActivationBusy] = useState(false); - - if (!companyId) return ; - - if (overview.loading && !overview.data) { - return ( -
-
- -
-
- ); - } - - if (overview.error) { - return ( -
-
-
Permissions
- Could not load permissions -
- {overview.error.code}: {overview.error.message} -
-
-
- ); - } - - const data = overview.data; - if (!data) { - return ( -
-
-
No permissions data returned yet.
-
-
- ); - } - - if (data.license.status !== "active") { - return ( - { - setActivationBusy(true); - void activate({ companyId }) - .then(() => overview.refresh()) - .finally(() => setActivationBusy(false)); - }} - /> - ); - } - - return ( -
-
-
- Advanced policy editing is active - -
- About enforcement -
- Policy data stays in core. If this plugin is unavailable later, existing restrictions remain server-enforced. -
-
-
-
- -
-
- - - -
- ); -} - -export function EePermissionsCompanySettingsPage(props: PluginCompanySettingsPageProps) { - return ( - The Paperclip EE permissions UI could not render.}> - - - ); -} diff --git a/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx b/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx deleted file mode 100644 index 3860a02129e..00000000000 --- a/packages/plugins/paperclip-ee-permissions/src/ui/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { EePermissionsCompanySettingsPage } from "./app.js"; diff --git a/packages/plugins/paperclip-ee-permissions/src/worker.ts b/packages/plugins/paperclip-ee-permissions/src/worker.ts deleted file mode 100644 index 38c580cc094..00000000000 --- a/packages/plugins/paperclip-ee-permissions/src/worker.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { - definePlugin, - runWorker, - PLUGIN_RPC_ERROR_CODES, - type HumanCompanyMembershipRole, - type MembershipStatus, - type PluginAccessMember, - type PluginAuthorizationAuditEntry, - type PluginAuthorizationPolicySummary, - type PluginContext, - type Issue, - type PermissionKey, - type PrincipalPermissionGrant, -} from "@paperclipai/plugin-sdk"; - -const LICENSE_STATE_SCOPE = "company"; -const LICENSE_STATE_KEY = "license"; - -/** - * Worker-visible license state. Phase 3A scaffolds a deterministic stub: - * the plugin defaults to "unlicensed" unless an operator has explicitly - * activated the EE permissions mode for the company. Later phases replace - * this with a real license check tied to a core-owned advanced-mode flag. - */ -export type EePermissionsLicense = { - status: "active" | "inactive"; - activatedAt?: string; - activatedByUserId?: string | null; - note?: string | null; -}; - -const DEFAULT_LICENSE: EePermissionsLicense = { status: "inactive" }; - -/** - * Shape returned by `getData("overview", { companyId })`. The UI uses this - * as the single source of truth for whether to render the advanced controls - * or one of the deterministic missing-state fallbacks. - */ -export type EePermissionsOverview = { - companyId: string; - license: EePermissionsLicense; - policySummary: PluginAuthorizationPolicySummary | null; - warnings: Array<{ code: string; message: string }>; -}; - -type AssignmentGrantMode = "broad" | "scoped_agent" | "clear"; - -const HUMAN_ROLE_VALUES: readonly HumanCompanyMembershipRole[] = ["owner", "admin", "operator", "viewer"]; -const MEMBER_EDITABLE_STATUSES: ReadonlyArray> = [ - "pending", - "active", - "suspended", -]; -const NON_MEMBER_PERMISSION_KEYS: ReadonlySet = new Set([ - "tasks:assign_scope", -]); - -export type EePermissionsMemberAccessData = { - companyId: string; - warnings: Array<{ code: string; message: string }>; - members: PluginAccessMember[]; - agents: Awaited>; -}; - -export type EePermissionsAdvancedPolicyData = { - companyId: string; - summary: PluginAuthorizationPolicySummary | null; - warnings: Array<{ code: string; message: string }>; - agents: Awaited>; - issues: Issue[]; - selected: { - actorAgentId: string | null; - targetAgentId: string | null; - projectId: string | null; - issueId: string | null; - }; - agentPolicy: Awaited>; - actorGrants: PrincipalPermissionGrant[]; - preview: Awaited> | null; - explanation: Awaited> | null; - auditEntries: PluginAuthorizationAuditEntry[]; -}; - -function readCompanyId(params: Record): string { - const value = params.companyId; - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error("companyId is required"); - } - return value; -} - -function readNumber(value: unknown, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -function readStringOrUndefined(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; -} - -function readString(params: Record, key: string): string { - const value = params[key]; - return typeof value === "string" ? value : ""; -} - -function readBoolean(params: Record, key: string, fallback = false): boolean { - const value = params[key]; - return typeof value === "boolean" ? value : fallback; -} - -function readGrantMode(value: unknown): AssignmentGrantMode { - if (value === "broad" || value === "scoped_agent" || value === "clear") return value; - return "scoped_agent"; -} - -function readHumanRole(value: unknown): HumanCompanyMembershipRole | null { - if (value === null) return null; - if (typeof value !== "string") return null; - if (value === "") return null; - return (HUMAN_ROLE_VALUES as readonly string[]).includes(value) - ? (value as HumanCompanyMembershipRole) - : null; -} - -function readEditableStatus(value: unknown): Extract { - if (typeof value === "string" && (MEMBER_EDITABLE_STATUSES as readonly string[]).includes(value)) { - return value as Extract; - } - return "active"; -} - -function readPermissionKeyArray(value: unknown): PermissionKey[] { - if (!Array.isArray(value)) return []; - const out: PermissionKey[] = []; - for (const entry of value) { - if (typeof entry === "string" && !out.includes(entry as PermissionKey)) { - out.push(entry as PermissionKey); - } - } - return out; -} - -function withoutAssignmentGrants(grants: PrincipalPermissionGrant[]) { - return grants - .filter((grant) => grant.permissionKey !== "tasks:assign" && grant.permissionKey !== "tasks:assign_scope") - .map((grant) => ({ - permissionKey: grant.permissionKey as PermissionKey, - scope: grant.scope && typeof grant.scope === "object" ? grant.scope as Record : null, - })); -} - -function isCapabilityDenied(error: unknown): boolean { - if (!error) return false; - const code = (error as { code?: unknown }).code; - if (code === PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED) return true; - if (code === "CAPABILITY_DENIED") return true; - const message = error instanceof Error ? error.message : String(error); - return /missing required capability/i.test(message); -} - -function formatError(error: unknown): { code: string; message: string } { - if (isCapabilityDenied(error)) { - return { - code: "CAPABILITY_DENIED", - message: error instanceof Error ? error.message : String(error), - }; - } - return { - code: "WORKER_ERROR", - message: error instanceof Error ? error.message : String(error), - }; -} - -async function readLicense( - ctx: PluginContext, - companyId: string, -): Promise { - const value = (await ctx.state.get({ - scopeKind: LICENSE_STATE_SCOPE, - scopeId: companyId, - stateKey: LICENSE_STATE_KEY, - })) as EePermissionsLicense | null; - if (!value || typeof value !== "object") return DEFAULT_LICENSE; - return value.status === "active" ? value : DEFAULT_LICENSE; -} - -const plugin = definePlugin({ - async setup(ctx) { - ctx.data.register("overview", async (params) => { - const companyId = readCompanyId(params ?? {}); - const license = await readLicense(ctx, companyId); - const warnings: EePermissionsOverview["warnings"] = []; - if (license.status !== "active") { - return { - companyId, - license, - policySummary: null, - warnings, - } satisfies EePermissionsOverview; - } - let policySummary: PluginAuthorizationPolicySummary | null = null; - try { - policySummary = await ctx.authorization.policies.summary(companyId); - } catch (error) { - warnings.push(formatError(error)); - } - return { - companyId, - license, - policySummary, - warnings, - } satisfies EePermissionsOverview; - }); - - ctx.data.register("members", async (params) => { - const companyId = readCompanyId(params ?? {}); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") return [] as PluginAccessMember[]; - try { - return await ctx.access.members.list({ companyId }); - } catch (error) { - if (isCapabilityDenied(error)) return [] as PluginAccessMember[]; - throw error; - } - }); - - ctx.data.register("memberAccess", async (params) => { - const companyId = readCompanyId(params ?? {}); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") { - return { - companyId, - warnings: [], - members: [], - agents: [], - } satisfies EePermissionsMemberAccessData; - } - const warnings: EePermissionsMemberAccessData["warnings"] = []; - let members: PluginAccessMember[] = []; - let agents: EePermissionsMemberAccessData["agents"] = []; - try { - members = await ctx.access.members.list({ companyId }); - } catch (error) { - warnings.push(formatError(error)); - } - try { - agents = await ctx.agents.list({ companyId, limit: 200, offset: 0 }); - } catch (error) { - warnings.push(formatError(error)); - } - return { companyId, warnings, members, agents } satisfies EePermissionsMemberAccessData; - }); - - ctx.data.register("grants", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") return [] as PrincipalPermissionGrant[]; - try { - return await ctx.authorization.grants.list({ - companyId, - principalType: readStringOrUndefined(input.principalType) as - | PrincipalPermissionGrant["principalType"] - | undefined, - principalId: readStringOrUndefined(input.principalId), - }); - } catch (error) { - if (isCapabilityDenied(error)) return [] as PrincipalPermissionGrant[]; - throw error; - } - }); - - ctx.data.register("audit", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") return [] as PluginAuthorizationAuditEntry[]; - try { - return await ctx.authorization.audit.search({ - companyId, - action: readStringOrUndefined(input.action), - actorType: readStringOrUndefined(input.actorType), - actorId: readStringOrUndefined(input.actorId), - entityType: readStringOrUndefined(input.entityType), - entityId: readStringOrUndefined(input.entityId), - decision: readStringOrUndefined(input.decision), - limit: readNumber(input.limit, 25), - offset: readNumber(input.offset, 0), - }); - } catch (error) { - if (isCapabilityDenied(error)) return [] as PluginAuthorizationAuditEntry[]; - throw error; - } - }); - - ctx.data.register("advancedPolicy", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") { - return { - companyId, - summary: null, - warnings: [], - agents: [], - issues: [], - selected: { - actorAgentId: null, - targetAgentId: null, - projectId: null, - issueId: null, - }, - agentPolicy: null, - actorGrants: [], - preview: null, - explanation: null, - auditEntries: [], - } satisfies EePermissionsAdvancedPolicyData; - } - - const warnings: EePermissionsAdvancedPolicyData["warnings"] = []; - const [summary, agents, issues] = await Promise.all([ - ctx.authorization.policies.summary(companyId).catch((error) => { - warnings.push(formatError(error)); - return null; - }), - ctx.agents.list({ companyId, limit: 200, offset: 0 }).catch((error) => { - warnings.push(formatError(error)); - return []; - }), - ctx.issues.list({ companyId, limit: 100, offset: 0 }).catch((error) => { - warnings.push(formatError(error)); - return [] as Issue[]; - }), - ]); - const actorAgent = agents.find((agent) => agent.id === readString(input, "actorAgentId")) ?? agents[0] ?? null; - const targetAgent = agents.find((agent) => agent.id === readString(input, "targetAgentId")) - ?? agents.find((agent) => agent.id !== actorAgent?.id) - ?? agents[0] - ?? null; - const selectedProjectId = readString(input, "projectId") || null; - const selectedIssueId = readString(input, "issueId") || null; - const [agentPolicy, actorGrants, preview, explanation, auditEntries] = await Promise.all([ - targetAgent - ? ctx.authorization.policies.get({ companyId, resourceType: "agent", resourceId: targetAgent.id }).catch((error) => { - warnings.push(formatError(error)); - return null; - }) - : Promise.resolve(null), - actorAgent - ? ctx.authorization.grants.list({ companyId, principalType: "agent", principalId: actorAgent.id }).catch((error) => { - warnings.push(formatError(error)); - return [] as PrincipalPermissionGrant[]; - }) - : Promise.resolve([] as PrincipalPermissionGrant[]), - actorAgent && targetAgent - ? ctx.authorization.policies.previewAssignment({ - companyId, - actor: { type: "agent", agentId: actorAgent.id, companyId }, - target: { - issueId: selectedIssueId, - projectId: selectedProjectId, - assigneeAgentId: targetAgent.id, - }, - }).catch((error) => { - warnings.push(formatError(error)); - return null; - }) - : Promise.resolve(null), - actorAgent && targetAgent - ? ctx.authorization.policies.explainAssignment({ - companyId, - actor: { type: "agent", agentId: actorAgent.id, companyId }, - target: { - issueId: selectedIssueId, - projectId: selectedProjectId, - assigneeAgentId: targetAgent.id, - }, - }).catch((error) => { - warnings.push(formatError(error)); - return null; - }) - : Promise.resolve(null), - ctx.authorization.audit.search({ - companyId, - action: readStringOrUndefined(input.action), - actorType: readStringOrUndefined(input.actorType), - actorId: readStringOrUndefined(input.actorId), - entityType: readStringOrUndefined(input.entityType), - entityId: readStringOrUndefined(input.entityId), - decision: readStringOrUndefined(input.decision), - limit: 25, - offset: 0, - }).catch((error) => { - warnings.push(formatError(error)); - return [] as PluginAuthorizationAuditEntry[]; - }), - ]); - - return { - companyId, - summary, - warnings, - agents, - issues, - selected: { - actorAgentId: actorAgent?.id ?? null, - targetAgentId: targetAgent?.id ?? null, - projectId: selectedProjectId, - issueId: selectedIssueId, - }, - agentPolicy, - actorGrants, - preview, - explanation, - auditEntries, - } satisfies EePermissionsAdvancedPolicyData; - }); - - ctx.actions.register("activateLicense", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license: EePermissionsLicense = { - status: "active", - activatedAt: new Date().toISOString(), - activatedByUserId: readStringOrUndefined(input.activatedByUserId) ?? null, - note: readStringOrUndefined(input.note) ?? null, - }; - await ctx.state.set( - { - scopeKind: LICENSE_STATE_SCOPE, - scopeId: companyId, - stateKey: LICENSE_STATE_KEY, - }, - license, - ); - return license; - }); - - ctx.actions.register("deactivateLicense", async (params) => { - const companyId = readCompanyId(params ?? {}); - await ctx.state.delete({ - scopeKind: LICENSE_STATE_SCOPE, - scopeId: companyId, - stateKey: LICENSE_STATE_KEY, - }); - return DEFAULT_LICENSE; - }); - - ctx.actions.register("saveMemberAccess", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") { - throw new Error("Paperclip EE permissions mode is not active for this company"); - } - const memberId = readString(input, "memberId"); - if (!memberId) throw new Error("memberId is required"); - const member = await ctx.access.members.get(memberId, companyId); - if (!member) throw new Error(`Membership not found: ${memberId}`); - const membershipRole = readHumanRole(input.membershipRole); - const status = readEditableStatus(input.status); - const explicitGrants = readPermissionKeyArray(input.grants) - .filter((key) => !NON_MEMBER_PERMISSION_KEYS.has(key)); - - const updatedMember = await ctx.access.members.update( - memberId, - { membershipRole, status }, - companyId, - ); - - const existing = await ctx.authorization.grants.list({ - companyId, - principalType: member.principalType, - principalId: member.principalId, - }); - const carriedOverNonMemberGrants = existing - .filter((grant) => NON_MEMBER_PERMISSION_KEYS.has(grant.permissionKey as PermissionKey)) - .map((grant) => ({ - permissionKey: grant.permissionKey as PermissionKey, - scope: grant.scope && typeof grant.scope === "object" ? grant.scope as Record : null, - })); - const nextGrants = [ - ...explicitGrants.map((permissionKey) => ({ permissionKey, scope: null as Record | null })), - ...carriedOverNonMemberGrants, - ]; - - const grants = await ctx.authorization.grants.set({ - companyId, - principalType: member.principalType, - principalId: member.principalId, - grants: nextGrants, - }); - - return { - member: { ...updatedMember, grants }, - }; - }); - - ctx.actions.register("saveAgentPolicy", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") throw new Error("Paperclip EE permissions mode is not active for this company"); - const agentId = readString(input, "agentId"); - if (!agentId) throw new Error("agentId is required"); - const visibilityMode = readString(input, "visibilityMode") === "private" ? "private" : "discoverable"; - const assignmentMode = readString(input, "assignmentMode") === "protected" ? "protected" : "company_default"; - const approvalReason = readString(input, "approvalReason").trim(); - return await ctx.authorization.policies.update({ - companyId, - resourceType: "agent", - resourceId: agentId, - policy: { - agentVisibility: { - mode: visibilityMode, - hiddenFromDefaultDirectory: visibilityMode === "private", - }, - assignmentPolicy: { - mode: assignmentMode, - protectedAgentRequiresApproval: assignmentMode === "protected", - }, - protectedAgent: { - requiresApproval: readBoolean(input, "requiresApproval", assignmentMode === "protected"), - approvalReason: approvalReason || null, - }, - managedBy: "paperclip-ee-permissions", - }, - }); - }); - - ctx.actions.register("saveAssignmentGrant", async (params) => { - const input = params ?? {}; - const companyId = readCompanyId(input); - const license = await readLicense(ctx, companyId); - if (license.status !== "active") throw new Error("Paperclip EE permissions mode is not active for this company"); - const actorAgentId = readString(input, "actorAgentId"); - const targetAgentId = readString(input, "targetAgentId"); - const projectId = readString(input, "projectId"); - const mode = readGrantMode(input.mode); - if (!actorAgentId) throw new Error("actorAgentId is required"); - const existing = await ctx.authorization.grants.list({ - companyId, - principalType: "agent", - principalId: actorAgentId, - }); - const grants = withoutAssignmentGrants(existing); - if (mode === "broad") { - grants.push({ permissionKey: "tasks:assign", scope: null }); - } else if (mode === "scoped_agent") { - if (!targetAgentId) throw new Error("targetAgentId is required for scoped assignment grants"); - grants.push({ - permissionKey: "tasks:assign_scope", - scope: { - assigneeAgentId: targetAgentId, - ...(projectId ? { projectId } : {}), - }, - }); - } - return await ctx.authorization.grants.set({ - companyId, - principalType: "agent", - principalId: actorAgentId, - grants, - }); - }); - }, - - async onHealth() { - return { status: "ok", message: "Paperclip EE Permissions worker is running" }; - }, -}); - -export default plugin; -runWorker(plugin, import.meta.url); diff --git a/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts b/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts deleted file mode 100644 index d912b3468a9..00000000000 --- a/packages/plugins/paperclip-ee-permissions/tests/plugin.spec.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; -import manifest, { EXPORT_NAMES, SLOT_IDS } from "../src/manifest.js"; -import plugin, { - type EePermissionsAdvancedPolicyData, - type EePermissionsLicense, - type EePermissionsMemberAccessData, - type EePermissionsOverview, -} from "../src/worker.js"; - -const COMPANY_ID = "00000000-0000-4000-8000-000000000001"; -const ACTOR_AGENT_ID = "00000000-0000-4000-8000-0000000000a1"; -const TARGET_AGENT_ID = "00000000-0000-4000-8000-0000000000a2"; -const ISSUE_ID = "00000000-0000-4000-8000-0000000000b1"; -const MEMBER_USER_ID = "00000000-0000-4000-8000-0000000000c1"; -const MEMBER_RECORD_ID = "00000000-0000-4000-8000-0000000000d1"; - -describe("paperclip-ee-permissions manifest", () => { - it("declares the company settings page slot", () => { - const slot = manifest.ui?.slots?.find( - (entry) => entry.id === SLOT_IDS.companySettingsPage, - ); - expect(slot).toBeDefined(); - expect(slot?.type).toBe("companySettingsPage"); - expect(slot?.exportName).toBe(EXPORT_NAMES.companySettingsPage); - expect(slot?.routePath).toBe("permissions"); - }); - - it("requires the access and authorization read capabilities", () => { - for (const capability of [ - "access.members.read", - "access.members.write", - "issues.read", - "authorization.grants.read", - "authorization.grants.write", - "authorization.policies.read", - "authorization.policies.write", - "authorization.audit.read", - "ui.page.register", - "instance.settings.register", - "plugin.state.read", - "plugin.state.write", - ] as const) { - expect(manifest.capabilities).toContain(capability); - } - }); -}); - -describe("paperclip-ee-permissions worker", () => { - it("returns an unlicensed overview by default", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - const overview = await harness.getData("overview", { - companyId: COMPANY_ID, - }); - expect(overview.companyId).toBe(COMPANY_ID); - expect(overview.license.status).toBe("inactive"); - expect(overview.policySummary).toBeNull(); - expect(overview.warnings).toEqual([]); - }); - - it("activates and deactivates the per-company license", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - - const activated = await harness.performAction( - "activateLicense", - { companyId: COMPANY_ID, activatedByUserId: "user-1" }, - ); - expect(activated.status).toBe("active"); - expect(activated.activatedByUserId).toBe("user-1"); - - const overview = await harness.getData("overview", { - companyId: COMPANY_ID, - }); - expect(overview.license.status).toBe("active"); - expect(overview.policySummary).toMatchObject({ - companyId: COMPANY_ID, - permissionsMode: "simple", - advancedPolicyAvailable: false, - }); - - const deactivated = await harness.performAction( - "deactivateLicense", - { companyId: COMPANY_ID }, - ); - expect(deactivated.status).toBe("inactive"); - - const reverted = await harness.getData("overview", { - companyId: COMPANY_ID, - }); - expect(reverted.license.status).toBe("inactive"); - expect(reverted.policySummary).toBeNull(); - }); - - it("returns empty lists for grants/members when unlicensed", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - const members = await harness.getData("members", { companyId: COMPANY_ID }); - const grants = await harness.getData("grants", { companyId: COMPANY_ID }); - const audit = await harness.getData("audit", { companyId: COMPANY_ID }); - expect(members).toEqual([]); - expect(grants).toEqual([]); - expect(audit).toEqual([]); - }); - - it("falls back to empty data when a capability is missing", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities.filter( - (capability) => capability !== "authorization.grants.read", - ), - }); - await plugin.definition.setup(harness.ctx); - await harness.performAction("activateLicense", { companyId: COMPANY_ID }); - const grants = await harness.getData("grants", { companyId: COMPANY_ID }); - expect(grants).toEqual([]); - }); - - it("loads advanced policy data and previews through the authorization SDK", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - harness.seed({ - agents: [ - { - id: ACTOR_AGENT_ID, - companyId: COMPANY_ID, - name: "Actor", - role: "engineer", - status: "active", - adapterType: "process", - adapterConfig: {}, - permissions: {}, - createdAt: new Date(), - updatedAt: new Date(), - } as any, - { - id: TARGET_AGENT_ID, - companyId: COMPANY_ID, - name: "Target", - role: "engineer", - status: "active", - adapterType: "process", - adapterConfig: {}, - permissions: {}, - createdAt: new Date(), - updatedAt: new Date(), - } as any, - ], - issues: [ - { - id: ISSUE_ID, - companyId: COMPANY_ID, - title: "Preview issue", - status: "todo", - createdAt: new Date(), - updatedAt: new Date(), - } as any, - ], - }); - await plugin.definition.setup(harness.ctx); - await harness.performAction("activateLicense", { companyId: COMPANY_ID }); - - const data = await harness.getData("advancedPolicy", { - companyId: COMPANY_ID, - actorAgentId: ACTOR_AGENT_ID, - targetAgentId: TARGET_AGENT_ID, - issueId: ISSUE_ID, - decision: "allow", - }); - - expect(data.agents).toHaveLength(2); - expect(data.issues).toHaveLength(1); - expect(data.selected).toMatchObject({ - actorAgentId: ACTOR_AGENT_ID, - targetAgentId: TARGET_AGENT_ID, - issueId: ISSUE_ID, - }); - expect(data.preview).toMatchObject({ allowed: true }); - expect(data.explanation).toMatchObject({ allowed: true }); - }); - - it("updates agent policies and assignment grants through SDK actions", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - await harness.performAction("activateLicense", { companyId: COMPANY_ID }); - - const policy = await harness.performAction>("saveAgentPolicy", { - companyId: COMPANY_ID, - agentId: TARGET_AGENT_ID, - visibilityMode: "private", - assignmentMode: "protected", - requiresApproval: true, - approvalReason: "Sensitive production agent", - }); - const grants = await harness.performAction("saveAssignmentGrant", { - companyId: COMPANY_ID, - actorAgentId: ACTOR_AGENT_ID, - targetAgentId: TARGET_AGENT_ID, - mode: "scoped_agent", - }); - - expect(policy.policy).toMatchObject({ - agentVisibility: { mode: "private" }, - protectedAgent: { requiresApproval: true }, - }); - expect(grants).toEqual([ - { - principalType: "agent", - principalId: ACTOR_AGENT_ID, - permissionKey: "tasks:assign_scope", - scope: { assigneeAgentId: TARGET_AGENT_ID }, - }, - ]); - }); - - it("returns empty member access while unlicensed", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - const data = await harness.getData("memberAccess", { - companyId: COMPANY_ID, - }); - expect(data.companyId).toBe(COMPANY_ID); - expect(data.members).toEqual([]); - expect(data.warnings).toEqual([]); - }); - - it("loads seeded company members once licensed", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - harness.seed({ - accessMembers: [ - { - id: MEMBER_RECORD_ID, - companyId: COMPANY_ID, - principalType: "user", - principalId: MEMBER_USER_ID, - status: "active", - membershipRole: "operator", - grants: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ], - principalGrants: [ - { - id: "grant-1", - companyId: COMPANY_ID, - principalType: "user", - principalId: MEMBER_USER_ID, - permissionKey: "users:invite", - scope: null, - grantedByUserId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }); - await plugin.definition.setup(harness.ctx); - await harness.performAction("activateLicense", { companyId: COMPANY_ID }); - - const data = await harness.getData("memberAccess", { - companyId: COMPANY_ID, - }); - expect(data.members).toHaveLength(1); - const [member] = data.members; - expect(member.principalId).toBe(MEMBER_USER_ID); - expect(member.membershipRole).toBe("operator"); - expect(member.grants.map((grant) => grant.permissionKey)).toEqual(["users:invite"]); - }); - - it("saves member role, status, and grants atomically", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - harness.seed({ - accessMembers: [ - { - id: MEMBER_RECORD_ID, - companyId: COMPANY_ID, - principalType: "user", - principalId: MEMBER_USER_ID, - status: "active", - membershipRole: "viewer", - grants: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ], - principalGrants: [ - // pre-existing scoped grant should survive a member-level save - { - id: "grant-scoped", - companyId: COMPANY_ID, - principalType: "user", - principalId: MEMBER_USER_ID, - permissionKey: "tasks:assign_scope", - scope: { assigneeAgentId: TARGET_AGENT_ID }, - grantedByUserId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ], - }); - await plugin.definition.setup(harness.ctx); - await harness.performAction("activateLicense", { companyId: COMPANY_ID }); - - const result = await harness.performAction<{ - member: EePermissionsMemberAccessData["members"][number]; - }>("saveMemberAccess", { - companyId: COMPANY_ID, - memberId: MEMBER_RECORD_ID, - membershipRole: "operator", - status: "active", - grants: ["users:invite", "tasks:assign"], - }); - expect(result.member.membershipRole).toBe("operator"); - expect(result.member.status).toBe("active"); - const grantKeys = result.member.grants.map((grant) => grant.permissionKey).sort(); - expect(grantKeys).toEqual(["tasks:assign", "tasks:assign_scope", "users:invite"]); - - const reread = await harness.getData("memberAccess", { - companyId: COMPANY_ID, - }); - const [updated] = reread.members; - expect(updated.membershipRole).toBe("operator"); - expect(updated.grants.map((grant) => grant.permissionKey).sort()).toEqual([ - "tasks:assign", - "tasks:assign_scope", - "users:invite", - ]); - }); - - it("refuses to save member access while unlicensed", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - harness.seed({ - accessMembers: [ - { - id: MEMBER_RECORD_ID, - companyId: COMPANY_ID, - principalType: "user", - principalId: MEMBER_USER_ID, - status: "active", - membershipRole: "viewer", - grants: [], - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - ], - }); - await plugin.definition.setup(harness.ctx); - await expect( - harness.performAction("saveMemberAccess", { - companyId: COMPANY_ID, - memberId: MEMBER_RECORD_ID, - membershipRole: "operator", - status: "active", - grants: [], - }), - ).rejects.toThrow(/permissions mode is not active/); - }); - - it("rejects requests that omit companyId", async () => { - const harness = createTestHarness({ - manifest, - capabilities: manifest.capabilities, - }); - await plugin.definition.setup(harness.ctx); - await expect(harness.getData("overview", {})).rejects.toThrow(/companyId/); - }); -}); diff --git a/packages/plugins/paperclip-ee-permissions/tsconfig.json b/packages/plugins/paperclip-ee-permissions/tsconfig.json deleted file mode 100644 index a697519ed9d..00000000000 --- a/packages/plugins/paperclip-ee-permissions/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": [ - "ES2022", - "DOM" - ], - "jsx": "react-jsx", - "strict": true, - "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "rootDir": "." - }, - "include": [ - "src", - "tests" - ], - "exclude": [ - "dist", - "node_modules" - ] -} diff --git a/packages/plugins/paperclip-ee-permissions/vitest.config.ts b/packages/plugins/paperclip-ee-permissions/vitest.config.ts deleted file mode 100644 index 649a293ef95..00000000000 --- a/packages/plugins/paperclip-ee-permissions/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["tests/**/*.spec.ts"], - environment: "node", - }, -}); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 0b3ee35c5a1..ab6a868275d 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -233,7 +233,7 @@ describeEmbeddedPostgres("authorization service", () => { requiresApproval: true, approvalReason: "Production deployment authority", }, - managedBy: "paperclip-ee-permissions", + managedBy: "permissions-extension", }, }, }); @@ -270,7 +270,7 @@ describeEmbeddedPostgres("authorization service", () => { protectedAgent: { requiresApproval: false, }, - managedBy: "paperclip-ee-permissions", + managedBy: "permissions-extension", }, }, }); diff --git a/server/src/__tests__/plugin-access-authorization-host-services.test.ts b/server/src/__tests__/plugin-access-authorization-host-services.test.ts index ab20f4f8b8a..0e1648965d1 100644 --- a/server/src/__tests__/plugin-access-authorization-host-services.test.ts +++ b/server/src/__tests__/plugin-access-authorization-host-services.test.ts @@ -79,7 +79,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => }) .returning() .then((rows) => rows[0]!); - const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); await expect( services.authorization.setGrants({ @@ -97,7 +97,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => it("redacts invite token hashes and sensitive defaults from plugin invite reads", async () => { const company = await createCompany(db, "PAZ"); - const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); const created = await services.access.createInvite({ companyId: company.id, @@ -124,7 +124,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => it("filters authorization audit entries by allow or deny decision details", async () => { const company = await createCompany(db, "PAU"); - const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); await db.insert(activityLog).values([ { companyId: company.id, @@ -198,7 +198,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => membershipRole: "member", }); - const services = buildHostServices(db, pluginId, "paperclip-ee", createEventBusStub()); + const services = buildHostServices(db, pluginId, "permissions-extension", createEventBusStub()); const updatedPolicy = await services.authorization.updatePolicy({ companyId: company.id, resourceType: "agent", @@ -212,7 +212,7 @@ describeEmbeddedPostgres("plugin access and authorization host services", () => requiresApproval: true, approvalReason: "Needs board approval", }, - managedBy: "paperclip-ee-permissions", + managedBy: "permissions-extension", }, }); const input = { diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d10c4e401ea..6514cd4a99b 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -175,8 +175,8 @@ describe("CompanySettingsSidebar", () => { exportName: "PermissionsPage", routePath: "permissions", pluginId: "plugin-1", - pluginKey: "paperclip-ee", - pluginDisplayName: "Paperclip EE", + pluginKey: "permissions-extension", + pluginDisplayName: "Permissions Extension", pluginVersion: "0.1.0", }, ], diff --git a/ui/src/pages/CompanyAccess.test.tsx b/ui/src/pages/CompanyAccess.test.tsx index aaf4979e272..73f9e3f4fea 100644 --- a/ui/src/pages/CompanyAccess.test.tsx +++ b/ui/src/pages/CompanyAccess.test.tsx @@ -209,7 +209,7 @@ describe("CompanyAccess", () => { 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("Core keeps this page focused on membership"); expect(container.textContent).toContain("Humans"); expect(container.textContent).toContain("Pending human joins"); expect(container.textContent).toContain("User account"); @@ -404,7 +404,7 @@ describe("CompanyAccess", () => { }); }); - it("redirects legacy access deep links to the EE permissions route when installed", async () => { + it("redirects legacy access deep links to the permissions extension route when installed", async () => { mockUsePluginSlots.mockReturnValue({ slots: [ { @@ -412,7 +412,7 @@ describe("CompanyAccess", () => { id: "permissions", displayName: "Permissions", routePath: "permissions", - pluginKey: "paperclip-ee", + pluginKey: "permissions-extension", }, ], isLoading: false, @@ -440,7 +440,7 @@ describe("CompanyAccess", () => { }); }); - it("shows a read-only EE unavailable fallback for legacy access deep links", async () => { + it("shows a read-only unavailable fallback for legacy access deep links", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -456,7 +456,7 @@ describe("CompanyAccess", () => { await flushReact(); expect(container.textContent).toContain("Advanced Permissions"); - expect(container.textContent).toContain("Paperclip EE unavailable"); + expect(container.textContent).toContain("Advanced permissions unavailable"); expect(container.textContent).toContain("Open Members"); expect(container.textContent).toContain("Open Invites"); diff --git a/ui/src/pages/CompanyAccess.tsx b/ui/src/pages/CompanyAccess.tsx index 0834e8c0266..9c43d5ae70a 100644 --- a/ui/src/pages/CompanyAccess.tsx +++ b/ui/src/pages/CompanyAccess.tsx @@ -244,7 +244,7 @@ export function CompanyAccess() { Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.

- Advanced permission controls are available in Paperclip EE. Core keeps this page focused on membership, invite approvals, and safe member removal. + Core keeps this page focused on membership, invite approvals, and safe member removal.
@@ -545,7 +545,7 @@ export function CompanyAccessLegacyRoute() { } if (isLoading) { - return
Checking for Paperclip EE permissions...
; + return
Checking for advanced permission extensions...
; } return ( @@ -556,15 +556,15 @@ export function CompanyAccessLegacyRoute() {

Advanced Permissions

- Advanced access, scoped assignment, and explicit grant controls are now owned by the Paperclip EE Permissions plugin. + Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.

-

Paperclip EE unavailable

+

Advanced permissions unavailable

- Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires Paperclip EE. + 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}

diff --git a/ui/src/pages/CompanySettingsPluginPage.test.tsx b/ui/src/pages/CompanySettingsPluginPage.test.tsx index 5c22a35057e..12c5a8bb17a 100644 --- a/ui/src/pages/CompanySettingsPluginPage.test.tsx +++ b/ui/src/pages/CompanySettingsPluginPage.test.tsx @@ -91,8 +91,8 @@ describe("CompanySettingsPluginPage", () => { exportName: "PermissionsPage", routePath: "permissions", pluginId: "plugin-1", - pluginKey: "paperclip-ee", - pluginDisplayName: "Paperclip EE", + pluginKey: "permissions-extension", + pluginDisplayName: "Permissions Extension", pluginVersion: "0.1.0", }, ],