[codex] Add agent permissions and controls plan (#6386)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies by keeping
task ownership, approvals, and operator control inside one control
plane.
> - Agent permissions and plugin-hosted company settings sit on the
boundary between autonomy and governance.
> - V1 needs scoped task assignment rules, plugin extension points, and
clearer company access surfaces without weakening company boundaries.
> - The branch builds the core authorization service, plugin SDK/host
APIs, and UI simplifications needed to support those controls.
> - Paperclip EE plugin surfaces were intentionally moved out of this
core PR per review direction, so this PR now carries only the public
core/plugin infrastructure work.
> - The latest updates preserve the PAP-9937 branch changes that belong
in this PR, remove the `design/` artifacts, and exclude the experimental
`plugin-briefs` package.
> - Greptile feedback was applied through the authorization/audit paths
and the final cleanup commit was re-reviewed at 5/5 with no unresolved
Greptile threads.
> - The benefit is safer assignment control with extension hooks for
richer permission products while preserving simple defaults for normal
operators.

## What Changed

- Added scoped task-assignment authorization decisions and routed
issue/agent assignment mutations through the authorization service.
- Added plugin SDK and host APIs for company settings slots,
authorization policy/grant management, assignment previews, and bridge
invocation scope propagation.
- Simplified core company access UI and moved advanced controls behind
plugin-provided settings surfaces.
- Added retry-now affordances for blocked issue next-step notices.
- Added protected-assignment enforcement for persisted
agent/project/issue policies, including explicit-grant fallback
behavior.
- Added incremental principal-access compatibility backfill for active
agent memberships and role-default human permission grants.
- Added the Markdown code block wrap action fix from the latest branch
changes.
- Removed `design/` artifacts from the PR and removed
`packages/plugins/plugin-briefs` from the final diff.
- Addressed Greptile feedback for plugin actor sanitization, legacy
membership handling, audit pagination, unknown grant-scope metadata, and
startup test mocks.

## Verification

- `pnpm exec vitest run server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 2 files passed, 54
tests passed.
- `pnpm exec vitest run
server/src/__tests__/server-startup-feedback-export.test.ts
server/src/__tests__/access-service.test.ts
server/src/__tests__/company-portability.test.ts` -> 3 files passed, 62
tests passed.
- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/plugin-access-authorization-host-services.test.ts
server/src/__tests__/server-startup-feedback-export.test.ts` -> 3 files
passed, 28 tests passed.
- `pnpm --filter @paperclipai/server typecheck` -> passed.
- `git diff --check` -> passed.
- `node ./scripts/check-docker-deps-stage.mjs` -> passed.
- `CI=true pnpm install --frozen-lockfile --ignore-scripts` -> passed
with no lockfile update.
- `pnpm exec vitest run
ui/src/components/MarkdownBody.interaction.test.tsx` -> 1 test passed.
- `git ls-files design packages/plugins/plugin-briefs | wc -l` -> 0.
- GitHub CI on `40cd83b53` -> all checks passed, merge state `CLEAN`.
- Greptile on `40cd83b53` -> 5/5, 102 files reviewed, 0
comments/annotations added, 0 unresolved review threads.
- Confirmed the PR diff contains no `design/`,
`packages/plugins/plugin-briefs`, `pnpm-lock.yaml`, or
`.github/workflows` changes.

## Risks

- Medium: task assignment authorization paths are behaviorally stricter
for protected/private policy data, so existing plugin-authored policies
may block assignment until explicit grants or approval flows are
configured.
- Medium: plugin-host authorization APIs expand the surface area
available to trusted plugins and need careful review for company
scoping.
- Low: startup now performs a principal-access compatibility backfill,
but the migration and runtime backfill use conflict-tolerant inserts.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent, tool-enabled workflow with shell,
git, and GitHub CLI access.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-22 08:12:52 -05:00 committed by GitHub
parent c91a062326
commit 38c185fb8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6744 additions and 395 deletions

View file

@ -45,6 +45,7 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
@ -1713,7 +1714,9 @@ function ConfigurationTab({
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
: taskAssignSource === "simple_default"
? "Enabled by simple company-wide task assignment defaults."
: "Disabled unless explicitly granted.";
return (
<div className="space-y-6">
@ -3863,8 +3866,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onopen = () => {

View file

@ -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("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");
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 permissions extension route when installed", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
routePath: "permissions",
pluginKey: "permissions-extension",
},
],
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 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("Advanced permissions unavailable");
expect(container.textContent).toContain("Open Members");
expect(container.textContent).toContain("Open Invites");
await act(async () => {
root.unmount();
});
});
});

