mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Human company members receive permission grants from their role preset. > - Human invitations use the Operator role by default. > - The old Operator preset only granted task assignment, so ordinary members could not edit agents or manage connections. > - This pull request gives Operators company editing and audit access while keeping join approval and member-permission management separate. > - Members can configure their agents and accounts without an Owner role. ## Linked Issues or Issue Description **What existing behavior does this improve?** The default permission grants for human Operators and the invitation role description. **Subsystem affected** server/ permission presets and invitation authorization, plus ui/ invitation and tool access gates. **Current behavior** Operators receive only `tasks:assign` as an explicit default grant. Agent configuration and managed account setup fail their permission checks. **Proposed behavior** Operators receive agent creation/configuration, skills, environments, invitations, task assignment, pipelines, connections, tool management/use, and audit grants. The preset excludes `joins:approve` and `users:manage_permissions`. Operators can invite Operators and Viewers. Inviting a role with either excluded power requires that power, so invitations cannot bypass the restriction. **Reason and benefit** Ordinary company members can edit company work and connect accounts through the existing routes. **Breaking changes** The Operator preset grants more permissions. The existing startup and Cloud sign-in seeding paths can insert these missing grants for existing Operators. They retain custom grant scopes. Explicit invitation grants still take precedence. Inviting Admins now requires join approval; inviting Owners also requires member-permission management. This PR adds no migration or new backfill path. Related: #5945 describes missing role grants in another membership entry point. This PR changes the preset and does not change that endpoint. ## What Changed - Expanded the Operator preset to 14 company editing, invitation, tool, and audit grants. - Kept join approval and member-permission management out of the preset. - Required those powers when an invitation's selected human role includes them, preventing Operators from delegating the excluded powers through Owner/Admin invitations. - Updated the invitation role copy and the product specifications. - Allowed active Operators (including legacy Member roles) through the invite shortcut and advanced tool/profile UI gates. Kept inactive memberships, Viewers, and other companies excluded; API grants remain authoritative. - Added tests for the default Operator grants, both excluded actions, agent editing, company boundaries, and invitation role authorization. - Simplified adapter-route test imports so authorization errors and the error handler use one module graph; retained the upstream mock-initialization fix. - Kept Owner/Admin/Viewer presets and explicit invitation grants unchanged. Added no EE code or database migration. ## Verification - Permission and invitation tests: 38 passed across access service, invitation defaults, and invitation creation routes. - UI access tests: 61 passed across invitation shortcuts, Operator tool/profile access, and invitation UI. - Adapter route tests: 15 passed after resolving the upstream test setup conflict. - `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`: passed on the final branch. - The initial local `pnpm test:run` general-server batch reported three tool-access failures and eight upload-helper timeouts. Both complete affected suites passed in isolation on the final branch: 403 tests. The initial full local run was not green. - Both complete tool-access and adapter route suites also passed on an untouched snapshot of base `88ff98b83`: 387 tests. The earlier tool failures did not reproduce there. - [All CI gates passed](https://github.com/paperclipai/paperclip/actions/runs/37527422346) for `068be153b9c2a064f2aaccc0627fdf458e73747e`, including the previously failing serialized adapter lane, general tests, E2E, typecheck, build, Runner checks, and canary dry run. - Greptile scored that exact commit 5/5. Superagent passed. Both invitation review threads are resolved. ## Risks - Operators gain broad company editing and invitation access by design. - Existing default seeding can add the new missing grants during startup or Cloud sign-in. It does not replace existing scopes. - Company boundaries and the two excluded permission checks still apply. - An Admin without member-permission management can no longer invite an Owner. Operators retain invitation access for Operator/Viewer roles and agent-only invites. - This PR changes company permissions. Cloud workspace invitation rules remain separate. ## Model Used OpenAI Codex, GPT-6. The exact deployment model ID and context window were not exposed in this session. Used repository inspection, code editing, shell execution, and test tools. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting a merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
BookOpen,
|
|
Flag,
|
|
LogOut,
|
|
Settings,
|
|
type LucideIcon,
|
|
UserPlus,
|
|
} from "lucide-react";
|
|
import { hidesCompanyPage, type DeploymentMode } from "@paperclipai/shared";
|
|
import { Link } from "@/lib/router";
|
|
import { authApi } from "@/api/auth";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
|
import { useCloudInviteUrl } from "@/hooks/useCloudInviteUrl";
|
|
import { useCanInviteCompanyMembers } from "@/hooks/useCompanyInviteAccess";
|
|
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
|
import { useSignOut } from "@/hooks/useSignOut";
|
|
import { useStagingCommit } from "@/hooks/useStagingCommit";
|
|
import { userProfilePath } from "@/lib/userProfileLinks";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
|
|
import { ThemeToggle } from "./ThemeToggle";
|
|
import { SidebarServerInfo } from "./SidebarServerInfo";
|
|
|
|
const INVITES_PATH = "/company/settings/members?tab=invites";
|
|
const DOCS_URL = "https://docs.paperclip.ing/";
|
|
const FEEDBACK_URL = "https://paperclip.ing/feedback";
|
|
|
|
interface SidebarAccountMenuProps {
|
|
deploymentMode?: DeploymentMode;
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
|
|
forceExpanded?: boolean;
|
|
}
|
|
|
|
interface MenuActionProps {
|
|
label: string;
|
|
icon: LucideIcon;
|
|
onClick?: () => void;
|
|
href?: string;
|
|
/** Opens `href` in a new tab (docs and other off-product links). */
|
|
external?: boolean;
|
|
/**
|
|
* Leaves the app in the current tab with a full navigation. Cloud links
|
|
* must use this: the cloud harness shadows those paths on tenant hosts, so
|
|
* the in-app router can never reach them.
|
|
*/
|
|
topLevel?: boolean;
|
|
}
|
|
|
|
function deriveInitials(name: string) {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length >= 2) {
|
|
return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
|
|
}
|
|
return name.slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
function MenuAction({
|
|
label,
|
|
icon: Icon,
|
|
onClick,
|
|
href,
|
|
external = false,
|
|
topLevel = false,
|
|
}: MenuActionProps) {
|
|
const className =
|
|
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-accent";
|
|
|
|
const content = (
|
|
<>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<Icon className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
</>
|
|
);
|
|
|
|
if (href) {
|
|
if (external) {
|
|
return (
|
|
<a href={href} target="_blank" rel="noreferrer" className={className} onClick={onClick}>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
if (topLevel) {
|
|
return (
|
|
<a href={href} className={className} onClick={onClick}>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link to={href} className={className} onClick={onClick}>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button type="button" className={className} onClick={onClick}>
|
|
{content}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function SidebarAccountMenu({
|
|
deploymentMode,
|
|
open: controlledOpen,
|
|
onOpenChange,
|
|
forceExpanded = false,
|
|
}: SidebarAccountMenuProps) {
|
|
const cloud = useCloudInstance();
|
|
const isCloud = Boolean(cloud);
|
|
// Invites live on the Members page (or in Cloud's People settings). Hide the
|
|
// shortcut when the hosting operator hides either surface, and until the
|
|
// health response resolves so a hidden surface never flashes.
|
|
const { hidden: hiddenSettings, loaded: hiddenSettingsLoaded } = useHiddenSettings();
|
|
// On Cloud the shortcut exists only for the current stack's owner/admin and
|
|
// only once the stack metadata is known; the in-app Invites tab is never a
|
|
// fallback there because it drives a different invitation flow.
|
|
const cloudInviteUrl = useCloudInviteUrl();
|
|
// Self-hosted invites need the `users:invite` grant. Offer the shortcut only
|
|
// to boards with role-default access (company owner/admin/operator,
|
|
// instance admins, local boards). The server checks the actual grants.
|
|
const canInviteMembers = useCanInviteCompanyMembers(!isCloud);
|
|
const inviteHref = isCloud ? cloudInviteUrl : canInviteMembers ? INVITES_PATH : null;
|
|
const showInvite =
|
|
hiddenSettingsLoaded &&
|
|
inviteHref !== null &&
|
|
!hidesCompanyPage(hiddenSettings, "company.members") &&
|
|
!hidesCompanyPage(hiddenSettings, "company.invites");
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
|
const rail = collapsed && !peeking && !forceExpanded;
|
|
const open = controlledOpen ?? internalOpen;
|
|
const stagingCommit = useStagingCommit(open);
|
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
retry: false,
|
|
});
|
|
|
|
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
|
|
|
const displayName = session?.user.name?.trim() || "Board";
|
|
const secondaryLabel =
|
|
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
|
const initials = deriveInitials(displayName);
|
|
const profileHref = userProfilePath(session?.user);
|
|
|
|
function closeNavigationChrome() {
|
|
setOpen(false);
|
|
if (isMobile) setSidebarOpen(false);
|
|
}
|
|
|
|
function handleSignOut() {
|
|
signOutMutation.mutate();
|
|
}
|
|
|
|
return (
|
|
<div className="bg-border/50 px-3 py-2 dark:bg-muted">
|
|
<div className={cn("flex items-center gap-0.5", !rail && "px-2")}>
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex min-w-0 items-center gap-2.5 rounded-lg text-left text-(length:--text-compact) font-medium text-foreground/80 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
rail ? "w-full px-3 py-2" : "flex-1 px-2 py-1.5",
|
|
)}
|
|
aria-label="Open account menu"
|
|
>
|
|
<Avatar size="sm">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback>{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<span className={cn("min-w-0 flex-1 truncate", rail && SIDEBAR_RAIL_HIDDEN_LABEL)}>{displayName}</span>
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
side="top"
|
|
align="start"
|
|
sideOffset={10}
|
|
className="min-h-(--profile-popover-min-height) w-(--profile-popover-width) max-w-(--sz-calc-24) overflow-hidden rounded-xl border-border bg-popover p-0 shadow-(--shadow-profile-popover)"
|
|
>
|
|
{/* The profile link is a stretched overlay so the staging SHA anchor can sit beside the email without nesting anchors. */}
|
|
<div className="relative flex h-(--profile-popover-header-height) shrink-0 items-center gap-2.5 px-3.5">
|
|
<Link
|
|
to={profileHref}
|
|
aria-label="View profile"
|
|
onClick={closeNavigationChrome}
|
|
className="absolute inset-0 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
/>
|
|
<Avatar className="pointer-events-none relative size-9">
|
|
{session?.user.image ? <AvatarImage src={session.user.image} alt={displayName} /> : null}
|
|
<AvatarFallback className="text-xs text-foreground">{initials}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="pointer-events-none relative min-w-0 flex-1">
|
|
<h2 className="truncate text-sm font-semibold leading-(--profile-popover-label-line-height) text-foreground">
|
|
{displayName}
|
|
</h2>
|
|
<p className="truncate text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground">
|
|
{secondaryLabel}
|
|
</p>
|
|
{stagingCommit ? (
|
|
<a
|
|
className="pointer-events-auto block truncate font-mono text-(length:--text-micro) leading-(--profile-popover-meta-line-height) text-muted-foreground hover:underline focus-visible:underline"
|
|
href={`https://github.com/paperclipai/paperclip/commit/${stagingCommit}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label={`View commit ${stagingCommit} on GitHub`}
|
|
title={stagingCommit}
|
|
>
|
|
SHA {stagingCommit.slice(0, 7)}
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col gap-0.5 border-t border-border px-2.5 pb-2.5 pt-2">
|
|
<MenuAction
|
|
label="Settings"
|
|
icon={Settings}
|
|
href="/company/settings"
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
{showInvite && inviteHref ? (
|
|
<MenuAction
|
|
label="Invite"
|
|
icon={UserPlus}
|
|
href={inviteHref}
|
|
topLevel={isCloud}
|
|
onClick={closeNavigationChrome}
|
|
/>
|
|
) : null}
|
|
<MenuAction
|
|
label="Documentation"
|
|
icon={BookOpen}
|
|
href={DOCS_URL}
|
|
external
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
<ThemeToggle variant="compact-menu-action" onAfterToggle={() => setOpen(false)} />
|
|
{deploymentMode === "authenticated" ? (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex h-(--profile-popover-row-height) w-full items-center gap-(--profile-popover-row-gap) rounded-lg px-2.5 text-left text-(length:--text-compact) font-medium leading-(--profile-popover-label-line-height) text-foreground transition-colors hover:bg-destructive/10",
|
|
signOutMutation.isPending && "cursor-not-allowed opacity-60",
|
|
)}
|
|
onClick={handleSignOut}
|
|
disabled={signOutMutation.isPending}
|
|
>
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
<LogOut className="size-4" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">
|
|
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
|
</span>
|
|
</button>
|
|
) : null}
|
|
<SidebarServerInfo />
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{!rail && !isCloud ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<a
|
|
href={FEEDBACK_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
aria-label="Share feedback"
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground/50 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Flag className="h-4 w-4" aria-hidden="true" />
|
|
</a>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Share feedback</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|