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