Files
PaperClipAI/ui/src/components/IssuePrivacyActions.tsx
T
DottaandPaperclip b315580640 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>
2026-10-07 07:27:52 -05:00

200 lines
7.7 KiB
TypeScript

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>
);
}