mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
abbd88007f
commit
b315580640
50 files changed
+5019
-56
No files matched your search
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+22
-2
@@ -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<IssuePrivacyConstraints>(`/issues/${id}/privacy-constraints`),
|
||||
listAccessGrants: (id: string, options?: RequestOptions) =>
|
||||
options
|
||||
? api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`, options)
|
||||
: api.get<IssueAccessGrant[]>(`/issues/${id}/access-grants`),
|
||||
createAccessGrant: (
|
||||
id: string,
|
||||
data: { subjectType: IssueAccessGrantSubjectType; subjectId: string },
|
||||
) => api.post<IssueAccessGrant>(`/issues/${id}/access-grants`, data),
|
||||
revokeAccessGrant: (id: string, grantId: string) =>
|
||||
api.post<IssueAccessGrant>(`/issues/${id}/access-grants/${grantId}/revoke`, {}),
|
||||
setVisibility: (id: string, visibility: IssueVisibility) =>
|
||||
api.patch<IssueUpdateResponse>(`/issues/${id}`, { visibility }),
|
||||
listApprovals: (id: string) => api.get<Approval[]>(`/issues/${id}/approvals`),
|
||||
linkApproval: (id: string, approvalId: string) =>
|
||||
api.post<Approval[]>(`/issues/${id}/approvals`, { approvalId }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectAccessMember,
|
||||
ProjectRepositoryOptions,
|
||||
ProjectWorkspace,
|
||||
WorkspaceOperation,
|
||||
@@ -32,6 +33,17 @@ export const projectsApi = {
|
||||
api.post<Project>(`/companies/${companyId}/projects`, data),
|
||||
update: (id: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
api.patch<Project>(projectPath(id, companyId), data),
|
||||
listAccessMembers: (id: string, companyId?: string) =>
|
||||
api.get<ProjectAccessMember[]>(projectPath(id, companyId, "/access-members")),
|
||||
addAccessMember: (
|
||||
id: string,
|
||||
data: { subjectType: "user" | "agent"; subjectId: string },
|
||||
companyId?: string,
|
||||
) => api.post<ProjectAccessMember>(projectPath(id, companyId, "/access-members"), data),
|
||||
removeAccessMember: (id: string, memberId: string, companyId?: string) =>
|
||||
api.delete<ProjectAccessMember>(
|
||||
projectPath(id, companyId, `/access-members/${encodeURIComponent(memberId)}`),
|
||||
),
|
||||
listWorkspaces: (projectId: string, companyId?: string) =>
|
||||
api.get<ProjectWorkspace[]>(projectPath(projectId, companyId, "/workspaces")),
|
||||
createWorkspace: (projectId: string, data: Record<string, unknown>, companyId?: string) =>
|
||||
|
||||
@@ -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 ? <span className="flex min-w-0 items-center gap-2">{option.identity}</span> : null;
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<Avatar size={size} shape={shape}>
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
|
||||
<Avatar size={size} shape={shape} aria-hidden="true">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
|
||||
<AvatarFallback>{displayInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className={cn("truncate", textSize[size])}>{name}</span>
|
||||
|
||||
@@ -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(<IssueBlockedNotice issueStatus="blocked" blockers={[
|
||||
{ id: "private-task", identifier: "PAP-42", locked: true } as unknown as IssueRelationIssueSummary,
|
||||
]} />);
|
||||
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(
|
||||
<IssueBlockedNotice
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
|
||||
import { formatMonitorOffset } from "../lib/issue-monitor";
|
||||
import { useRetryNowMutation } from "../hooks/useRetryNowMutation";
|
||||
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
|
||||
import { LockedIssueChip, isLockedIssueStub } from "./LockedIssueChip";
|
||||
import { RetryErrorBand } from "./IssueScheduledRetryCard";
|
||||
import {
|
||||
isAssignedBacklogBlocker,
|
||||
@@ -539,11 +540,23 @@ export function IssueBlockedNotice({
|
||||
? reopenSuppressedLeaf.identifier ?? reopenSuppressedLeaf.id.slice(0, 8)
|
||||
: null;
|
||||
const reopenSuppressedLeafStatus = reopenSuppressedLeaf
|
||||
? reopenSuppressedLeaf.status.replace(/_/g, " ")
|
||||
? isLockedIssueStub(reopenSuppressedLeaf) ? "unavailable" : reopenSuppressedLeaf.status.replace(/_/g, " ")
|
||||
: null;
|
||||
const reopenSuppressedOtherCount = Math.max(unresolvedLeafBlockers.length - 1, 0);
|
||||
|
||||
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
|
||||
// A private blocker arrives as a locked stub (no title/status). Show the
|
||||
// locked chip plus the access note instead of a link into a 404.
|
||||
if (isLockedIssueStub(blocker)) {
|
||||
return (
|
||||
<span key={blocker.id} className="inline-flex max-w-full items-center gap-1.5">
|
||||
<LockedIssueChip identifier={blocker.identifier} />
|
||||
<span className="text-(length:--text-micro) text-muted-foreground">
|
||||
Private — you don't have access
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const issuePathId = blocker.identifier ?? blocker.id;
|
||||
const recoveryAction = blocker.activeRecoveryAction ?? null;
|
||||
return (
|
||||
|
||||
@@ -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<Issue, "id" | "identifier" | "visibility"> & Partial<Pick<Issue, "privacyParentIssueId" | "projectId">>;
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block w-full" tabIndex={0}>{node}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">{canManage ? blockedReason : NON_SETTER_TOOLTIP}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{children(isPrivate ? (
|
||||
<>
|
||||
{withTooltip(
|
||||
<button
|
||||
type="button"
|
||||
className={MENU_ITEM_CLASS}
|
||||
disabled={!canManage}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
setShareOpen(true);
|
||||
}}
|
||||
>
|
||||
<Users className="h-4 w-4" aria-hidden="true" /> Share…
|
||||
</button>,
|
||||
)}
|
||||
{withTooltip(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(MENU_ITEM_CLASS, "text-destructive")}
|
||||
disabled={!canManage || Boolean(publicBlockedReason)}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
setMakePublicOpen(true);
|
||||
}}
|
||||
>
|
||||
<Globe className="h-4 w-4" aria-hidden="true" /> Make public
|
||||
</button>,
|
||||
publicBlockedReason,
|
||||
)}
|
||||
{canManage && constraintsQuery.isError ? (
|
||||
<button type="button" className={MENU_ITEM_CLASS} onClick={() => {
|
||||
void constraintsQuery.refetch();
|
||||
}}>Retry access check</button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
withTooltip(
|
||||
<button
|
||||
type="button"
|
||||
className={MENU_ITEM_CLASS}
|
||||
disabled={!canManage || visibilityMutation.isPending}
|
||||
onClick={() => {
|
||||
closeMenu();
|
||||
visibilityMutation.mutate("private");
|
||||
}}
|
||||
>
|
||||
<Lock className="h-4 w-4" aria-hidden="true" /> Make private
|
||||
</button>,
|
||||
)
|
||||
))}
|
||||
|
||||
<IssueShareSheet
|
||||
issueId={issue.id}
|
||||
companyId={companyId}
|
||||
canManage={canManage}
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
implicitPrincipals={implicitPrincipals}
|
||||
/>
|
||||
|
||||
<AlertDialog open={makePublicOpen} onOpenChange={setMakePublicOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Make this task public?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Everyone in the company will be able to read this task, its comments, documents, and
|
||||
run history. Existing private subtasks keep their privacy.{" "}
|
||||
{constraintsQuery.data?.leavesPersonalProject
|
||||
? "This task will also leave its personal project. " : null}
|
||||
<span className="font-semibold text-foreground">Content already seen by others cannot be taken back.</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep private</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
visibilityMutation.mutate("open");
|
||||
}}
|
||||
disabled={visibilityMutation.isPending || Boolean(publicBlockedReason)}
|
||||
>
|
||||
{visibilityMutation.isPending ? "Making public…" : "Make public"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
|
||||
{ status?: string };
|
||||
issue:
|
||||
| (Pick<IssueRelationIssueSummary, "id" | "identifier" | "title"> &
|
||||
{ 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 <LockedIssueChip identifier={issue.identifier} className={className} />;
|
||||
}
|
||||
const issueLabel = issue.identifier ?? issue.title;
|
||||
const classNames = cn(
|
||||
variant === "property" || onRemove
|
||||
|
||||
@@ -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<void>) {
|
||||
let result: void | Promise<void> | 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>): 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(
|
||||
<QueryClientProvider client={client}>
|
||||
<IssueShareSheet
|
||||
implicitPrincipals={implicitPrincipals}
|
||||
issueId="i1"
|
||||
companyId="c1"
|
||||
canManage={canManage}
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<IssueAccessGrantSource, SourceBadgeSpec> = {
|
||||
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 (
|
||||
<span
|
||||
data-testid={`grant-source-badge-${source}`}
|
||||
data-source={source}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1 rounded-md border px-1.5 py-0.5 text-(length:--text-nano) font-medium leading-tight",
|
||||
spec.className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
{spec.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PrincipalAvatar({
|
||||
displayName,
|
||||
avatarUrl,
|
||||
initials,
|
||||
}: {
|
||||
displayName: string;
|
||||
avatarUrl?: string | null;
|
||||
initials?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Avatar size="sm" className="mt-0.5" aria-hidden="true">
|
||||
{avatarUrl ? <AvatarImage src={avatarUrl} alt="" /> : null}
|
||||
<AvatarFallback>{initials ?? displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-start gap-2.5 py-2">
|
||||
{avatar}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{badge}
|
||||
</div>
|
||||
{subtitle ? <div className="truncate text-xs text-muted-foreground">{subtitle}</div> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0 self-center">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function IssueShareSheet({
|
||||
issueId,
|
||||
companyId,
|
||||
canManage,
|
||||
open,
|
||||
onOpenChange,
|
||||
aclDescription = "Sharing grants access to this task and its descendants, including comments, documents, and run history. Parents and siblings need separate access.",
|
||||
implicitPrincipals = [],
|
||||
initialView = "list",
|
||||
initialAddSelection = "",
|
||||
}: {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
canManage: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
aclDescription?: string;
|
||||
implicitPrincipals?: ShareSheetImplicitPrincipal[];
|
||||
/** Initial panel — `add` opens straight into the add flow (stories/tests). */
|
||||
initialView?: "list" | "add";
|
||||
/** Pre-selected add subject (`type:id`), used by stories/tests to show the caution. */
|
||||
initialAddSelection?: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToastActions();
|
||||
const [view, setView] = useState<"list" | "add">(initialView);
|
||||
const [addSelection, setAddSelection] = useState<string>(initialAddSelection);
|
||||
const [revokeTarget, setRevokeTarget] = useState<IssueAccessGrant | null>(null);
|
||||
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: queryKeys.issues.accessGrants(issueId),
|
||||
queryFn: () => issuesApi.listAccessGrants(issueId),
|
||||
enabled: open,
|
||||
});
|
||||
const directoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId),
|
||||
enabled: open && view === "add" && canManage,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
enabled: open && view === "add" && canManage,
|
||||
});
|
||||
|
||||
const activeGrants = useMemo(
|
||||
() => (grantsQuery.data ?? []).filter((grant) => grant.revokedAt === null),
|
||||
[grantsQuery.data],
|
||||
);
|
||||
const grantedSubjectKeys = useMemo(
|
||||
() => new Set(activeGrants.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<SearchableSelectGroup<string, AccessSelectOption>[]>(() => {
|
||||
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: <Identity name={entry.user!.name ?? entry.user!.email ?? "Unknown user"} avatarUrl={entry.user!.image} size="sm" />,
|
||||
}));
|
||||
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: <><AgentAvatar agent={agent} size={24} /><span className="truncate">{agent.name}</span></>,
|
||||
}));
|
||||
const groups: SearchableSelectGroup<string, AccessSelectOption>[] = [];
|
||||
if (users.length > 0) groups.push({ id: "people", label: "People", options: users });
|
||||
if (agents.length > 0) groups.push({ id: "agents", label: "Agents", options: agents });
|
||||
return groups;
|
||||
}, [directoryQuery.data, agentsQuery.data, grantedSubjectKeys]);
|
||||
|
||||
// Is the currently-selected add subject a *shared* agent? Drives the caution.
|
||||
const selectedSharedAgentName = useMemo(() => {
|
||||
if (!addSelection.startsWith("agent:")) return null;
|
||||
const agentId = addSelection.slice("agent:".length);
|
||||
const agent = (agentsQuery.data ?? []).find((candidate) => candidate.id === agentId);
|
||||
if (!agent) return null;
|
||||
return isSharedAgentVisibility(agentVisibilityFromPermissions(agent.permissions))
|
||||
? agent.name
|
||||
: null;
|
||||
}, [addSelection, agentsQuery.data]);
|
||||
|
||||
function resetAndClose(next: boolean) {
|
||||
if (!next) {
|
||||
setView("list");
|
||||
setAddSelection("");
|
||||
setRevokeTarget(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function submitAdd() {
|
||||
if (!addSelection) return;
|
||||
const [subjectType, subjectId] = addSelection.split(/:(.+)/) as ["user" | "agent", string];
|
||||
if (!subjectId) return;
|
||||
addMutation.mutate({ subjectType, subjectId });
|
||||
}
|
||||
|
||||
const isLoading = grantsQuery.isLoading;
|
||||
const isError = grantsQuery.isError;
|
||||
const isEmpty = !isLoading && !isError && activeGrants.length === 0 && implicitPrincipals.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
Who can access this task
|
||||
</DialogTitle>
|
||||
<DialogDescription>{aclDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{view === "list" ? (
|
||||
<div className="space-y-1">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2 py-1" data-testid="share-sheet-loading">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<div key={row} className="flex items-center gap-2.5 py-1">
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
|
||||
data-testid="share-sheet-error"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
Couldn't load access.
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => grantsQuery.refetch()}>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<p className="py-3 text-sm text-muted-foreground" data-testid="share-sheet-empty">
|
||||
Only you can see this task.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => (
|
||||
<RowShell
|
||||
key={`implicit:${principal.id}`}
|
||||
avatar={
|
||||
<PrincipalAvatar
|
||||
displayName={principal.displayName}
|
||||
avatarUrl={principal.avatarUrl}
|
||||
initials={principal.initials}
|
||||
/>
|
||||
}
|
||||
title={principal.displayName}
|
||||
subtitle={principal.subtitle ?? undefined}
|
||||
badge={
|
||||
<span className="inline-flex shrink-0 items-center rounded-md border border-border bg-muted/50 px-1.5 py-0.5 text-(length:--text-nano) font-medium text-muted-foreground">
|
||||
{principal.roleLabel}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{activeGrants.map((grant) => {
|
||||
const displayName = grant.subjectDisplayName ?? "Unknown";
|
||||
const 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 (
|
||||
<RowShell
|
||||
key={grant.id}
|
||||
avatar={
|
||||
<PrincipalAvatar
|
||||
displayName={displayName}
|
||||
avatarUrl={grant.subjectAvatarUrl}
|
||||
initials={grant.subjectInitials}
|
||||
/>
|
||||
}
|
||||
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={<SourceBadge source={grant.source} />}
|
||||
action={
|
||||
revocable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setRevokeTarget(grant)}
|
||||
>
|
||||
{implicitPrincipal ? "Remove saved grant" : "Revoke"}
|
||||
</Button>
|
||||
) : grant.source === "project" ? (
|
||||
<span className="text-(length:--text-nano) text-muted-foreground">
|
||||
project-managed
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setView("add")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add someone
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<SearchableSelect
|
||||
value={addSelection}
|
||||
groups={addGroups}
|
||||
renderValue={(option) => 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 ? (
|
||||
<div
|
||||
role="note"
|
||||
aria-label="Shared agent caution"
|
||||
data-testid="shared-agent-caution"
|
||||
className="flex items-start gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs leading-relaxed text-foreground"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="font-semibold">Shared agent.</span> {selectedSharedAgentName}'s
|
||||
memory & workspace may carry residual private context from this task into its
|
||||
later runs for other people. Grant only if that's acceptable.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setView("list");
|
||||
setAddSelection("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button size="sm" onClick={submitAdd} disabled={!addSelection || addMutation.isPending}>
|
||||
{addMutation.isPending ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<AlertDialog
|
||||
open={revokeTarget !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setRevokeTarget(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Remove {revokeTarget?.subjectDisplayName ?? "this subject"}'s saved grant?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This removes this grant and the access it supplies to this task's descendants.
|
||||
Ownership, current assignment, project membership, inherited access, or another grant
|
||||
can still provide access. Revoking access does <span className="font-semibold">not</span> erase
|
||||
content already seen or retained in an agent's memory.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (revokeTarget) revokeMutation.mutate(revokeTarget);
|
||||
}}
|
||||
disabled={revokeMutation.isPending}
|
||||
>
|
||||
{revokeMutation.isPending ? "Removing…" : "Remove grant"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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(<LockedIssueChip identifier="PAP-1234" />);
|
||||
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
|
||||
expect(chip).not.toBeNull();
|
||||
expect(chip?.tagName).toBe("SPAN");
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
expect(chip?.querySelector("svg")).not.toBeNull();
|
||||
expect(chip?.textContent).toContain("PAP-1234");
|
||||
expect(chip?.getAttribute("aria-label")).toContain("private");
|
||||
});
|
||||
|
||||
it("does not claim a missing or forbidden Markdown target is private", () => {
|
||||
renderChip(<LockedIssueChip identifier="PAP-404" unavailable />);
|
||||
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(<LockedIssueChip identifier={null} />);
|
||||
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
|
||||
expect(chip?.textContent).toContain("Private");
|
||||
});
|
||||
|
||||
it("uses a dashed, muted border (design-token classes)", () => {
|
||||
renderChip(<LockedIssueChip identifier="PAP-9" />);
|
||||
const chip = container.querySelector('[data-testid="locked-issue-chip"]');
|
||||
expect(chip?.className).toContain("border-dashed");
|
||||
expect(chip?.className).toContain("text-muted-foreground");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLockedIssueStub", () => {
|
||||
it("recognizes only objects with locked === true", () => {
|
||||
expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", locked: true })).toBe(true);
|
||||
expect(isLockedIssueStub({ id: "x", identifier: "PAP-1", title: "t", status: "todo" })).toBe(false);
|
||||
expect(isLockedIssueStub({ locked: false })).toBe(false);
|
||||
expect(isLockedIssueStub(null)).toBe(false);
|
||||
expect(isLockedIssueStub(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span
|
||||
data-testid="locked-issue-chip"
|
||||
aria-label={`${label} — ${unavailable ? "unavailable or you don't have access" : "private, you don't have access"}`}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 select-none items-center gap-1 rounded-md border border-dashed border-border px-1.5 py-0.5",
|
||||
"font-mono text-(length:--text-nano) leading-tight text-muted-foreground sm:text-(length:--text-micro)",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 <LockedIssueChip identifier={issuePathId} unavailable />;
|
||||
}
|
||||
|
||||
const identifier = data?.identifier ?? issuePathId;
|
||||
const title = data?.title ?? identifier;
|
||||
const status = data?.status;
|
||||
const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`;
|
||||
|
||||
// Until the fetch settles we don't yet know whether this viewer can read the
|
||||
// issue. Keep the mention a plain link (clickable, styled as today) but hold
|
||||
// off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal —
|
||||
// until `data` confirms the issue is readable.
|
||||
// - Correctness (PAP-16070): a private mention 404s straight to the locked
|
||||
// chip. Mounting the quicklook popover during the loading `<Link>` only to
|
||||
// tear the portal down and swap in a plain `<span>` chip on the 404 is the
|
||||
// element churn that crashed the chat transcript's primary renderer (it
|
||||
// fell through to the safe fallback). Loading link → chip stays portal-free.
|
||||
// - Privacy: don't prefetch / hover-preview an issue of unconfirmed
|
||||
// readability. `data-mention-pending` marks the transient state for tests.
|
||||
const pending = !data;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
data-mention-kind="issue"
|
||||
disableIssueQuicklook={pending}
|
||||
data-mention-pending={pending ? "true" : undefined}
|
||||
onPointerEnter={() => setEngaged(true)}
|
||||
onFocus={() => setEngaged(true)}
|
||||
// Boxless inline mention: the unified status glyph + a regular-weight
|
||||
|
||||
@@ -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 }) => (
|
||||
<button type="button" aria-pressed={checked} onClick={onCheckedChange}>toggle</button>
|
||||
ToggleSwitch: ({ checked, onCheckedChange, ...props }: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button type="button" aria-pressed={checked} onClick={() => onCheckedChange(!checked)} {...props}>toggle</button>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Create sub-task"]')!;
|
||||
expect(submit.disabled).toBe(true);
|
||||
expect(container.textContent).toContain("Checking parent access");
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('[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<HTMLButtonElement>('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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-testid="task-chat-composer-add"]')!.click());
|
||||
await flush();
|
||||
const privateToggle = document.querySelector<HTMLButtonElement>('[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();
|
||||
|
||||
@@ -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<string>("shared_workspace");
|
||||
const [selectedExecutionWorkspaceId, setSelectedExecutionWorkspaceId] = useState("");
|
||||
const [workMode, setWorkMode] = useState<IssueWorkMode>("standard");
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const { isMobile } = useSidebar();
|
||||
const [composerSettings, setComposerSettings] = useState<ComposerRunSettings | null>(null);
|
||||
const [stagedFiles, setStagedFiles] = useState<StagedIssueFile[]>([]);
|
||||
@@ -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 ? (
|
||||
<div role={parentPrivacyError ? "alert" : "status"} className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{parentPrivacyError ? "Couldn't check parent access." : "Checking parent access…"}</span>
|
||||
{parentPrivacyError ? <Button variant="ghost" size="sm" onClick={() => void refetchParentPrivacy()}>Retry</Button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{worktreeSelectionIncomplete && !worktreesLoading ? (
|
||||
<p role="alert" className="mb-2 text-xs text-destructive">
|
||||
{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: (
|
||||
<>
|
||||
<InlineEntitySelector
|
||||
@@ -1358,6 +1400,7 @@ export function NewIssueDialog() {
|
||||
<>
|
||||
<Folder className="size-3.5 shrink-0" style={{ color: currentProject.color ?? "var(--project-seed)" }} aria-hidden />
|
||||
<span className="truncate">{option.label}</span>
|
||||
{currentProject.visibility === "private" ? <Lock className="size-3 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</>
|
||||
) : (
|
||||
@@ -1374,7 +1417,8 @@ export function NewIssueDialog() {
|
||||
return (
|
||||
<>
|
||||
<Folder className="size-4 shrink-0" style={{ color: project?.color ?? "var(--project-seed)" }} aria-hidden />
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{project?.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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(<QueryClientProvider client={client}>{node}</QueryClientProvider>)); }
|
||||
function publicButton() { return [...document.body.querySelectorAll("button")].find(b => b.textContent?.trim() === "Make public")!; }
|
||||
function actions(scope: { privacyParentIssueId?: string; projectId?: string } = {}) {
|
||||
render(<IssuePrivacyActions issue={{ id: "child", visibility: "private", identifier: "PAP-1", ...scope }} companyId="company" canManage closeMenu={() => {}}>{items => <div>{items}</div>}</IssuePrivacyActions>);
|
||||
}
|
||||
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(<ProjectAccessMembers project={{ id: "project", companyId: "company" } as Project} canManage />);
|
||||
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);
|
||||
});
|
||||
@@ -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<SearchableSelectGroup<string, AccessSelectOption>[]>(() => {
|
||||
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: <Identity name={entry.user!.name ?? entry.user!.email ?? "Unknown user"} avatarUrl={entry.user!.image} size="sm" />,
|
||||
}));
|
||||
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: <><AgentAvatar agent={agent} size={24} /><span className="truncate">{agent.name}</span></> }));
|
||||
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 (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Lock className="h-3.5 w-3.5" aria-hidden="true" /> {canManage ? "Manage access" : "View access"}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Private project access</DialogTitle>
|
||||
<DialogDescription>
|
||||
Members can discover this project and read every task in it. A task can still be shared directly.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{directoryQuery.isError || agentsQuery.isError ? (
|
||||
<p className="text-sm text-destructive">Couldn't load the company access directory.</p>
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<SearchableSelect
|
||||
value={selection}
|
||||
groups={groups}
|
||||
renderValue={(option) => 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"
|
||||
/>
|
||||
<Button size="sm" disabled={!canManage || !selection || addMember.isPending} onClick={() => addMember.mutate()}>
|
||||
<UserPlus className="h-3.5 w-3.5" aria-hidden="true" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{sharingWithSharedAgent ? (
|
||||
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm text-foreground">
|
||||
This agent can retain private project context in its memory and workspace for later runs.
|
||||
Removing access cannot erase what it has already learned.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="divide-y divide-border/60">
|
||||
{(membersQuery.data ?? []).map((member) => {
|
||||
const isPersonalOwner = member.subjectType === "user" && [project.personalOwnerUserId, project.privacyOwnerUserId].includes(member.subjectId);
|
||||
return (
|
||||
<div key={member.id} className="flex items-center gap-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{member.subjectDisplayName ?? "Unknown member"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isPersonalOwner ? "Owner" : member.subjectType === "agent" ? "Agent" : "Person"}
|
||||
</div>
|
||||
</div>
|
||||
{!isPersonalOwner ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${member.subjectDisplayName ?? "member"}`}
|
||||
disabled={!canManage || removeMember.isPending}
|
||||
onClick={() => removeMember.mutate(member.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{membersQuery.isLoading ? <p className="py-3 text-sm text-muted-foreground">Loading access…</p> : null}
|
||||
{membersQuery.isError ? (
|
||||
<p className="py-3 text-sm text-destructive">Couldn't load project access members.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<AlertDialog open={openProjectConfirmation} onOpenChange={setOpenProjectConfirmation}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Open this project to the company?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
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.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Keep private</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => commitField("visibility", { visibility: "open" })}>
|
||||
Open to company
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="space-y-1 pb-4">
|
||||
<PropertyRow label={<FieldLabel label="Name" state={fieldState("name")} />}>
|
||||
{onUpdate || onFieldUpdate ? (
|
||||
@@ -454,6 +483,31 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
|
||||
</p>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={<FieldLabel label="Visibility" state={fieldState("visibility")} />} alignStart>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<ToggleSwitch
|
||||
aria-label="Private project"
|
||||
checked={project.visibility === "private"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!checked) {
|
||||
setOpenProjectConfirmation(true);
|
||||
return;
|
||||
}
|
||||
commitField("visibility", { visibility: "private" });
|
||||
}}
|
||||
disabled={!canManagePrivacy || (!onUpdate && !onFieldUpdate)}
|
||||
/>
|
||||
<span>{project.visibility === "private" ? "Private" : "Open to company"}</span>
|
||||
</label>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
{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."}
|
||||
</p>
|
||||
{project.visibility === "private" ? <ProjectAccessMembers project={project} canManage={canManagePrivacy} /> : null}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
|
||||
<PropertyRow
|
||||
label={<FieldLabel label="Env" state={fieldState("env")} />}
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail && project.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
{!rail ? <ExternalObjectStatusSummary summary={externalObjectsSummary} compact /> : null}
|
||||
{!rail && project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail && project.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
{!rail && project.pauseReason === "budget" ? (
|
||||
<BudgetSidebarMarker title="Project paused by budget" />
|
||||
) : null}
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className={rail ? SIDEBAR_RAIL_HIDDEN_LABEL : "flex-1 truncate"}>{project.name}</span>
|
||||
{!rail && project.visibility === "private" ? <Lock className="size-3.5 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
{!rail && project.pauseReason === "budget" ? (
|
||||
<BudgetSidebarMarker title="Project paused by budget" />
|
||||
) : null}
|
||||
|
||||
@@ -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 ? (
|
||||
<IssueReferencePill
|
||||
variant="property"
|
||||
issue={{
|
||||
issue={isLockedIssueStub(parentAncestor) ? parentAncestor : {
|
||||
id: issue.parentId,
|
||||
identifier: parentIdentifier ?? issue.parentId,
|
||||
title: parentTitle ?? "Parent task",
|
||||
status: issue.ancestors?.[0]?.status ?? currentParentIssue?.status,
|
||||
status: parentAncestor?.status ?? currentParentIssue?.status,
|
||||
}}
|
||||
className="min-w-0 max-w-full"
|
||||
/>
|
||||
|
||||
@@ -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<Issue["blockedBy"]>[number];
|
||||
issue: NonNullable<Issue["blockedBy"]>[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 ? (
|
||||
<LockedIssueChip identifier={issue.identifier} />
|
||||
) : (
|
||||
<>
|
||||
<StatusIcon status={issue.status} className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{issueLabel}</span>
|
||||
@@ -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}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{issue.identifier ? (
|
||||
{!locked && issue.identifier ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/issues/${issue.identifier}`}>
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
@@ -98,7 +111,9 @@ export function RemovableIssueReferencePill({
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
{issue.identifier ? (
|
||||
{locked ? (
|
||||
content
|
||||
) : issue.identifier ? (
|
||||
<Link
|
||||
to={`/issues/${issue.identifier}`}
|
||||
data-mention-kind="issue"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { IssueWorkMode } from "@paperclipai/shared";
|
||||
import { Check, ClipboardList, MessageCircleQuestion, Paperclip, Plus, Target, X, type LucideIcon } from "lucide-react";
|
||||
import { Check, ClipboardList, MessageCircleQuestion, Lock, Paperclip, Plus, Target, X, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMobileEntityPickerViewportStyle } from "@/hooks/useMobileEntityPickerViewportStyle";
|
||||
import { workModeMetaFor } from "@/lib/work-mode-meta";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,6 +22,7 @@ interface ComposerAddMenuProps {
|
||||
onAttachFile?: () => 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 <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent aria-describedby={undefined} showCloseButton={false} data-testid={menuTestId}
|
||||
<DialogContent aria-describedby={undefined} showCloseButton={false} data-testid={menuTestId} style={mobileViewportStyle}
|
||||
onCloseAutoFocus={(event) => { 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">
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
className="composer-mobile-dialog flex flex-col translate-y-0 gap-0 overflow-hidden p-0 md:translate-y-0">
|
||||
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
|
||||
<DialogTitle className="min-w-0 flex-1 text-sm font-medium">Add</DialogTitle>
|
||||
<DialogClose asChild><button type="button" aria-label="Close Add menu" className="grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent"><X className="size-4" /></button></DialogClose>
|
||||
<DialogClose asChild><button type="button" aria-label="Close Add menu" className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent"><X className="size-4" /></button></DialogClose>
|
||||
</div>
|
||||
<div className="p-2 pt-0">{actions.map((action) => <button key={action.id} type="button" disabled={action.disabled} data-testid={action.id}
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-2 pt-0">{actions.map((action) => <button key={action.id} type="button" disabled={action.disabled} data-testid={action.id}
|
||||
onClick={() => { goalFocusRef.current = action.id === "composer-add-goal"; action.select(); setOpen(false); }}
|
||||
className="flex min-h-11 w-full items-center gap-3 rounded-md px-2 text-left text-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||
{content(action)}
|
||||
@@ -107,3 +112,28 @@ export function ComposerModeChip({ mode, onRemove, disabled, testId, mobile = fa
|
||||
<X className="size-3.5" aria-hidden />
|
||||
</button>;
|
||||
}
|
||||
|
||||
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 <TooltipProvider><Tooltip open={hintOpen} onOpenChange={setHintOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0" role={inherited ? "button" : undefined}
|
||||
tabIndex={inherited ? 0 : undefined} aria-label={inherited ? explanation : undefined}
|
||||
onClick={inherited ? (event) => { event.preventDefault(); setHintOpen(true); } : undefined}
|
||||
onKeyDown={inherited ? (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setHintOpen(true); }
|
||||
} : undefined}>
|
||||
<button type="button" onClick={onRemove} aria-label={inherited ? "Private task" : "Remove private task"}
|
||||
title={explanation}
|
||||
disabled={disabled || Boolean(inherited)} data-testid="composer-private-chip"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-border bg-muted px-2 text-xs font-medium text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50">
|
||||
<Lock className="size-3.5" aria-hidden />
|
||||
<span className="max-sm:sr-only">Private</span>
|
||||
{!inherited ? <X className="size-3.5" aria-hidden /> : null}
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{explanation}</TooltipContent>
|
||||
</Tooltip></TooltipProvider>;
|
||||
}
|
||||
@@ -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({
|
||||
<input ref={fileInputRef} type="file" className="hidden" multiple={Boolean(creation)} onChange={handleFileInputChange} />
|
||||
) : null}
|
||||
<ComposerAddMenu
|
||||
privacy={creation?.privacy}
|
||||
mode={pendingMode}
|
||||
onModeChange={!queuedEdit && onWorkModeChange ? changeMode : undefined}
|
||||
onAttachFile={canAcceptFiles ? () => 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 ? <ComposerPrivacyChip inherited={creation.privacy.inherited} onRemove={() => creation.privacy!.onChange(false)} disabled={disabled} /> : null}
|
||||
{queuedEdit ? (
|
||||
<span className="px-1 text-xs font-medium text-muted-foreground">
|
||||
{queuedEdit.stale
|
||||
|
||||
@@ -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 (
|
||||
<ul className="flex flex-col gap-0.5" aria-label={ariaLabel}>
|
||||
{items.map((item) => {
|
||||
if (isLockedIssueStub(item)) {
|
||||
return <li key={item.id} className="px-2 py-2"><LockedIssueChip identifier={item.identifier} /></li>;
|
||||
}
|
||||
const pathId = item.identifier ?? item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof canManageIssuePrivacy>[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<typeof canManageProjectPrivacy>[2];
|
||||
const project = { companyId: "c1", privacyOwnerUserId: "owner" };
|
||||
expect(canManageProjectPrivacy(project, "reader", access)).toBe(false);
|
||||
expect(canManageProjectPrivacy(project, "owner", access)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>).authorizationPolicy;
|
||||
if (!authorizationPolicy || typeof authorizationPolicy !== "object" || Array.isArray(authorizationPolicy)) {
|
||||
return null;
|
||||
}
|
||||
const agentVisibility = (authorizationPolicy as Record<string, unknown>).agentVisibility;
|
||||
if (!agentVisibility || typeof agentVisibility !== "object" || Array.isArray(agentVisibility)) return null;
|
||||
const mode = (agentVisibility as Record<string, unknown>).mode;
|
||||
return mode === "private" || mode === "discoverable" ? mode : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked decision `trust-agent`: a *shared* agent is any agent not explicitly
|
||||
* scoped to `private`. Sharing a private task with one means its memory &
|
||||
* workspace may carry residual private context into later runs for other
|
||||
* people, so the amber caution fires. Users and private/dedicated agents never
|
||||
* trip it.
|
||||
*/
|
||||
export function isSharedAgentVisibility(
|
||||
agentVisibility: IssueAccessGrantAgentVisibility | null,
|
||||
): boolean {
|
||||
return agentVisibility !== "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<Issue, "companyId" | "responsibleUserId" | "createdByUserId" | "assigneeUserId"> | 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<Project, "companyId" | "privacyOwnerUserId" | "personalOwnerUserId">, userId: string | null, access: CurrentBoardAccess | undefined): boolean {
|
||||
return canManageIssuePrivacy({ companyId: project.companyId, responsibleUserId: project.privacyOwnerUserId ?? null,
|
||||
createdByUserId: project.personalOwnerUserId ?? null, assigneeUserId: null }, userId, access);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
<IssueReferencePill strikethrough issue={{ id: "demo-5", identifier: "PAP-202", title: "Removed (strikethrough)", status: "todo" }} />
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="LockedIssueChip">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existence-only reference to a private task shown from a surface the viewer can see
|
||||
(a blocker edge, a mention). Mono id + lock, dashed muted border,{" "}
|
||||
<strong>no title, never a link</strong>. Falls back to "Private" when even the
|
||||
identifier is withheld.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LockedIssueChip identifier="PAP-1234" />
|
||||
<LockedIssueChip identifier={null} />
|
||||
</div>
|
||||
</SubSection>
|
||||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
|
||||
@@ -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<ShareSheetImplicitPrincipal[]>(() => {
|
||||
if (!issue) return [];
|
||||
const list: ShareSheetImplicitPrincipal[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushUser = (userId: string | null, roleLabel: string) => {
|
||||
if (!userId || seen.has(`user:${userId}`)) return;
|
||||
seen.add(`user:${userId}`);
|
||||
const profile = userProfileMap.get(userId);
|
||||
list.push({
|
||||
id: `user:${userId}`,
|
||||
displayName: profile?.label ?? userId.slice(0, 5),
|
||||
roleLabel,
|
||||
avatarUrl: profile?.image ?? null,
|
||||
});
|
||||
};
|
||||
const pushAgent = (agentId: string | null, roleLabel: string) => {
|
||||
if (!agentId || seen.has(`agent:${agentId}`)) return;
|
||||
seen.add(`agent:${agentId}`);
|
||||
const agent = agentMap.get(agentId);
|
||||
list.push({
|
||||
id: `agent:${agentId}`,
|
||||
displayName: agent?.name ?? agentId.slice(0, 8),
|
||||
roleLabel,
|
||||
});
|
||||
};
|
||||
pushUser(issue.responsibleUserId, "Owner");
|
||||
pushAgent(issue.assigneeAgentId, "Assignee");
|
||||
pushUser(issue.assigneeUserId, "Assignee");
|
||||
return list;
|
||||
}, [issue, userProfileMap, agentMap]);
|
||||
const mentionOptions = useMemo<MentionOption[]>(() => {
|
||||
return buildMarkdownMentionOptions({
|
||||
agents,
|
||||
@@ -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) => (
|
||||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<Link
|
||||
{isLockedIssueStub(ancestor) ? <LockedIssueChip identifier={ancestor.identifier} /> : <Link
|
||||
to={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
|
||||
state={resolvedIssueDetailState ?? location.state}
|
||||
onClickCapture={() =>
|
||||
@@ -7005,7 +7041,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
title={ancestor.title}
|
||||
>
|
||||
{ancestor.title}
|
||||
</Link>
|
||||
</Link>}
|
||||
</span>
|
||||
))}
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
@@ -7299,6 +7335,14 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
"absolute right-0 top-0 flex h-7 items-center",
|
||||
)}
|
||||
>
|
||||
<IssuePrivacyActions
|
||||
issue={issue}
|
||||
companyId={issue.companyId}
|
||||
canManage={canManagePrivacy}
|
||||
closeMenu={() => setMoreOpen(false)}
|
||||
implicitPrincipals={privacyImplicitPrincipals}
|
||||
>
|
||||
{(privacyMenuItems) => (
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -7360,6 +7404,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{privacyMenuItems}
|
||||
<TaskTreeControlMenuItems
|
||||
scope={treeControlScope}
|
||||
canPause={
|
||||
@@ -7417,6 +7462,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</IssuePrivacyActions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: () => <div data-testid="project-properties"><input aria-label="Unsaved project field" /></div>,
|
||||
ProjectProperties: ({ onFieldUpdate }: { onFieldUpdate: (field: string, data: Record<string, unknown>) => void }) => <div data-testid="project-properties"><input aria-label="Unsaved project field" /><button onClick={() => onFieldUpdate("visibility", { visibility: "open" })}>Open project through autosave</button></div>,
|
||||
}));
|
||||
vi.mock("../components/BudgetPolicyCard", () => ({
|
||||
BudgetPolicyCard: () => <div data-testid="budget-policy-card" />,
|
||||
@@ -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<void>) {
|
||||
let result: void | Promise<void> = 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(<QueryClientProvider client={queryClient}><ProjectDetail /></QueryClientProvider>));
|
||||
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 } } });
|
||||
|
||||
|
||||
@@ -449,7 +449,10 @@ export function ProjectDetail() {
|
||||
const updateProject = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
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(
|
||||
|
||||
@@ -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={
|
||||
<div className="flex items-center gap-3">
|
||||
{project.visibility === "private" ? <Lock className="size-4 shrink-0 text-muted-foreground" aria-label="Private project" /> : null}
|
||||
<span
|
||||
className="hidden text-xs text-muted-foreground tabular-nums sm:inline"
|
||||
title={`${formatNumber(project.taskCount ?? 0)} task${(project.taskCount ?? 0) === 1 ? "" : "s"}`}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPrivacyState, installPrivacyApi, privacyCompanyId, privacyTask, privacyGrant } from "./privateTasks";
|
||||
describe("private task creation preview", () => {
|
||||
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(); }
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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> = {}): 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> = {},
|
||||
): 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<IssueAccessGrant, "subjectDisplayName" | "subjectInitials" | "agentVisibility"> {
|
||||
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<typeof createPrivacyState>;
|
||||
|
||||
/** 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<string>();
|
||||
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<Response>((_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 });
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<div className="mx-auto max-w-6xl space-y-8">
|
||||
<header className="max-w-3xl space-y-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Private tasks · Review guide
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold">
|
||||
Every changed surface, in context
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p className="rounded-lg border border-border bg-muted p-4 text-sm">
|
||||
Privacy flows down the task tree. Sharing a child grants access to
|
||||
that child and its descendants. Ancestors and siblings need a
|
||||
separate grant.
|
||||
</p>
|
||||
</header>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Start with the user stories</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{journeys.map(([name, id, description]) => (
|
||||
<a
|
||||
key={id}
|
||||
href={storyLink("07 User stories", id!)}
|
||||
target="_top"
|
||||
className="space-y-1 rounded-lg border border-border bg-card p-4 hover:bg-accent"
|
||||
>
|
||||
<h3 className="text-sm font-semibold">{name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Complete component inventory
|
||||
</h2>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="p-3">Production surface</th>
|
||||
<th className="p-3">Change</th>
|
||||
<th className="p-3">Review coverage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{surfaces.map(([name, change, group, id, coverage]) => (
|
||||
<tr key={name} className="border-t border-border">
|
||||
<td className="p-3 align-top font-medium">
|
||||
<a
|
||||
href={storyLink(group!, id!)}
|
||||
target="_top"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
</td>
|
||||
<td className="p-3 align-top text-muted-foreground">
|
||||
{change}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{coverage}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section className="max-w-3xl space-y-3 text-sm text-muted-foreground">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
How to review
|
||||
</h2>
|
||||
<ol className="list-decimal space-y-2 pl-5">
|
||||
<li>
|
||||
Walk the {journeys.length} user stories, then use the inventory to inspect
|
||||
every component's individual states.
|
||||
</li>
|
||||
<li>
|
||||
Use the theme and viewport toolbar for any canvas. Explicit
|
||||
mobile/light stories preserve important review views.
|
||||
</li>
|
||||
<li>
|
||||
Inspect the full task pages to verify menu integration, permission
|
||||
gates, locked ancestry, and layout.
|
||||
</li>
|
||||
<li>
|
||||
Reload a story to reset its in-memory changes. No story connects
|
||||
to Gmail or changes a real company's data.
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
export default {
|
||||
title: "Private tasks/00 Review guide",
|
||||
parameters: { layout: "fullscreen", docs: { story: { inline: false } } },
|
||||
render: () => <Overview />,
|
||||
} satisfies Meta;
|
||||
export const CoverageAndUserStories: StoryObj = {};
|
||||
@@ -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 <PrivacyPage tasks />;
|
||||
}
|
||||
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) => <Creation {...args} />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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) => <Creation {...args} parent />,
|
||||
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: () => <Creation project />,
|
||||
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: () => <Creation project />,
|
||||
};
|
||||
export const RestoredPrivateDraft: Story = {
|
||||
parameters: { privacy: { draft: true } },
|
||||
render: () => <Creation draft />,
|
||||
};
|
||||
export const CreationFailure: Story = {
|
||||
parameters: { privacy: { failure: "create", draft: true } },
|
||||
render: () => <Creation draft />,
|
||||
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: () => <Creation parent />,
|
||||
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: () => <Creation draft />,
|
||||
};
|
||||
|
||||
export const ParentAccessLoading: Story = {
|
||||
parameters: { privacy: { loading: "parent" } },
|
||||
render: (args) => <Creation {...args} parent />,
|
||||
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) => <Creation {...args} parent />,
|
||||
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) => <Creation {...args} parent />,
|
||||
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 };
|
||||
@@ -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 (
|
||||
<StoryFrame
|
||||
title={
|
||||
child ? "Share only the research subtask" : "Who can access this task"
|
||||
}
|
||||
story={
|
||||
child
|
||||
? "As the CEO, I share a research subtask with Morgan while keeping its parent briefing and sibling compensation task private."
|
||||
: "As the owner, I can inspect why each person or agent has access, share the task, or remove a saved grant."
|
||||
}
|
||||
checks={[
|
||||
"Owner, explicit, assignment, and project grants have distinct source badges.",
|
||||
"Inherited access is managed at its source. Removing one saved grant does not cancel other access.",
|
||||
"A child grant includes that child's descendants; it gives no access to ancestors or siblings.",
|
||||
]}
|
||||
>
|
||||
<Button onClick={() => setOpen(true)}>Open sharing</Button>
|
||||
<IssueShareSheet
|
||||
issueId={child ? "privacy-child" : "privacy-root"}
|
||||
companyId={privacyCompanyId}
|
||||
canManage={state.options.role !== "reader"}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
initialView={add ? "add" : "list"}
|
||||
initialAddSelection={add === "choose" ? "" : add}
|
||||
implicitPrincipals={
|
||||
implicit
|
||||
? [
|
||||
{
|
||||
id: "user:user-board",
|
||||
displayName: "Avery Chen",
|
||||
roleLabel: "Owner",
|
||||
},
|
||||
{
|
||||
id: "agent:agent-dedicated",
|
||||
displayName: "Executive assistant",
|
||||
roleLabel: "Current assignee",
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
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: () => <Sharing />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const AllAccessSources: Story = { parameters: { privacy: { taskProject: true } } };
|
||||
export const OwnerAndCurrentAssignee: Story = {
|
||||
render: () => <Sharing implicit />,
|
||||
};
|
||||
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: () => <Sharing child />,
|
||||
};
|
||||
export const StickyAssignmentAfterUnassignment: Story = {
|
||||
parameters: { privacy: { grants: "assignment" } },
|
||||
};
|
||||
export const RemoveCurrentAssigneesSavedGrant: Story = {
|
||||
parameters: { privacy: { grants: "assignment" } },
|
||||
render: () => <Sharing implicit />,
|
||||
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: () => <Sharing add="choose" />,
|
||||
};
|
||||
export const ShareChildWithPerson: Story = {
|
||||
parameters: { privacy: { grants: "empty" } },
|
||||
render: () => <Sharing add="user:user-product" child />,
|
||||
};
|
||||
export const DedicatedAgent: Story = {
|
||||
parameters: { privacy: { grants: "empty" } },
|
||||
render: () => <Sharing add="agent:agent-dedicated" />,
|
||||
};
|
||||
export const SharedAgentCaution: Story = {
|
||||
parameters: { privacy: { grants: "empty" } },
|
||||
render: () => <Sharing add="agent:agent-shared" />,
|
||||
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: () => <Sharing add="agent:agent-unspecified" />,
|
||||
};
|
||||
export const AddFailure: Story = {
|
||||
parameters: { privacy: { grants: "empty", failure: "add" } },
|
||||
render: () => <Sharing add="user:user-product" />,
|
||||
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: () => <Sharing add="choose" />,
|
||||
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: () => <Sharing add="choose" child />,
|
||||
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: () => <Sharing add="choose" />,
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<StoryFrame
|
||||
title="Task privacy actions"
|
||||
story="As the owner or a company admin, I manage the audience from the task menu. A shared reader can read the task but cannot change its audience."
|
||||
checks={[
|
||||
"Private tasks offer Share and Make public. Open tasks offer Make private.",
|
||||
"Making public requires confirmation explaining disclosure and private descendants.",
|
||||
"Shared readers see disabled actions with a permission explanation.",
|
||||
]}
|
||||
>
|
||||
{issue && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">
|
||||
{issue.identifier} ·{" "}
|
||||
{issue.visibility === "private" ? "Private" : "Open to company"}
|
||||
</p>
|
||||
<IssuePrivacyActions
|
||||
issue={issue}
|
||||
companyId={privacyCompanyId}
|
||||
canManage={state.options.role !== "reader"}
|
||||
closeMenu={() => {}}
|
||||
>
|
||||
{(items) => (
|
||||
<div
|
||||
aria-label="Task privacy menu"
|
||||
className="max-w-xs rounded-md border border-border bg-popover p-1"
|
||||
>
|
||||
{items}
|
||||
</div>
|
||||
)}
|
||||
</IssuePrivacyActions>
|
||||
</div>
|
||||
)}
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
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: () => <PrivacyActionsStory />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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: () => <PrivacyActionsStory child />,
|
||||
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: () => <PrivacyActionsStory child />,
|
||||
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();
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<StoryFrame
|
||||
title="References across a privacy boundary"
|
||||
story="As someone working on a shared child, I can understand its visible relationships without learning the title or content of a private parent or blocker."
|
||||
checks={[
|
||||
"A locked reference has no private title, destination link, status, or quicklook.",
|
||||
"An unavailable markdown mention does not reveal whether the task exists.",
|
||||
"I can detach a private blocker after confirmation without opening it.",
|
||||
]}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{mode === "chips" && (
|
||||
<>
|
||||
<div>
|
||||
<LockedIssueChip identifier="PAP-410" />
|
||||
</div>
|
||||
<div>
|
||||
<LockedIssueChip identifier={null} />
|
||||
</div>
|
||||
<div>
|
||||
<LockedIssueChip identifier="PAP-498" unavailable />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{mode === "pills" && (
|
||||
<>
|
||||
<p className="flex items-center gap-3 text-sm">
|
||||
Readable <IssueReferencePill issue={readable} />
|
||||
</p>
|
||||
<p className="flex items-center gap-3 text-sm">
|
||||
Private <IssueReferencePill issue={locked} />
|
||||
</p>
|
||||
<p className="flex items-center gap-3 text-sm">
|
||||
Property variant{" "}
|
||||
<IssueReferencePill issue={locked} variant="property" />
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{mode === "markdown" && (
|
||||
<MarkdownBody linkIssueReferences>
|
||||
{
|
||||
"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."
|
||||
}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
{(mode === "blocker" || mode === "mixed") && (
|
||||
<IssueBlockedNotice
|
||||
issueId="privacy-child"
|
||||
issueStatus="blocked"
|
||||
blockers={
|
||||
mode === "mixed" ? [lockedBlocker, readable] : [lockedBlocker]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mode === "remove" &&
|
||||
(removed ? (
|
||||
<p role="status" className="text-sm">
|
||||
Blocker removed. The private task itself is unchanged.
|
||||
</p>
|
||||
) : (
|
||||
<RemovableIssueReferencePill
|
||||
issue={locked}
|
||||
isMobile={mobileControl}
|
||||
onRemove={() => setRemoved(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
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: () => <References />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LockedChipVariants: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(
|
||||
within(canvasElement).queryByRole("link"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ReferencePillVariants: Story = {
|
||||
render: () => <References mode="pills" />,
|
||||
};
|
||||
export const MarkdownMentions: Story = {
|
||||
render: () => <References mode="markdown" />,
|
||||
};
|
||||
export const PendingMention: Story = {
|
||||
parameters: { privacy: { loading: "mention" } },
|
||||
render: () => <References mode="markdown" />,
|
||||
};
|
||||
export const PrivateBlocker: Story = {
|
||||
render: () => <References mode="blocker" />,
|
||||
};
|
||||
export const MixedBlockers: Story = {
|
||||
render: () => <References mode="mixed" />,
|
||||
};
|
||||
export const RemovablePrivateBlocker: Story = {
|
||||
render: () => <References mode="remove" />,
|
||||
};
|
||||
export const RemoveBlockerConfirmation: Story = {
|
||||
render: () => <References mode="remove" />,
|
||||
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: () => <References mode="remove" mobileControl />,
|
||||
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: () => <References mode="remove" mobileControl />,
|
||||
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: () => <References mode="pills" />,
|
||||
};
|
||||
|
||||
export const AncestorNavigation: Story = {
|
||||
render: () => (
|
||||
<StoryFrame
|
||||
title="Ancestor navigation"
|
||||
story="As a shared reader, I see locked ancestors without a destination link, alongside any ancestors I can read."
|
||||
checks={[
|
||||
"Private ancestors have no title, status, or quicklook.",
|
||||
"Readable ancestors remain navigable.",
|
||||
]}
|
||||
>
|
||||
<RelationNavigationList
|
||||
items={[locked, readable]}
|
||||
emptyMessage="No ancestors"
|
||||
ariaLabel="Ancestor tasks"
|
||||
/>
|
||||
</StoryFrame>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
const page = within(canvasElement);
|
||||
await expect(page.getByTestId("locked-issue-chip")).toHaveTextContent(
|
||||
"PAP-410",
|
||||
);
|
||||
await expect(page.getAllByRole("link")).toHaveLength(1);
|
||||
},
|
||||
};
|
||||
@@ -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<Project>(() => ({
|
||||
...state.projects[0]!,
|
||||
name: projectName,
|
||||
visibility: open ? ("open" as const) : ("private" as const),
|
||||
}));
|
||||
useEffect(() => setProject(value => ({ ...value, name: projectName })), [projectName]);
|
||||
return (
|
||||
<StoryFrame
|
||||
title={
|
||||
settings
|
||||
? "Project visibility settings"
|
||||
: "Manage private project members"
|
||||
}
|
||||
story="As an owner, I can create a private place for related work and grant members access to its tasks. People directly invited to one task do not automatically see the project."
|
||||
checks={[
|
||||
"Only the owner or an admin can change privacy and memberships.",
|
||||
"The owner's access cannot be removed. Readers can inspect the member list.",
|
||||
"Shared agents show a memory and workspace caution before being added.",
|
||||
]}
|
||||
>
|
||||
{settings ? (
|
||||
<ProjectProperties
|
||||
project={project}
|
||||
onUpdate={async (data) => {
|
||||
const updated = await projectsApi.update(
|
||||
project.id,
|
||||
data,
|
||||
project.companyId,
|
||||
);
|
||||
setProject(updated);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProjectAccessMembers
|
||||
project={project}
|
||||
canManage={state.options.role !== "reader"}
|
||||
/>
|
||||
)}
|
||||
</StoryFrame>
|
||||
);
|
||||
}
|
||||
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) => <ProjectStory {...args} />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const PrivateProjectSettings: Story = {
|
||||
render: (args) => <ProjectStory {...args} settings />,
|
||||
};
|
||||
export const OpenProjectSettings: Story = {
|
||||
render: (args) => <ProjectStory {...args} settings open />,
|
||||
};
|
||||
export const PersonalProjectSettings: Story = {
|
||||
parameters: { privacy: { personal: true } },
|
||||
render: (args) => <ProjectStory {...args} settings />,
|
||||
};
|
||||
export const ReaderProjectSettings: Story = {
|
||||
parameters: { privacy: { role: "reader" } },
|
||||
render: (args) => <ProjectStory {...args} settings />,
|
||||
};
|
||||
export const AdminProjectSettings: Story = {
|
||||
parameters: { privacy: { role: "admin" } },
|
||||
render: (args) => <ProjectStory {...args} settings />,
|
||||
};
|
||||
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) => <ProjectStory {...args} settings />,
|
||||
};
|
||||
|
||||
export const OpenProjectConfirmation: Story = {
|
||||
render: (args) => <ProjectStory {...args} settings />,
|
||||
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 <StoryFrame title="Project membership and task audience" story="Removing project access updates a previously opened task share sheet." checks={[]}>
|
||||
<Button onClick={() => setOpen(true)}>View task audience</Button>
|
||||
<ProjectAccessMembers project={state.projects[0]!} canManage />
|
||||
<IssueShareSheet issueId="privacy-root" companyId={state.projects[0]!.companyId} canManage open={open} onOpenChange={setOpen} />
|
||||
</StoryFrame>;
|
||||
}
|
||||
export const MembershipUpdatesTaskAudience: Story = {
|
||||
render: () => <ProjectAudienceJourney />,
|
||||
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());
|
||||
},
|
||||
};
|
||||
@@ -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 (
|
||||
<PluginLauncherProvider>
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:projectId/*" element={<ProjectDetail />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
}
|
||||
const meta = {
|
||||
title: "Private tasks/06 Full product pages",
|
||||
excludeStories: ["PrivacyPage"],
|
||||
decorators: [privacyDecorator],
|
||||
parameters: { ...privacyParameters, waitForViewport: true },
|
||||
render: () => <PrivacyPage />,
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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: () => <PrivacyPage child />,
|
||||
};
|
||||
export const SharedReaderMenu: Story = {
|
||||
parameters: { privacy: { role: "reader", childOnly: true } },
|
||||
render: () => <PrivacyPage child />,
|
||||
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: () => <PrivacyPage child />,
|
||||
};
|
||||
export const LightTask: Story = { globals: { theme: "light" } };
|
||||
export const DesignGuideLockedReferences: Story = {
|
||||
render: () => <PrivacyPage guide />,
|
||||
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: () => <PrivacyPage child />,
|
||||
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: () => <PrivacyPage child />,
|
||||
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: () => <PrivacyPage projects /> };
|
||||
export const ClassicPrivateProjects: Story = { parameters: { privacy: { classic: true } }, render: () => <PrivacyPage projects /> };
|
||||
export const MobilePrivateProjects: Story = { globals: mobile, render: () => <PrivacyPage projects /> };
|
||||
|
||||
export const OwnerInheritedTaskMenu: Story = {
|
||||
render: () => <PrivacyPage child />,
|
||||
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: () => <PrivacyPage projectSettings />,
|
||||
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: () => <PrivacyPage child />,
|
||||
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: () => <PrivacyPage />,
|
||||
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?");
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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<PrivacyState | null>(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 ? (
|
||||
<PrivacyContext.Provider value={state}>{children}</PrivacyContext.Provider>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export const privacyDecorator: Decorator = (Story, context) => (
|
||||
<PrivacySandbox key={context.id} options={context.parameters.privacy ?? {}}>
|
||||
<Story />
|
||||
</PrivacySandbox>
|
||||
);
|
||||
|
||||
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 <main className="min-h-screen bg-background p-6 text-foreground"><div className="mx-auto max-w-4xl">{children}<ToastViewport /></div></main>;
|
||||
}
|
||||
|
||||
export async function choosePrivateTask(page: ReturnType<typeof within>) {
|
||||
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" });
|
||||
}
|
||||
Reference in new issue
Block a user