Separate private task enforcement from the sharing interface

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-30 09:08:20 -05:00
commit 8dad3ebc73
22 files changed
+22 -1553

No files matched your search

+1 -1
View File
@@ -128,7 +128,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable.
- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review.
- Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane.
- Do not interpret agent-profile privacy flags as project/issue privacy. Work remains company-open by default, while explicitly private issues and projects use their own task ACLs.
- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped.
- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath.
- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real.
+2 -20
View File
@@ -10,9 +10,6 @@ import type {
FeedbackTrace,
FeedbackVote,
Issue,
IssueAccessGrant,
IssueAccessGrantSubjectType,
IssueVisibility,
IssueChanges,
IssueAttachment,
IssueCostSummary,
@@ -589,23 +586,8 @@ export const issuesApi = {
form,
);
},
deleteAttachment: (id: string) => api.delete<{ ok: true }>(`/attachments/${id}`),
// --- Privacy / sharing (PAP-16066) -------------------------------------
// Enriched grants for the share sheet: implicit-by-source rows (assignment /
// project) plus explicit grants, each carrying subjectDisplayName / avatar /
// agentVisibility from the server enrichment pass.
listAccessGrants: (id: string, options?: RequestOptions) =>
options
? api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`, options)
: api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`),
createAccessGrant: (
id: string,
data: { subjectType: IssueAccessGrantSubjectType; subjectId: string },
) => api.post<IssueAccessGrant>(`/issues/${id}/access-grants`, data),
revokeAccessGrant: (id: string, grantId: string) =>
api.post<IssueAccessGrant>(`/issues/${id}/access-grants/${grantId}/revoke`, {}),
setVisibility: (id: string, visibility: IssueVisibility) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, { visibility }),
deleteAttachment: (id: string) =>
api.delete<{ ok: true }>(`/attachments/${id}`),
listApprovals: (id: string) => api.get<Approval[]>(`/issues/${id}/approvals`),
linkApproval: (id: string, approvalId: string) =>
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
-12
View File
@@ -1,6 +1,5 @@
import type {
Project,
ProjectAccessMember,
ProjectRepositoryOptions,
ProjectWorkspace,
WorkspaceOperation,
@@ -33,17 +32,6 @@ export const projectsApi = {
api.post<Project>(`/companies/${companyId}/projects`, data),
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
api.patch<Project>(projectPath(id, companyId), data),
listAccessMembers: (id: string, companyId?: string) =>
api.get<ProjectAccessMember[]>(projectPath(id, companyId, "/access-members")),
addAccessMember: (
id: string,
data: { subjectType: "user" | "agent"; subjectId: string },
companyId?: string,
) => api.post<ProjectAccessMember>(projectPath(id, companyId, "/access-members"), data),
removeAccessMember: (id: string, memberId: string, companyId?: string) =>
api.delete<ProjectAccessMember>(
projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`),
),
listWorkspaces: (projectId: string, companyId?: string) =>
api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
-13
View File
@@ -14,7 +14,6 @@ import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { formatMonitorOffset } from "../lib/issue-monitor";
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
import { RetryErrorBand } from "./IssueScheduledRetryCard";
import {
isAssignedBacklogBlocker,
@@ -545,18 +544,6 @@ export function IssueBlockedNotice({
const reopenSuppressedOtherCount = Math.max(unresolvedLeafBlockers.length - 1, 0);
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
// A private blocker arrives as a locked stub (no title/status). Show the
// locked chip plus the access note instead of a link into a 404.
if (isLockedIssueStub(blocker)) {
return (
<span key={blocker.id} className="inline-flex max-w-full items-center gap-1.5">
<LockedIssueChip identifier={blocker.identifier} />
<span className="text-(length:--text-micro) text-amber-800 dark:text-amber-200">
Private — you don't have access
</span>
</span>
);
}
const issuePathId = blocker.identifier ?? blocker.id;
const recoveryAction = blocker.activeRecoveryAction ?? null;
return (
-175
View File
@@ -1,175 +0,0 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Globe, Lock, Users } from "lucide-react";
import type { Issue, IssueVisibility } from "@paperclipai/shared";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { issuesApi } from "@/api/issues";
import { useToastActions } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { IssueShareSheet, type ShareSheetImplicitPrincipal } from "./IssueShareSheet";
const MENU_ITEM_CLASS =
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent";
const NON_SETTER_TOOLTIP =
"Only the task owner or an admin can change who can see this task.";
/**
* Privacy actions for the task `⋯` menu. Renders the menu buttons plus the
* two dialogs. The render prop keeps this component outside the menu's
* unmounting content, so dialogs survive the menu closing. `closeMenu` collapses the parent popover when an item that
* opens a dialog is clicked.
*
* Setter rules (locked decision, server-gated via
* `resolveIssuePrivacyManagementRoot`): only the responsible user + admins can
* change visibility or grants. Non-setters see the items disabled with a
* tooltip. Make-**public** always confirms (one-way disclosure); make-private
* never does.
*/
export function IssuePrivacyActions({
issue,
companyId,
canManage,
closeMenu,
implicitPrincipals = [],
children,
}: {
issue: Pick<Issue, "id" | "identifier" | "visibility">;
companyId: string;
canManage: boolean;
closeMenu: () => void;
implicitPrincipals?: ShareSheetImplicitPrincipal[];
children: (menuItems: React.ReactNode) => React.ReactNode;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [shareOpen, setShareOpen] = useState(false);
const [makePublicOpen, setMakePublicOpen] = useState(false);
const isPrivate = issue.visibility === "private";
const visibilityMutation = useMutation({
mutationFn: (visibility: IssueVisibility) => issuesApi.setVisibility(issue.id, visibility),
onSuccess: (_result, visibility) => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) });
if (issue.identifier) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.identifier) });
}
queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issue.id) });
setMakePublicOpen(false);
pushToast({
title: visibility === "private" ? "Task is now private" : "Task is now public",
tone: "success",
});
},
onError: (error) => {
pushToast({ title: "Couldn't change visibility", body: (error as Error).message, tone: "error" });
},
});
function withTooltip(node: React.ReactNode) {
if (canManage) return node;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="block w-full">{node}</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">{NON_SETTER_TOOLTIP}</TooltipContent>
</Tooltip>
);
}
return (
<TooltipProvider>
{children(isPrivate ? (
<>
{withTooltip(
<button
type="button"
className={MENU_ITEM_CLASS}
disabled={!canManage}
onClick={() => {
closeMenu();
setShareOpen(true);
}}
>
<Users className="h-4 w-4" aria-hidden="true" /> Share…
</button>,
)}
{withTooltip(
<button
type="button"
className={cn(MENU_ITEM_CLASS, "text-destructive")}
disabled={!canManage}
onClick={() => {
closeMenu();
setMakePublicOpen(true);
}}
>
<Globe className="h-4 w-4" aria-hidden="true" /> Make public
</button>,
)}
</>
) : (
withTooltip(
<button
type="button"
className={MENU_ITEM_CLASS}
disabled={!canManage || visibilityMutation.isPending}
onClick={() => {
closeMenu();
visibilityMutation.mutate("private");
}}
>
<Lock className="h-4 w-4" aria-hidden="true" /> Make private
</button>,
)
))}
<IssueShareSheet
issueId={issue.id}
companyId={companyId}
canManage={canManage}
open={shareOpen}
onOpenChange={setShareOpen}
implicitPrincipals={implicitPrincipals}
/>
<AlertDialog open={makePublicOpen} onOpenChange={setMakePublicOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Make this task public?</AlertDialogTitle>
<AlertDialogDescription>
Everyone in the company will be able to read this task, its comments, documents, and
run history. Existing private subtasks keep their privacy.{" "}
<span className="font-semibold text-foreground">Content already seen by others cannot be taken back.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep private</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={(event) => {
event.preventDefault();
visibilityMutation.mutate("open");
}}
disabled={visibilityMutation.isPending}
>
{visibilityMutation.isPending ? "Making public…" : "Make public"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipProvider>
);
}
+3 -10
View File
@@ -1,9 +1,8 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
import type { IssueLockedStub, IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { cn } from "../lib/utils";
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
import { badgeVariants } from "./ui/badge";
import { StatusIcon } from "./StatusIcon";
@@ -15,10 +14,8 @@ export function IssueReferencePill({
onRemove,
variant = "mention",
}: {
issue:
| (Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
{ status?: string })
| IssueLockedStub;
issue: Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
{ status?: string };
strikethrough?: boolean;
variant?: "mention" | "property";
className?: string;
@@ -26,10 +23,6 @@ export function IssueReferencePill({
/** Reserves space for a separate hover/focus action without moving the task link. */
onRemove?: (issueId: string) => void;
}) {
// Private issue referenced from a visible edge — never leak a title or a link.
if (isLockedIssueStub(issue)) {
return <LockedIssueChip identifier={issue.identifier} className={className} />;
}
const issueLabel = issue.identifier ?? issue.title;
const classNames = cn(
variant === "property" || onRemove
-157
View File
@@ -1,157 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IssueAccessGrant } from "@paperclipai/shared";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
const listAccessGrants = vi.fn();
const createAccessGrant = vi.fn();
const revokeAccessGrant = vi.fn();
vi.mock("@/api/issues", () => ({
issuesApi: {
listAccessGrants: (...args: unknown[]) => listAccessGrants(...args),
createAccessGrant: (...args: unknown[]) => createAccessGrant(...args),
revokeAccessGrant: (...args: unknown[]) => revokeAccessGrant(...args),
},
}));
vi.mock("@/api/access", () => ({
accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) },
}));
vi.mock("@/api/agents", () => ({
agentsApi: { list: vi.fn().mockResolvedValue([]) },
}));
vi.mock("@/context/ToastContext", () => ({
useToastActions: () => ({ pushToast: vi.fn() }),
}));
import { IssueShareSheet } from "./IssueShareSheet";
function grant(overrides: Partial<IssueAccessGrant>): IssueAccessGrant {
return {
id: "g",
issueId: "i1",
subjectType: "user",
subjectId: "u1",
source: "explicit",
grantedByUserId: null,
grantedByAgentId: null,
createdAt: new Date("2026-08-01T00:00:00Z"),
revokedAt: null,
subjectDisplayName: "Ada",
subjectAvatarUrl: null,
subjectInitials: "A",
agentVisibility: null,
...overrides,
};
}
// React Query resolves its mocked promises on the microtask/macrotask queue,
// outside flushSync. Interleave real awaits with flushSync to let the observer
// receive data and re-render.
async function settle(times = 6) {
for (let i = 0; i < times; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
flushSync(() => {});
}
}
describe("IssueShareSheet", () => {
let container: HTMLDivElement;
let root: Root | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
listAccessGrants.mockReset();
createAccessGrant.mockReset();
revokeAccessGrant.mockReset();
});
afterEach(() => {
if (root) act(() => root?.unmount());
container.remove();
document.body.innerHTML = "";
});
async function renderSheet(canManage = true) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root = createRoot(container);
act(() => {
root!.render(
<QueryClientProvider client={client}>
<IssueShareSheet
issueId="i1"
companyId="c1"
canManage={canManage}
open
onOpenChange={() => {}}
/>
</QueryClientProvider>,
);
});
await settle();
}
it("renders a source badge per grant and gates Revoke by source", async () => {
listAccessGrants.mockResolvedValue([
grant({ id: "g1", source: "explicit", subjectDisplayName: "Ada" }),
grant({ id: "g2", source: "assignment", subjectType: "agent", subjectId: "a1", subjectDisplayName: "Helper" }),
grant({ id: "g3", source: "project", subjectDisplayName: "Proj User" }),
]);
await renderSheet(true);
// Body is portalled to document.body by Radix Dialog.
const scope = document.body;
expect(scope.querySelector('[data-testid="grant-source-badge-explicit"]')).not.toBeNull();
expect(scope.querySelector('[data-testid="grant-source-badge-assignment"]')).not.toBeNull();
expect(scope.querySelector('[data-testid="grant-source-badge-project"]')).not.toBeNull();
const revokeButtons = [...scope.querySelectorAll("button")].filter((b) =>
b.textContent?.trim() === "Revoke",
);
// explicit + assignment are revocable; project is not.
expect(revokeButtons).toHaveLength(2);
expect(scope.textContent).toContain("project-managed");
});
it("hides Revoke entirely for non-setters", async () => {
listAccessGrants.mockResolvedValue([grant({ id: "g1", source: "explicit" })]);
await renderSheet(false);
const revokeButtons = [...document.body.querySelectorAll("button")].filter((b) =>
b.textContent?.trim() === "Revoke",
);
expect(revokeButtons).toHaveLength(0);
});
it("shows the empty state when there are no grants", async () => {
listAccessGrants.mockResolvedValue([]);
await renderSheet(true);
expect(document.body.querySelector('[data-testid="share-sheet-empty"]')?.textContent).toContain(
"Only you can see this task",
);
});
it("ignores revoked grants", async () => {
listAccessGrants.mockResolvedValue([
grant({ id: "g1", source: "explicit", revokedAt: new Date("2026-08-01T01:00:00Z") }),
]);
await renderSheet(true);
expect(document.body.querySelector('[data-testid="share-sheet-empty"]')).not.toBeNull();
});
});
-500
View File
@@ -1,500 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Check,
Folder,
Lock,
RotateCcw,
UserPlus,
} from "lucide-react";
import type { IssueAccessGrant, IssueAccessGrantSource } from "@paperclipai/shared";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
import { Skeleton } from "@/components/ui/skeleton";
import { agentsApi } from "@/api/agents";
import { accessApi } from "@/api/access";
import { issuesApi } from "@/api/issues";
import { useToastActions } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { timeAgo } from "@/lib/timeAgo";
import { cn } from "@/lib/utils";
import {
agentVisibilityFromPermissions,
grantIsRevocable,
isSharedAgentVisibility,
} from "@/lib/issuePrivacy";
/**
* A role-based principal that can read the task by virtue of *who they are*
* (responsible user, creator, current assignee) rather than an explicit grant.
* Implicit principals are shown with a muted role badge and have NO revoke —
* you change their access by changing the role, not from the share sheet.
* The caller derives these (it holds the agent/user directories); the sheet
* only renders them.
*/
export interface ShareSheetImplicitPrincipal {
id: string;
displayName: string;
roleLabel: string;
subtitle?: string | null;
avatarUrl?: string | null;
initials?: string | null;
}
interface SourceBadgeSpec {
label: string;
icon: typeof UserPlus;
className: string;
}
// Source badges map 1:1 to `grant.source`. Colors mirror the approved
// wireframe: explicit = blue, assignment = green, project = violet.
const SOURCE_BADGES: Record<IssueAccessGrantSource, SourceBadgeSpec> = {
owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" },
explicit: {
label: "shared directly",
icon: UserPlus,
className:
"border-blue-300/70 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-300",
},
assignment: {
label: "via assignment",
icon: Check,
className:
"border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300",
},
project: {
label: "via project",
icon: Folder,
className:
"border-violet-300/70 bg-violet-50 text-violet-800 dark:border-violet-500/30 dark:bg-violet-500/10 dark:text-violet-300",
},
};
function SourceBadge({ source }: { source: IssueAccessGrantSource }) {
const spec = SOURCE_BADGES[source];
const Icon = spec.icon;
return (
<span
data-testid={`grant-source-badge-${source}`}
data-source={source}
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-(length:--text-nano) font-medium leading-tight",
spec.className,
)}
>
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
{spec.label}
</span>
);
}
function PrincipalAvatar({
displayName,
avatarUrl,
initials,
}: {
displayName: string;
avatarUrl?: string | null;
initials?: string | null;
}) {
return (
<Avatar size="sm" className="mt-0.5">
{avatarUrl ? <AvatarImage src={avatarUrl} alt={displayName} /> : null}
<AvatarFallback>{initials ?? displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
function RowShell({
avatar,
title,
subtitle,
badge,
action,
}: {
avatar: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
badge?: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="flex items-start gap-2.5 py-2">
{avatar}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium">{title}</span>
{badge}
</div>
{subtitle ? <div className="truncate text-xs text-muted-foreground">{subtitle}</div> : null}
</div>
{action ? <div className="shrink-0 self-center">{action}</div> : null}
</div>
);
}
export function IssueShareSheet({
issueId,
companyId,
canManage,
open,
onOpenChange,
aclDescription = "Sharing grants access to this task and its descendants, including comments, documents, and run history. Parents and siblings need separate access.",
implicitPrincipals = [],
initialView = "list",
initialAddSelection = "",
}: {
issueId: string;
companyId: string;
canManage: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
aclDescription?: string;
implicitPrincipals?: ShareSheetImplicitPrincipal[];
/** Initial panel — `add` opens straight into the add flow (stories/tests). */
initialView?: "list" | "add";
/** Pre-selected add subject (`type:id`), used by stories/tests to show the caution. */
initialAddSelection?: string;
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [view, setView] = useState<"list" | "add">(initialView);
const [addSelection, setAddSelection] = useState<string>(initialAddSelection);
const [revokeTarget, setRevokeTarget] = useState<IssueAccessGrant | null>(null);
const grantsQuery = useQuery({
queryKey: queryKeys.issues.accessGrants(issueId),
queryFn: () => issuesApi.listAccessGrants(issueId),
enabled: open,
});
const directoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(companyId),
queryFn: () => accessApi.listUserDirectory(companyId),
enabled: open && view === "add" && canManage,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
enabled: open && view === "add" && canManage,
});
const activeGrants = useMemo(
() => (grantsQuery.data ?? []).filter((grant) => grant.revokedAt === null),
[grantsQuery.data],
);
const grantedSubjectKeys = useMemo(
() => new Set(activeGrants.map((grant) => `${grant.subjectType}:${grant.subjectId}`)),
[activeGrants],
);
const addMutation = useMutation({
mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) =>
issuesApi.createAccessGrant(issueId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issueId) });
setAddSelection("");
setView("list");
pushToast({ title: "Access granted", tone: "success" });
},
onError: (error) => {
pushToast({ title: "Couldn't add access", body: (error as Error).message, tone: "error" });
},
});
const revokeMutation = useMutation({
mutationFn: (grant: IssueAccessGrant) => issuesApi.revokeAccessGrant(issueId, grant.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.accessGrants(issueId) });
setRevokeTarget(null);
pushToast({ title: "Access revoked", tone: "success" });
},
onError: (error) => {
pushToast({ title: "Couldn't revoke access", body: (error as Error).message, tone: "error" });
},
});
// Candidate subjects for the "Add someone" combobox, minus anyone who already
// holds an active grant. Value encodes both type and id: `type:id`.
const addGroups = useMemo<SearchableSelectGroup[]>(() => {
const users = (directoryQuery.data?.users ?? [])
.filter((entry) => entry.user && !grantedSubjectKeys.has(`user:${entry.user.id}`))
.map((entry) => ({
key: `user:${entry.user!.id}`,
value: `user:${entry.user!.id}`,
label: entry.user!.name ?? entry.user!.email ?? "Unknown user",
searchText: [entry.user!.name, entry.user!.email].filter(Boolean).join(" "),
}));
const agents = (agentsQuery.data ?? [])
.filter((agent) => !grantedSubjectKeys.has(`agent:${agent.id}`))
.map((agent) => ({
key: `agent:${agent.id}`,
value: `agent:${agent.id}`,
label: agent.name,
searchText: agent.name,
}));
const groups: SearchableSelectGroup[] = [];
if (users.length > 0) groups.push({ id: "people", label: "People", options: users });
if (agents.length > 0) groups.push({ id: "agents", label: "Agents", options: agents });
return groups;
}, [directoryQuery.data, agentsQuery.data, grantedSubjectKeys]);
// Is the currently-selected add subject a *shared* agent? Drives the caution.
const selectedSharedAgentName = useMemo(() => {
if (!addSelection.startsWith("agent:")) return null;
const agentId = addSelection.slice("agent:".length);
const agent = (agentsQuery.data ?? []).find((candidate) => candidate.id === agentId);
if (!agent) return null;
return isSharedAgentVisibility(agentVisibilityFromPermissions(agent.permissions))
? agent.name
: null;
}, [addSelection, agentsQuery.data]);
function resetAndClose(next: boolean) {
if (!next) {
setView("list");
setAddSelection("");
setRevokeTarget(null);
}
onOpenChange(next);
}
function submitAdd() {
if (!addSelection) return;
const [subjectType, subjectId] = addSelection.split(/:(.+)/) as ["user" | "agent", string];
if (!subjectId) return;
addMutation.mutate({ subjectType, subjectId });
}
const isLoading = grantsQuery.isLoading;
const isError = grantsQuery.isError;
const isEmpty = !isLoading && !isError && activeGrants.length === 0 && implicitPrincipals.length === 0;
return (
<Dialog open={open} onOpenChange={resetAndClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
Who can access this task
</DialogTitle>
<DialogDescription>{aclDescription}</DialogDescription>
</DialogHeader>
{view === "list" ? (
<div className="space-y-1">
{isLoading ? (
<div className="space-y-2 py-1" data-testid="share-sheet-loading">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-2.5 py-1">
<Skeleton className="h-6 w-6 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
))}
</div>
) : isError ? (
<div
className="flex items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
data-testid="share-sheet-error"
>
<span className="flex items-center gap-1.5">
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
Couldn't load access.
</span>
<Button variant="ghost" size="sm" onClick={() => grantsQuery.refetch()}>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Retry
</Button>
</div>
) : isEmpty ? (
<p className="py-3 text-sm text-muted-foreground" data-testid="share-sheet-empty">
Only you can see this task.
</p>
) : (
<div className="divide-y divide-border/60">
{implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => (
<RowShell
key={`implicit:${principal.id}`}
avatar={
<PrincipalAvatar
displayName={principal.displayName}
avatarUrl={principal.avatarUrl}
initials={principal.initials}
/>
}
title={principal.displayName}
subtitle={principal.subtitle ?? undefined}
badge={
<span className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/50 px-1.5 py-0.5 text-(length:--text-nano) font-medium text-muted-foreground">
{principal.roleLabel}
</span>
}
/>
))}
{activeGrants.map((grant) => {
const displayName = grant.subjectDisplayName ?? "Unknown";
const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source);
const granter = grant.source === "explicit" ? "Shared" : "Granted";
return (
<RowShell
key={grant.id}
avatar={
<PrincipalAvatar
displayName={displayName}
avatarUrl={grant.subjectAvatarUrl}
initials={grant.subjectInitials}
/>
}
title={displayName}
subtitle={grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`}
badge={<SourceBadge source={grant.source} />}
action={
revocable ? (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => setRevokeTarget(grant)}
>
Revoke
</Button>
) : grant.source === "project" ? (
<span className="text-(length:--text-nano) text-muted-foreground">
project-managed
</span>
) : null
}
/>
);
})}
</div>
)}
{canManage ? (
<div className="pt-2">
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => setView("add")}
disabled={isLoading}
>
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add someone
</Button>
</div>
) : null}
</div>
) : (
<div className="space-y-3">
<SearchableSelect
value={addSelection}
groups={addGroups}
onValueChange={(value) => setAddSelection(value)}
placeholder="Choose a person or agent…"
searchPlaceholder="Search people and agents…"
loading={directoryQuery.isLoading || agentsQuery.isLoading}
loadingMessage="Loading directory…"
emptyMessage="No one left to add."
triggerClassName="w-full"
/>
{selectedSharedAgentName ? (
<div
role="note"
aria-label="Shared agent caution"
data-testid="shared-agent-caution"
className="flex items-start gap-2 rounded-md border border-amber-400/70 bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-200"
>
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
<span className="font-semibold">Shared agent.</span> {selectedSharedAgentName}'s
memory &amp; workspace may carry residual private context from this task into its
later runs for other people. Grant only if that's acceptable.
</span>
</div>
) : null}
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>
<span className="font-medium text-foreground">Access is sticky.</span> It lasts until
you revoke it — unassignment or completion won't remove it.
</span>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setView("list");
setAddSelection("");
}}
>
Back
</Button>
<Button size="sm" onClick={submitAdd} disabled={!addSelection || addMutation.isPending}>
{addMutation.isPending ? "Adding…" : "Add"}
</Button>
</div>
</div>
)}
</DialogContent>
<AlertDialog
open={revokeTarget !== null}
onOpenChange={(next) => {
if (!next) setRevokeTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Revoke {revokeTarget?.subjectDisplayName ?? "this subject"}'s access?
</AlertDialogTitle>
<AlertDialogDescription>
This removes this grant and the access it supplies to this task's descendants.
Ownership, current assignment, project membership, inherited access, or another grant
can still provide access. Revoking access does <span className="font-semibold">not</span> erase
content already seen or retained in an agent's memory.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
onClick={(event) => {
event.preventDefault();
if (revokeTarget) revokeMutation.mutate(revokeTarget);
}}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? "Revoking…" : "Revoke access"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
@@ -1,68 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(callback);
}
describe("LockedIssueChip", () => {
let container: HTMLDivElement;
let root: Root | null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) act(() => root?.unmount());
container.remove();
});
function renderChip(node: React.ReactNode) {
root = createRoot(container);
act(() => root!.render(node));
}
it("shows the identifier and a lock icon, never a title, and is not a link", () => {
renderChip(<LockedIssueChip identifier="PAP-1234" />);
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
expect(chip).not.toBeNull();
expect(chip?.tagName).toBe("SPAN");
expect(container.querySelector("a")).toBeNull();
expect(chip?.querySelector("svg")).not.toBeNull();
expect(chip?.textContent).toContain("PAP-1234");
expect(chip?.getAttribute("aria-label")).toContain("private");
});
it("falls back to a neutral Private label when the identifier is withheld", () => {
renderChip(<LockedIssueChip identifier={null} />);
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
expect(chip?.textContent).toContain("Private");
});
it("uses a dashed, muted border (design-token classes)", () => {
renderChip(<LockedIssueChip identifier="PAP-9" />);
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
expect(chip?.className).toContain("border-dashed");
expect(chip?.className).toContain("text-muted-foreground");
});
});
describe("isLockedIssueStub", () => {
it("recognizes only objects with locked === true", () => {
expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", locked: true })).toBe(true);
expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", title: "t", status: "todo" })).toBe(false);
expect(isLockedIssueStub({ locked: false })).toBe(false);
expect(isLockedIssueStub(null)).toBe(false);
expect(isLockedIssueStub(undefined)).toBe(false);
});
});
-48
View File
@@ -1,48 +0,0 @@
import { Lock } from "lucide-react";
import type { IssueLockedStub } from "@paperclipai/shared";
import { cn } from "../lib/utils";
/**
* Existence-only chip for a private issue referenced from a surface the current
* viewer *can* see (a visible blocker/relation edge, or a mention in a visible
* comment). Renders the mono identifier + a lock icon and nothing else — no
* title ever leaks, and the chip is intentionally non-interactive (a private
* issue 404s on direct fetch, so there is nowhere to link to).
*
* Feed it the exact `{ id, identifier, locked: true }` stub the server emits in
* place of an `IssueRelationIssueSummary`; use `isLockedIssueStub` to branch.
*/
export function isLockedIssueStub(value: unknown): value is IssueLockedStub {
return (
typeof value === "object" &&
value !== null &&
(value as { locked?: unknown }).locked === true
);
}
export function LockedIssueChip({
identifier,
className,
}: {
identifier: string | null;
className?: string;
}) {
// Identifier is the only viewer-safe field on the stub. When the server
// withheld even that, fall back to a neutral "Private" label so the chip
// still reads as a locked reference rather than an empty box.
const label = identifier ?? "Private";
return (
<span
data-testid="locked-issue-chip"
aria-label={`${label} — private, you don't have access`}
className={cn(
"inline-flex shrink-0 select-none items-center gap-1 rounded-md border border-dashed border-border px-1.5 py-0.5",
"font-mono text-(length:--text-nano) leading-tight text-muted-foreground sm:text-(length:--text-micro)",
className,
)}
>
<Lock className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate">{label}</span>
</span>
);
}
-26
View File
@@ -277,32 +277,6 @@ describe("MarkdownBody", () => {
expect(html).not.toContain("paperclip-mention-chip--issue");
});
it("holds the quicklook preview until an issue mention is confirmed readable (PAP-16070)", () => {
// While the fetch is in flight we don't know if the viewer can read the
// issue. The mention stays a clickable link, but the IssueLinkQuicklook
// hover preview (a Radix Popover portal) must NOT mount yet: a private
// mention 404s straight to the locked chip, and mounting then tearing down
// that portal to swap in the chip is the element churn that crashed the
// chat transcript renderer.
const html = renderMarkdown("Depends on PAP-1271 for the hover state.");
expect(html).toContain('data-mention-pending="true"');
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
// Clickability is preserved through the loading state.
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain(">PAP-1271<");
});
it("drops the pending marker once a seeded (readable) issue mention resolves", () => {
const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [
{ identifier: "PAP-1271", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).not.toContain('data-mention-pending="true"');
});
it("uses concise issue aria labels until a distinct title is available", () => {
const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [
{ identifier: "PAP-1271", status: "done" },
+1 -28
View File
@@ -10,8 +10,6 @@ import { useTheme } from "../context/ThemeContext";
import { useOptionalCompany } from "../context/CompanyContext";
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
import { issuesApi } from "../api/issues";
import { ApiError } from "../api/client";
import { LockedIssueChip } from "./LockedIssueChip";
import { queryKeys } from "../lib/queryKeys";
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
import { remarkLinkCaseReferences } from "../lib/case-reference";
@@ -114,46 +112,21 @@ function MarkdownIssueLink({
issuePathId: string;
children: ReactNode;
}) {
const { data, error } = useQuery({
const { data } = useQuery({
queryKey: queryKeys.issues.detail(issuePathId),
queryFn: () => issuesApi.get(issuePathId),
staleTime: 60_000,
// A private issue 404s on direct fetch (indistinguishable from deleted, by
// design). Don't burn retries on it — settle straight to the locked chip.
retry: (failureCount, err) =>
!(err instanceof ApiError && err.status === 404) && failureCount < 3,
});
// Mention of an issue this viewer can't read → existence-only locked chip
// (never a title or a link), matching the locked-stub treatment on edges.
if (error instanceof ApiError && error.status === 404) {
return <LockedIssueChip identifier={issuePathId} />;
}
const identifier = data?.identifier ?? issuePathId;
const title = data?.title ?? identifier;
const status = data?.status;
const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`;
// Until the fetch settles we don't yet know whether this viewer can read the
// issue. Keep the mention a plain link (clickable, styled as today) but hold
// off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal —
// until `data` confirms the issue is readable.
// - Correctness (PAP-16070): a private mention 404s straight to the locked
// chip. Mounting the quicklook popover during the loading `<Link>` only to
// tear the portal down and swap in a plain `<span>` chip on the 404 is the
// element churn that crashed the chat transcript's primary renderer (it
// fell through to the safe fallback). Loading link → chip stays portal-free.
// - Privacy: don't prefetch / hover-preview an issue of unconfirmed
// readability. `data-mention-pending` marks the transient state for tests.
const pending = !data;
return (
<Link
to={`/issues/${identifier}`}
data-mention-kind="issue"
disableIssueQuicklook={pending}
data-mention-pending={pending ? "true" : undefined}
// Boxless inline mention: the unified status glyph + a regular-weight
// underlined link, optically centered with the body text.
className={cn("paperclip-markdown-issue-ref", "font-normal underline")}
+2 -76
View File
@@ -45,7 +45,6 @@ const toastState = vi.hoisted(() => ({
}));
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
create: vi.fn(),
upsertDocument: vi.fn(),
uploadAttachment: vi.fn(),
@@ -232,12 +231,8 @@ vi.mock("@/components/ui/button", () => ({
}));
vi.mock("@/components/ui/toggle-switch", () => ({
ToggleSwitch: ({ checked, onCheckedChange, ...props }: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
"aria-label"?: string;
}) => (
<button type="button" aria-pressed={checked} onClick={() => onCheckedChange(!checked)} {...props}>toggle</button>
ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => (
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
),
}));
@@ -349,7 +344,6 @@ describe("NewIssueDialog", () => {
mockIssuesApi.create.mockReset();
mockIssuesApi.upsertDocument.mockReset();
mockIssuesApi.uploadAttachment.mockReset();
mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open" });
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
@@ -523,74 +517,6 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("restores private visibility from a saved draft without a project", async () => {
localStorage.setItem("paperclip:issue-draft", JSON.stringify({ title: "Private draft", description: "", status: "todo", priority: "", projectId: "", isPrivate: true }));
const { root } = renderDialog(container);
await flush();
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Private task"]')!;
await waitForAssertion(() => expect(toggle.getAttribute("aria-pressed")).toBe("true"));
const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Task"))!;
await act(async () => submit.click());
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ title: "Private draft", visibility: "private" }));
act(() => root.unmount());
});
it("locks inherited privacy and submits the child as private", async () => {
mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "private" });
dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" };
const { root } = renderDialog(container);
await flush();
await flush();
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Private task"]')!;
await waitForAssertion(() => expect(toggle.getAttribute("aria-pressed")).toBe("true"));
expect(toggle.disabled).toBe(true);
expect(container.textContent).toContain("inherits private access from its parent");
const submit = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Create Sub-Task"))!;
await act(async () => submit.click());
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ parentId: "issue-1", visibility: "private" }));
act(() => root.unmount());
});
it("defaults a private task to the current user's personal private project", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "personal-project",
companyId: "company-1",
name: "My private tasks",
visibility: "private",
personalOwnerUserId: "user-1",
archivedAt: null,
},
]);
dialogState.newIssueDefaults = { title: "Private scratch task" };
const { root } = renderDialog(container);
await flush();
await flush();
const privateToggle = container.querySelector<HTMLButtonElement>('button[aria-label="Private task"]');
expect(privateToggle).not.toBeNull();
await act(async () => privateToggle!.click());
await flush();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Task"));
await waitForAssertion(() => expect(submitButton?.hasAttribute("disabled")).toBe(false));
await act(async () => submitButton!.click());
await flush();
expect(mockIssuesApi.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
title: "Private scratch task",
visibility: "private",
projectId: "personal-project",
}),
);
act(() => root.unmount());
});
it("does not show user-secret warnings when the draft will not run an env binding that needs them", async () => {
const { root } = renderDialog(container);
await flush();
-45
View File
@@ -158,7 +158,6 @@ function useVisualViewportLayout(enabled: boolean) {
}
interface IssueDraft {
isPrivate?: boolean;
title: string;
description: string;
status: string;
@@ -517,7 +516,6 @@ export function NewIssueDialog() {
const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState<string>("shared_workspace");
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
const [isPrivate, setIsPrivate] = useState(false);
const [expanded, setExpanded] = useState(false);
const [dialogCompanyId, setDialogCompanyId] = useState<string | null>(null);
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
@@ -600,15 +598,6 @@ export function NewIssueDialog() {
userId: currentUserId,
});
const { data: privacyParent } = useQuery({
queryKey: queryKeys.issues.detail(newIssueDefaults.parentId ?? ""),
queryFn: () => issuesApi.get(newIssueDefaults.parentId!),
enabled: newIssueOpen && Boolean(newIssueDefaults.parentId),
});
const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private";
const effectivePrivate = isPrivate || inheritsPrivateAccess
|| orderedProjects.some(project => project.id === projectId && project.visibility === "private");
const selectedAssignee = useMemo(() => parseAssigneeValue(assigneeValue), [assigneeValue]);
const selectedAssigneeAgentId = selectedAssignee.assigneeAgentId;
const selectedAssigneeUserId = selectedAssignee.assigneeUserId;
@@ -753,12 +742,10 @@ export function NewIssueDialog() {
executionWorkspaceMode,
selectedExecutionWorkspaceId,
workMode,
isPrivate: effectivePrivate,
});
}, [
newIssueOpen,
scheduleSave,
effectivePrivate,
status,
priority,
assigneeValue,
@@ -888,7 +875,6 @@ export function NewIssueDialog() {
const hasExplicitProjectWorkspaceId = newIssueDefaults.projectWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceId = newIssueDefaults.executionWorkspaceId !== undefined;
const hasExplicitExecutionWorkspaceMode = newIssueDefaults.executionWorkspaceMode !== undefined;
setIsPrivate(draft.isPrivate ?? false);
setIssueText(draft.title, draft.description);
setStatus(draft.status || "todo");
setPriority(draft.priority);
@@ -1014,7 +1000,6 @@ export function NewIssueDialog() {
setExecutionWorkspaceMode("shared_workspace");
setSelectedExecutionWorkspaceId("");
setWorkMode("standard");
setIsPrivate(false);
setExpanded(false);
setDialogCompanyId(null);
setStagedFiles([]);
@@ -1098,7 +1083,6 @@ export function NewIssueDialog() {
status,
priority: priority || "medium",
workMode,
...(effectivePrivate ? { visibility: "private" } : {}),
...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}),
...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}),
...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}),
@@ -1297,7 +1281,6 @@ export function NewIssueDialog() {
if (nextProjectId) trackRecentProject(nextProjectId);
setProjectId(nextProjectId);
const nextProject = orderedProjects.find((project) => project.id === nextProjectId);
if (nextProject?.visibility === "private") setIsPrivate(true);
executionWorkspaceDefaultProjectId.current = nextProjectId || null;
setProjectWorkspaceId(defaultProjectWorkspaceIdForProject(nextProject));
setExecutionWorkspaceMode(defaultExecutionWorkspaceModeForProject(nextProject));
@@ -2036,34 +2019,6 @@ export function NewIssueDialog() {
</div>
)}
{/* Private task toggle */}
<div className="flex items-start justify-between gap-3 border-t border-border/60 px-4 py-2.5">
<div className="min-w-0">
<div className="text-xs font-medium">Private task</div>
<div className="text-(length:--text-micro) text-muted-foreground">
{inheritsPrivateAccess ? "This subtask inherits private access from its parent." : "Only you and people you share with can read it. Subtasks stay private too."}
</div>
</div>
<ToggleSwitch
checked={effectivePrivate}
disabled={inheritsPrivateAccess}
onCheckedChange={(checked) => {
setIsPrivate(checked);
if (!checked) {
const selectedProject = orderedProjects.find((project) => project.id === projectId);
if (selectedProject?.visibility === "private") handleProjectChange("");
return;
}
if (projectId || !currentUserId) return;
const personalProject = orderedProjects.find(
(project) => project.personalOwnerUserId === currentUserId,
);
if (personalProject) handleProjectChange(personalProject.id);
}}
aria-label="Private task"
/>
</div>
{/* Description */}
<div
className="border-t border-border/60 px-4 pb-2 pt-3"
-144
View File
@@ -1,144 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Trash2, UserPlus } from "lucide-react";
import type { Project } from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { projectsApi } from "@/api/projects";
import { useToastActions } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
export function ProjectAccessMembers({ project }: { project: Project }) {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState("");
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const membersKey = queryKeys.projects.accessMembers(project.id);
const membersQuery = useQuery({
queryKey: membersKey,
queryFn: () => projectsApi.listAccessMembers(project.id, project.companyId),
enabled: open,
});
const directoryQuery = useQuery({
queryKey: queryKeys.access.companyUserDirectory(project.companyId),
queryFn: () => accessApi.listUserDirectory(project.companyId),
enabled: open,
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(project.companyId),
queryFn: () => agentsApi.list(project.companyId),
enabled: open,
});
const activeKeys = useMemo(
() => new Set((membersQuery.data ?? []).map((member) => `${member.subjectType}:${member.subjectId}`)),
[membersQuery.data],
);
const groups = useMemo<SearchableSelectGroup[]>(() => {
const people = (directoryQuery.data?.users ?? [])
.filter((entry) => entry.user && !activeKeys.has(`user:${entry.user.id}`))
.map((entry) => ({
key: `user:${entry.user!.id}`,
value: `user:${entry.user!.id}`,
label: entry.user!.name ?? entry.user!.email ?? "Unknown user",
searchText: entry.user!.email ?? "",
}));
const agentOptions = (agentsQuery.data ?? [])
.filter((agent) => !activeKeys.has(`agent:${agent.id}`))
.map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name }));
return [
...(people.length ? [{ id: "people", label: "People", options: people }] : []),
...(agentOptions.length ? [{ id: "agents", label: "Agents", options: agentOptions }] : []),
];
}, [activeKeys, agentsQuery.data, directoryQuery.data]);
const addMember = useMutation({
mutationFn: async () => {
const [subjectType, subjectId] = selection.split(/:(.+)/) as ["user" | "agent", string];
return projectsApi.addAccessMember(project.id, { subjectType, subjectId }, project.companyId);
},
onSuccess: () => {
setSelection("");
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access added", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't add project access", body: (error as Error).message, tone: "error" }),
});
const removeMember = useMutation({
mutationFn: (memberId: string) => projectsApi.removeAccessMember(project.id, memberId, project.companyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: membersKey });
pushToast({ title: "Project access removed", tone: "success" });
},
onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }),
});
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Lock className="h-3.5 w-3.5" aria-hidden="true" /> Manage access
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Private project access</DialogTitle>
<DialogDescription>
Members can discover this project and read every task in it. A task can still be shared directly.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{directoryQuery.isError || agentsQuery.isError ? (
<p className="text-sm text-destructive">Couldn&apos;t load the company access directory.</p>
) : null}
<div className="flex items-end gap-2">
<SearchableSelect
value={selection}
groups={groups}
onValueChange={(value) => setSelection(value)}
placeholder="Add a person or agent"
searchPlaceholder="Search people and agents"
emptyMessage="No more company members to add"
loading={directoryQuery.isLoading || agentsQuery.isLoading}
className="min-w-0 flex-1"
/>
<Button size="sm" disabled={!selection || addMember.isPending} onClick={() => addMember.mutate()}>
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add
</Button>
</div>
<div className="divide-y divide-border/60">
{(membersQuery.data ?? []).map((member) => {
const isPersonalOwner = project.personalOwnerUserId === member.subjectId && member.subjectType === "user";
return (
<div key={member.id} className="flex items-center gap-3 py-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{member.subjectDisplayName ?? "Unknown member"}</div>
<div className="text-xs text-muted-foreground">
{isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"}
</div>
</div>
{!isPersonalOwner ? (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove ${member.subjectDisplayName ?? "member"}`}
disabled={removeMember.isPending}
onClick={() => removeMember.mutate(member.id)}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
);
})}
{membersQuery.isLoading ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
{membersQuery.isError ? (
<p className="py-3 text-sm text-destructive">Couldn&apos;t load project access members.</p>
) : null}
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
-21
View File
@@ -22,7 +22,6 @@ import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
import { ProjectAccessMembers } from "./ProjectAccessMembers";
interface ProjectPropertiesProps {
project: Project;
@@ -39,7 +38,6 @@ export type ProjectConfigFieldKey =
| "name"
| "description"
| "status"
| "visibility"
| "goals"
| "env"
| "execution_workspace_enabled"
@@ -453,25 +451,6 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
</p>
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Visibility" state={fieldState("visibility")} />} alignStart>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<ToggleSwitch
checked={project.visibility === "private"}
onCheckedChange={(checked) => {
if (!checked && !window.confirm("Make this project open to everyone in the company?")) return;
commitField("visibility", { visibility: checked ? "private" : "open" });
}}
disabled={!onUpdate && !onFieldUpdate}
/>
<span>{project.visibility === "private" ? "Private" : "Open to company"}</span>
</label>
<p className="text-(length:--text-micro) text-muted-foreground">
Only access members can discover this project. Tasks shared directly remain readable on their own.
</p>
{project.visibility === "private" ? <ProjectAccessMembers project={project} /> : null}
</div>
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
@@ -1,7 +1,6 @@
import { useState, type MouseEvent } from "react";
import type { Issue, IssueLockedStub } from "@paperclipai/shared";
import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { LockedIssueChip, isLockedIssueStub } from "../LockedIssueChip";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -27,31 +26,19 @@ export function RemovableIssueReferencePill({
onRemove,
isMobile = false,
}: {
issue: NonNullable<Issue["blockedBy"]>[number] | IssueLockedStub;
issue: NonNullable<Issue["blockedBy"]>[number];
onRemove: (issueId: string) => void;
isMobile?: boolean;
}) {
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const locked = isLockedIssueStub(issue);
const issueLabel = locked
? issue.identifier ?? "Private task"
: issue.identifier ?? issue.title;
const confirmLabel = locked
? issue.identifier ?? "this private task"
: issue.identifier
? `${issue.identifier}: ${issue.title}`
: issue.title;
const issueLabel = issue.identifier ?? issue.title;
const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title;
const chipClassName = cn(
"paperclip-mention-chip paperclip-mention-chip--issue",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
!locked && issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring",
);
// A private blocker referenced from a task you *can* see: show the locked chip
// (never a title/link) but keep the detach affordance so you aren't stuck
// blocked by something invisible.
const content = locked ? (
<LockedIssueChip identifier={issue.identifier} />
) : (
const content = (
<>
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
<span className="truncate">{issueLabel}</span>
@@ -79,14 +66,14 @@ export function RemovableIssueReferencePill({
type="button"
data-mention-kind="issue"
className={chipClassName}
title={locked ? "Private task" : issue.title}
title={issue.title}
aria-label={`Actions for blocker ${issueLabel}`}
>
{content}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{!locked && issue.identifier ? (
{issue.identifier ? (
<DropdownMenuItem asChild>
<Link to={`/issues/${issue.identifier}`}>
<ArrowUpRight className="h-4 w-4" />
@@ -111,9 +98,7 @@ export function RemovableIssueReferencePill({
>
<X className="h-3 w-3" />
</button>
{locked ? (
content
) : issue.identifier ? (
{issue.identifier ? (
<Link
to={`/issues/${issue.identifier}`}
data-mention-kind="issue"
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it } from "vitest";
import type { IssueAccessGrant } from "@paperclipai/shared";
import {
agentVisibilityFromPermissions,
grantIsRevocable,
grantIsSharedAgent,
isSharedAgentVisibility,
} from "./issuePrivacy";
describe("agentVisibilityFromPermissions", () => {
it("reads the nested authorizationPolicy.agentVisibility.mode", () => {
expect(
agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "discoverable" } } }),
).toBe("discoverable");
expect(
agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "private" } } }),
).toBe("private");
});
it("returns null for malformed / missing shapes", () => {
expect(agentVisibilityFromPermissions(null)).toBeNull();
expect(agentVisibilityFromPermissions({})).toBeNull();
expect(agentVisibilityFromPermissions({ authorizationPolicy: {} })).toBeNull();
expect(
agentVisibilityFromPermissions({ authorizationPolicy: { agentVisibility: { mode: "bogus" } } }),
).toBeNull();
});
});
describe("isSharedAgentVisibility", () => {
it("treats anything not explicitly private as shared", () => {
expect(isSharedAgentVisibility("discoverable")).toBe(true);
expect(isSharedAgentVisibility("private")).toBe(false);
expect(isSharedAgentVisibility(null)).toBe(false);
});
});
describe("grantIsSharedAgent", () => {
const base: IssueAccessGrant = {
id: "g1",
issueId: "i1",
subjectType: "agent",
subjectId: "a1",
source: "explicit",
grantedByUserId: null,
grantedByAgentId: null,
createdAt: new Date(),
revokedAt: null,
subjectDisplayName: "Helper",
subjectAvatarUrl: null,
subjectInitials: "H",
agentVisibility: "discoverable",
};
it("is true only for non-private agents", () => {
expect(grantIsSharedAgent(base)).toBe(true);
expect(grantIsSharedAgent({ ...base, agentVisibility: "private" })).toBe(false);
expect(grantIsSharedAgent({ ...base, subjectType: "user", agentVisibility: null })).toBe(false);
});
});
describe("grantIsRevocable", () => {
it("allows explicit + assignment, never project", () => {
expect(grantIsRevocable("explicit")).toBe(true);
expect(grantIsRevocable("assignment")).toBe(true);
expect(grantIsRevocable("project")).toBe(false);
});
});
-48
View File
@@ -1,48 +0,0 @@
import type {
IssueAccessGrant,
IssueAccessGrantAgentVisibility,
IssueAccessGrantSource,
} from "@paperclipai/shared";
/**
* Client mirror of the server's `issueGrantAgentVisibility`
* (server/src/routes/issues.ts) — reads an agent's visibility mode out of its
* loosely-typed `permissions.authorizationPolicy.agentVisibility.mode`. Used to
* preview the shared-agent caution *before* a grant exists (the enriched grant
* carries `agentVisibility`, but the add flow only has the candidate agent).
*/
export function agentVisibilityFromPermissions(
permissions: unknown,
): IssueAccessGrantAgentVisibility | null {
if (!permissions || typeof permissions !== "object" || Array.isArray(permissions)) return null;
const authorizationPolicy = (permissions as Record<string, unknown>).authorizationPolicy;
if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) {
return null;
}
const agentVisibility = (authorizationPolicy as Record<string, unknown>).agentVisibility;
if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null;
const mode = (agentVisibility as Record<string, unknown>).mode;
return mode === "private" || mode === "discoverable" ? mode : null;
}
/**
* Locked decision `trust-agent`: a *shared* agent is any agent not explicitly
* scoped to `private`. Sharing a private task with one means its memory &
* workspace may carry residual private context into later runs for other
* people, so the amber caution fires. Users and private/dedicated agents never
* trip it.
*/
export function isSharedAgentVisibility(
agentVisibility: IssueAccessGrantAgentVisibility | null,
): boolean {
return agentVisibility !== null && agentVisibility !== "private";
}
export function grantIsSharedAgent(grant: IssueAccessGrant): boolean {
return grant.subjectType === "agent" && isSharedAgentVisibility(grant.agentVisibility);
}
/** Only explicit + assignment grants are revocable from the share sheet; project grants are managed on the project. */
export function grantIsRevocable(source: IssueAccessGrantSource): boolean {
return source === "explicit" || source === "assignment";
}
+4 -4
View File
@@ -364,9 +364,10 @@ export const queryKeys = {
options.excludeRoot
? (["issues", "cost-summary", issueId, "exclude-root"] as const)
: (["issues", "cost-summary", issueId] as const),
accessGrants: (issueId: string) => ["issues", "access-grants", issueId] as const,
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
attachmentPreview: (attachmentId: string) => ["issues", "attachment-preview", attachmentId] as const,
attachments: (issueId: string) =>
["issues", "attachments", issueId] as const,
attachmentPreview: (attachmentId: string) =>
["issues", "attachment-preview", attachmentId] as const,
documents: (issueId: string) => ["issues", "documents", issueId] as const,
document: (issueId: string, key: string) =>
["issues", "document", issueId, key] as const,
@@ -518,7 +519,6 @@ export const queryKeys = {
{ includeArchived: opts.includeArchived === true },
] as const,
detail: (id: string) => ["projects", "detail", id] as const,
accessMembers: (id: string) => ["projects", "access-members", id] as const,
},
cases: {
list: (companyId: string) => ["cases", companyId] as const,
-14
View File
@@ -168,7 +168,6 @@ import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { AppLogo } from "@/pages/apps/AppLogo";
import { IssueReferencePill } from "@/components/IssueReferencePill";
import { LockedIssueChip } from "@/components/LockedIssueChip";
import { MembershipAction } from "@/components/MembershipAction";
import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection";
import { EnvironmentVariablesEditor } from "@/components/environment-variables-editor";
@@ -890,19 +889,6 @@ export function DesignGuide() {
<IssueReferencePill strikethrough issue={{ id: "demo-5", identifier: "PAP-202", title: "Removed (strikethrough)", status: "todo" }} />
</div>
</SubSection>
<SubSection title="LockedIssueChip">
<p className="text-xs text-muted-foreground">
Existence-only reference to a private task shown from a surface the viewer can see
(a blocker edge, a mention). Mono id + lock, dashed muted border,{" "}
<strong>no title, never a link</strong>. Falls back to &quot;Private&quot; when even the
identifier is withheld.
</p>
<div className="flex items-center gap-2 flex-wrap">
<LockedIssueChip identifier="PAP-1234" />
<LockedIssueChip identifier={null} />
</div>
</SubSection>
</Section>
{/* ============================================================ */}
-51
View File
@@ -334,8 +334,6 @@ import {
SlidersHorizontal,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { IssuePrivacyActions } from "@/components/IssuePrivacyActions";
import type { ShareSheetImplicitPrincipal } from "@/components/IssueShareSheet";
import {
deriveOriginatingActor,
isClosedIsolatedExecutionWorkspace,
@@ -3497,44 +3495,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
() => buildCompanyUserLabelMap(companyMembers?.users),
[companyMembers?.users],
);
// Privacy setter gate (UI mirror of the server's
// `resolveIssuePrivacyManagementRoot`): responsible user + board managers /
// admins. The server is authoritative; this only decides what to enable.
const canManagePrivacy = Boolean(
issue &&
(canManageBoardRuntime || (currentUserId && issue.responsibleUserId === currentUserId)),
);
// Role-based principals for the share sheet's implicit rows (no revoke).
const privacyImplicitPrincipals = useMemo<ShareSheetImplicitPrincipal[]>(() => {
if (!issue) return [];
const list: ShareSheetImplicitPrincipal[] = [];
const seen = new Set<string>();
const pushUser = (userId: string | null, roleLabel: string) => {
if (!userId || seen.has(`user:${userId}`)) return;
seen.add(`user:${userId}`);
const profile = userProfileMap.get(userId);
list.push({
id: `user:${userId}`,
displayName: profile?.label ?? userId.slice(0, 5),
roleLabel,
avatarUrl: profile?.image ?? null,
});
};
const pushAgent = (agentId: string | null, roleLabel: string) => {
if (!agentId || seen.has(`agent:${agentId}`)) return;
seen.add(`agent:${agentId}`);
const agent = agentMap.get(agentId);
list.push({
id: `agent:${agentId}`,
displayName: agent?.name ?? agentId.slice(0, 8),
roleLabel,
});
};
pushUser(issue.responsibleUserId, "Owner");
pushAgent(issue.assigneeAgentId, "Assignee");
pushUser(issue.assigneeUserId, "Assignee");
return list;
}, [issue, userProfileMap, agentMap]);
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
@@ -7244,14 +7204,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
"absolute right-0 top-0 flex h-7 items-center",
)}
>
<IssuePrivacyActions
issue={issue}
companyId={issue.companyId}
canManage={canManagePrivacy}
closeMenu={() => setMoreOpen(false)}
implicitPrincipals={privacyImplicitPrincipals}
>
{(privacyMenuItems) => (
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button
@@ -7313,7 +7265,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
) : null}
</>
) : null}
{privacyMenuItems}
<TaskTreeControlMenuItems
scope={treeControlScope}
canPause={
@@ -7371,8 +7322,6 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
</button>
</PopoverContent>
</Popover>
)}
</IssuePrivacyActions>
</div>
</div>
</div>