mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Separate private task enforcement from the sharing interface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
8dad3ebc73
22 files changed
+22
-1553
No files matched your search
+1
-1
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 & 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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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't load project access members.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "Private" 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>
|
||||
|
||||
{/* ============================================================ */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user