Files
PaperClipAI/ui/src/components/IssueShareSheet.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

507 lines
19 KiB
TypeScript

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