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 { QueryErrorState, useQueryView } from "@/components/QueryView"; import { agentsApi } from "@/api/agents"; import { accessApi } from "@/api/access"; import { issuesApi } from "@/api/issues"; import { useToastActions } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { timeAgo } from "@/lib/timeAgo"; import { cn } from "@/lib/utils"; import { agentVisibilityFromPermissions, grantIsRevocable, isSharedAgentVisibility, } from "@/lib/issuePrivacy"; /** * A role-based principal that can read the task by virtue of *who they are* * (responsible user, creator, current assignee) rather than an explicit grant. * Implicit principals are shown with a muted role badge and have NO revoke — * you change their access by changing the role, not from the share sheet. * The caller derives these (it holds the agent/user directories); the sheet * only renders them. */ export interface ShareSheetImplicitPrincipal { id: string; displayName: string; roleLabel: string; subtitle?: string | null; avatarUrl?: string | null; initials?: string | null; } interface SourceBadgeSpec { label: string; icon: typeof UserPlus; className: string; } // Source labels and icons distinguish access reasons; colors use semantic tokens. const SOURCE_BADGES: Record = { owner: { label: "task owner", icon: Lock, className: "border-border bg-muted text-muted-foreground" }, explicit: { label: "shared directly", icon: UserPlus, className: "border-primary/30 bg-primary/10 text-primary", }, assignment: { label: "via assignment", icon: Check, className: "border-border bg-secondary text-secondary-foreground", }, project: { label: "via project", icon: Folder, className: "border-border bg-accent text-accent-foreground", }, }; function SourceBadge({ source }: { source: IssueAccessGrantSource }) { const spec = SOURCE_BADGES[source]; const Icon = spec.icon; return ( ); } function PrincipalAvatar({ displayName, avatarUrl, initials, }: { displayName: string; avatarUrl?: string | null; initials?: string | null; }) { return ( ); } // The named type ladder in index.css explicitly prefers Tailwind text-xs and // text-sm for its 12px and 14px tiers; these are the shared scale tokens. function RowShell({ avatar, title, subtitle, badge, action, }: { avatar: React.ReactNode; title: React.ReactNode; subtitle?: React.ReactNode; badge?: React.ReactNode; action?: React.ReactNode; }) { return (
{avatar}
{title} {badge}
{subtitle ?
{subtitle}
: null}
{action ?
{action}
: null}
); } export function IssueShareSheet({ issueId, companyId, canManage, open, onOpenChange, aclDescription = "Sharing grants access to this task and its descendants, including comments, documents, and run history. Parents and siblings need separate access.", implicitPrincipals = [], initialView = "list", initialAddSelection = "", }: { issueId: string; companyId: string; canManage: boolean; open: boolean; onOpenChange: (open: boolean) => void; aclDescription?: string; implicitPrincipals?: ShareSheetImplicitPrincipal[]; /** Initial panel — `add` opens straight into the add flow (stories/tests). */ initialView?: "list" | "add"; /** Pre-selected add subject (`type:id`), used by stories/tests to show the caution. */ initialAddSelection?: string; }) { const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [view, setView] = useState<"list" | "add">(initialView); const [addSelection, setAddSelection] = useState(initialAddSelection); const [revokeTarget, setRevokeTarget] = useState(null); const grantsQuery = useQuery({ queryKey: queryKeys.issues.accessGrants(issueId), queryFn: () => issuesApi.listAccessGrants(issueId), enabled: open, }); const directoryQuery = useQuery({ queryKey: queryKeys.access.companyUserDirectory(companyId), queryFn: () => accessApi.listUserDirectory(companyId), enabled: open && view === "add" && canManage, }); const agentsQuery = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: open && view === "add" && canManage, }); const activeGrants = useMemo( () => (grantsQuery.data ?? []).filter((grant) => grant.revokedAt === null), [grantsQuery.data], ); const grantedSubjectKeys = useMemo( () => new Set(activeGrants.filter((grant) => !grant.inherited && grant.source !== "project") .map((grant) => `${grant.subjectType}:${grant.subjectId}`)), [activeGrants], ); const addMutation = useMutation({ mutationFn: (payload: { subjectType: "user" | "agent"; subjectId: string }) => issuesApi.createAccessGrant(issueId, payload), onSuccess: () => { // A task grant also changes descendant access and cached task discovery. queryClient.invalidateQueries({ queryKey: ["issues"] }); setAddSelection(""); setView("list"); pushToast({ title: "Access granted", tone: "success" }); }, onError: (error) => { pushToast({ title: "Couldn't add access", body: (error as Error).message, tone: "error" }); }, }); const revokeMutation = useMutation({ mutationFn: (grant: IssueAccessGrant) => issuesApi.revokeAccessGrant(issueId, grant.id), onSuccess: () => { // A task grant also changes descendant access and cached task discovery. queryClient.invalidateQueries({ queryKey: ["issues"] }); setRevokeTarget(null); pushToast({ title: "Access revoked", tone: "success" }); }, onError: (error) => { pushToast({ title: "Couldn't revoke access", body: (error as Error).message, tone: "error" }); }, }); // Candidate subjects for the "Add someone" combobox, minus anyone who already // holds a direct grant on this task. Inherited/project access can be supplemented // with an independent child grant before broader access is removed. Value encodes both type and id: `type:id`. const addGroups = useMemo[]>(() => { const users = (directoryQuery.data?.users ?? []) .filter((entry) => entry.user && !grantedSubjectKeys.has(`user:${entry.user.id}`)) .map((entry) => ({ key: `user:${entry.user!.id}`, value: `user:${entry.user!.id}`, label: entry.user!.name ?? entry.user!.email ?? "Unknown user", searchText: [entry.user!.name, entry.user!.email].filter(Boolean).join(" "), identity: , })); const agents = (agentsQuery.data ?? []) .filter((agent) => !grantedSubjectKeys.has(`agent:${agent.id}`)) .map((agent) => ({ key: `agent:${agent.id}`, value: `agent:${agent.id}`, label: agent.name, searchText: agent.name, identity: <>{agent.name}, })); const groups: SearchableSelectGroup[] = []; if (users.length > 0) groups.push({ id: "people", label: "People", options: users }); if (agents.length > 0) groups.push({ id: "agents", label: "Agents", options: agents }); return groups; }, [directoryQuery.data, agentsQuery.data, grantedSubjectKeys]); // Is the currently-selected add subject a *shared* agent? Drives the caution. const selectedSharedAgentName = useMemo(() => { if (!addSelection.startsWith("agent:")) return null; const agentId = addSelection.slice("agent:".length); const agent = (agentsQuery.data ?? []).find((candidate) => candidate.id === agentId); if (!agent) return null; return isSharedAgentVisibility(agentVisibilityFromPermissions(agent.permissions)) ? agent.name : null; }, [addSelection, agentsQuery.data]); function resetAndClose(next: boolean) { if (!next) { setView("list"); setAddSelection(""); setRevokeTarget(null); } onOpenChange(next); } function submitAdd() { if (!addSelection) return; const [subjectType, subjectId] = addSelection.split(/:(.+)/) as ["user" | "agent", string]; if (!subjectId) return; addMutation.mutate({ subjectType, subjectId }); } const grantsView = useQueryView(grantsQuery); const isLoading = grantsQuery.isLoading || grantsView.kind === "reconnecting"; const loadFailed = grantsView.kind === "error"; const isEmpty = !isLoading && !loadFailed && activeGrants.length === 0 && implicitPrincipals.length === 0; return ( {aclDescription} {view === "list" ? (
{isLoading ? (
{[0, 1, 2].map((row) => (
))}
) : loadFailed ? (
) : isEmpty ? (

Only you can see this task.

) : (
{implicitPrincipals.filter(principal => !activeGrants.some(grant => grant.subjectId === principal.id || `${grant.subjectType}:${grant.subjectId}` === principal.id)).map((principal) => ( } title={principal.displayName} subtitle={principal.subtitle ?? undefined} badge={ {principal.roleLabel} } /> ))} {activeGrants.map((grant) => { const displayName = grant.subjectDisplayName ?? "Unknown"; const implicitPrincipal = implicitPrincipals.find(principal => principal.id === `${grant.subjectType}:${grant.subjectId}`); const revocable = canManage && !grant.inherited && grantIsRevocable(grant.source); const granter = grant.source === "explicit" ? "Shared" : "Granted"; return ( } title={displayName} subtitle={implicitPrincipal ? `${implicitPrincipal.roleLabel} — access remains while this role applies` : grant.inherited ? "Inherited access — managed at its source" : `${granter} ${timeAgo(grant.createdAt)}`} badge={} action={ revocable ? ( ) : grant.source === "project" ? ( project-managed ) : null } /> ); })}
)} {canManage ? (
) : null}
) : (
option ? renderAccessIdentity(option) : "Choose a person or agent…"} renderOption={renderAccessIdentity} onValueChange={(value) => setAddSelection(value)} placeholder="Choose a person or agent…" searchPlaceholder="Search people and agents…" loading={directoryQuery.isLoading || agentsQuery.isLoading} loadingMessage="Loading directory…" emptyMessage="No one left to add." triggerClassName="w-full" /> {selectedSharedAgentName ? (
) : null}
)}
{ if (!next) setRevokeTarget(null); }} > Remove {revokeTarget?.subjectDisplayName ?? "this subject"}'s saved grant? This removes this grant and the access it supplies to this task's descendants. Ownership, current assignment, project membership, inherited access, or another grant can still provide access. Revoking access does not erase content already seen or retained in an agent's memory. Cancel { event.preventDefault(); if (revokeTarget) revokeMutation.mutate(revokeTarget); }} disabled={revokeMutation.isPending} > {revokeMutation.isPending ? "Removing…" : "Remove grant"}
); }