View file

@ -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">
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 advanced permission extensions...</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 provided by installed company settings extensions.
</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">Advanced permissions 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 an installed extension.
</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;

View file

@ -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(

View file

@ -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;

View file

@ -0,0 +1,140 @@
// @vitest-environment jsdom
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 { CompanySettingsPluginPage } from "./CompanySettingsPluginPage";
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({
companyPrefix: "PAP" as string | undefined,
settingsRoutePath: "permissions" as string | undefined,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }),
useParams: () => mockParams,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
PluginSlotMount: ({
slot,
context,
}: {
slot: { displayName: string };
context: { companyId: string | null; companyPrefix: string | null };
}) => (
<div data-testid="plugin-slot-mount">
{slot.displayName}:{context.companyId}:{context.companyPrefix}
</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function renderPage(container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsPluginPage />
</QueryClientProvider>,
);
});
await flushReact();
return root;
}
describe("CompanySettingsPluginPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockParams.companyPrefix = "PAP";
mockParams.settingsRoutePath = "permissions";
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("mounts the matching company settings slot with company context", async () => {
const root = await renderPage(container);
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe(
"Permissions:company-1:PAP",
);
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ label: "Settings", href: "/company/settings" },
{ label: "Permissions" },
]);
await act(async () => {
root.unmount();
});
});
it("fails closed when no ready plugin declares the route", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
const root = await renderPage(container);
expect(container.textContent).toContain("Page not found");
await act(async () => {
root.unmount();
});
});
});

View file

@ -0,0 +1,88 @@
import { useEffect, useMemo } from "react";
import { useParams } from "@/lib/router";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { NotFoundPage } from "./NotFound";
export function CompanySettingsPluginPage() {
const params = useParams<{
companyPrefix?: string;
settingsRoutePath?: string;
}>();
const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params;
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
if (!routeCompanyPrefix) return null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
}, [companies, routeCompanyPrefix]);
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null);
const companyPrefix = resolvedCompanyId
? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null
: null;
const { slots, isLoading, errorMessage } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: resolvedCompanyId,
enabled: Boolean(resolvedCompanyId && settingsRoutePath),
});
const pageSlots = useMemo(() => {
if (!settingsRoutePath) return [];
return slots.filter((slot) => slot.routePath === settingsRoutePath);
}, [settingsRoutePath, slots]);
const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null;
useEffect(() => {
if (!pageSlot) return;
setBreadcrumbs([
{ label: "Settings", href: "/company/settings" },
{ label: pageSlot.displayName },
]);
}, [pageSlot, setBreadcrumbs]);
if (!resolvedCompanyId) {
if (hasInvalidCompanyPrefix) {
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
return <div className="text-sm text-muted-foreground">Select a company to view this page.</div>;
}
if (!settingsRoutePath || isLoading) {
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
if (errorMessage) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Plugin extensions unavailable: {errorMessage}
</div>
);
}
if (pageSlots.length > 1) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Multiple plugins declare the company settings route <code>{settingsRoutePath}</code>. Disable one plugin or change its route.
</div>
);
}
if (!pageSlot) {
return <NotFoundPage scope="board" />;
}
return (
<PluginSlotMount
slot={pageSlot}
context={{ companyId: resolvedCompanyId, companyPrefix }}
className="min-h-[200px]"
missingBehavior="placeholder"
/>
);
}

View file

@ -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 () => {

View file

@ -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.

View file

@ -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>

View file

@ -617,6 +617,7 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
scheduledRetry: Issue["scheduledRetry"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
blockerAttention,
successfulRunHandoff,
scheduledRetry,
recoveryAction,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction,
@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
timelineEvents={timelineEvents}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
issueId={issueId}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
scheduledRetry={scheduledRetry}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
@ -3914,6 +3918,7 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
scheduledRetry={issue.scheduledRetry ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}