mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
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>
200 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|