From b31558064093564fb937cf046596d1d497f5be27 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:27:52 -0500 Subject: [PATCH] feat: add private task creation and sharing controls (#14718) Add private task creation and sharing controls, effective access explanations, private project management, and locked task references. Constrain the mobile composer plus menu to the viewport and explain named inherited privacy on hover, focus, or tap. Include 125 production-component Storybook stories and thirteen interactive user journeys. Final head passes all CI checks and Greptile Apex 5/5 with no findings. Co-Authored-By: Paperclip --- doc/PRODUCT.md | 2 +- ui/src/api/issues.ts | 24 +- ui/src/api/projects.ts | 12 + ui/src/components/AccessSelectIdentity.tsx | 10 + ui/src/components/Identity.tsx | 4 +- ui/src/components/IssueBlockedNotice.test.tsx | 9 + ui/src/components/IssueBlockedNotice.tsx | 15 +- ui/src/components/IssuePrivacyActions.tsx | 199 +++++ ui/src/components/IssueProperties.test.tsx | 14 + ui/src/components/IssueReferencePill.tsx | 13 +- ui/src/components/IssueShareSheet.test.tsx | 220 +++++ ui/src/components/IssueShareSheet.tsx | 506 ++++++++++++ ui/src/components/LockedIssueChip.test.tsx | 75 ++ ui/src/components/LockedIssueChip.tsx | 51 ++ ui/src/components/MarkdownBody.test.tsx | 26 + ui/src/components/MarkdownBody.tsx | 29 +- ui/src/components/NewIssueDialog.test.tsx | 132 ++- ui/src/components/NewIssueDialog.tsx | 52 +- .../PrivateTaskPrivacyControls.test.tsx | 123 +++ ui/src/components/ProjectAccessMembers.tsx | 165 ++++ ui/src/components/ProjectProperties.tsx | 54 ++ ui/src/components/SidebarProjects.tsx | 3 +- .../SidebarStarredProjects.production.tsx | 3 +- ui/src/components/SidebarStarredProjects.tsx | 3 +- .../issue-properties/IssueProperties.tsx | 10 +- .../issue-properties/relation-controls.tsx | 33 +- .../components/task-chat/ComposerAddMenu.tsx | 46 +- .../components/task-chat/TaskChatComposer.tsx | 5 +- .../task-detail/TaskDetailRelationsPanel.tsx | 8 +- .../task-detail/TaskDetailTasksPanel.test.tsx | 8 + ui/src/lib/issuePrivacy.test.ts | 93 +++ ui/src/lib/issuePrivacy.ts | 68 ++ ui/src/lib/queryKeys.ts | 9 +- ui/src/lib/sentry.test.ts | 6 +- ui/src/pages/DesignGuide.tsx | 14 + ui/src/pages/IssueDetail.tsx | 53 +- ui/src/pages/ProjectDetail.test.tsx | 23 +- ui/src/pages/ProjectDetail.tsx | 8 +- ui/src/pages/Projects.tsx | 2 + ui/storybook/fixtures/privateTasks.test.ts | 228 +++++ ui/storybook/fixtures/privateTasks.ts | 780 ++++++++++++++++++ .../private-tasks/00-review-guide.stories.tsx | 272 ++++++ .../private-tasks/01-creation.stories.tsx | 257 ++++++ .../private-tasks/02-sharing.stories.tsx | 242 ++++++ .../private-tasks/03-task-actions.stories.tsx | 152 ++++ .../04-references-and-blockers.stories.tsx | 219 +++++ .../private-tasks/05-projects.stories.tsx | 244 ++++++ .../06-full-product-pages.stories.tsx | 247 ++++++ .../private-tasks/07-user-stories.stories.tsx | 210 +++++ .../stories/private-tasks/PrivacyStory.tsx | 94 +++ 50 files changed, 5019 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/AccessSelectIdentity.tsx create mode 100644 ui/src/components/IssuePrivacyActions.tsx create mode 100644 ui/src/components/IssueShareSheet.test.tsx create mode 100644 ui/src/components/IssueShareSheet.tsx create mode 100644 ui/src/components/LockedIssueChip.test.tsx create mode 100644 ui/src/components/LockedIssueChip.tsx create mode 100644 ui/src/components/PrivateTaskPrivacyControls.test.tsx create mode 100644 ui/src/components/ProjectAccessMembers.tsx create mode 100644 ui/src/lib/issuePrivacy.test.ts create mode 100644 ui/src/lib/issuePrivacy.ts create mode 100644 ui/storybook/fixtures/privateTasks.test.ts create mode 100644 ui/storybook/fixtures/privateTasks.ts create mode 100644 ui/storybook/stories/private-tasks/00-review-guide.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/01-creation.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/02-sharing.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/03-task-actions.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/05-projects.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/07-user-stories.stories.tsx create mode 100644 ui/storybook/stories/private-tasks/PrivacyStory.tsx diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 502b1d42dd9..97560d6d9ea 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -135,7 +135,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-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. +- 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 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. diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 4f1b6d907d5..4b057f5e772 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { FeedbackTrace, FeedbackVote, Issue, + IssueAccessGrant, + IssueAccessGrantSubjectType, + IssueVisibility, + IssuePrivacyConstraints, IssueChanges, IssueAttachment, IssueCostSummary, @@ -589,8 +593,24 @@ export const issuesApi = { form, ); }, - deleteAttachment: (id: string) => - api.delete<{ ok: true }>(`/attachments/${id}`), + 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. + privacyConstraints: (id: string) => api.get(`/issues/${id}/privacy-constraints`), + listAccessGrants: (id: string, options?: RequestOptions) => + options + ? api.get(`/issues/${id}/access-grants`, options) + : api.get(`/issues/${id}/access-grants`), + createAccessGrant: ( + id: string, + data: { subjectType: IssueAccessGrantSubjectType; subjectId: string }, + ) => api.post(`/issues/${id}/access-grants`, data), + revokeAccessGrant: (id: string, grantId: string) => + api.post(`/issues/${id}/access-grants/${grantId}/revoke`, {}), + setVisibility: (id: string, visibility: IssueVisibility) => + api.patch(`/issues/${id}`, { visibility }), listApprovals: (id: string) => api.get(`/issues/${id}/approvals`), linkApproval: (id: string, approvalId: string) => api.post(`/issues/${id}/approvals`, { approvalId }), diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index 32f296e31c4..35b5c33567f 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,5 +1,6 @@ import type { Project, + ProjectAccessMember, ProjectRepositoryOptions, ProjectWorkspace, WorkspaceOperation, @@ -32,6 +33,17 @@ export const projectsApi = { api.post(`/companies/${companyId}/projects`, data), update: (id: string, data: Record, companyId?: string) => api.patch(projectPath(id, companyId), data), + listAccessMembers: (id: string, companyId?: string) => + api.get(projectPath(id, companyId, "/access-members")), + addAccessMember: ( + id: string, + data: { subjectType: "user" | "agent"; subjectId: string }, + companyId?: string, + ) => api.post(projectPath(id, companyId, "/access-members"), data), + removeAccessMember: (id: string, memberId: string, companyId?: string) => + api.delete( + projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`), + ), listWorkspaces: (projectId: string, companyId?: string) => api.get(projectPath(projectId, companyId, "/workspaces")), createWorkspace: (projectId: string, data: Record, companyId?: string) => diff --git a/ui/src/components/AccessSelectIdentity.tsx b/ui/src/components/AccessSelectIdentity.tsx new file mode 100644 index 00000000000..5e3ddb19ba7 --- /dev/null +++ b/ui/src/components/AccessSelectIdentity.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; +import type { SearchableSelectOption } from "./SearchableSelect"; + +export interface AccessSelectOption extends SearchableSelectOption { + identity: ReactNode; +} + +export function renderAccessIdentity(option: AccessSelectOption | null) { + return option ? {option.identity} : null; +} diff --git a/ui/src/components/Identity.tsx b/ui/src/components/Identity.tsx index e1decbebdaf..b6e3945b18a 100644 --- a/ui/src/components/Identity.tsx +++ b/ui/src/components/Identity.tsx @@ -34,8 +34,8 @@ export function Identity({ name, avatarUrl, initials, size = "default", shape = className={cn("inline-flex min-w-0 gap-1.5 items-center", size === "xs" && "gap-1", size === "lg" && "gap-2", className)} title={name} > - - {avatarUrl && } + {name} diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index 8651ff7ac05..49ca976585e 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import type { + IssueRelationIssueSummary, IssueRecoveryAction, IssueRetryNowOutcome, IssueScheduledRetry, @@ -123,6 +124,14 @@ function render(element: ReactElement) { } describe("IssueBlockedNotice", () => { + it("renders a locked blocker without reading its withheld status", () => { + const node = render(); + expect(node.textContent).toContain("PAP-42"); + expect(node.textContent).toContain("Private"); + }); + it("renders a successful-run next-step notice without requiring blockers", () => { const node = render( { + // 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 ( + + + + Private — you don't have access + + + ); + } const issuePathId = blocker.identifier ?? blocker.id; const recoveryAction = blocker.activeRecoveryAction ?? null; return ( diff --git a/ui/src/components/IssuePrivacyActions.tsx b/ui/src/components/IssuePrivacyActions.tsx new file mode 100644 index 00000000000..98a6edf4beb --- /dev/null +++ b/ui/src/components/IssuePrivacyActions.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { useMutation, useQuery, 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 + * `resolveManagedIssueForPrivacy`): 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 & Partial>; + 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 constraintsQuery = useQuery({ + queryKey: [...queryKeys.issues.privacyConstraints(issue.id), issue.privacyParentIssueId ?? null, issue.projectId ?? null], + queryFn: () => issuesApi.privacyConstraints(issue.id), + enabled: canManage && isPrivate, + }); + const publicBlockedReason = constraintsQuery.isError + ? "Couldn't check task privacy. Retry before making this task public." + : !constraintsQuery.data || constraintsQuery.isFetching + ? "Checking task privacy…" + : constraintsQuery.data.publicBlockedBy === "parent" + ? "Move this task out of its private parent before making it public." + : constraintsQuery.data.publicBlockedBy === "project" + ? "Move this task out of its private project before making it public." + : null; + + const visibilityMutation = useMutation({ + mutationFn: (visibility: IssueVisibility) => issuesApi.setVisibility(issue.id, visibility), + onSuccess: (_result, visibility) => { + // Privacy can change descendants and remove a task from a private project. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) }); + 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, blockedReason?: string | null) { + if (canManage && !blockedReason) return node; + return ( + + + {node} + + {canManage ? blockedReason : NON_SETTER_TOOLTIP} + + ); + } + + return ( + + {children(isPrivate ? ( + <> + {withTooltip( + , + )} + {withTooltip( + , + publicBlockedReason, + )} + {canManage && constraintsQuery.isError ? ( + + ) : null} + + ) : ( + withTooltip( + , + ) + ))} + + + + + + + Make this task public? + + Everyone in the company will be able to read this task, its comments, documents, and + run history. Existing private subtasks keep their privacy.{" "} + {constraintsQuery.data?.leavesPersonalProject + ? "This task will also leave its personal project. " : null} + Content already seen by others cannot be taken back. + + + + Keep private + { + event.preventDefault(); + visibilityMutation.mutate("open"); + }} + disabled={visibilityMutation.isPending || Boolean(publicBlockedReason)} + > + {visibilityMutation.isPending ? "Making public…" : "Make public"} + + + + + + ); +} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 38a550a2217..683acb658ec 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -1363,6 +1363,20 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("preserves a locked parent stub without manufacturing a title or navigation link", async () => { + const root = renderProperties(container, { + issue: createIssue({ parentId: "private-parent", ancestors: [{ id: "private-parent", identifier: "PAP-99", locked: true }] as unknown as Issue["ancestors"] }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + expect(container.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toContain("PAP-99"); + expect(container.querySelector('a[href="/issues/PAP-99"]')).toBeNull(); + expect(container.querySelector('[title="private-"]')).toBeNull(); + act(() => root.unmount()); + }); + it("links relationship status and IDs and removes only the selected blocker with its X", async () => { const onUpdate = vi.fn(); const blockers = [ diff --git a/ui/src/components/IssueReferencePill.tsx b/ui/src/components/IssueReferencePill.tsx index e376db9444d..e47c8fb142f 100644 --- a/ui/src/components/IssueReferencePill.tsx +++ b/ui/src/components/IssueReferencePill.tsx @@ -1,8 +1,9 @@ import { X } from "lucide-react"; import type { ReactNode } from "react"; -import type { IssueRelationIssueSummary } from "@paperclipai/shared"; +import type { IssueLockedStub, 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"; @@ -14,8 +15,10 @@ export function IssueReferencePill({ onRemove, variant = "mention", }: { - issue: Pick & - { status?: string }; + issue: + | (Pick & + { status?: string }) + | IssueLockedStub; strikethrough?: boolean; variant?: "mention" | "property"; className?: string; @@ -23,6 +26,10 @@ 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 ; + } const issueLabel = issue.identifier ?? issue.title; const classNames = cn( variant === "property" || onRemove diff --git a/ui/src/components/IssueShareSheet.test.tsx b/ui/src/components/IssueShareSheet.test.tsx new file mode 100644 index 00000000000..84ab5c0c900 --- /dev/null +++ b/ui/src/components/IssueShareSheet.test.tsx @@ -0,0 +1,220 @@ +// @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) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +const originalScrollIntoView = Element.prototype.scrollIntoView; +const listAccessGrants = vi.fn(); +const createAccessGrant = vi.fn(); +const revokeAccessGrant = vi.fn(); +const listUserDirectory = 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: (...args: unknown[]) => listUserDirectory(...args) }, +})); +vi.mock("@/api/agents", () => ({ + agentsApi: { list: vi.fn().mockResolvedValue([]) }, +})); +vi.mock("@/context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: vi.fn() }), +})); + +import { IssueShareSheet } from "./IssueShareSheet"; +import { queryKeys } from "@/lib/queryKeys"; + +function grant(overrides: Partial): 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(() => { + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + Element.prototype.scrollIntoView = () => {}; + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + listAccessGrants.mockReset(); + createAccessGrant.mockReset(); + revokeAccessGrant.mockReset(); + listUserDirectory.mockReset().mockResolvedValue({ users: [] }); + }); + + afterEach(() => { + if (root) act(() => root?.unmount()); + container.remove(); + document.body.innerHTML = ""; + vi.unstubAllGlobals(); + Element.prototype.scrollIntoView = originalScrollIntoView; + }); + + async function renderSheet(canManage = true, implicitPrincipals: import("./IssueShareSheet").ShareSheetImplicitPrincipal[] = []) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(queryKeys.issues.accessGrants("descendant"), []); + root = createRoot(container); + act(() => { + root!.render( + + {}} + /> + , + ); + }); + await settle(); + return client; + } + + 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("removes a saved grant while explaining that assignment access remains", async () => { + listAccessGrants.mockResolvedValue([grant({ id: "g1", source: "assignment", subjectType: "agent", subjectId: "a1" })]); + const client = await renderSheet(true, [{ id: "agent:a1", displayName: "Helper", roleLabel: "Current assignee" }]); + expect([...document.body.querySelectorAll("button")].filter(button => button.textContent?.trim() === "Revoke")).toHaveLength(0); + expect(document.body.textContent).toContain("access remains while this role applies"); + const removeSaved = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove saved grant"); + expect(removeSaved).toBeDefined(); + act(() => removeSaved!.click()); + await settle(); + expect(document.body.textContent).toContain("saved grant?"); + revokeAccessGrant.mockResolvedValue({}); + const confirm = [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Remove grant"); + act(() => confirm!.click()); + await settle(); + expect(revokeAccessGrant).toHaveBeenCalledWith("i1", "g1"); + expect(client.getQueryState(queryKeys.issues.accessGrants("descendant"))?.isInvalidated).toBe(true); + }); + + it.each([ + { source: "project" as const, inherited: true }, + { source: "explicit" as const, inherited: true }, + ])("allows an independent direct grant alongside $source access", async (access) => { + listAccessGrants.mockResolvedValue([grant({ id: "broader", ...access })]); + listUserDirectory.mockResolvedValue({ users: [{ user: { id: "u1", name: "Ada", email: "ada@example.test", image: null } }] }); + createAccessGrant.mockResolvedValue(grant({ id: "direct" })); + const client = await renderSheet(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add someone")!.click()); + await settle(); + act(() => (document.body.querySelector('[role="combobox"]') as HTMLElement).click()); + await settle(); + const option = [...document.body.querySelectorAll('[role="option"]')].find(option => option.textContent?.includes("Ada")); + expect(option).toBeDefined(); + act(() => (option as HTMLElement).click()); + await settle(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add")!.click()); + await settle(); + expect(createAccessGrant).toHaveBeenCalledWith("i1", { subjectType: "user", subjectId: "u1" }); + expect(client.getQueryState(queryKeys.issues.accessGrants("descendant"))?.isInvalidated).toBe(true); + }); + + it("does not offer a second grant for an existing direct assignment grant", async () => { + listAccessGrants.mockResolvedValue([grant({ source: "assignment" })]); + listUserDirectory.mockResolvedValue({ users: [{ user: { id: "u1", name: "Ada", image: null } }] }); + await renderSheet(); + act(() => [...document.body.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add someone")!.click()); + await settle(); + act(() => (document.body.querySelector('[role="combobox"]') as HTMLElement).click()); + await settle(); + expect([...document.body.querySelectorAll('[role="option"]')].some(option => option.textContent?.includes("Ada"))).toBe(false); + expect(createAccessGrant).not.toHaveBeenCalled(); + }); + + 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(); + }); +}); diff --git a/ui/src/components/IssueShareSheet.tsx b/ui/src/components/IssueShareSheet.tsx new file mode 100644 index 00000000000..c2269bc397b --- /dev/null +++ b/ui/src/components/IssueShareSheet.tsx @@ -0,0 +1,506 @@ +import { AgentAvatar } from "./AgentAvatar"; +import { Identity } from "./Identity"; +import { renderAccessIdentity, type AccessSelectOption } from "./AccessSelectIdentity"; +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 labels and icons distinguish access reasons; colors use semantic tokens. +const SOURCE_BADGES: Record = { + owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" }, + explicit: { + label: "shared directly", + icon: UserPlus, + className: + "border-primary/30 bg-primary/10 text-primary", + }, + assignment: { + label: "via assignment", + icon: Check, + className: + "border-border bg-secondary text-secondary-foreground", + }, + project: { + label: "via project", + icon: Folder, + className: + "border-border bg-accent text-accent-foreground", + }, +}; + +function SourceBadge({ source }: { source: IssueAccessGrantSource }) { + const spec = SOURCE_BADGES[source]; + const Icon = spec.icon; + return ( + + + ); +} + +function PrincipalAvatar({ + displayName, + avatarUrl, + initials, +}: { + displayName: string; + avatarUrl?: string | null; + initials?: string | null; +}) { + return ( + + ); +} + +// The named type ladder in index.css explicitly prefers Tailwind text-xs and +// text-sm for its 12px and 14px tiers; these are the shared scale tokens. +function RowShell({ + avatar, + title, + subtitle, + badge, + action, +}: { + avatar: React.ReactNode; + title: React.ReactNode; + subtitle?: React.ReactNode; + badge?: React.ReactNode; + action?: React.ReactNode; +}) { + return ( +
+ {avatar} +
+
+ {title} + {badge} +
+ {subtitle ?
{subtitle}
: null} +
+ {action ?
{action}
: null} +
+ ); +} + +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(initialAddSelection); + const [revokeTarget, setRevokeTarget] = useState(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.filter((grant) => !grant.inherited && grant.source !== "project") + .map((grant) => `${grant.subjectType}:${grant.subjectId}`)), + [activeGrants], + ); + + const addMutation = useMutation({ + mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) => + issuesApi.createAccessGrant(issueId, payload), + onSuccess: () => { + // A task grant also changes descendant access and cached task discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + 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: () => { + // A task grant also changes descendant access and cached task discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + 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 a direct grant on this task. Inherited/project access can be supplemented + // with an independent child grant before broader access is removed. Value encodes both type and id: `type:id`. + const addGroups = useMemo[]>(() => { + 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(" "), + identity: , + })); + 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, + identity: <>{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 ( + + + + + + {aclDescription} + + + {view === "list" ? ( +
+ {isLoading ? ( +
+ {[0, 1, 2].map((row) => ( +
+ +
+ + +
+
+ ))} +
+ ) : isError ? ( +
+ + + +
+ ) : isEmpty ? ( +

+ Only you can see this task. +

+ ) : ( +
+ {implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => ( + + } + title={principal.displayName} + subtitle={principal.subtitle ?? undefined} + badge={ + + {principal.roleLabel} + + } + /> + ))} + {activeGrants.map((grant) => { + const displayName = grant.subjectDisplayName ?? "Unknown"; + const implicitPrincipal = implicitPrincipals.find(principal => + principal.id === `${grant.subjectType}:${grant.subjectId}`); + const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source); + const granter = grant.source === "explicit" ? "Shared" : "Granted"; + return ( + + } + title={displayName} + subtitle={implicitPrincipal ? `${implicitPrincipal.roleLabel} — access remains while this role applies` : grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`} + badge={} + action={ + revocable ? ( + + ) : grant.source === "project" ? ( + + project-managed + + ) : null + } + /> + ); + })} +
+ )} + + {canManage ? ( +
+ +
+ ) : null} +
+ ) : ( +
+ option ? renderAccessIdentity(option) : "Choose a person or agent…"} + renderOption={renderAccessIdentity} + 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 ? ( +
+
+ ) : null} + +
+ + +
+
+ )} +
+ + { + if (!next) setRevokeTarget(null); + }} + > + + + + Remove {revokeTarget?.subjectDisplayName ?? "this subject"}'s saved grant? + + + 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 not erase + content already seen or retained in an agent's memory. + + + + Cancel + { + event.preventDefault(); + if (revokeTarget) revokeMutation.mutate(revokeTarget); + }} + disabled={revokeMutation.isPending} + > + {revokeMutation.isPending ? "Removing…" : "Remove grant"} + + + + +
+ ); +} diff --git a/ui/src/components/LockedIssueChip.test.tsx b/ui/src/components/LockedIssueChip.test.tsx new file mode 100644 index 00000000000..f94bb6a1ff1 --- /dev/null +++ b/ui/src/components/LockedIssueChip.test.tsx @@ -0,0 +1,75 @@ +// @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(); + 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("does not claim a missing or forbidden Markdown target is private", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip?.getAttribute("aria-label")).toContain("unavailable"); + expect(chip?.getAttribute("aria-label")).not.toContain("private"); + }); + + it("falls back to a neutral Private label when the identifier is withheld", () => { + renderChip(); + const chip = container.querySelector('[data-testid="locked-issue-chip"]'); + expect(chip?.textContent).toContain("Private"); + }); + + it("uses a dashed, muted border (design-token classes)", () => { + renderChip(); + 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); + }); +}); diff --git a/ui/src/components/LockedIssueChip.tsx b/ui/src/components/LockedIssueChip.tsx new file mode 100644 index 00000000000..a714c88c68b --- /dev/null +++ b/ui/src/components/LockedIssueChip.tsx @@ -0,0 +1,51 @@ +import { CircleHelp, 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, + unavailable = false, +}: { + identifier: string | null; + className?: string; + unavailable?: boolean; +}) { + // 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 ?? (unavailable ? "Unavailable" : "Private"); + const Icon = unavailable ? CircleHelp : Lock; + return ( + + + ); +} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 7d5aba00be0..4c07fdef31a 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -307,6 +307,32 @@ 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" }, diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 6c4a9f11b45..e45e93fd9b0 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -10,6 +10,8 @@ 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 { agentsApi } from "../api/agents"; import { getCachedIssueDetail } from "../lib/issueDetailCache"; import { queryKeys } from "../lib/queryKeys"; @@ -145,7 +147,7 @@ function MarkdownIssueLink({ }) { const queryClient = useQueryClient(); const [engaged, setEngaged] = useState(false); - const { data } = useQuery({ + const { data, error } = useQuery({ queryKey: queryKeys.issues.detail(issuePathId), // A transcript can mention dozens of tasks. Their full detail projections // are hover information, not prerequisites for reading this conversation. @@ -153,17 +155,42 @@ function MarkdownIssueLink({ placeholderData: getCachedIssueDetail(queryClient, 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 ; + } + 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 `` only to + // tear the portal down and swap in a plain `` 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 ( setEngaged(true)} onFocus={() => setEngaged(true)} // Boxless inline mention: the unified status glyph + a regular-weight diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index a203a293e84..731ed34a569 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -49,6 +49,7 @@ const toastState = vi.hoisted(() => ({ const navigateMock = vi.hoisted(() => vi.fn()); const mockIssuesApi = vi.hoisted(() => ({ + get: vi.fn(), list: vi.fn(), create: vi.fn(), upsertDocument: vi.fn(), @@ -245,8 +246,12 @@ vi.mock("@/components/ui/button", () => ({ })); vi.mock("@/components/ui/toggle-switch", () => ({ - ToggleSwitch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: () => void }) => ( - + ToggleSwitch: ({ checked, onCheckedChange, ...props }: { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + "aria-label"?: string; + }) => ( + ), })); @@ -368,6 +373,7 @@ describe("NewIssueDialog", () => { mockIssuesApi.list.mockReset().mockResolvedValue([]); mockIssuesApi.upsertDocument.mockReset(); mockIssuesApi.uploadAttachment.mockReset(); + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open" }); mockExecutionWorkspacesApi.list.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockReset(); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); @@ -691,6 +697,128 @@ 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('button[data-testid="composer-private-chip"]')!; + await waitForAssertion(() => expect(toggle).not.toBeNull()); + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "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("waits for parent access before offering privacy or creating a child", async () => { + let resolveParent!: (value: unknown) => void; + mockIssuesApi.get.mockReturnValue(new Promise(resolve => { resolveParent = resolve; })); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + const submit = container.querySelector('button[aria-label="Create sub-task"]')!; + expect(submit.disabled).toBe(true); + expect(container.textContent).toContain("Checking parent access"); + await act(async () => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + await flush(); + expect(document.querySelector('[data-testid="composer-add-private"]')).toBeNull(); + expect(mockIssuesApi.create).not.toHaveBeenCalled(); + await act(async () => resolveParent({ id: "issue-1", visibility: "private" })); + await flush(); + await waitForAssertion(() => expect(container.querySelector('[data-testid="composer-private-chip"]')?.disabled).toBe(true)); + expect(submit.disabled).toBe(false); + act(() => root.unmount()); + }); + + it("keeps submission blocked after a parent lookup error and lets the user retry", async () => { + mockIssuesApi.get.mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValue({ id: "issue-1", visibility: "private" }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await waitForAssertion(() => expect(container.textContent).toContain("Couldn't check parent access.")); + expect(container.querySelector('button[aria-label="Create sub-task"]')?.disabled).toBe(true); + await act(async () => Array.from(container.querySelectorAll('button')).find(button => button.textContent === "Retry")!.click()); + await flush(); + await waitForAssertion(() => expect(container.querySelector('[data-testid="composer-private-chip"]')?.disabled).toBe(true)); + expect(mockIssuesApi.create).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("locks inherited privacy and submits the child as private", async () => { + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "private", title: "Board briefing" }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await flush(); + const toggle = container.querySelector('button[data-testid="composer-private-chip"]')!; + await waitForAssertion(() => expect(toggle).not.toBeNull()); + expect(toggle.disabled).toBe(true); + expect(toggle.title).toBe("Subtask of private task Board briefing"); + await typeTextareaValue(container.querySelector('textarea')!, "Private child draft"); + // Saving this form must retain only the explicit toggle choice. A later + // standalone draft must not acquire a sticky private flag from its parent. + await act(async () => { await new Promise(resolve => setTimeout(resolve, 850)); }); + expect(JSON.parse(localStorage.getItem("paperclip:issue-draft")!).isPrivate).toBe(false); + const submit = Array.from(container.querySelectorAll("button")).find(button => button.getAttribute("aria-label") === "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("names the private project inherited through an open parent", async () => { + mockIssuesApi.get.mockResolvedValue({ id: "issue-1", visibility: "open", title: "Board briefing", project: { id: "private-project", name: "Executive planning", visibility: "private" } }); + dialogState.newIssueDefaults = { parentId: "issue-1", title: "Private child" }; + const { root } = renderDialog(container); + await flush(); + await waitForAssertion(() => { + const chip = container.querySelector('[data-testid="composer-private-chip"]'); + expect(chip?.disabled).toBe(true); + expect(chip?.title).toBe("In private project Executive planning"); + }); + 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(); + await act(async () => container.querySelector('[data-testid="task-chat-composer-add"]')!.click()); + await flush(); + const privateToggle = document.querySelector('[data-testid="composer-add-private"]'); + expect(privateToggle).not.toBeNull(); + await act(async () => privateToggle!.click()); + await flush(); + + const submitButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.getAttribute("aria-label") === "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(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 9967daeea83..c4eb7c7c9d9 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -33,7 +33,7 @@ import { useToastActions } from "../context/ToastContext"; import { assigneeValueFromSelection, currentUserAssigneeOption, parseAssigneeValue } from "../lib/assignees"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown } from "lucide-react"; +import { Paperclip, FileText, Flag, PauseCircle, ListTree, X, ShieldAlert, Folder, ChevronDown, Lock } from "lucide-react"; import { cn } from "../lib/utils"; import type { MentionOption } from "./MarkdownEditor"; import { TaskChatComposer } from "./task-chat/TaskChatComposer"; @@ -113,6 +113,7 @@ function useVisualViewportLayout(enabled: boolean) { } interface IssueDraft { + isPrivate?: boolean; title: string; description: string; status: string; @@ -305,6 +306,7 @@ export function NewIssueDialog() { const [executionWorkspaceMode, setExecutionWorkspaceMode] = useState("shared_workspace"); const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState(""); const [workMode, setWorkMode] = useState("standard"); + const [isPrivate, setIsPrivate] = useState(false); const { isMobile } = useSidebar(); const [composerSettings, setComposerSettings] = useState(null); const [stagedFiles, setStagedFiles] = useState([]); @@ -388,6 +390,22 @@ export function NewIssueDialog() { retry: false, }); + const { data: privacyParent, isError: parentPrivacyError, refetch: refetchParentPrivacy } = useQuery({ + queryKey: queryKeys.issues.detail(newIssueDefaults.parentId ?? ""), + queryFn: () => issuesApi.get(newIssueDefaults.parentId!), + enabled: newIssueOpen && Boolean(newIssueDefaults.parentId), + retry: false, + }); + const parentPrivacyUnresolved = isSubIssueMode && !privacyParent; + const inheritsPrivateAccess = privacyParent?.visibility === "private" || privacyParent?.project?.visibility === "private"; + const privateParentProject = privacyParent?.project?.visibility === "private" ? privacyParent.project : null; + const inheritedPrivateProject = privateParentProject ?? (!isPrivate && currentProject?.visibility === "private" ? currentProject : null); + const inheritedPrivacyReason = privacyParent?.visibility === "private" + ? `Subtask of private task ${privacyParent.title || newIssueDefaults.parentTitle || parentIssueLabel}` + : inheritedPrivateProject ? `In private project ${inheritedPrivateProject.name}` : undefined; + 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; @@ -513,6 +531,7 @@ export function NewIssueDialog() { executionWorkspaceMode, selectedExecutionWorkspaceId, workMode, + isPrivate, composerSettings, }); }, @@ -534,6 +553,7 @@ export function NewIssueDialog() { executionWorkspaceMode, selectedExecutionWorkspaceId, workMode, + isPrivate, composerSettings, ], ); @@ -591,6 +611,7 @@ export function NewIssueDialog() { defaultProjectPendingRef.current = newIssueDefaults.projectId === undefined && !newIssueDefaults.parentId; defaultAssigneePendingRef.current = !newIssueDefaults.assigneeAgentId && !newIssueDefaults.assigneeUserId; setComposerSettings(null); + setIsPrivate(false); createIssue.reset(); if (newIssueDefaults.parentId) { const nextWorkMode = isIssueWorkMode(newIssueDefaults.workMode) ? newIssueDefaults.workMode : "standard"; @@ -654,6 +675,7 @@ 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); setComposerSettings(draft.composerSettings ?? null); setStatus(draft.status || "todo"); @@ -756,6 +778,7 @@ export function NewIssueDialog() { setWorkMode("standard"); setComposerSettings(null); + setIsPrivate(false); setStagedFiles([]); setIsFileDragOver(false); @@ -767,7 +790,7 @@ export function NewIssueDialog() { async function handleSubmit(body: string, mode: IssueWorkMode, settings: ComposerRunSettings | null) { const currentTitle = titleRef.current.trim(); const currentDescription = body.trim(); - if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || worktreeSelectionIncomplete) return; + if (!effectiveCompanyId || (!currentTitle && !currentDescription) || createIssue.isPending || worktreeSelectionIncomplete || parentPrivacyUnresolved) return; const inheritedOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, lane: assigneeChrome ? "custom" : assigneeModelLane, @@ -815,6 +838,7 @@ export function NewIssueDialog() { status, priority: priority || "medium", workMode: mode, + ...(effectivePrivate ? { visibility: "private" } : {}), ...(selectedAssigneeAgentId ? { assigneeAgentId: selectedAssigneeAgentId } : {}), ...(selectedAssigneeUserId ? { assigneeUserId: selectedAssigneeUserId } : {}), ...(newIssueDefaults.parentId ? { parentId: newIssueDefaults.parentId } : {}), @@ -1156,6 +1180,18 @@ export function NewIssueDialog() { value: description, onChange: handleDescriptionChange, onSubmit: handleSubmit, + privacy: parentPrivacyUnresolved ? undefined : { + private: effectivePrivate, + inherited: inheritedPrivacyReason, + onChange: (checked) => { + setIsPrivate(checked); + if (!checked && currentProject?.visibility === "private") handleProjectChange(""); + if (checked && !projectId && currentUserId) { + const personalProject = orderedProjects.find(project => project.personalOwnerUserId === currentUserId); + if (personalProject) handleProjectChange(personalProject.id); + } + }, + }, submitLabel: isSubIssueMode ? "Create sub-task" : "Create task", canSubmitWithoutBody: Boolean(title.trim()), onSelectFiles: stageFiles, @@ -1195,6 +1231,12 @@ export function NewIssueDialog() { ) : undefined, details: ( <> + {parentPrivacyUnresolved ? ( +
+ {parentPrivacyError ? "Couldn't check parent access." : "Checking parent access…"} + {parentPrivacyError ? : null} +
+ ) : null} {worktreeSelectionIncomplete && !worktreesLoading ? (

{worktreesError ? "Couldn't check the selected worktree. Retry in Worktrees or choose New worktree." @@ -1334,7 +1376,7 @@ export function NewIssueDialog() { ) : null} ), - submitDisabled: worktreeSelectionIncomplete, + submitDisabled: worktreeSelectionIncomplete || parentPrivacyUnresolved, contextBar: ( <> {option.label} + {currentProject.visibility === "private" ? : null} ) : ( @@ -1374,7 +1417,8 @@ export function NewIssueDialog() { return ( <> - {option.label} + {option.label} + {project?.visibility === "private" ? : null} ); }} diff --git a/ui/src/components/PrivateTaskPrivacyControls.test.tsx b/ui/src/components/PrivateTaskPrivacyControls.test.tsx new file mode 100644 index 00000000000..51082cf5a3a --- /dev/null +++ b/ui/src/components/PrivateTaskPrivacyControls.test.tsx @@ -0,0 +1,123 @@ +// @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 { queryKeys } from "@/lib/queryKeys"; +const api = vi.hoisted(() => ({ getIssue: vi.fn(), getProject: vi.fn(), constraints: vi.fn(), setVisibility: vi.fn(), + members: vi.fn(), add: vi.fn(), remove: vi.fn(), directory: vi.fn() })); +vi.mock("@/api/issues", () => ({ issuesApi: { get: api.getIssue, privacyConstraints: api.constraints, setVisibility: api.setVisibility } })); +vi.mock("@/api/projects", () => ({ projectsApi: { get: api.getProject, listAccessMembers: api.members, + addAccessMember: api.add, removeAccessMember: api.remove } })); +vi.mock("@/api/access", () => ({ accessApi: { listUserDirectory: api.directory } })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: () => Promise.resolve([]) } })); +vi.mock("@/context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); +import { IssuePrivacyActions } from "./IssuePrivacyActions"; +import { ProjectAccessMembers } from "./ProjectAccessMembers"; +import type { Project } from "@paperclipai/shared"; +async function settle() { + for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } +} +const originalScrollIntoView = Element.prototype.scrollIntoView; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} }); + Element.prototype.scrollIntoView = () => {}; + vi.resetAllMocks(); + api.directory.mockResolvedValue({ users: [] }); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + api.members.mockResolvedValue([]); + container = document.createElement("div"); document.body.append(container); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(() => { flushSync(() => root.unmount()); client.clear(); document.body.innerHTML = ""; + vi.unstubAllGlobals(); + Element.prototype.scrollIntoView = originalScrollIntoView; }); +function render(node: React.ReactNode) { flushSync(() => root.render({node})); } +function publicButton() { return [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === "Make public")!; } +function actions(scope: { privacyParentIssueId?: string; projectId?: string } = {}) { + render( {}}>{items =>

{items}
}); +} +describe("inherited privacy controls", () => { + it("blocks a private parent and does not send an impossible visibility change", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); + expect(publicButton().disabled).toBe(true); + publicButton().click(); expect(api.setVisibility).not.toHaveBeenCalled(); + }); + it("blocks a non-personal private project but permits leaving a personal project", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "project", leavesPersonalProject: false }); + actions({ projectId: "project" }); await settle(); expect(publicButton().disabled).toBe(true); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); + await client.invalidateQueries({ queryKey: queryKeys.issues.privacyConstraints("child") }); await settle(); + expect(publicButton().disabled).toBe(false); + }); + it.each([ + ["parent", { privacyParentIssueId: "private-parent" }], + ["project", { projectId: "private-project" }], + ] as const)("refreshes task privacy after moving out of a private %s", async (kind, scope) => { + api.constraints.mockResolvedValue({ publicBlockedBy: kind, leavesPersonalProject: false }); + actions(scope); await settle(); + expect(publicButton().disabled).toBe(true); + expect(api.constraints).toHaveBeenCalledTimes(1); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + // Same task ID and mounted action menu: only the surrounding scope changes. + actions(); await settle(); + expect(api.constraints).toHaveBeenCalledTimes(2); + expect(publicButton().disabled).toBe(false); + }); + it("checks a parent's private project even when the parent itself is open", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); + }); + it("permits making a child public after its parent is open", async () => { + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(false); + }); + it("allows publishing from an unreadable personal project without fetching that project", async () => { + api.getProject.mockRejectedValue(new Error("404")); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: true }); + api.setVisibility.mockResolvedValue({}); + actions({ projectId: "protected-project" }); await settle(); + expect(publicButton().disabled).toBe(false); + expect(api.getProject).not.toHaveBeenCalled(); + flushSync(() => publicButton().click()); await settle(); + expect(document.body.textContent).toContain("leave its personal project"); + const confirm = [...document.body.querySelectorAll('[role="alertdialog"] button')].find(button => button.textContent === "Make public")!; + flushSync(() => (confirm as HTMLButtonElement).click()); await settle(); + expect(api.setVisibility).toHaveBeenCalledWith("child", "open"); + }); + it("explains a protected private parent without offering an impossible retry", async () => { + api.getIssue.mockRejectedValue(new Error("404")); + api.constraints.mockResolvedValue({ publicBlockedBy: "parent", leavesPersonalProject: false }); + actions({ privacyParentIssueId: "protected-parent" }); await settle(); + expect(publicButton().disabled).toBe(true); + expect(api.getIssue).not.toHaveBeenCalled(); + expect(document.body.textContent).not.toContain("Retry access check"); + }); + it("keeps unresolved and failed scope checks closed, and retries in place", async () => { + let reject!: (error: Error) => void; + api.constraints.mockReturnValue(new Promise((_resolve, rejectFn) => { reject = rejectFn; })); + actions({ privacyParentIssueId: "parent" }); await settle(); expect(publicButton().disabled).toBe(true); + reject(new Error("Unavailable")); await settle(); expect(publicButton().disabled).toBe(true); + api.constraints.mockResolvedValue({ publicBlockedBy: null, leavesPersonalProject: false }); + flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent === "Retry access check")!.click()); + await settle(); expect(publicButton().disabled).toBe(false); + }); +}); +it("invalidates cached task audience when project membership is removed", async () => { + const key = queryKeys.issues.accessGrants("child"); + client.setQueryData(key, [{ subjectId: "member", source: "project" }]); + api.members.mockResolvedValue([{ id: "m1", subjectType: "user", subjectId: "member", subjectDisplayName: "Morgan" }]); + api.remove.mockResolvedValue({}); + render(); + flushSync(() => [...document.body.querySelectorAll("button")].find(b => b.textContent?.includes("Manage access"))!.click()); + await settle(); + flushSync(() => (document.body.querySelector('[aria-label="Remove Morgan"]') as HTMLButtonElement).click()); + await settle(); + expect(api.remove).toHaveBeenCalledWith("project", "m1", "company"); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); +}); diff --git a/ui/src/components/ProjectAccessMembers.tsx b/ui/src/components/ProjectAccessMembers.tsx new file mode 100644 index 00000000000..6c12fd8c43c --- /dev/null +++ b/ui/src/components/ProjectAccessMembers.tsx @@ -0,0 +1,165 @@ +import { AgentAvatar } from "./AgentAvatar"; +import { Identity } from "./Identity"; +import { renderAccessIdentity, type AccessSelectOption } from "./AccessSelectIdentity"; +import { agentVisibilityFromPermissions, isSharedAgentVisibility } from "@/lib/issuePrivacy"; +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, canManage }: { project: Project; canManage: boolean }) { + 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[]>(() => { + 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 ?? "", + identity: , + })); + const agentOptions = (agentsQuery.data ?? []) + .filter((agent) => !activeKeys.has(`agent:${agent.id}`)) + .map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name, identity: <>{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 selectedAgent = selection.startsWith("agent:") + ? agentsQuery.data?.find(agent => agent.id === selection.slice("agent:".length)) : null; + const sharingWithSharedAgent = selectedAgent + && isSharedAgentVisibility(agentVisibilityFromPermissions(selectedAgent.permissions)); + 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 }); + // Project membership changes task access, descendants and cached discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + 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 }); + // Project membership changes task access, descendants and cached discovery. + queryClient.invalidateQueries({ queryKey: ["issues"] }); + pushToast({ title: "Project access removed", tone: "success" }); + }, + onError: (error) => pushToast({ title: "Couldn't remove project access", body: (error as Error).message, tone: "error" }), + }); + + return ( + <> + + + + + Private project access + + Members can discover this project and read every task in it. A task can still be shared directly. + + +
+ {directoryQuery.isError || agentsQuery.isError ? ( +

Couldn't load the company access directory.

+ ) : null} +
+ option ? renderAccessIdentity(option) : "Add a person or agent"} + renderOption={renderAccessIdentity} + 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" + /> + +
+ {sharingWithSharedAgent ? ( +

+ This agent can retain private project context in its memory and workspace for later runs. + Removing access cannot erase what it has already learned. +

+ ) : null} +
+ {(membersQuery.data ?? []).map((member) => { + const isPersonalOwner = member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId); + return ( +
+
+
{member.subjectDisplayName ?? "Unknown member"}
+
+ {isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"} +
+
+ {!isPersonalOwner ? ( + + ) : null} +
+ ); + })} + {membersQuery.isLoading ?

Loading access…

: null} + {membersQuery.isError ? ( +

Couldn't load project access members.

+ ) : null} +
+
+
+
+ + ); +} diff --git a/ui/src/components/ProjectProperties.tsx b/ui/src/components/ProjectProperties.tsx index e7e71a5d0ed..aa65c10ee50 100644 --- a/ui/src/components/ProjectProperties.tsx +++ b/ui/src/components/ProjectProperties.tsx @@ -1,3 +1,6 @@ +import { authApi } from "@/api/auth"; +import { accessApi } from "@/api/access"; +import { canManageProjectPrivacy } from "@/lib/issuePrivacy"; import { useWorkspaceIsolationControls } from "@/hooks/useWorkspaceIsolationControls"; import { useState, type ReactNode } from "react"; import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment"; @@ -22,6 +25,9 @@ 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"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "./ui/alert-dialog"; interface ProjectPropertiesProps { project: Project; @@ -38,6 +44,7 @@ export type ProjectConfigFieldKey = | "name" | "description" | "status" + | "visibility" | "goals" | "env" | "execution_workspace_enabled" @@ -208,7 +215,11 @@ function ArchiveDangerZone({ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) { const { visible: workspaceIsolationControlsVisible } = useWorkspaceIsolationControls(); const { selectedCompanyId } = useCompany(); + const { data: privacySession } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession() }); + const { data: privacyAccess } = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess() }); + const canManagePrivacy = canManageProjectPrivacy(project, privacySession?.user?.id ?? privacySession?.session?.userId ?? null, privacyAccess); const queryClient = useQueryClient(); + const [openProjectConfirmation, setOpenProjectConfirmation] = useState(false); const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false); const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null); const [workspaceCwd, setWorkspaceCwd] = useState(""); @@ -419,6 +430,24 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda return (
+ + + + Open this project to the company? + + Everyone in the company will be able to discover this project. Its private tasks stay private. + Members who only have access through this project will lose access to those tasks. + Share tasks directly first if you want them to keep access. + + + + Keep private + commitField("visibility", { visibility: "open" })}> + Open to company + + + +
}> {onUpdate || onFieldUpdate ? ( @@ -454,6 +483,31 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda

)}
+ } alignStart> +
+ +

+ {project.visibility === "private" + ? "Only access members can discover this project. Tasks shared directly remain readable on their own." + : "Everyone in the company can discover this project. Individually private tasks keep their own access rules."} +

+ {project.visibility === "private" ? : null} +
+
{repositories ?? } } diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index f35105461bd..7e1c2a7e2d7 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { FolderOpen, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react"; +import { FolderOpen, Lock, Loader2, LogOut, MoreHorizontal, Plus } from "lucide-react"; import { DndContext, MouseSensor, @@ -146,6 +146,7 @@ function ProjectItem({ > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail ? : null} {!rail && project.pauseReason === "budget" ? : null} diff --git a/ui/src/components/SidebarStarredProjects.production.tsx b/ui/src/components/SidebarStarredProjects.production.tsx index 264be977160..f5f26fed4f9 100644 --- a/ui/src/components/SidebarStarredProjects.production.tsx +++ b/ui/src/components/SidebarStarredProjects.production.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; +import { Lock, Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { projectsApi } from "../api/projects"; @@ -130,6 +130,7 @@ export function SidebarStarredProjects() { > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail && project.pauseReason === "budget" ? ( ) : null} diff --git a/ui/src/components/SidebarStarredProjects.tsx b/ui/src/components/SidebarStarredProjects.tsx index 14a5247f1ce..97e724b7e5e 100644 --- a/ui/src/components/SidebarStarredProjects.tsx +++ b/ui/src/components/SidebarStarredProjects.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { NavLink, useLocation } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; +import { Lock, Loader2, LogOut, MoreHorizontal, Star } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { projectsApi } from "../api/projects"; @@ -130,6 +130,7 @@ export function SidebarStarredProjects() { > {project.name} + {!rail && project.visibility === "private" ? : null} {!rail && project.pauseReason === "budget" ? ( ) : null} diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 8e2f6763199..5ea44bca681 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -1,3 +1,4 @@ +import { isLockedIssueStub } from "@/components/LockedIssueChip"; import { IssuePullRequestLinks } from "../IssuePullRequestLinks"; import { useIssueWorkProducts } from "../../hooks/useIssueWorkProducts"; import { getIssuePullRequests, pullRequestHref, pullRequestIdentity } from "../../lib/issue-pull-requests"; @@ -2109,16 +2110,17 @@ export function IssueProperties({ if (!issue.parentId) return null; return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; }, [allIssues, issue.parentId]); - const parentIdentifier = issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier; - const parentTitle = issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8); + const parentAncestor = issue.ancestors?.find((ancestor) => ancestor.id === issue.parentId); + const parentIdentifier = parentAncestor?.identifier ?? currentParentIssue?.identifier; + const parentTitle = parentAncestor?.title ?? currentParentIssue?.title ?? issue.parentId?.slice(0, 8); const parentTrigger = issue.parentId ? ( diff --git a/ui/src/components/issue-properties/relation-controls.tsx b/ui/src/components/issue-properties/relation-controls.tsx index 8dbc36f9a51..a5e1c6d6f2a 100644 --- a/ui/src/components/issue-properties/relation-controls.tsx +++ b/ui/src/components/issue-properties/relation-controls.tsx @@ -1,6 +1,7 @@ import { useState, type MouseEvent } from "react"; -import type { Issue } from "@paperclipai/shared"; +import type { Issue, IssueLockedStub } from "@paperclipai/shared"; import { Link } from "@/lib/router"; +import { LockedIssueChip, isLockedIssueStub } from "../LockedIssueChip"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -26,19 +27,31 @@ export function RemovableIssueReferencePill({ onRemove, isMobile = false, }: { - issue: NonNullable[number]; + issue: NonNullable[number] | IssueLockedStub; onRemove: (issueId: string) => void; isMobile?: boolean; }) { const [isConfirmOpen, setIsConfirmOpen] = useState(false); - const issueLabel = issue.identifier ?? issue.title; - const confirmLabel = issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title; + 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 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", - issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", + !locked && issue.identifier && "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring", ); - const content = ( + // 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 ? ( + + ) : ( <> {issueLabel} @@ -66,14 +79,14 @@ export function RemovableIssueReferencePill({ type="button" data-mention-kind="issue" className={chipClassName} - title={issue.title} + title={locked ? "Private task" : issue.title} aria-label={`Actions for blocker ${issueLabel}`} > {content} - {issue.identifier ? ( + {!locked && issue.identifier ? ( @@ -98,7 +111,9 @@ export function RemovableIssueReferencePill({ > - {issue.identifier ? ( + {locked ? ( + content + ) : issue.identifier ? ( void; attachDisabled?: boolean; onGoal?: () => void; + privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void }; disabled?: boolean; mobile?: boolean; triggerTestId?: string; @@ -27,9 +30,10 @@ interface ComposerAddMenuProps { } export function ComposerAddMenu({ - mode, onModeChange, onAttachFile, attachDisabled, onGoal, disabled, mobile: mobileProp, triggerTestId, menuTestId, + mode, onModeChange, onAttachFile, attachDisabled, onGoal, privacy, disabled, mobile: mobileProp, triggerTestId, menuTestId, }: ComposerAddMenuProps) { const [open, setOpen] = useState(false); + const mobileViewportStyle = useMobileEntityPickerViewportStyle(); const goalFocusRef = useRef(false); const [narrow, setNarrow] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia(MOBILE_SHELL_QUERY).matches); useEffect(() => { @@ -40,9 +44,10 @@ export function ComposerAddMenu({ return () => query.removeEventListener("change", update); }, []); const mobile = mobileProp ?? narrow; - if (!onModeChange && !onAttachFile && !onGoal) return null; + if (!onModeChange && !onAttachFile && !onGoal && !privacy) return null; const actions: Array<{ id: string; label: string; detail?: string; Icon: LucideIcon; select: () => void; disabled?: boolean; selected?: boolean }> = [ ...(onAttachFile ? [{ id: "composer-add-file", label: "Files and images", Icon: Paperclip, select: onAttachFile, disabled: attachDisabled }] : []), + ...(privacy ? [{ id: "composer-add-private", label: "Private task", detail: privacy.inherited, Icon: Lock, select: () => privacy.onChange(!privacy.private), selected: privacy.private, disabled: Boolean(privacy.inherited) }] : []), ...(onGoal ? [{ id: "composer-add-goal", label: "Goal", detail: "Keep pursuing", Icon: Target, select: onGoal }] : []), ...(onModeChange ? [ { id: "composer-add-plan", label: "Plan mode", detail: "Plan before acting", Icon: ClipboardList, select: () => onModeChange(mode === "planning" ? "standard" : "planning"), selected: mode === "planning" }, @@ -61,14 +66,14 @@ export function ComposerAddMenu({ ; if (mobile) return {trigger} - { if (goalFocusRef.current) { event.preventDefault(); goalFocusRef.current = false; } }} - className="composer-mobile-dialog top-(--pct-50) -translate-y-(--pct-50) gap-0 overflow-y-auto p-0"> -
+ className="composer-mobile-dialog flex flex-col translate-y-0 gap-0 overflow-hidden p-0 md:translate-y-0"> +
Add - +
-
{actions.map((action) => ; } + +export function ComposerPrivacyChip({ inherited, onRemove, disabled }: { inherited?: string; onRemove: () => void; disabled?: boolean }) { + const [hintOpen, setHintOpen] = useState(false); + const explanation = inherited ?? "Only you and people you share with can read this task"; + return + + { event.preventDefault(); setHintOpen(true); } : undefined} + onKeyDown={inherited ? (event) => { + if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setHintOpen(true); } + } : undefined}> + + + + {explanation} + ; +} diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 19ed3daf866..7d8bc011698 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -64,7 +64,7 @@ import { TaskChatComposerTakeoverActionsContext } from "./TaskChatComposerTakeov import { TaskChatPausedTakeover, type TaskComposerPause } from "./TaskChatPausedTakeover"; import { ComposerRunSettingsPicker } from "./ComposerRunSettingsPicker"; import { TaskChatComposerBar } from "./TaskChatComposerBar"; -import { ComposerAddMenu, ComposerModeChip } from "./ComposerAddMenu"; +import { ComposerAddMenu, ComposerModeChip, ComposerPrivacyChip } from "./ComposerAddMenu"; import type { ComposerRunSettings } from "./composer-run-settings"; import type { Agent, IssueAssigneeAdapterOverrides } from "@paperclipai/shared"; @@ -110,6 +110,7 @@ interface TaskChatComposerProps { details?: ReactNode; contextBar?: ReactNode; submitDisabled?: boolean; + privacy?: { private: boolean; inherited?: string; onChange: (value: boolean) => void }; onSelectFiles: (files: File[]) => void; runSettings: ComposerRunSettings | null; onRunSettingsChange: (settings: ComposerRunSettings | null) => void; @@ -1460,6 +1461,7 @@ export function TaskChatComposer({ ) : null} fileInputRef.current?.click() : undefined} @@ -1471,6 +1473,7 @@ export function TaskChatComposer({ triggerTestId="task-chat-composer-add" menuTestId="task-chat-composer-add-menu" /> + {creation?.privacy?.private ? creation.privacy!.onChange(false)} disabled={disabled} /> : null} {queuedEdit ? ( {queuedEdit.stale diff --git a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx index 855fa0a13ec..5bd5442bd17 100644 --- a/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx +++ b/ui/src/components/task-detail/TaskDetailRelationsPanel.tsx @@ -1,4 +1,5 @@ -import type { Issue, IssueStatus } from "@paperclipai/shared"; +import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; +import type { Issue, IssueStatus, IssueLockedStub } from "@paperclipai/shared"; import { Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { IssueRow } from "@/components/IssueRow"; @@ -22,7 +23,7 @@ export function RelationNavigationList({ ariaLabel, issueLinkState, }: { - items: TaskDetailRelationItem[]; + items: (TaskDetailRelationItem | IssueLockedStub)[]; emptyMessage: string; ariaLabel: string; issueLinkState?: unknown; @@ -34,6 +35,9 @@ export function RelationNavigationList({ return (
    {items.map((item) => { + if (isLockedIssueStub(item)) { + return
  • ; + } const pathId = item.identifier ?? item.id; return (
  • diff --git a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx index 1e233452a52..8da9642cf0c 100644 --- a/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx +++ b/ui/src/components/task-detail/TaskDetailTasksPanel.test.tsx @@ -38,6 +38,14 @@ describe("TaskDetailTasksPanel", () => { expect(container.querySelector('[data-task-id="child"]')).not.toBeNull(); }); + it("renders locked ancestors as non-interactive chips alongside readable ancestors", () => { + render({ ancestors: [{ id: "private-parent", identifier: "PAP-99", locked: true }, ancestors[1]] as unknown as Issue["ancestors"], subtasks: [], createdTasks: [], projects: [] }); + const group = container.querySelector('section[aria-label="Ancestors"]')!; + expect(group.querySelector('[data-testid="locked-issue-chip"]')?.textContent).toContain("PAP-99"); + expect([...group.querySelectorAll('a')].map(link => link.getAttribute("href"))).toEqual(["/issues/root"]); + expect(group.textContent).not.toContain("undefined"); + }); + it("keeps ancestor-only tasks navigable instead of showing an empty state", () => { render({ ancestors, subtasks: [], createdTasks: [], projects: [] }); expect(container.querySelectorAll('a')).toHaveLength(2); diff --git a/ui/src/lib/issuePrivacy.test.ts b/ui/src/lib/issuePrivacy.test.ts new file mode 100644 index 00000000000..b6618ab87d0 --- /dev/null +++ b/ui/src/lib/issuePrivacy.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { IssueAccessGrant } from "@paperclipai/shared"; +import { + agentVisibilityFromPermissions, + canManageIssuePrivacy, + canManageProjectPrivacy, + 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(true); + }); +}); + +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); + }); +}); + +describe("canManageIssuePrivacy", () => { + const issue = { companyId: "c1", responsibleUserId: "responsible", createdByUserId: "creator", assigneeUserId: "assignee" }; + const access = (role: string) => ({ source: "session", isInstanceAdmin: false, companyIds: ["c1"], + memberships: [{ companyId: "c1", status: "active", membershipRole: role }] }) as Parameters[2]; + it("allows task principals even with viewer membership", () => { + for (const user of ["responsible", "creator", "assignee"]) expect(canManageIssuePrivacy(issue, user, access("viewer"))).toBe(true); + }); + it("does not treat ordinary runtime management as privacy ownership", () => { + expect(canManageIssuePrivacy(issue, "other", access("member"))).toBe(false); + expect(canManageIssuePrivacy(issue, "other", access("admin"))).toBe(true); + expect(canManageIssuePrivacy(issue, "other", access("owner"))).toBe(true); + }); +}); + +describe("canManageProjectPrivacy", () => { + it("keeps project read membership separate from privacy management", () => { + const access = { source: "session", isInstanceAdmin: false, memberships: [{ companyId: "c1", status: "active", membershipRole: "member" }] } as Parameters[2]; + const project = { companyId: "c1", privacyOwnerUserId: "owner" }; + expect(canManageProjectPrivacy(project, "reader", access)).toBe(false); + expect(canManageProjectPrivacy(project, "owner", access)).toBe(true); + }); +}); diff --git a/ui/src/lib/issuePrivacy.ts b/ui/src/lib/issuePrivacy.ts new file mode 100644 index 00000000000..649b95c8fca --- /dev/null +++ b/ui/src/lib/issuePrivacy.ts @@ -0,0 +1,68 @@ +import type { CurrentBoardAccess } from "../api/access"; +import type { Issue, Project } from "@paperclipai/shared"; +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).authorizationPolicy; + if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) { + return null; + } + const agentVisibility = (authorizationPolicy as Record).agentVisibility; + if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null; + const mode = (agentVisibility as Record).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 !== "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"; +} + +/** Keep this UI gate aligned with assertCanManageIssuePrivacy on the server. */ +export function canManageIssuePrivacy( + issue: Pick | undefined, + userId: string | null, + access: CurrentBoardAccess | undefined, +): boolean { + if (!issue || !access) return false; + if (access.source === "local_implicit" || access.isInstanceAdmin) return true; + if (userId && [issue.responsibleUserId, issue.createdByUserId, issue.assigneeUserId].includes(userId)) return true; + return access.memberships?.some(member => member.companyId === issue.companyId + && member.status === "active" && ["owner", "admin"].includes(member.membershipRole ?? "")) ?? false; +} + +export function canManageProjectPrivacy(project: Pick, userId: string | null, access: CurrentBoardAccess | undefined): boolean { + return canManageIssuePrivacy({ companyId: project.companyId, responsibleUserId: project.privacyOwnerUserId ?? null, + createdByUserId: project.personalOwnerUserId ?? null, assigneeUserId: null }, userId, access); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 27a5a65a438..085e9ec20fc 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -374,10 +374,10 @@ export const queryKeys = { options.excludeRoot ? (["issues", "cost-summary", issueId, "exclude-root"] as const) : (["issues", "cost-summary", issueId] as const), - attachments: (issueId: string) => - ["issues", "attachments", issueId] as const, - attachmentPreview: (attachmentId: string) => - ["issues", "attachment-preview", attachmentId] as const, + privacyConstraints: (issueId: string) => ["issues", "privacy-constraints", 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, documents: (issueId: string) => ["issues", "documents", issueId] as const, document: (issueId: string, key: string) => ["issues", "document", issueId, key] as const, @@ -531,6 +531,7 @@ 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, diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts index 4e87c29b20a..8ed4f83c7e5 100644 --- a/ui/src/lib/sentry.test.ts +++ b/ui/src/lib/sentry.test.ts @@ -337,12 +337,12 @@ describe("browser error diagnostics with the real SDK", () => { const gate = await importFreshSentry(); await gate.initBrowserErrorMonitoring(DSN, "staging"); const previousPath = window.location.pathname + window.location.search; - window.history.replaceState({}, "", "/tasks/private-task?token=private-capability"); + window.history.replaceState({}, "", "/tasks/sentry-fixture-secret-task-7fa83e?token=private-capability"); document.documentElement.classList.add("translated-ltr", "private-customer-class"); try { gate.captureBrowserException(new DOMException("insertBefore failed", "NotFoundError"), { boundary: "route", - componentStack: "\n at TaskDetail (https://tenant.example/tasks/private-task?token=private-capability:3:4)", + componentStack: "\n at TaskDetail (https://tenant.example/tasks/sentry-fixture-secret-task-7fa83e?token=private-capability:3:4)", }); document.documentElement.classList.remove("translated-ltr"); gate.captureBrowserException(new Error("unrelated failure")); @@ -364,7 +364,7 @@ describe("browser error diagnostics with the real SDK", () => { expect(event).not.toHaveProperty("request"); expect(event.breadcrumbs).toBeUndefined(); } - for (const secret of ["tenant.example", "private-task", "private-capability", "private-customer-class"]) { + for (const secret of ["tenant.example", "sentry-fixture-secret-task-7fa83e", "private-capability", "private-customer-class"]) { expect(JSON.stringify(events)).not.toContain(secret); } await gate.teardownBrowserErrorMonitoring(); diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index e09b59d8d85..b129da8a233 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -172,6 +172,7 @@ 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"; @@ -894,6 +895,19 @@ export function DesignGuide() {
+ + +

+ 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,{" "} + no title, never a link. Falls back to "Private" when even the + identifier is withheld. +

+
+ + +
+
{/* ============================================================ */} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 31e4b70dc01..ee00bb2c464 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -1,3 +1,5 @@ +import { isLockedIssueStub, LockedIssueChip } from "@/components/LockedIssueChip"; +import { canManageIssuePrivacy } from "../lib/issuePrivacy"; import { TextAttachmentContext } from "../context/TextAttachmentContext"; import { useTaskBrowsers, useBrowserArrivals } from "@/hooks/useTaskBrowsers"; import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery"; @@ -338,6 +340,8 @@ 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, @@ -3584,6 +3588,38 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS () => buildCompanyUserLabelMap(companyMembers?.users), [companyMembers?.users], ); + const canManagePrivacy = canManageIssuePrivacy(issue, currentUserId, boardAccess); + // Role-based principals for the share sheet's implicit rows (no revoke). + const privacyImplicitPrincipals = useMemo(() => { + if (!issue) return []; + const list: ShareSheetImplicitPrincipal[] = []; + const seen = new Set(); + 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(() => { return buildMarkdownMentionOptions({ agents, @@ -6869,7 +6905,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS return null; } const parent = ancestors.length > 0 ? ancestors[0] : null; - if (!parent) return null; + if (!parent || isLockedIssueStub(parent)) return null; const ref = parent.identifier ?? parent.id; return { identifier: parent.identifier ?? null, @@ -6991,7 +7027,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS {[...ancestors].reverse().map((ancestor, i) => ( {i > 0 && } - : @@ -7005,7 +7041,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS title={ancestor.title} > {ancestor.title} - + } ))} @@ -7299,6 +7335,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS "absolute right-0 top-0 flex h-7 items-center", )} > + setMoreOpen(false)} + implicitPrincipals={privacyImplicitPrincipals} +> + {(privacyMenuItems) => (
diff --git a/ui/src/pages/ProjectDetail.test.tsx b/ui/src/pages/ProjectDetail.test.tsx index 59cd9c74d48..4e987e6091f 100644 --- a/ui/src/pages/ProjectDetail.test.tsx +++ b/ui/src/pages/ProjectDetail.test.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { queryKeys } from "../lib/queryKeys"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Project } from "@paperclipai/shared"; import type { ReactNode } from "react"; @@ -83,7 +84,7 @@ vi.mock("@/plugins/slots", () => ({ })); vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null })); vi.mock("../components/ProjectProperties", () => ({ - ProjectProperties: () =>
, + ProjectProperties: ({ onFieldUpdate }: { onFieldUpdate: (field: string, data: Record) => void }) =>
, })); vi.mock("../components/BudgetPolicyCard", () => ({ BudgetPolicyCard: () =>
, @@ -114,6 +115,10 @@ vi.mock("../components/IssuesList", () => ({ }, })); +async function settle() { + for (let i = 0; i < 8; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(() => {}); } +} + async function act(callback: () => void | Promise) { let result: void | Promise = undefined; flushSync(() => { @@ -241,6 +246,22 @@ describe("ProjectDetail", () => { await act(() => queryClient.clear()); }); + it("refreshes cached task audiences when visibility is saved through the field autosave path", async () => { + mockLocation.pathname = "/projects/project-1/configuration"; + mockProjectsApi.update.mockResolvedValue(project({ visibility: "open" })); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const key = queryKeys.issues.accessGrants("cached-task"); + queryClient.setQueryData(key, [{ source: "project" }]); + root = createRoot(container); + await act(() => root!.render()); + await settle(); + await act(() => [...container.querySelectorAll("button")].find(button => button.textContent === "Open project through autosave")!.click()); + await settle(); + expect(mockProjectsApi.update).toHaveBeenCalledWith("project-1", { visibility: "open" }, "company-1"); + expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true); + queryClient.clear(); + }); + it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 8c8943411d3..8ce3fe22b7a 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -449,7 +449,10 @@ export function ProjectDetail() { const updateProject = useMutation({ mutationFn: (data: Record) => projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId), - onSuccess: invalidateProject, + onSuccess: (_project, data) => { + invalidateProject(); + if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] }); + }, }); const archiveProject = useMutation({ @@ -580,6 +583,7 @@ export function ProjectDetail() { try { await projectsApi.update(projectLookupRef, data, resolvedCompanyId ?? lookupCompanyId); invalidateProject(); + if (data.visibility !== undefined) queryClient.invalidateQueries({ queryKey: ["issues"] }); if (fieldSaveRequestIds.current[field] !== requestId) return; setFieldState(field, "saved"); scheduleFieldReset(field, 1800); @@ -589,7 +593,7 @@ export function ProjectDetail() { scheduleFieldReset(field, 3000); throw error; } - }, [invalidateProject, lookupCompanyId, projectLookupRef, resolvedCompanyId, scheduleFieldReset, setFieldState]); + }, [invalidateProject, lookupCompanyId, projectLookupRef, queryClient, resolvedCompanyId, scheduleFieldReset, setFieldState]); const projectBudgetSummary = useMemo(() => { const matched = budgetOverview?.policies.find( diff --git a/ui/src/pages/Projects.tsx b/ui/src/pages/Projects.tsx index 02006e0f28a..796efa180dd 100644 --- a/ui/src/pages/Projects.tsx +++ b/ui/src/pages/Projects.tsx @@ -23,6 +23,7 @@ import { import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ArrowUpDown, Check, Hexagon, Plus } from "lucide-react"; +import { Lock } from "lucide-react"; import { Card } from "@/components/ui/card"; type ProjectSortField = "name" | "updated" | "created" | "targetDate"; @@ -221,6 +222,7 @@ export function Projects() { className={state === "left" ? "group text-foreground/55" : "group"} trailing={
+ {project.visibility === "private" ? : null} { + it.each([ + [{ title: "Open task" }, "open", null, null], + [{ title: "Private task", visibility: "private" }, "private", "privacy-created", null], + [{ title: "Private child", parentId: "privacy-root" }, "private", "privacy-root", "privacy-root"], + [{ title: "Private project task", projectId: "project-private" }, "private", "privacy-created", null], + ])("matches creation defaults for %j", async (data, visibility, privacyRootIssueId, privacyParentIssueId) => { + const restore = installPrivacyApi(createPrivacyState({})); + try { + const response = await fetch(`/api/companies/${privacyCompanyId}/issues`, { method: "POST", body: JSON.stringify(data) }); + const task = await response.json(); + expect(task).toMatchObject({ visibility, privacyRootIssueId, privacyParentIssueId }); + } finally { restore(); } + }); +}); + +describe("task privacy moves in the preview", () => { + it.each([ + [{ projectId: "project-private" }, "privacy-root", "privacy-root", null], + [{ parentId: "privacy-child" }, "privacy-sibling", "privacy-root", "privacy-child"], + ])("inherits privacy when moving an open task into %j", async (data, id, root, parent) => { + const restore = installPrivacyApi(createPrivacyState({ visibility: "open" })); + try { + const response = await fetch(`/api/issues/${id}`, { method: "PATCH", body: JSON.stringify(data) }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ visibility: "private", privacyRootIssueId: root, privacyParentIssueId: parent }); + } finally { restore(); } + }); + it("inherits a parent's private project even if the parent is open", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true }); + const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-sibling", { method: "PATCH", body: JSON.stringify({ parentId: "privacy-root" }) }); + expect(await response.json()).toMatchObject({ visibility: "private", privacyRootIssueId: "privacy-root", privacyParentIssueId: "privacy-root" }); + } finally { restore(); } + }); + it("rejects publishing under a private project without mutating its scope", async () => { + const state = createPrivacyState({ taskProject: true }); const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(response.status).toBe(422); + expect(state.tasks[0]).toMatchObject({ visibility: "private", projectId: "project-private" }); + } finally { restore(); } + }); + it("leaves a personal project when making its task public", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, personal: true })); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(await response.json()).toMatchObject({ visibility: "open", projectId: null, project: null }); + } finally { restore(); } + }); +}); + + +describe("preview descendants and effective access", () => { + it.each([ + { projectId: "project-private" }, + { parentId: "private-peer" }, + { visibility: "private" }, + ])("protects the whole task subtree for %j", async (data) => { + const state = createPrivacyState({ visibility: "open", grants: "empty" }); + for (const task of state.tasks) { + task.visibility = "open"; + task.privacyRootIssueId = null; + task.privacyParentIssueId = null; + } + // Include a provenance-only descendant, as well as ordinary child links. + state.tasks[2]!.parentId = null; + state.tasks[2]!.privacyParentIssueId = "privacy-child"; + state.tasks.push(privacyTask({ id: "private-peer", privacyRootIssueId: "private-peer" })); + state.tasks.push(privacyTask({ id: "unrelated", visibility: "open", privacyRootIssueId: null })); + const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify(data) }); + const root = await response.json(); + expect(response.status).toBe(200); + for (const id of ["privacy-child", "privacy-grandchild", "privacy-sibling"]) { + const child = await fetch(`/api/issues/${id}`).then(response => response.json()); + expect(child).toMatchObject({ visibility: "private", privacyRootIssueId: root.privacyRootIssueId }); + expect(child.privacyParentIssueId).not.toBeNull(); + } + expect(state.tasks.find(task => task.id === "unrelated")?.visibility).toBe("open"); + } finally { restore(); } + }); + it("drops former personal-project members after publishing and making the task private again", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, personal: true, grants: "empty" })); + try { + const grants = () => fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(await grants()).toContainEqual(expect.objectContaining({ subjectId: "user-product", source: "project" })); + const published = await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }).then(response => response.json()); + expect(published).toMatchObject({ projectId: null, privacyRootIssueId: null }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + expect(await grants()).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("removes inherited project membership after detaching from its parent", async () => { + const restore = installPrivacyApi(createPrivacyState({ taskProject: true, grants: "empty" })); + try { + const grants = () => fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + expect(await grants()).toContainEqual(expect.objectContaining({ subjectId: "user-product", source: "project" })); + await fetch("/api/issues/privacy-child", { method: "PATCH", body: JSON.stringify({ parentId: null }) }); + expect(await grants()).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("shows a child share only on that child and its descendants", async () => { + const restore = installPrivacyApi(createPrivacyState({ grants: "empty" })); + try { + await fetch("/api/issues/privacy-child/access-grants", { method: "POST", body: JSON.stringify({ subjectType: "user", subjectId: "user-product" }) }); + for (const [id, expected] of [["privacy-root", false], ["privacy-child", true], ["privacy-grandchild", true], ["privacy-sibling", false]] as const) { + const grants = await fetch(`/api/issues/${id}/access-grants`).then(response => response.json()); + expect(grants.some((grant: { subjectId: string }) => grant.subjectId === "user-product")).toBe(expected); + if (id === "privacy-grandchild") expect(grants).toContainEqual(expect.objectContaining({ inherited: true, issueId: "privacy-child" })); + } + } finally { restore(); } + }); + it("protects descendants when a project becomes private and keeps tasks private after opening it", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true, grants: "empty" }); + state.projects[0]!.visibility = "open"; + for (const task of state.tasks) { task.visibility = "open"; task.privacyRootIssueId = null; } + const restore = installPrivacyApi(state); + try { + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + expect(state.tasks.every(task => task.visibility === "private" && task.privacyRootIssueId !== null)).toBe(true); + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(state.tasks.every(task => task.visibility === "private")).toBe(true); + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(grants).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("keeps existing private descendants private when their parent is published", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + expect(state.tasks[0]).toMatchObject({ visibility: "open", privacyRootIssueId: null }); + expect(state.tasks.slice(1).every(task => task.visibility === "private")).toBe(true); + } finally { restore(); } + }); + it("does not revoke an ancestor grant through the child's endpoint", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + const response = await fetch("/api/issues/privacy-child/access-grants/grant-morgan/revoke", { method: "POST", body: "{}" }); + expect(response.status).toBe(404); + expect(state.grants.find(grant => grant.id === "grant-morgan")?.revokedAt).toBeNull(); + } finally { restore(); } + }); +}); + + +describe("preview owner and saved assignment access", () => { + it("keeps a child's own owner and assignment reasons after its parent becomes public", async () => { + const state = createPrivacyState(); + state.tasks[1]!.responsibleUserId = "user-product"; + state.tasks[1]!.createdByUserId = "user-finance"; + const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + const grants = await fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + for (const subjectId of ["user-product", "user-finance"]) expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId, source: "owner", inherited: false })); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId: "agent-dedicated", source: "assignment", inherited: false })); + expect(grants.every((grant: { issueId: string }) => grant.issueId === "privacy-child")).toBe(true); + } finally { restore(); } + }); + it("saves assignment access when a project becomes private and retains it after unassignment", async () => { + const state = createPrivacyState({ visibility: "open", taskProject: true }); + state.projects[0]!.visibility = "open"; + for (const task of state.tasks) { task.visibility = "open"; task.privacyRootIssueId = null; } + state.grants = []; + const restore = installPrivacyApi(state); + try { + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "private" }) }); + await fetch("/api/issues/privacy-child", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: null }) }); + await fetch("/api/projects/project-private", { method: "PATCH", body: JSON.stringify({ visibility: "open" }) }); + const grants = await fetch("/api/issues/privacy-child/access-grants").then(response => response.json()); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-child", subjectId: "agent-dedicated", source: "assignment", inherited: false })); + expect(grants).not.toContainEqual(expect.objectContaining({ source: "project" })); + } finally { restore(); } + }); + it("does not add an assignment grant beside an existing independent direct grant", async () => { + const state = createPrivacyState({ grants: "empty" }); + state.grants.push(privacyGrant({ subjectType: "agent", subjectId: "agent-dedicated" })); + const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: "agent-dedicated" }) }); + expect(state.grants.filter(grant => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated")).toHaveLength(1); + } finally { restore(); } + }); + it.each([null, "agent-dedicated"])("uses the new task's actual assignment %s", async (assigneeAgentId) => { + const restore = installPrivacyApi(createPrivacyState({ grants: "empty" })); + try { + await fetch(`/api/companies/${privacyCompanyId}/issues`, { method: "POST", body: JSON.stringify({ title: "Created private task", visibility: "private", assigneeAgentId }) }); + const grants = await fetch("/api/issues/privacy-created/access-grants").then(response => response.json()); + expect(grants).toContainEqual(expect.objectContaining({ issueId: "privacy-created", subjectId: "user-board", source: "owner", inherited: false })); + expect(grants.filter((grant: { source: string }) => grant.source === "assignment")).toHaveLength(assigneeAgentId ? 1 : 0); + } finally { restore(); } + }); +}); + + +describe("revoked assignment access in previews", () => { + it("does not restore a revoked assignment grant after unrelated edits", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root/access-grants/grant-assignment/revoke", { method: "POST", body: "{}" }); + for (const data of [{ title: "Edited after revocation" }, { status: "done" }]) { + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify(data) }); + } + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + expect(grants.filter((grant: { issueId: string; subjectId: string; revokedAt: string | null }) => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated" && grant.revokedAt === null)).toHaveLength(0); + expect(state.tasks[0]!.assigneeAgentId).toBe("agent-dedicated"); + } finally { restore(); } + }); + it("saves a fresh assignment grant after an actual reassignment", async () => { + const state = createPrivacyState(); const restore = installPrivacyApi(state); + try { + await fetch("/api/issues/privacy-root/access-grants/grant-assignment/revoke", { method: "POST", body: "{}" }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: null }) }); + await fetch("/api/issues/privacy-root", { method: "PATCH", body: JSON.stringify({ assigneeAgentId: "agent-dedicated" }) }); + const grants = await fetch("/api/issues/privacy-root/access-grants").then(response => response.json()); + const active = grants.filter((grant: { issueId: string; subjectId: string; revokedAt: string | null }) => grant.issueId === "privacy-root" && grant.subjectId === "agent-dedicated" && grant.revokedAt === null); + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ source: "assignment", inherited: false }); + expect(active[0].id).not.toBe("grant-assignment"); + } finally { restore(); } + }); +}); diff --git a/ui/storybook/fixtures/privateTasks.ts b/ui/storybook/fixtures/privateTasks.ts new file mode 100644 index 00000000000..4d330a0ebe5 --- /dev/null +++ b/ui/storybook/fixtures/privateTasks.ts @@ -0,0 +1,780 @@ +import type { + Agent, + Issue, + IssueAccessGrant, + Project, + ProjectAccessMember, +} from "@paperclipai/shared"; +import type { CurrentBoardAccess } from "@/api/access"; +import { + storybookAgents, + storybookAuthSession, + storybookCompanies, + storybookIssues, + storybookProjects, + storybookSidebarBadges, + storybookDashboardSummary, +} from "./paperclipData"; + +export const privacyCompanyId = "company-storybook"; +export const privacyOwnerId = "user-board"; +export const privacyReaderId = "user-product"; +const createdAt = new Date("2026-09-30T09:00:00Z"); + +export type PrivacyScenario = { + role?: "owner" | "reader" | "admin"; + visibility?: "open" | "private"; + grants?: "all" | "empty" | "assignment" | "inherited" | "long"; + failure?: + | "parent" + | "grants" + | "directory" + | "add" + | "revoke" + | "visibility" + | "members" + | "create" + | "project-add" + | "project-remove"; + loading?: "parent" | "grants" | "members" | "mention"; + retryOnce?: boolean; + childOnly?: boolean; + personal?: boolean; + draft?: boolean; + classic?: boolean; + taskProject?: boolean; +}; + +export const privacyUsers = [ + { + id: privacyOwnerId, + name: "Avery Chen", + email: "avery@example.test", + image: null, + }, + { + id: privacyReaderId, + name: "Morgan Reed", + email: "morgan@example.test", + image: null, + }, + { + id: "user-finance", + name: "Sam Rivera", + email: "sam@example.test", + image: null, + }, + { + id: "user-admin", + name: "Jordan Ellis", + email: "jordan@example.test", + image: null, + }, +]; + +export const privacyAgents: Agent[] = [ + { + ...storybookAgents[0]!, + id: "agent-dedicated", + name: "Executive assistant", + urlKey: "executive-assistant", + status: "idle", + permissions: { + canCreateAgents: false, + authorizationPolicy: { agentVisibility: { mode: "private" } }, + }, + }, + { + ...storybookAgents[1]!, + id: "agent-shared", + name: "Research team agent", + urlKey: "research-team-agent", + status: "idle", + permissions: { + canCreateAgents: false, + authorizationPolicy: { agentVisibility: { mode: "discoverable" } }, + }, + }, + { + ...storybookAgents[0]!, + id: "agent-unspecified", + name: "Legacy shared agent", + urlKey: "legacy-shared-agent", + status: "idle", + permissions: { canCreateAgents: false }, + }, +]; + +export function privacyProject(overrides: Partial = {}): Project { + return { + ...storybookProjects[0]!, + id: "project-private", + name: "Executive planning", + urlKey: "executive-planning", + description: "Plans shared with the executive team.", + visibility: "private", + privacyOwnerUserId: privacyOwnerId, + personalOwnerUserId: null, + workspaces: [], + primaryWorkspace: null, + ...overrides, + }; +} + +export function privacyTask(overrides: Partial = {}): Issue { + return { + ...storybookIssues[0]!, + id: "privacy-root", + identifier: "PAP-410", + title: "Prepare my board briefing", + description: + "Draft a briefing from my personal inbox. Share only the research subtask with Morgan.", + visibility: "private", + privacyRootIssueId: "privacy-root", + privacyParentIssueId: null, + companyId: privacyCompanyId, + responsibleUserId: privacyOwnerId, + createdByUserId: privacyOwnerId, + createdByAgentId: null, + assigneeAgentId: "agent-dedicated", + assigneeUserId: null, + parentId: null, + projectId: null, + project: null, + status: "todo", + checkoutRunId: null, + executionRunId: null, + executionWorkspaceId: null, + currentExecutionWorkspace: null, + projectWorkspaceId: null, + startedAt: null, + executionLockedAt: null, + executionAgentNameKey: null, + executionWorkspaceSettings: null, + executionState: null, + executionPolicy: null, + blockedBy: [], + blocks: [], + ancestors: [], + labels: [], + labelIds: [], + createdAt, + updatedAt: createdAt, + ...overrides, + }; +} + +export function privacyGrant( + overrides: Partial = {}, +): IssueAccessGrant { + return { + id: "grant-morgan", + issueId: "privacy-root", + subjectType: "user", + subjectId: privacyReaderId, + source: "explicit", + inherited: false, + grantedByUserId: privacyOwnerId, + grantedByAgentId: null, + createdAt, + revokedAt: null, + subjectDisplayName: "Morgan Reed", + subjectAvatarUrl: null, + subjectInitials: "MR", + agentVisibility: null, + ...overrides, + }; +} + +function privacySubjectMetadata(subjectType: IssueAccessGrant["subjectType"], subjectId: string): Pick { + const subject = (subjectType === "user" ? privacyUsers : privacyAgents).find(row => row.id === subjectId); + const name = subject?.name ?? "Unknown"; + return { + subjectDisplayName: name, + subjectInitials: name.split(" ").map(part => part[0]).join("").slice(0, 2), + agentVisibility: subjectType === "agent" ? subjectId === "agent-dedicated" ? "private" + : subjectId === "agent-shared" ? "discoverable" : null : null, + }; +} +function savePrivacyAssignments(tasks: Issue[], grants: IssueAccessGrant[]) { + for (const task of tasks) { + if (task.visibility !== "private") continue; + for (const [subjectType, subjectId] of [["agent", task.assigneeAgentId], ["user", task.assigneeUserId]] as const) { + if (!subjectId || grants.some(grant => grant.issueId === task.id && grant.subjectType === subjectType + && grant.subjectId === subjectId && grant.revokedAt === null && grant.source !== "owner" && grant.source !== "project")) continue; + grants.push(privacyGrant({ + ...privacySubjectMetadata(subjectType, subjectId), + id: `assignment-${task.id}-${subjectType}-${subjectId}-${grants.length}`, + issueId: task.id, subjectType, subjectId, source: "assignment", + })); + } + } +} +export function createPrivacyState(options: PrivacyScenario = {}) { + const role = options.role ?? "owner"; + const user = privacyUsers.find( + (item) => + item.id === + (role === "reader" + ? privacyReaderId + : role === "admin" + ? "user-admin" + : privacyOwnerId), + )!; + const project = privacyProject( + options.personal + ? { name: "My private tasks", personalOwnerUserId: privacyOwnerId } + : {}, + ); + const projects = [ + project, + privacyProject({ + id: "project-open", + name: "Company operations", + urlKey: "company-operations", + visibility: "open", + }), + ]; + const tasks = [ + privacyTask({ visibility: options.visibility ?? "private", ...(options.taskProject ? { projectId: project.id, project } : {}) }), + privacyTask({ + id: "privacy-child", + identifier: "PAP-411", + title: "Research market benchmarks", + description: + "Research publicly available market benchmarks and summarize the sources.", + parentId: "privacy-root", + privacyParentIssueId: "privacy-root", + }), + privacyTask({ + id: "privacy-grandchild", + identifier: "PAP-412", + title: "Summarize public market reports", + parentId: "privacy-child", + privacyParentIssueId: "privacy-child", + }), + privacyTask({ + id: "privacy-sibling", + identifier: "PAP-413", + title: "Review compensation notes", + parentId: "privacy-root", + privacyParentIssueId: "privacy-root", + }), + ]; + if (options.childOnly) + tasks[1]!.ancestors = [ + { id: "privacy-root", identifier: "PAP-410", locked: true }, + ] as unknown as Issue["ancestors"]; // Redacted wire shape; never invent a private title for the legacy ancestor type. + let grants = [ + privacyGrant(), + privacyGrant({ + id: "grant-assignment", + subjectType: "agent", + subjectId: "agent-dedicated", + subjectDisplayName: "Executive assistant", + subjectInitials: "EA", + source: "assignment", + agentVisibility: "private", + }), + ]; + if (options.grants === "empty") grants = []; + if (options.grants === "assignment") + grants = grants.filter((item) => item.source === "assignment"); + if (options.grants === "inherited") + grants = grants + .filter((item) => item.source !== "owner") + .map((item) => ({ ...item, inherited: true, issueId: "privacy-root" })); + if (options.grants === "long") + grants = [ + privacyGrant({ + subjectDisplayName: + "Morgan Reed — International market strategy and research", + subjectInitials: "MR", + }), + ]; + if (options.grants === "empty") { + for (const task of tasks) { task.assigneeAgentId = null; task.assigneeUserId = null; } + } + savePrivacyAssignments(tasks, grants); + const members: ProjectAccessMember[] = privacyUsers + .slice(0, 2) + .map((item) => ({ + id: "member-" + item.id, + projectId: project.id, + companyId: privacyCompanyId, + subjectType: "user", + subjectId: item.id, + subjectDisplayName: item.name, + subjectAvatarUrl: null, + createdAt, + })); + const access: CurrentBoardAccess = { + user, + userId: user.id, + isInstanceAdmin: false, + companyIds: [privacyCompanyId], + memberships: [ + { + companyId: privacyCompanyId, + membershipRole: role === "admin" ? "admin" : "member", + status: "active", + }, + ], + source: "session", + keyId: null, + }; + return { + options, + user, + access, + session: { + ...storybookAuthSession, + user, + session: { ...storybookAuthSession.session!, userId: user.id }, + }, + projects, + tasks, + grants, + members, + operations: [] as string[], + }; +} +export type PrivacyState = ReturnType; + +/** Deterministic UX fixtures, not a second authorization implementation. All writes stay in this story. */ +export function installPrivacyApi(state: PrivacyState) { + const previous = window.fetch; + const failed = new Set(); + const oldDraft = localStorage.getItem("paperclip:issue-draft"); + localStorage.removeItem("paperclip:issue-draft"); + if (state.options.draft) + localStorage.setItem( + "paperclip:issue-draft", + JSON.stringify({ + title: "Continue my private briefing", + description: "Saved personal draft.", + isPrivate: true, + status: "todo", + priority: "medium", + companyId: privacyCompanyId, + }), + ); + const fail = (key: PrivacyScenario["failure"]) => { + if ( + state.options.failure !== key || + (state.options.retryOnce && failed.has(key!)) + ) + return null; + failed.add(key!); + return Response.json( + { error: "The request could not be completed. Try again." }, + { status: 503 }, + ); + }; + const pause = (signal?: AbortSignal | null) => + new Promise((_resolve, reject) => { + if (signal?.aborted) reject(new DOMException("Aborted", "AbortError")); + signal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + const privateTaskScope = (task: Issue) => task.visibility === "private" + || state.projects.some(project => project.id === task.projectId && project.visibility === "private"); + const privacyAncestry = (task: Issue) => { + const ancestry = [task]; + const seen = new Set([task.id]); + let parent = state.tasks.find(row => row.id === task.privacyParentIssueId); + while (parent && !seen.has(parent.id) && privateTaskScope(parent)) { + ancestry.push(parent); + seen.add(parent.id); + parent = state.tasks.find(row => row.id === parent!.privacyParentIssueId); + } + return ancestry; + }; + const privacySubtree = (roots: string[]) => { + const seen = new Set(roots); + const pending = [...roots]; + while (pending.length) { + const parentId = pending.shift()!; + for (const task of state.tasks) { + if (!seen.has(task.id) && (task.parentId === parentId || task.privacyParentIssueId === parentId)) { + seen.add(task.id); + pending.push(task.id); + } + } + } + return state.tasks.filter(task => seen.has(task.id)); + }; + window.fetch = async (input, init) => { + const request = input instanceof Request ? input : null; + const url = new URL( + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url, + location.origin, + ); + const path = url.pathname; + const method = (init?.method ?? request?.method ?? "GET").toUpperCase(); + const data = typeof init?.body === "string" ? JSON.parse(init.body) : {}; + const json = (value: unknown) => Response.json(value); + if (!path.startsWith("/api/")) return previous(input, init); + if (path.includes("/email/tasks/")) return json({ messages: [], publications: [] }); + if (path.endsWith("/budgets/overview")) return json({ policies: [] }); + if (path === "/api/auth/get-session") return json(state.session); + if (path === "/api/cli-auth/me") return json(state.access); + if (path === "/api/companies") return json(storybookCompanies); + if (path === "/api/health") + return json({ + status: "ok", + deploymentMode: "authenticated", + authReady: true, + bootstrapStatus: "ready", + }); + if (path.endsWith("/user-directory") || path.endsWith("/members")) + return ( + fail("directory") ?? + json({ + users: privacyUsers.map((user) => ({ + principalId: user.id, + status: "active", + user, + })), + agents: [], + }) + ); + if (path.endsWith("/resource-memberships/me")) + return json({ + projectMemberships: {}, + agentMemberships: {}, + starredProjectIds: ["project-private"], + starredAgentIds: [], + starredDocumentIds: [], + projectStarredAt: {}, + agentStarredAt: {}, + documentStarredAt: {}, + updatedAt: null, + }); + if (path === "/api/companies/" + privacyCompanyId + "/agents") + return json(privacyAgents); + if (path === "/api/companies/" + privacyCompanyId + "/projects") + return json(state.projects); + if (path === "/api/companies/" + privacyCompanyId + "/issues") { + if (method === "POST") { + const error = fail("create"); + if (error) return error; + const parent = state.tasks.find(task => task.id === data.parentId); + const project = state.projects.find(project => project.id === data.projectId); + const privateParent = parent && (parent.visibility === "private" + || state.projects.some(project => project.id === parent.projectId && project.visibility === "private")); + const visibility = privateParent || project?.visibility === "private" ? "private" : data.visibility ?? "open"; + const issue = privacyTask({ + ...data, + visibility, + privacyParentIssueId: parent?.id ?? null, + privacyRootIssueId: visibility === "private" + ? privateParent ? parent.privacyRootIssueId ?? parent.id : "privacy-created" + : null, + id: "privacy-created", + identifier: "PAP-414", + assigneeAgentId: data.assigneeAgentId ?? null, + assigneeUserId: data.assigneeUserId ?? null, + project: project ?? null, + responsibleUserId: privacyOwnerId, + }); + state.tasks.push(issue); + savePrivacyAssignments([issue], state.grants); + state.operations.push( + "Created " + issue.identifier + " · " + issue.visibility, + ); + return json(issue); + } + let rows = state.options.childOnly + ? state.tasks.filter((item) => + ["privacy-child", "privacy-grandchild"].includes(item.id), + ) + : state.tasks; + const parent = + url.searchParams.get("parentId") ?? + url.searchParams.get("descendantOf"); + if (parent) rows = rows.filter((item) => item.parentId === parent); + const query = url.searchParams.get("q")?.toLowerCase(); + return json( + query + ? rows.filter((item) => + (item.title + item.identifier).toLowerCase().includes(query), + ) + : rows, + ); + } + const issueMatch = path.match(/^\/api\/issues\/([^/]+)(?:\/(.*))?$/); + if (issueMatch) { + const item = state.tasks.find( + (row) => row.id === issueMatch[1] || row.identifier === issueMatch[1], + ); + const resource = issueMatch[2]; + if (issueMatch[1] === "PAP-499") + return state.options.loading === "mention" + ? pause(init?.signal) + : json( + privacyTask({ + identifier: "PAP-499", + title: "Readable task", + status: "in_progress", + }), + ); + if ( + !item || + (state.options.childOnly && + ["privacy-root", "privacy-sibling"].includes(item.id)) + ) + return jsonError404(); + if (!resource) { + if (method === "GET" && issueMatch[1] === "privacy-root") { + if (state.options.loading === "parent") return pause(init?.signal); + const error = fail("parent"); + if (error) return error; + } + if (method === "PATCH") { + const error = fail("visibility"); + if (error) return error; + const next = { ...item, ...data }; + const changesPrivacy = data.visibility !== undefined || data.parentId !== undefined || data.projectId !== undefined; + if (data.visibility === "open") next.privacyRootIssueId = null; + if (changesPrivacy) { + const project = state.projects.find(project => project.id === next.projectId); + if (project?.visibility === "private") { + if (data.visibility === "open") { + if (!project.personalOwnerUserId) return Response.json({ error: "Move this task out of its private project before making it public" }, { status: 422 }); + next.projectId = null; + next.project = null; + } else { + next.visibility = "private"; + next.privacyRootIssueId = item.privacyRootIssueId ?? item.id; + } + } + const parentId = data.parentId !== undefined ? data.parentId : item.privacyParentIssueId; + const parent = state.tasks.find(task => task.id === parentId); + const parentProject = state.projects.find(project => project.id === parent?.projectId); + next.privacyParentIssueId = parent?.id ?? null; + if (parent && (parent.visibility === "private" || parentProject?.visibility === "private")) { + if (data.visibility === "open") return Response.json({ error: "A task cannot be made public while it inherits private access" }, { status: 422 }); + next.visibility = "private"; + next.privacyRootIssueId = parent.privacyRootIssueId ?? parent.id; + } else if (data.visibility === "private") next.privacyRootIssueId = item.id; + if (data.parentId !== undefined) next.ancestors = parent ? [{ ...parent, + project: parent.project ? { ...parent.project, workspaces: parent.project.workspaces ?? [], primaryWorkspace: parent.project.primaryWorkspace ?? null } : null, + goal: parent.goal ?? null, + }] : []; + } + if (data.projectId !== undefined && next.projectId) next.project = state.projects.find(project => project.id === next.projectId) ?? null; + if (!next.projectId) next.project = null; + const assignmentChanged = item.visibility !== "private" || item.assigneeAgentId !== next.assigneeAgentId || item.assigneeUserId !== next.assigneeUserId; + Object.assign(item, next); + if (changesPrivacy && item.visibility === "private") { + for (const descendant of privacySubtree([item.id])) { + if (descendant.id === item.id) continue; + descendant.visibility = "private"; + descendant.privacyRootIssueId = item.privacyRootIssueId ?? item.id; + descendant.privacyParentIssueId ??= descendant.parentId; + } + savePrivacyAssignments(privacySubtree([item.id]).filter(task => task.id !== item.id), state.grants); + } + if (assignmentChanged) savePrivacyAssignments([item], state.grants); + state.operations.push("Task audience: " + item.visibility); + } + return json(item); + } + if (resource === "privacy-constraints") { + if (state.options.loading === "parent") return pause(init?.signal); + const error = fail("parent"); if (error) return error; + const project = state.projects.find(project => project.id === item.projectId); + const parent = state.tasks.find(task => task.id === item.privacyParentIssueId); + const parentProject = state.projects.find(project => project.id === parent?.projectId); + return json({ + publicBlockedBy: project?.visibility === "private" && !project.personalOwnerUserId ? "project" + : parent?.visibility === "private" || parentProject?.visibility === "private" ? "parent" : null, + leavesPersonalProject: project?.visibility === "private" && Boolean(project.personalOwnerUserId), + }); + } + if (resource === "access-grants") { + if (method === "GET") { + if (state.options.loading === "grants") return pause(init?.signal); + const error = fail("grants"); if (error) return error; + const ancestry = privacyAncestry(item); + const taskIds = new Set(ancestry.map(task => task.id)); + const projectIds = new Set(ancestry.flatMap(task => state.projects + .filter(project => project.id === task.projectId && project.visibility === "private").map(project => project.id))); + return json([ + ...state.grants.filter(grant => grant.source !== "project" && grant.source !== "owner" && taskIds.has(grant.issueId)) + .map(grant => ({ ...grant, inherited: grant.issueId !== item.id })), + ...ancestry.flatMap(task => Array.from(new Set([task.responsibleUserId, task.createdByUserId] + .filter((id): id is string => Boolean(id)))).map(subjectId => { + return privacyGrant({ + ...privacySubjectMetadata("user", subjectId), + id: `owner:${task.id}:${subjectId}`, issueId: task.id, subjectId, source: "owner", + inherited: task.id !== item.id, + }); + })), + ...state.members.filter(member => projectIds.has(member.projectId)) + .map(member => privacyGrant({ + ...privacySubjectMetadata(member.subjectType, member.subjectId), + id: "project-" + member.id, issueId: item.id, subjectType: member.subjectType, + subjectId: member.subjectId, subjectDisplayName: member.subjectDisplayName, + source: "project", inherited: true, + })), + ]); + } + const error = fail("add"); + if (error) return error; + const subject = [...privacyUsers, ...privacyAgents].find( + (row) => row.id === data.subjectId, + ); + const grant = privacyGrant({ + ...data, + ...privacySubjectMetadata(data.subjectType, data.subjectId), + id: "grant-added-" + data.subjectId, + issueId: item.id, + subjectDisplayName: subject?.name ?? "Unknown", + }); + state.grants.push(grant); + state.operations.push( + "Shared " + item.identifier + " with " + subject?.name, + ); + return json(grant); + } + if (resource.match(/^access-grants\/.+\/revoke$/)) { + const error = fail("revoke"); + if (error) return error; + const grant = state.grants.find( + (row) => row.id === resource.split("/")[1] && row.issueId === item.id && row.revokedAt === null, + ); + if (!grant) return jsonError404(); + grant.revokedAt = new Date(); + state.operations.push("Removed a saved task grant"); + return json(grant); + } + if ( + ["active-run", "runner-goal", "execution-workspace"].includes(resource) + ) + return json(null); + if (resource === "queued-comments") + return json({ queues: [], items: [], revision: "privacy-story" }); + if (resource === "cost-summary") + return json({ + costCents: 0, + inputTokens: 0, + outputTokens: 0, + runCount: 0, + }); + if (resource.startsWith("documents/")) return jsonError404(); + if (resource === "comments") + return json([ + { + id: "privacy-comment", + issueId: item.id, + companyId: privacyCompanyId, + authorUserId: privacyOwnerId, + authorAgentId: null, + body: "Use the research in [PAP-411](/PAP/issues/PAP-411). Keep the briefing and compensation notes private.", + createdAt, + updatedAt: createdAt, + }, + ]); + return json([]); + } + const projectMatch = path.match(/^\/api\/projects\/([^/]+)(?:\/(.*))?$/); + if (projectMatch) { + const project = state.projects.find( + (row) => row.id === projectMatch[1] || row.urlKey === projectMatch[1], + ); + if (!project) return jsonError404(); + if (!projectMatch[2]) { + if (method === "PATCH") { + Object.assign(project, data); + if (data.visibility === "private") { + for (const task of privacySubtree(state.tasks.filter(task => task.projectId === project.id).map(task => task.id))) { + task.visibility = "private"; + task.privacyRootIssueId ??= task.id; + task.privacyParentIssueId ??= task.parentId; + } + savePrivacyAssignments(privacySubtree(state.tasks.filter(task => task.projectId === project.id).map(task => task.id)), state.grants); + } + } + return json(project); + } + if (projectMatch[2] === "access-members") { + if (method === "GET") + return state.options.loading === "members" + ? pause(init?.signal) + : (fail("members") ?? json(state.members.filter(member => member.projectId === project.id))); + const error = fail("project-add"); + if (error) return error; + const subject = [...privacyUsers, ...privacyAgents].find( + (row) => row.id === data.subjectId, + ); + const member: ProjectAccessMember = { + id: "member-added-" + data.subjectId, + companyId: privacyCompanyId, + projectId: project.id, + createdAt, + subjectAvatarUrl: null, + subjectDisplayName: subject?.name ?? "Unknown", + ...data, + }; + state.members.push(member); + return json(member); + } + if (projectMatch[2].startsWith("access-members/")) { + const error = fail("project-remove"); + if (error) return error; + const id = projectMatch[2].split("/")[1]; + state.members = state.members.filter((row) => row.id !== id || row.projectId !== project.id); + return json({ id }); + } + return json([]); + } + if (path.startsWith("/api/agents/")) + return json( + path.split("/").length > 4 + ? [] + : (privacyAgents.find((agent) => path.split("/")[3] === agent.id) ?? + privacyAgents[0]), + ); + if (path === "/api/instance/settings/experimental") + return json({ + enableClassicTaskInterface: state.options.classic === true, + enableIsolatedWorkspaces: false, + enableManagedSandboxOnly: false, + }); + if (path.includes("/adapters") || path === "/api/adapters") + return previous(input, init); + if (path.endsWith("/dashboard")) return json(storybookDashboardSummary); + if (path.endsWith("/sidebar-badges")) return json(storybookSidebarBadges); + if (path.endsWith("/budgets/overview")) + return json({ + companyId: privacyCompanyId, + policies: [], + activeIncidents: [], + pausedAgentCount: 0, + pausedProjectCount: 0, + pendingApprovalCount: 0, + }); + if (path.endsWith("/budget-summary")) return json({}); + if (path.includes("/settings")) return json({}); + // These stories never contact a real company, connector, or backend. + return json([]); + }; + return () => { + window.fetch = previous; + if (oldDraft === null) localStorage.removeItem("paperclip:issue-draft"); + else localStorage.setItem("paperclip:issue-draft", oldDraft); + }; +} + +function jsonError404() { + return Response.json({ error: "Not found" }, { status: 404 }); +} diff --git a/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx b/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx new file mode 100644 index 00000000000..2853525d174 --- /dev/null +++ b/ui/storybook/stories/private-tasks/00-review-guide.stories.tsx @@ -0,0 +1,272 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const surfaces = [ + ["ComposerAddMenu · ComposerPrivacyChip · TaskChatComposer", "Changed", "01 Creation", "mobile-plus-menu", "Private task under +; a mobile menu bounded by the visible viewport; selected lock chip and removal; inherited parent/project explanations on hover, focus, and tap; small-phone, short-viewport, and landscape layouts."], + ["Identity · AccessSelectIdentity", "Added", "02 Sharing", "search-people-and-agents", "Shared rendering of human pictures or initials and agent character avatars in task and project sharing results and selected values."], + ["SidebarProjects · SidebarStarredProjects · production variant · Projects", "Changed", "06 Full product pages", "private-projects-in-navigation", "Right-side locks in actual navigation and project rows, current and classic shells, mobile."], + + [ + "NewIssueDialog", + "Changed", + "01 Creation", + "open-by-default", + "Open and private creation; unresolved parent access; lookup failure and retry; inherited child; private/personal project; restored draft; submit failure; mobile/light.", + ], + [ + "IssueShareSheet", + "Added", + "02 Sharing", + "all-access-sources", + "All four grant sources; implicit roles; read-only; inherited and independent direct child grants; retained assignment grants; add/revoke; confirmations; agent cautions; loading/error/retry; long names; mobile/light.", + ], + [ + "IssuePrivacyActions", + "Added", + "03 Task actions", + "private-owner", + "Private/open owner; inherited parent/project restrictions; personal-project escape; access retry; reader/admin; public confirmation and failed save; mobile.", + ], + [ + "LockedIssueChip", + "Added", + "04 References and blockers", + "locked-chip-variants", + "Identifier, missing identifier, neutral unavailable state, and no link or quicklook.", + ], + [ + "IssueReferencePill", + "Changed", + "04 References and blockers", + "reference-pill-variants", + "Readable and locked references, mention and property variants, light theme.", + ], + [ + "MarkdownBody", + "Changed", + "04 References and blockers", + "markdown-mentions", + "Readable and unavailable links/bare mentions; pending lookup without premature navigation.", + ], + [ + "IssueBlockedNotice", + "Changed", + "04 References and blockers", + "mixed-blockers", + "Private blocker alone and mixed with readable blockers.", + ], + [ + "RemovableIssueReferencePill (relation-controls)", + "Changed", + "04 References and blockers", + "remove-blocker-confirmation", + "Desktop detach; mobile action menu without Visit task; both confirmation layouts; successful removal.", + ], + [ + "ProjectProperties", + "Changed", + "05 Projects", + "private-project-settings", + "Open/private/personal project; audience copy; access-loss confirmation; owner/reader/admin gates; full settings context; light/mobile.", + ], + [ + "ProjectAccessMembers", + "Added", + "05 Projects", + "member-list", + "Owner protection; reader; shared-agent caution; loading; directory/member/add/remove failures; successful add; mobile.", + ], + [ + "IssueDetail", + "Changed", + "06 Full product pages", + "owner-sharing-from-menu", + "Real page and task menu; sharing survives menu dismissal; owner/reader/admin; shared child with locked ancestor; mobile/light.", + ], + [ + "IssueProperties", + "Changed", + "06 Full product pages", + "shared-child-reader", + "Private parent remains a locked, non-interactive chip in task properties.", + ], + [ + "RelationNavigationList (TaskDetailRelationsPanel)", + "Changed", + "04 References and blockers", + "ancestor-navigation", + "Mixed readable and locked ancestors; also shown in the full task's Tasks tab.", + ], + [ + "DesignGuide", + "Changed", + "06 Full product pages", + "design-guide-locked-references", + "Actual design-guide page, scrolled to the new locked-reference section.", + ], +]; +function storyLink(group: string, story: string) { + return `./?path=/story/private-tasks-${group.toLowerCase().replaceAll(" ", "-")}--${story}`; +} +const journeys = [ + ["Owner · Keep child access independent", "owner-keeps-child-access-independent", "Add a direct child grant before removing an inherited source of access."], + ["Project owner · Remove a member", "owner-removes-project-only-task-access", "Remove project-only task access and inspect the refreshed task audience."], + ["Project owner · Open the project", "owner-opens-project-with-private-tasks", "Review the loss of project-only access before opening the project."], + ["Owner · Move out of a private parent", "owner-moves-child-then-reviews-publishing", "Use the actual task properties picker, then review publishing without reloading."], + ["Owner · Move out of a private project", "owner-moves-task-then-reviews-publishing", "Remove a private project through the actual task page and see publishing become available."], + [ + "CEO · Create a private task", + "ceo-creates-private-task", + "Choose Private task before saving a personal briefing.", + ], + ["CEO · Create a private task on mobile", "mobile-ceo-creates-private-task", "Choose privacy from the mobile Add menu, review the lock chip, and create the briefing."], + [ + "CEO · Share only a child", + "ceo-shares-only-child", + "Invite Morgan to research; the parent briefing and sibling compensation task remain outside the grant.", + ], + [ + "Owner · Revoke a grant", + "owner-revokes-an-explicit-grant", + "Review the warning, revoke Morgan's saved grant, and see the list update.", + ], + [ + "Owner · Assignment remains", + "current-assignment-still-grants-access", + "Remove a saved agent grant while the current assignment still supplies access.", + ], + [ + "Owner · Make public", + "owner-makes-task-public", + "Confirm disclosure; private children retain their restrictions.", + ], + [ + "Reader · Detach a blocker", + "reader-detaches-private-blocker", + "Remove a visible relationship without learning private content.", + ], + [ + "Project owner · Add a member", + "owner-adds-project-member", + "Find Sam in the company directory and grant project access.", + ], +]; +function Overview() { + return ( +
+
+
+

+ Private tasks · Review guide +

+

+ Every changed surface, in context +

+

+ This review covers all changed components and pages changed by + the private-task feature. + Each canvas uses the production component with fictional, isolated + API fixtures. +

+

+ Privacy flows down the task tree. Sharing a child grants access to + that child and its descendants. Ancestors and siblings need a + separate grant. +

+
+
+

Start with the user stories

+

+ These {journeys.length} journeys run their interactions automatically and leave + the resulting screen visible. Use the Storybook interactions panel + to inspect each named step, or reload a component story to explore + manually. +

+
+ {journeys.map(([name, id, description]) => ( + +

{name}

+

{description}

+
+ ))} +
+
+
+

+ Complete component inventory +

+
+ + + + + + + + + + {surfaces.map(([name, change, group, id, coverage]) => ( + + + + + + ))} + +
Production surfaceChangeReview coverage
+ + {name} + + + {change} + {coverage}
+
+
+
+

+ How to review +

+
    +
  1. + Walk the {journeys.length} user stories, then use the inventory to inspect + every component's individual states. +
  2. +
  3. + Use the theme and viewport toolbar for any canvas. Explicit + mobile/light stories preserve important review views. +
  4. +
  5. + Inspect the full task pages to verify menu integration, permission + gates, locked ancestry, and layout. +
  6. +
  7. + Reload a story to reset its in-memory changes. No story connects + to Gmail or changes a real company's data. +
  8. +
+

+ Server-only changes—authorization, search filtering, notification + delivery, run/workspace history, connector credentials, and + descendant propagation—do not add independent UI components. The + displayed responses model those outcomes; a Storybook interaction is + not evidence of backend security enforcement. +

+
+
+
+ ); +} +export default { + title: "Private tasks/00 Review guide", + parameters: { layout: "fullscreen", docs: { story: { inline: false } } }, + render: () => , +} satisfies Meta; +export const CoverageAndUserStories: StoryObj = {}; diff --git a/ui/storybook/stories/private-tasks/01-creation.stories.tsx b/ui/storybook/stories/private-tasks/01-creation.stories.tsx new file mode 100644 index 00000000000..ad44dd8ddf7 --- /dev/null +++ b/ui/storybook/stories/private-tasks/01-creation.stories.tsx @@ -0,0 +1,257 @@ +import { useEffect, useRef } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { PrivacyPage } from "./06-full-product-pages.stories"; +import { useDialog } from "@/context/DialogContext"; +import { Button } from "@/components/ui/button"; +import { + mobile, + choosePrivateTask, + privacyDecorator, + privacyParameters, + StoryFrame, +} from "./PrivacyStory"; + +function Creation({ + parent = false, + project = false, + draft = false, + taskTitle = "Prepare my board briefing", +}: { + parent?: boolean; + project?: boolean; + draft?: boolean; + taskTitle?: string; +}) { + const { openNewIssue } = useDialog(); + const opened = useRef(false); + const open = () => + openNewIssue({ + ...(draft + ? {} + : { + title: parent + ? "Research market benchmarks" + : taskTitle, + }), + ...(parent + ? { + parentId: "privacy-root", + parentIdentifier: "PAP-410", + parentTitle: taskTitle, + } + : {}), + ...(project ? { projectId: "project-private" } : {}), + }); + useEffect(() => { + open(); + }, [parent, project, draft, taskTitle]); + return ; +} +const meta = { + title: "Private tasks/01 Creation", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true, docs: { description: { component: "Route /PAP/issues with the new-task composer. Choose Private task from +; the lock chip keeps the selection visible. Hover, focus, or tap an inherited chip to see the parent task or private project that requires privacy. Mobile stories use the real shell and check that menu actions and composer controls stay inside the viewport." } } }, + args: { taskTitle: "Prepare my board briefing" }, + argTypes: { taskTitle: { control: "text" } }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const OpenByDefault: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await page.findByRole("button", { name: "Add to composer" }); + await waitFor(() => expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument()); + }, +}; +export const PrivacyInPlusMenu: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await expect(await page.findByTestId("composer-add-private")).toBeVisible(); + }, +}; +export const PrivateBeforeSaving: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await choosePrivateTask(page); + await expect(await page.findByRole("button", { name: "Remove private task" })).toBeVisible(); + }, +}; +export const RemovePrivateChoice: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await choosePrivateTask(page); + await userEvent.click(await page.findByRole("button", { name: "Remove private task" })); + await waitFor(() => expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument()); + }, +}; +export const ChildInheritsPrivacy: Story = { + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await expect(chip).toBeDisabled(); + await userEvent.hover(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("Subtask of private task Prepare my board briefing"); + }, +}; +export const MobilePrivateChoice: Story = { ...PrivateBeforeSaving, globals: mobile }; +async function expectInsideViewport(element: HTMLElement) { + await waitFor(() => { + const rect = element.getBoundingClientRect(); + const view = element.ownerDocument.defaultView!; + expect(rect.width).toBeGreaterThan(0); + expect(rect.left).toBeGreaterThanOrEqual(0); + expect(rect.top).toBeGreaterThanOrEqual(0); + expect(rect.right).toBeLessThanOrEqual(view.innerWidth); + expect(rect.bottom).toBeLessThanOrEqual(view.innerHeight); + }); +} +async function openMobilePlusMenu(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + const menu = await page.findByRole("dialog", { name: "Add" }); + await expectInsideViewport(menu); + await expectInsideViewport(within(menu).getByRole("button", { name: "Close Add menu" })); + for (const action of within(menu).getAllByRole("button")) await expectInsideViewport(action); + return page; +} +export const MobilePlusMenu: Story = { + globals: mobile, + parameters: { docs: { description: { story: "390 × 844. The Add menu stays below the safe-area inset; its header, close action, and every option remain on screen." } } }, + play: async ({ canvasElement }) => { await openMobilePlusMenu(canvasElement); }, +}; +export const SmallPhonePlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "smallPhone", isRotated: false } }, + parameters: { viewport: { options: { smallPhone: { name: "Small phone", styles: { width: "320px", height: "568px" } } } }, docs: { description: { story: "320 × 568. All menu actions remain reachable on the smallest supported phone layout." } } }, +}; +export const ShortViewportPlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "shortPhone", isRotated: false } }, + parameters: { viewport: { options: { shortPhone: { name: "Short phone viewport", styles: { width: "390px", height: "360px" } } } }, docs: { description: { story: "390 × 360. A short visible viewport exercises the menu's height limit and internal scrolling. This is a layout check, not a simulation of a native software keyboard." } } }, +}; +export const LandscapePlusMenu: Story = { + ...MobilePlusMenu, + globals: { viewport: { value: "landscapePhone", isRotated: false } }, + parameters: { viewport: { options: { landscapePhone: { name: "Landscape phone", styles: { width: "667px", height: "375px" } } } }, docs: { description: { story: "667 × 375. The same mobile menu fits when the phone is rotated." } } }, +}; +export const PrivateProject: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await expect(chip).toBeDisabled(); + await userEvent.hover(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("In private project Executive planning"); + }, +}; +export const PersonalProject: Story = { + parameters: { privacy: { personal: true } }, + render: () => , +}; +export const RestoredPrivateDraft: Story = { + parameters: { privacy: { draft: true } }, + render: () => , +}; +export const CreationFailure: Story = { + parameters: { privacy: { failure: "create", draft: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Create task" }), + ); + await expect( + await page.findByText("The request could not be completed. Try again."), + ).toBeVisible(); + }, +}; +export const MobilePrivateChild: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await userEvent.click(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("Subtask of private task Prepare my board briefing"); + }, +}; +export const MobilePrivateProject: Story = { + ...PrivateProject, + globals: mobile, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const chip = await page.findByRole("button", { name: "Private task" }); + await userEvent.click(chip.parentElement!); + await expect(await page.findByRole("tooltip")).toHaveTextContent("In private project Executive planning"); + }, +}; +export const SmallPhoneComposer: Story = { + globals: SmallPhonePlusMenu.globals, + parameters: SmallPhonePlusMenu.parameters, + play: async ({ canvasElement }) => { + const page = await openMobilePlusMenu(canvasElement); + await userEvent.click(page.getByTestId("composer-add-private")); + await userEvent.type(page.getByRole("textbox", { name: "Task title" }), " with a longer title"); + await expectInsideViewport(page.getByTestId("composer-private-chip")); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + await userEvent.click(page.getByRole("button", { name: "Add to composer" })); + await userEvent.click(page.getByTestId("composer-add-plan")); + await expectInsideViewport(page.getByTestId("task-chat-composer-mode")); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + await userEvent.click(page.getByRole("button", { name: "Remove private task" })); + await expect(page.queryByTestId("composer-private-chip")).not.toBeInTheDocument(); + await expect(page.getByRole("textbox", { name: "Task title" })).toHaveValue("Prepare my board briefing with a longer title"); + await choosePrivateTask(page); + await expectInsideViewport(page.getByTestId("composer-private-chip")); + await waitFor(() => expect(page.getByTestId("task-chat-composer-mode").getBoundingClientRect().right) + .toBeLessThanOrEqual(page.getByTestId("task-chat-composer-selection").getBoundingClientRect().left)); + await userEvent.click(page.getByRole("button", { name: "Select assignee" })); + await expectInsideViewport(await page.findByRole("dialog", { name: "Select assignee" })); + await userEvent.click(page.getByRole("button", { name: "Close picker" })); + await userEvent.click(page.getByRole("button", { name: "Select model and effort" })); + await expectInsideViewport(await page.findByRole("dialog", { name: "Select model and effort" })); + await userEvent.click(page.getByRole("button", { name: "Close picker" })); + await expectInsideViewport(page.getByRole("button", { name: "Create task" })); + }, +}; +export const LightPrivateDraft: Story = { + globals: { theme: "light" }, + parameters: { privacy: { draft: true } }, + render: () => , +}; + +export const ParentAccessLoading: Story = { + parameters: { privacy: { loading: "parent" } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByText("Checking parent access…")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeDisabled(); + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await expect(page.queryByTestId("composer-add-private")).not.toBeInTheDocument(); + }, +}; +export const ParentAccessUnavailable: Story = { + parameters: { privacy: { failure: "parent" } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await expect(await page.findByText("Couldn't check parent access.")).toBeVisible(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeDisabled(); + }, +}; +export const RetryParentAccess: Story = { + parameters: { privacy: { failure: "parent", retryOnce: true } }, + render: (args) => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry" })); + await expect(await page.findByRole("button", { name: "Private task" })).toBeDisabled(); + await expect(await page.findByRole("button", { name: "Create sub-task" })).toBeEnabled(); + }, +}; +export const MobileParentAccessUnavailable: Story = { ...ParentAccessUnavailable, globals: mobile }; diff --git a/ui/storybook/stories/private-tasks/02-sharing.stories.tsx b/ui/storybook/stories/private-tasks/02-sharing.stories.tsx new file mode 100644 index 00000000000..c20a70273d2 --- /dev/null +++ b/ui/storybook/stories/private-tasks/02-sharing.stories.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { IssueShareSheet } from "@/components/IssueShareSheet"; +import { Button } from "@/components/ui/button"; +import { privacyCompanyId } from "../../fixtures/privateTasks"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function Sharing({ + add = "", + implicit = false, + child = false, +}: { + add?: string; + implicit?: boolean; + child?: boolean; +}) { + const state = usePrivacyStory(); + const [open, setOpen] = useState(true); + return ( + + + + + ); +} +const meta = { + title: "Private tasks/02 Sharing", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task menu → Share → Add someone. Inspect avatars in search and selected values, inherited grants, reader access, confirmations, empty/loading/error states and mobile. The sticky-access paragraph is removed." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const AllAccessSources: Story = { parameters: { privacy: { taskProject: true } } }; +export const OwnerAndCurrentAssignee: Story = { + render: () => , +}; +export const OnlyYou: Story = { parameters: { privacy: { grants: "empty" } } }; +export const ReadOnlyMember: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await page.findByText("Morgan Reed"); + await expect( + page.queryByRole("button", { name: "Add someone" }), + ).not.toBeInTheDocument(); + await expect( + page.queryByRole("button", { name: "Revoke" }), + ).not.toBeInTheDocument(); + }, +}; +export const InheritedAccess: Story = { + parameters: { privacy: { grants: "inherited" } }, + render: () => , +}; +export const StickyAssignmentAfterUnassignment: Story = { + parameters: { privacy: { grants: "assignment" } }, +}; +export const RemoveCurrentAssigneesSavedGrant: Story = { + parameters: { privacy: { grants: "assignment" } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Remove saved grant" }), + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "current assignment", + ); + }, +}; +export const RevokeConfirmation: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "descendants", + ); + }, +}; +export const RevokeFailure: Story = { + parameters: { privacy: { failure: "revoke" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await userEvent.click( + await page.findByRole("button", { name: "Remove grant" }), + ); + await expect(await page.findByRole("alertdialog")).toBeVisible(); + }, +}; +export const ChoosePersonOrAgent: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const ShareChildWithPerson: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const DedicatedAgent: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const SharedAgentCaution: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , + play: async ({ canvasElement }) => { + await expect( + await within(canvasElement.ownerDocument.body).findByRole("note", { + name: "Shared agent caution", + }), + ).toHaveTextContent("residual private context"); + }, +}; +export const LegacyAgentCaution: Story = { + parameters: { privacy: { grants: "empty" } }, + render: () => , +}; +export const AddFailure: Story = { + parameters: { privacy: { grants: "empty", failure: "add" } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.keyboard("{Escape}"); + await userEvent.click(await page.findByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Couldn't add access")).toBeVisible(), + ); + }, +}; +export const Loading: Story = { + parameters: { privacy: { loading: "grants" } }, +}; +export const LoadFailure: Story = { + parameters: { privacy: { failure: "grants" } }, +}; +export const RetryRecovers: Story = { + parameters: { privacy: { failure: "grants", retryOnce: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry" })); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + }, +}; +export const LongNamesMobile: Story = { + globals: mobile, + parameters: { privacy: { grants: "long" } }, +}; +export const MobileSharing: Story = { globals: mobile }; +export const LightSharing: Story = { globals: { theme: "light" } }; + +export const SearchPeopleAndAgents: Story = { + render: () => , + parameters: { privacy: { grants: "empty" }, docs: { description: { story: "Task sharing search: human avatar or initials and agent character avatars stay visible in results and after selection." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await expect(await page.findByRole("option", { name: "Executive assistant" })).toBeVisible(); + }, +}; +export const MobileSearchPeopleAndAgents: Story = { ...SearchPeopleAndAgents, globals: mobile }; + +export const AddDirectGrantAlongsideInheritedAccess: Story = { + parameters: { privacy: { grants: "inherited" }, docs: { description: { story: "Give Morgan an independent grant to this child before removing parent access. The dropdown includes people whose current access is inherited." } } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await userEvent.click(await page.findByRole("option", { name: "Morgan Reed" })); + await userEvent.click(page.getByRole("button", { name: "Add" })); + await page.findByText("Access granted"); + await waitFor(() => expect(page.getAllByText("Morgan Reed")).toHaveLength(2)); + await expect((await page.findAllByRole("button", { name: "Revoke" }))[0]!).toBeEnabled(); + }, +}; +export const MobileIndependentChildGrant: Story = { ...AddDirectGrantAlongsideInheritedAccess, globals: mobile }; + +export const CurrentAssigneeAlreadyHasSavedAccess: Story = { + parameters: { privacy: { grants: "assignment" }, docs: { description: { story: "A direct assignment grant is already saved on this task. The API deduplicates subjects, so the add picker does not promise a second independent grant for the same assignee." } } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") await userEvent.click(trigger); + await page.findByRole("option", { name: "Morgan Reed" }); + await expect(page.queryByRole("option", { name: "Executive assistant" })).not.toBeInTheDocument(); + }, +}; diff --git a/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx b/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx new file mode 100644 index 00000000000..cbeb5fd9750 --- /dev/null +++ b/ui/storybook/stories/private-tasks/03-task-actions.stories.tsx @@ -0,0 +1,152 @@ +import { useQuery } from "@tanstack/react-query"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { IssuePrivacyActions } from "@/components/IssuePrivacyActions"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { privacyCompanyId } from "../../fixtures/privateTasks"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function PrivacyActionsStory({ child = false }: { child?: boolean }) { + const state = usePrivacyStory(); + const { data: issue } = useQuery({ + queryKey: queryKeys.issues.detail(child ? "privacy-child" : "privacy-root"), + queryFn: () => issuesApi.get(child ? "privacy-child" : "privacy-root"), + }); + return ( + + {issue && ( +
+

+ {issue.identifier} ·{" "} + {issue.visibility === "private" ? "Private" : "Open to company"} +

+ {}} + > + {(items) => ( +
+ {items} +
+ )} +
+
+ )} +
+ ); +} +const meta = { + title: "Private tasks/03 Task actions", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task menu privacy actions, permission gates and disclosure confirmation." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const PrivateOwner: Story = {}; +export const OpenOwner: Story = { + parameters: { privacy: { visibility: "open" } }, +}; +export const SharedReader: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Make public" }), + ).toBeDisabled(); + }, +}; +export const CompanyAdmin: Story = { + parameters: { privacy: { role: "admin" } }, +}; +export const MakePublicConfirmation: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "Existing private subtasks keep their privacy", + ); + }, +}; +export const MakePublicFailure: Story = { + parameters: { privacy: { failure: "visibility" } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Make public" }), + ); + await waitFor(() => + expect(page.getByText("Couldn't change visibility")).toBeVisible(), + ); + await expect(dialog).toBeVisible(); + }, +}; +export const MobileConfirmation: Story = { + ...MakePublicConfirmation, + globals: mobile, +}; + +export const InheritedPrivateParent: Story = { + render: () => , + parameters: { docs: { description: { story: "Make public is disabled while a private parent controls this task. Hover or focus explains that the task must be moved first." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const button = await page.findByRole("button", { name: "Make public" }); + await expect(button).toBeDisabled(); + await userEvent.hover(button.parentElement!); + await waitFor(async () => expect(await page.findByRole("tooltip")).toHaveTextContent("private parent")); + }, +}; +export const PrivateProjectRestriction: Story = { + parameters: { privacy: { taskProject: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + const button = await page.findByRole("button", { name: "Make public" }); + await waitFor(() => expect(button).toBeDisabled()); + await userEvent.hover(button.parentElement!); + await waitFor(async () => expect(await page.findByRole("tooltip")).toHaveTextContent("private project")); + }, +}; +export const PersonalProjectCanMakePublic: Story = { + parameters: { privacy: { taskProject: true, personal: true } }, + play: async ({ canvasElement }) => { + await waitFor(() => expect(within(canvasElement).getByRole("button", { name: "Make public" })).toBeEnabled()); + }, +}; +export const RetryInheritedAccessCheck: Story = { + render: () => , + parameters: { privacy: { failure: "parent", retryOnce: true } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "Retry access check" })); + await waitFor(() => expect(page.queryByRole("button", { name: "Retry access check" })).not.toBeInTheDocument()); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + }, +}; diff --git a/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx b/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx new file mode 100644 index 00000000000..22a618518d4 --- /dev/null +++ b/ui/storybook/stories/private-tasks/04-references-and-blockers.stories.tsx @@ -0,0 +1,219 @@ +import type { IssueRelationIssueSummary } from "@paperclipai/shared"; +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { RelationNavigationList } from "@/components/task-detail/TaskDetailRelationsPanel"; +import { LockedIssueChip } from "@/components/LockedIssueChip"; +import { IssueReferencePill } from "@/components/IssueReferencePill"; +import { IssueBlockedNotice } from "@/components/IssueBlockedNotice"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { RemovableIssueReferencePill } from "@/components/issue-properties/relation-controls"; +import { + mobile, + privacyDecorator, + privacyParameters, + StoryFrame, +} from "./PrivacyStory"; + +const locked = { + id: "privacy-root", + identifier: "PAP-410", + locked: true as const, +}; +// Runtime redaction deliberately omits private title/status fields. +const lockedBlocker = locked as unknown as IssueRelationIssueSummary; +const readable: IssueRelationIssueSummary = { + id: "privacy-child", + identifier: "PAP-411", + title: "Research market benchmarks", + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, +}; +function References({ + mode = "chips", + mobileControl = false, +}: { + mode?: "chips" | "pills" | "markdown" | "blocker" | "mixed" | "remove"; + mobileControl?: boolean; +}) { + const [removed, setRemoved] = useState(false); + return ( + +
+ {mode === "chips" && ( + <> +
+ +
+
+ +
+
+ +
+ + )} + {mode === "pills" && ( + <> +

+ Readable +

+

+ Private +

+

+ Property variant{" "} + +

+ + )} + {mode === "markdown" && ( + + { + "Read the public research in [PAP-499](/PAP/issues/PAP-499). The original briefing [PAP-498](/PAP/issues/PAP-498) is unavailable to this reader.\n\nBare references are also checked: PAP-499 and PAP-498." + } + + )} + {(mode === "blocker" || mode === "mixed") && ( + + )} + {mode === "remove" && + (removed ? ( +

+ Blocker removed. The private task itself is unchanged. +

+ ) : ( + setRemoved(true)} + /> + ))} +
+
+ ); +} +const meta = { + title: "Private tasks/04 References and blockers", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Task references, parent navigation and blockers redact unreadable task content. Inspect all existing reference variants and mobile." } } }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const LockedChipVariants: Story = { + play: async ({ canvasElement }) => { + await expect( + within(canvasElement).queryByRole("link"), + ).not.toBeInTheDocument(); + }, +}; +export const ReferencePillVariants: Story = { + render: () => , +}; +export const MarkdownMentions: Story = { + render: () => , +}; +export const PendingMention: Story = { + parameters: { privacy: { loading: "mention" } }, + render: () => , +}; +export const PrivateBlocker: Story = { + render: () => , +}; +export const MixedBlockers: Story = { + render: () => , +}; +export const RemovablePrivateBlocker: Story = { + render: () => , +}; +export const RemoveBlockerConfirmation: Story = { + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Remove PAP-410 as blocker" }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, +}; +export const MobileBlockerActions: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Actions for blocker PAP-410" }), + ); + await expect( + await page.findByRole("menuitem", { name: "Remove blocker" }), + ).toBeVisible(); + await expect( + page.queryByRole("menuitem", { name: "Visit task" }), + ).not.toBeInTheDocument(); + }, +}; +export const MobileBlockerConfirmation: Story = { + globals: mobile, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: "Actions for blocker PAP-410" }), + ); + await userEvent.click( + await page.findByRole("menuitem", { name: "Remove blocker" }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, +}; +export const LightReferences: Story = { + globals: { theme: "light" }, + render: () => , +}; + +export const AncestorNavigation: Story = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect(page.getByTestId("locked-issue-chip")).toHaveTextContent( + "PAP-410", + ); + await expect(page.getAllByRole("link")).toHaveLength(1); + }, +}; diff --git a/ui/storybook/stories/private-tasks/05-projects.stories.tsx b/ui/storybook/stories/private-tasks/05-projects.stories.tsx new file mode 100644 index 00000000000..85d32a36829 --- /dev/null +++ b/ui/storybook/stories/private-tasks/05-projects.stories.tsx @@ -0,0 +1,244 @@ +import type { Project } from "@paperclipai/shared"; +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { ProjectProperties } from "@/components/ProjectProperties"; +import { IssueShareSheet } from "@/components/IssueShareSheet"; +import { Button } from "@/components/ui/button"; +import { ProjectAccessMembers } from "@/components/ProjectAccessMembers"; +import { projectsApi } from "@/api/projects"; +import { + mobile, + openProjectMemberPicker, + privacyDecorator, + privacyParameters, + StoryFrame, + usePrivacyStory, +} from "./PrivacyStory"; + +function ProjectStory({ + settings = false, + open = false, + projectName = "Executive planning", +}: { + settings?: boolean; + open?: boolean; + projectName?: string; +}) { + const state = usePrivacyStory(); + const [project, setProject] = useState(() => ({ + ...state.projects[0]!, + name: projectName, + visibility: open ? ("open" as const) : ("private" as const), + })); + useEffect(() => setProject(value => ({ ...value, name: projectName })), [projectName]); + return ( + + {settings ? ( + { + const updated = await projectsApi.update( + project.id, + data, + project.companyId, + ); + setProject(updated); + }} + /> + ) : ( + + )} + + ); +} +const openMembers = async (canvasElement: HTMLElement) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("button", { name: /^(Manage|View) access$/ }), + ); + await page.findByRole("dialog", { name: "Private project access" }); + return page; +}; +const meta = { + title: "Private tasks/05 Projects", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, docs: { description: { component: "Project settings → Manage access. Human and agent identities appear in the search dropdown and selected value. Owner, reader, error, loading and mobile states use production components." } } }, + args: { projectName: "Executive planning" }, + argTypes: { projectName: { control: "text" } }, + render: (args) => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const PrivateProjectSettings: Story = { + render: (args) => , +}; +export const OpenProjectSettings: Story = { + render: (args) => , +}; +export const PersonalProjectSettings: Story = { + parameters: { privacy: { personal: true } }, + render: (args) => , +}; +export const ReaderProjectSettings: Story = { + parameters: { privacy: { role: "reader" } }, + render: (args) => , +}; +export const AdminProjectSettings: Story = { + parameters: { privacy: { role: "admin" } }, + render: (args) => , +}; +export const MemberList: Story = { + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + await expect( + page.queryByRole("button", { name: "Remove Avery Chen" }), + ).not.toBeInTheDocument(); + }, +}; +export const ReaderMemberList: Story = { + parameters: { privacy: { role: "reader" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByRole("button", { name: "Remove Morgan Reed" }), + ).toBeDisabled(); + await expect(page.getByRole("button", { name: "Add" })).toBeDisabled(); + }, +}; +export const SharedAgentSelected: Story = { + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: "Research team agent" }), + ); + await expect( + await page.findByText(/This agent can retain private project context/), + ).toBeVisible(); + }, +}; +export const LoadingMembers: Story = { + parameters: { privacy: { loading: "members" } }, + play: async ({ canvasElement }) => { + await openMembers(canvasElement); + }, +}; +export const MemberLoadFailure: Story = { + parameters: { privacy: { failure: "members" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByText("Couldn't load project access members."), + ).toBeVisible(); + }, +}; +export const DirectoryFailure: Story = { + parameters: { privacy: { failure: "directory" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await expect( + await page.findByText("Couldn't load the company access directory."), + ).toBeVisible(); + }, +}; +export const RemoveFailure: Story = { + parameters: { privacy: { failure: "project-remove" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await userEvent.click( + await page.findByRole("button", { name: "Remove Morgan Reed" }), + ); + await waitFor(() => + expect(page.getByText("Couldn't remove project access")).toBeVisible(), + ); + }, +}; +export const AddFailure: Story = { + parameters: { privacy: { failure: "project-add" } }, + play: async ({ canvasElement }) => { + const page = await openMembers(canvasElement); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: /Sam Rivera/ }), + ); + await userEvent.click(page.getByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Couldn't add project access")).toBeVisible(), + ); + }, +}; +export const MobileMembers: Story = { + globals: mobile, + play: async ({ canvasElement }) => { + await openMembers(canvasElement); + }, +}; +export const LightProjectSettings: Story = { + globals: { theme: "light" }, + render: (args) => , +}; + +export const OpenProjectConfirmation: Story = { + render: (args) => , + parameters: { docs: { description: { story: "Before opening a private project, the owner sees that project-only access to private tasks will end and can share tasks directly first." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("will lose access"); + }, +}; +export const OpenProjectAudienceCopy: Story = { + ...OpenProjectConfirmation, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Open to company" })); + await expect(await page.findByText(/Everyone in the company can discover this project/)).toBeVisible(); + await expect(page.queryByRole("button", { name: "Manage access" })).not.toBeInTheDocument(); + }, +}; +export const MobileOpenProjectConfirmation: Story = { ...OpenProjectConfirmation, globals: mobile }; +function ProjectAudienceJourney() { + const state = usePrivacyStory(); + const [open, setOpen] = useState(false); + return + + + + ; +} +export const MembershipUpdatesTaskAudience: Story = { + render: () => , + parameters: { privacy: { taskProject: true, grants: "empty" }, docs: { description: { story: "Open the task audience, remove Morgan from the project, then reopen that cached share sheet. Project-only access disappears without reloading the page." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("button", { name: "View task audience" })); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + await userEvent.keyboard("{Escape}"); + await userEvent.click(page.getByRole("button", { name: "Manage access" })); + await userEvent.click(await page.findByRole("button", { name: "Remove Morgan Reed" })); + await page.findByText("Project access removed"); + await userEvent.keyboard("{Escape}"); + await userEvent.click(page.getByRole("button", { name: "View task audience" })); + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + await waitFor(() => expect(within(dialog).queryByText("Morgan Reed")).not.toBeInTheDocument()); + }, +}; diff --git a/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx b/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx new file mode 100644 index 00000000000..c0550d77410 --- /dev/null +++ b/ui/storybook/stories/private-tasks/06-full-product-pages.stories.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import { Route, Routes, useNavigate } from "@/lib/router"; +import { Layout } from "@/components/Layout"; +import { IssueDetail } from "@/pages/IssueDetail"; +import { Issues } from "@/pages/Issues"; +import { ProjectDetail } from "@/pages/ProjectDetail"; +import { Projects } from "@/pages/Projects"; +import { DesignGuide } from "@/pages/DesignGuide"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { mobile, privacyDecorator, privacyParameters } from "./PrivacyStory"; + +export function PrivacyPage({ + child = false, + guide = false, + projects = false, + tasks = false, + projectSettings = false, +}: { + child?: boolean; + guide?: boolean; + projects?: boolean; + tasks?: boolean; + projectSettings?: boolean; +}) { + const navigate = useNavigate(); + const [ready, setReady] = useState(false); + useEffect(() => { + navigate( + projectSettings ? "/PAP/projects/project-private/configuration" : tasks ? "/PAP/issues" : projects ? "/PAP/projects" : guide + ? "/PAP/design-guide" + : `/PAP/issues/${child ? "PAP-411" : "PAP-410"}`, + { replace: true }, + ); + setReady(true); + }, [navigate, child, guide, projects, tasks, projectSettings]); + if (!ready) return null; + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + + + + ); +} +const meta = { + title: "Private tasks/06 Full product pages", + excludeStories: ["PrivacyPage"], + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true }, + render: () => , +} satisfies Meta; +export default meta; +type Story = StoryObj; +async function openTaskMenu(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + await page.findByRole( + "button", + { name: "More task actions" }, + { timeout: 15000 }, + ); + // Navigation focus and async page queries can replace or dismiss the first + // trigger. Reacquire it, and only open when closed; never toggle an open menu. + await waitFor( + async () => { + const trigger = page.getByRole("button", { name: "More task actions" }); + if (trigger.getAttribute("aria-expanded") !== "true") { + await userEvent.click(trigger); + } + await expect(page.getByRole("button", { name: "Share…" })).toBeVisible(); + }, + { timeout: 15000 }, + ); + return page; +} +export const OwnerTask: Story = {}; +export const OwnerTaskMenu: Story = { + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeEnabled(); + }, +}; +export const OwnerSharingFromMenu: Story = { + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await userEvent.click(await page.findByRole("button", { name: "Share…" })); + await expect( + await page.findByRole("dialog", { name: "Who can access this task" }), + ).toBeVisible(); + }, +}; +export const SharedChildReader: Story = { + play: async ({ canvasElement }) => { + const page = within(canvasElement); + await expect( + await page.findByTestId("locked-issue-chip", {}, { timeout: 15000 }), + ).toHaveTextContent("PAP-410"); + await expect( + page.queryByRole("link", { name: /Task PAP-410/ }), + ).not.toBeInTheDocument(); + await expect( + page.queryByText("Prepare my board briefing"), + ).not.toBeInTheDocument(); + }, + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , +}; +export const SharedReaderMenu: Story = { + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect( + await page.findByRole("button", { name: "Share…" }), + ).toBeDisabled(); + }, +}; +export const AdminTask: Story = { parameters: { privacy: { role: "admin" } } }; +export const MobileSharedChild: Story = { + globals: mobile, + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , +}; +export const LightTask: Story = { globals: { theme: "light" } }; +export const DesignGuideLockedReferences: Story = { + render: () => , + play: async ({ canvasElement }) => { + const section = await within(canvasElement).findByText( + "LockedIssueChip", + {}, + { timeout: 15000 }, + ); + section.scrollIntoView({ block: "center" }); + await expect(section).toBeVisible(); + }, +}; + +export const SharedChildTasksPanel: Story = { + parameters: { privacy: { role: "reader", childOnly: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click( + await page.findByRole("tab", { name: "Tasks" }, { timeout: 15000 }), + ); + const ancestors = await page.findByRole("list", { + name: "Ancestor tasks, root to parent", + }); + await expect( + within(ancestors).getByTestId("locked-issue-chip"), + ).toHaveTextContent("PAP-410"); + await expect(within(ancestors).queryByRole("link")).not.toBeInTheDocument(); + }, +}; + +export const ClassicSharedChild: Story = { + parameters: { privacy: { role: "reader", childOnly: true, classic: true } }, + render: () => , + play: async ({ canvasElement }) => { + const page = within(canvasElement); + const chips = await page.findAllByTestId( + "locked-issue-chip", + {}, + { timeout: 15000 }, + ); + await expect(chips.length).toBeGreaterThanOrEqual(1); + await expect( + page.queryByRole("link", { name: /PAP-410/ }), + ).not.toBeInTheDocument(); + }, +}; + +export const PrivateProjectsInNavigation: Story = { parameters: { docs: { description: { story: "Route /PAP/projects. Private project locks sit on the right in the list and starred navigation." } } }, render: () => }; +export const ClassicPrivateProjects: Story = { parameters: { privacy: { classic: true } }, render: () => }; +export const MobilePrivateProjects: Story = { globals: mobile, render: () => }; + +export const OwnerInheritedTaskMenu: Story = { + render: () => , + parameters: { docs: { description: { story: "Actual /PAP/issues/PAP-411 page: sharing remains available, while Make public explains the private parent restriction." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Share…" })).toBeEnabled(); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + }, +}; +export const MobileInheritedTaskMenu: Story = { ...OwnerInheritedTaskMenu, globals: mobile }; + +export const ProjectVisibilityThroughAutosave: Story = { + render: () => , + parameters: { docs: { description: { story: "Actual project Configuration page. Open the project through its field-autosave control and review the private-task access consequence before saving." } } }, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" }, { timeout: 15000 })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("will lose access"); + }, +}; +export const ProjectVisibilityAutosaveCompletes: Story = { + ...ProjectVisibilityThroughAutosave, + play: async ({ canvasElement }) => { + const page = within(canvasElement.ownerDocument.body); + await userEvent.click(await page.findByRole("switch", { name: "Private project" }, { timeout: 15000 })); + const dialog = await page.findByRole("alertdialog"); + await userEvent.click(within(dialog).getByRole("button", { name: "Open to company" })); + await expect(await page.findByText(/Everyone in the company can discover this project/)).toBeVisible(); + }, +}; +export const MobileProjectVisibilityThroughAutosave: Story = { ...ProjectVisibilityThroughAutosave, globals: mobile }; + +export const OwnerMovesOutOfPrivateParent: Story = { + render: () => , + parameters: { docs: { description: { story: "The owner removes a private parent through the production properties picker, then reviews Make public on the same task. Privacy hints refresh without reloading the page." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + await userEvent.click(page.getByRole("button", { name: "More task actions" })); + await userEvent.click(await page.findByRole("button", { name: "Edit parent" })); + await userEvent.click(await page.findByRole("button", { name: "No parent" })); + await openTaskMenu(canvasElement); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("Existing private subtasks keep their privacy"); + }, +}; +export const OwnerMovesOutOfPrivateProject: Story = { + render: () => , + parameters: { privacy: { taskProject: true }, docs: { description: { story: "The owner removes the private project through the production properties picker. The task keeps its private visibility, and Make public becomes available without a page reload." } } }, + play: async ({ canvasElement }) => { + const page = await openTaskMenu(canvasElement); + await expect(page.getByRole("button", { name: "Make public" })).toBeDisabled(); + await userEvent.click(page.getByRole("button", { name: "More task actions" })); + await userEvent.click(await page.findByRole("button", { name: "Executive planning" })); + await userEvent.click(await page.findByRole("button", { name: "No project" })); + await openTaskMenu(canvasElement); + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent("Make this task public?"); + }, +}; diff --git a/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx b/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx new file mode 100644 index 00000000000..746661637fc --- /dev/null +++ b/ui/storybook/stories/private-tasks/07-user-stories.stories.tsx @@ -0,0 +1,210 @@ +import { OwnerMovesOutOfPrivateParent, OwnerMovesOutOfPrivateProject } from "./06-full-product-pages.stories"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within, waitFor } from "storybook/test"; +import creation from "./01-creation.stories"; +import sharing, { + ShareChildWithPerson, + RemoveCurrentAssigneesSavedGrant, + AddDirectGrantAlongsideInheritedAccess, +} from "./02-sharing.stories"; +import actions from "./03-task-actions.stories"; +import { RemovablePrivateBlocker } from "./04-references-and-blockers.stories"; +import projects, { MembershipUpdatesTaskAudience, OpenProjectAudienceCopy } from "./05-projects.stories"; +import { + openProjectMemberPicker, + choosePrivateTask, + privacyDecorator, + privacyParameters, + mobile, +} from "./PrivacyStory"; + +// Reuse the same real-component canvases. Each play is a complete user journey, +// while the component stories deliberately stop at individual review states. +const meta = { + title: "Private tasks/07 User stories", + decorators: [privacyDecorator], + parameters: { ...privacyParameters, waitForViewport: true }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const CeoCreatesPrivateTask: Story = { + name: "CEO · Create a private task", + render: creation.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Choose privacy before saving", async () => { + await choosePrivateTask(page); + await expect(await page.findByRole("button", { name: "Remove private task" })).toBeVisible(); + }); + await step("Create the private briefing", async () => { + await userEvent.click(page.getByRole("button", { name: "Create task" })); + await waitFor(() => expect(page.getByText("Created PAP-414")).toBeVisible()); + await expect( + page.queryByTestId("composer-private-chip"), + ).not.toBeInTheDocument(); + }); + }, +}; +export const MobileCeoCreatesPrivateTask: Story = { + ...CeoCreatesPrivateTask, + name: "CEO · Create a private task on mobile", + globals: mobile, + parameters: { docs: { description: { story: "Use the actual new-task composer in the mobile shell: choose Private task from +, review the lock chip, and save the private briefing." } } }, +}; +export const CeoSharesOnlyChild: Story = { + name: "CEO · Share only a child", + render: ShareChildWithPerson.render, + parameters: { privacy: { grants: "empty" } }, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Review the selected person", async () => { + const dialog = await page.findByRole("dialog", { name: "Who can access this task" }); + await waitFor(() => expect(dialog).toHaveTextContent("Morgan Reed")); + }); + await step("Share the research child with Morgan", async () => { + await userEvent.keyboard("{Escape}"); + await userEvent.click(await page.findByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Access granted")).toBeVisible(), + ); + await expect(await page.findByText("Morgan Reed")).toBeVisible(); + }); + }, +}; +export const OwnerRevokesAnExplicitGrant: Story = { + name: "Owner · Revoke a saved grant", + render: sharing.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Inspect the grant removal explanation", async () => { + await userEvent.click( + (await page.findAllByRole("button", { name: "Revoke" }))[0]!, + ); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "another grant", + ); + }); + await step("Confirm removal", async () => { + await userEvent.click(page.getByRole("button", { name: "Remove grant" })); + await waitFor(() => + expect(page.getByText("Access revoked")).toBeVisible(), + ); + await expect(page.queryByText("Morgan Reed")).not.toBeInTheDocument(); + }); + }, +}; +export const CurrentAssignmentStillGrantsAccess: Story = { + name: "Owner · Remove a grant while assignment remains", + render: RemoveCurrentAssigneesSavedGrant.render, + parameters: { privacy: { grants: "assignment" } }, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Remove the assignee's saved grant", async () => { + await userEvent.click( + await page.findByRole("button", { name: "Remove saved grant" }), + ); + await userEvent.click( + await page.findByRole("button", { name: "Remove grant" }), + ); + await page.findByText("Access revoked"); + }); + await step( + "Current assignment remains visible as an independent reason", + async () => { + await expect( + await page.findByText("Executive assistant"), + ).toBeVisible(); + await expect(page.getByText("Current assignee")).toBeVisible(); + }, + ); + }, +}; +export const OwnerMakesTaskPublic: Story = { + name: "Owner · Make a task public", + render: actions.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Review disclosure before changing the audience", async () => { + await waitFor(() => expect(page.getByRole("button", { name: "Make public" })).toBeEnabled()); + await userEvent.click(page.getByRole("button", { name: "Make public" })); + await expect(await page.findByRole("alertdialog")).toHaveTextContent( + "Existing private subtasks keep their privacy", + ); + }); + await step("Confirm company-wide access", async () => { + const dialog = page.getByRole("alertdialog"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Make public" }), + ); + await expect( + await page.findByRole("button", { name: "Make private" }), + ).toBeEnabled(); + }); + }, +}; +export const ReaderDetachesPrivateBlocker: Story = { + name: "Reader · Detach a private blocker", + render: RemovablePrivateBlocker.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step( + "Remove a relationship without opening the private task", + async () => { + await userEvent.click( + await page.findByRole("button", { + name: "Remove PAP-410 as blocker", + }), + ); + await expect(await page.findByRole("dialog")).toHaveTextContent( + "Remove PAP-410 as a blocker", + ); + }, + ); + await step("Confirm the relationship change", async () => { + await userEvent.click( + page.getByRole("button", { name: "Remove blocker" }), + ); + await expect(await page.findByRole("status")).toHaveTextContent( + "Blocker removed", + ); + }); + }, +}; +export const OwnerAddsProjectMember: Story = { + name: "Project owner · Add a member", + render: projects.render, + play: async ({ canvasElement, step }) => { + const page = within(canvasElement.ownerDocument.body); + await step("Choose a company member", async () => { + await userEvent.click( + await page.findByRole("button", { name: "Manage access" }), + ); + await openProjectMemberPicker(canvasElement); + await userEvent.click( + await page.findByRole("option", { name: /Sam Rivera/ }), + ); + }); + await step("Grant access to the project", async () => { + await userEvent.click(page.getByRole("button", { name: "Add" })); + await waitFor(() => + expect(page.getByText("Project access added")).toBeVisible(), + ); + await expect( + await page.findByRole("button", { name: "Remove Sam Rivera" }), + ).toBeEnabled(); + }); + }, +}; + +export const OwnerKeepsChildAccessIndependent: Story = { ...AddDirectGrantAlongsideInheritedAccess, name: "Owner · Add a child grant before removing parent access" }; +export const OwnerRemovesProjectOnlyTaskAccess: Story = { ...MembershipUpdatesTaskAudience, name: "Project owner · Remove a member and check the task audience" }; +export const OwnerOpensProjectWithPrivateTasks: Story = { ...OpenProjectAudienceCopy, name: "Project owner · Review access loss before opening the project" }; + +export const OwnerMovesChildThenReviewsPublishing: Story = { + ...OwnerMovesOutOfPrivateParent, + name: "Owner · Move out of a private parent and review publishing", +}; +export const OwnerMovesTaskThenReviewsPublishing: Story = { + ...OwnerMovesOutOfPrivateProject, + name: "Owner · Move out of a private project and review publishing", +}; diff --git a/ui/storybook/stories/private-tasks/PrivacyStory.tsx b/ui/storybook/stories/private-tasks/PrivacyStory.tsx new file mode 100644 index 00000000000..979e55649fd --- /dev/null +++ b/ui/storybook/stories/private-tasks/PrivacyStory.tsx @@ -0,0 +1,94 @@ +import { userEvent, within } from "storybook/test"; +import { + createContext, + useContext, + useLayoutEffect, + useState, + type ReactNode, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Decorator } from "@storybook/react-vite"; +import { useCompany } from "@/context/CompanyContext"; +import { ToastViewport } from "@/components/ToastViewport"; +import { queryKeys } from "@/lib/queryKeys"; +import { + createPrivacyState, + installPrivacyApi, + privacyAgents, + privacyCompanyId, + type PrivacyScenario, + type PrivacyState, +} from "../../fixtures/privateTasks"; + +const PrivacyContext = createContext(null); +export function usePrivacyStory() { + const state = useContext(PrivacyContext); + if (!state) throw new Error("Private task stories require PrivacySandbox"); + return state; +} + +function PrivacySandbox({ + options, + children, +}: { + options: PrivacyScenario; + children: ReactNode; +}) { + const [state] = useState(() => createPrivacyState(options)); + const [ready, setReady] = useState(false); + const queryClient = useQueryClient(); + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useLayoutEffect(() => { + const restore = installPrivacyApi(state); + queryClient.setQueryData(queryKeys.auth.session, state.session); + queryClient.setQueryData(queryKeys.access.currentBoardAccess, state.access); + queryClient.setQueryData( + queryKeys.agents.list(privacyCompanyId), + privacyAgents, + ); + queryClient.setQueryData( + queryKeys.projects.list(privacyCompanyId), + state.projects, + ); + setSelectedCompanyId(privacyCompanyId); + setReady(true); + return restore; + }, [state, queryClient, setSelectedCompanyId]); + return ready && selectedCompanyId === privacyCompanyId ? ( + {children} + ) : null; +} + +export const privacyDecorator: Decorator = (Story, context) => ( + + + +); + +export const privacyParameters = { + layout: "fullscreen", + // Each canvas gets an iframe: several open dialogs and scoped fetch fixtures + // must never share one document on the autodocs page. + docs: { story: { inline: false, height: "760px" } }, +}; +export const mobile = { viewport: { value: "mobile", isRotated: false } }; + +export function StoryFrame({ children }: { title: string; story: string; checks: string[]; children: ReactNode }) { + return
{children}
; +} + +export async function choosePrivateTask(page: ReturnType) { + await userEvent.click(await page.findByRole("button", { name: "Add to composer" })); + await userEvent.click(await page.findByTestId("composer-add-private")); +} + +export async function openProjectMemberPicker(canvasElement: HTMLElement) { + const page = within(canvasElement.ownerDocument.body); + const dialog = await page.findByRole("dialog", { + name: "Private project access", + }); + const trigger = within(dialog).getByRole("combobox"); + if (trigger.getAttribute("aria-expanded") !== "true") + await userEvent.click(trigger); + await page.findByRole("dialog", { name: "Add a person or agent" }); +}