SharedWithMeRoot.tsx8.7 KBView on GitHub
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { UserMinus } from 'lucide-react';
import { useState } from 'react';

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { FileListRow, FileRowMenu, type FileRowMenuItem } from '@/modules/files/components/list';
import { errorMessage } from '@/modules/sharing/types';
import { FormError } from '@/components/ui/field';
import { useSession } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
import { ShareTrigger } from './ShareTrigger';

/**
 * `Shared with me` — the third root of the file tree.
 *
 * ── WHY A ROOT AND NOT A FILTER ─────────────────────────────────────────────
 *
 * A Cedar tree is browsable: you reach a document by walking down from a root. The rows
 * here are the ones no walk can reach — you hold access to the document and to no
 * ancestor of it. Everything else you can see is already findable where it lives, and
 * listing it again here would draw the same file twice. The server decides this
 * (`files.listShared` → `services/access/shared-root.ts`); the client never re-derives it,
 * because a second opinion about what is reachable is exactly how a tree ends up showing
 * a file that will not open.
 *
 * ── THE ROW CARRIES NO PATH, AND THAT IS THE WHOLE POINT ────────────────────
 *
 * A Cedar path is folder titles all the way down, and an orphan is precisely a document
 * whose folders you may NOT see — so `organisation/deals/acme-churn-risk` would tell
 * somebody who was handed one attachment that there is an Acme deal at risk. The server
 * therefore never sends the path, and the row says **"Shared by <person>"** in its place:
 * the minimum context that makes an orphan comprehensible, and a fact about the SHARE
 * rather than about the folder.
 *
 * The root hides itself when it is empty. Two roots and a permanently "(0)" third is a
 * question the reader has to answer every time they open the tree; nothing is being
 * concealed, because a person with no orphaned shares has nothing this root could hold.
 *
 * Design: apps/server/docs/sharing.md §3.2 items H22 and H24.
 */
export function SharedWithMeRoot({
  selectedId,
  onSelect,
}: {
  selectedId?: string;
  onSelect: (documentId: string) => void;
}) {
  const trpc = useTRPC();
  const [expanded, setExpanded] = useState(true);
  const { data: rows } = useQuery({
    ...trpc.files.listShared.queryOptions(),
    staleTime: 60_000,
  });

  if (!rows || rows.length === 0) return null;

  return (
    <FileListRow
      as="div"
      prominent
      item={{ id: 'scope-shared', title: 'Shared with me', kind: 'folder', count: rows.length }}
      expandable
      expanded={expanded}
      onActivate={() => setExpanded((open) => !open)}
      testId="brain-scope-shared"
      slots={{
        after: expanded ? (
          <div>
            {rows.map((row) => (
              <SharedRootRow
                key=[redacted]
                row={row}
                selected={selectedId === row.documentId}
                onSelect={onSelect}
              />
            ))}
          </div>
        ) : undefined,
      }}
    />
  );
}

function SharedRootRow({
  row,
  selected,
  onSelect,
}: {
  row: {
    documentId: string;
    title: string | null;
    documentType: string;
    updatedAt: Date | string;
    role: string;
    sharedBy: { userId: string; name: string | null } | null;
  };
  selected: boolean;
  onSelect: (documentId: string) => void;
}) {
  const [confirming, setConfirming] = useState(false);
  const sharer = row.sharedBy?.name ?? 'someone';
  const title = row.title ?? 'Untitled';

  // Only a MANAGER may re-share what they were given; an editor changes the content, not
  // the audience. Rendering the pill for anybody else would offer a control the mutation
  // refuses — the one thing a share surface must never do.
  const canShare = row.role === 'manager' || row.role === 'owner';

  const items: FileRowMenuItem[] = [
    {
      label: 'Remove myself',
      icon: UserMinus,
      destructive: true,
      onSelect: () => setConfirming(true),
    },
  ];

  return (
    <>
      <FileListRow
        as="div"
        depth={1}
        selected={selected}
        onActivate={() => onSelect(row.documentId)}
        testId={`shared-row-${row.documentId}`}
        item={{
          id: row.documentId,
          title,
          kind: row.documentType === 'folder' ? 'folder' : 'document',
          updatedAt: row.updatedAt,
          // No `shared` value: this row IS the answer to "who can see it", and the panel
          // behind the pill has the detail.
        }}
        slots={{
          // In place of a path — see the header. Beside the name rather than in the
          // Modified column, because it explains why the row is HERE, which is the first
          // question a root under `Shared` raises.
          meta: (
            <span className="text-muted-foreground shrink-0 truncate text-xs">
              Shared by {sharer}
            </span>
          ),
          share: canShare ? <ShareTrigger documentId={row.documentId} variant="pill" /> : undefined,
          actions: <FileRowMenu items={[items]} label={`Actions for ${title}`} />,
        }}
      />
      <RemoveMyselfDialog
        documentId={row.documentId}
        title={title}
        sharer={sharer}
        open={confirming}
        onOpenChange={setConfirming}
      />
    </>
  );
}

/**
 * Leaving a shared file, confirmed.
 *
 * Confirmed because it is the one removal you cannot reverse from your own side: the
 * grant is gone and only its author can write another. So the dialog names who that is,
 * rather than asking "are you sure?" about a consequence it has not stated.
 */
function RemoveMyselfDialog({
  documentId,
  title,
  sharer,
  open,
  onOpenChange,
}: {
  documentId: string;
  title: string;
  sharer: string;
  open: boolean;
  onOpenChange: (open: boolean) => void;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  // Removing YOURSELF is `revokeAccess` aimed at your own principal — the same mutation
  // and the same permission check as removing anybody else, which is why there is no
  // second route for it. The session is the only place the caller's own id lives.
  const { data: session } = useSession();
  const userId = session?.user?.id ?? null;

  const [error, setError] = useState<string | null>(null);

  const { mutate: revoke, isPending } = useMutation({
    ...trpc.files.revokeAccess.mutationOptions(),
    onSuccess: () => {
      onOpenChange(false);
      setError(null);
      // Both readers of the audience, the same pair `use-share-state`'s `refresh`
      // names. Invalidating only this root left an open Share panel for the document
      // still listing the person who had just taken themselves off it.
      void queryClient.invalidateQueries({ queryKey=[redacted] });
      void queryClient.invalidateQueries({
        queryKey=[redacted] documentId }),
      });
    },
    // In the dialog it happened in, never nowhere. A refused revoke used to leave the
    // dialog open, the button live and no sentence anywhere — so the reader clicks it
    // again, and again.
    onError: (err) => setError(errorMessage(err, 'Could not remove you from this.')),
  });

  return (
    <AlertDialog open={open} onOpenChange={onOpenChange}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Remove yourself from {title}?</AlertDialogTitle>
          <AlertDialogDescription>
            You&apos;ll lose access to {title}. {sharer} would have to share it again.
          </AlertDialogDescription>
        </AlertDialogHeader>
        {/* In the dialog, under the sentence it contradicts — never a toast, which
            disappears while the reader is still looking at the button (CLAUDE.md). */}
        {error ? <FormError>{error}</FormError> : null}
        <AlertDialogFooter>
          <AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
          <AlertDialogAction
            className="cursor-pointer"
            disabled={isPending || !userId}
            onClick={() => {
              if (!userId) return;
              revoke({ documentId, principal: { type: 'user', id: userId } });
            }}
          >
            Remove myself
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}