RequestAccessScreen.tsx7.5 KBView on GitHub import { useMutation, useQuery } from '@tanstack/react-query';
import { Lock } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { FormActions, FormError, TextareaField } from '@/components/ui/field';
import { useTRPC } from '@/providers/query-provider';
import { cn } from '@/lib/utils';
/**
* What a person sees instead of an empty state when they are refused.
*
* ── WHY THIS EXISTS AT ALL ───────────────────────────────────────────────────
*
* Enforcement TIGHTENED. Cedar had two conversation gates that disagreed — the Files
* tab required deal membership, `documents.getDoc` required only the same
* organisation — and roughly 240 of 398 sampled (document, person) pairs lose access
* as a result. Every one of those people used to be able to open the thing and now
* cannot, and the difference between "you were refused" and "this is broken" is
* entirely what appears on the screen.
*
* The old rendering was "Could not load this document." — which is what a network
* failure, a deleted row and a refusal all look like. This says which one happened,
* and offers the way out.
*
* ── WHAT IS DELIBERATELY NOT ON THIS SCREEN ──────────────────────────────────
*
* THE TITLE. A Cedar document title is information — "Acme renewal Q4" tells the
* reader a deal exists, who it is with, and roughly when. A refusal that names the
* thing it refused has refused nothing. So does the path, which is folder titles all
* the way down. What is named is a PERSON who can let them in, because that is the
* one fact that turns a dead end into a next step.
*
* The errors render in the form (CLAUDE.md → Forms), never as a toast: a toast is
* gone while the reader is still deciding what to type.
*
* Design: apps/server/docs/sharing.md §3.2 I, Phase 6.
*/
export function RequestAccessScreen({
documentId,
agentId,
className,
}: {
/** Refused a DOCUMENT. Exactly one of this and `agentId`. */
documentId?: string;
/**
* Refused an AGENT.
*
* An agent is a document, so the refusal and the way out are the same — but the caller has an
* agent id from a URL somebody sent them and cannot turn it into a document id, because the
* resolver that would is the one refusing them. The server answers that, and the request is
* then written against the document id it hands back.
*/
agentId?: string;
className?: string;
}) {
const trpc = useTRPC();
const [message, setMessage] = useState('');
const [error, setError] = useState<string | null>(null);
// One of the two, never both. `enabled` rather than a branch so the hook order is fixed.
const asAgent = !!agentId;
const docQuery = useQuery({
...trpc.files.refusalContext.queryOptions({ documentId: documentId ?? '' }),
enabled: !asAgent && !!documentId,
});
const agentQuery = useQuery({
...trpc.files.agentRefusalContext.queryOptions({ agentId: agentId ?? '' }),
enabled: asAgent && !!agentId,
});
const { data, isPending, refetch } = asAgent ? agentQuery : docQuery;
/**
* What a request is written against — an agent's is its own document.
*
* Read off `agentQuery` rather than off the merged `data`, whose union an `in` check narrows
* to `{}`. The route already types this field; taking it from the typed query keeps it.
*/
const targetDocumentId = asAgent ? (agentQuery.data?.documentId ?? null) : (documentId ?? null);
const noun = asAgent ? 'agent' : 'document';
const { mutate: askForAccess, isPending: asking } = useMutation({
...trpc.files.requestAccess.mutationOptions(),
onSuccess: () => {
setError(null);
// Refetch rather than setting local state: the screen's whole job is to say
// what is true, and "a request is open" is a server fact from here on.
void refetch();
},
onError: (err) => {
setError(err instanceof Error ? err.message : 'Could not send the request.');
},
});
// Three skeleton lines at the real heights, so the panel does not jump when the
// answer lands — the same rule the share panel's loading state follows.
if (isPending) {
return (
<div className={cn('flex flex-col gap-3 px-3 py-6', className)}>
<div className="bg-muted h-4 w-40 animate-pulse rounded" />
<div className="bg-muted h-3 w-64 animate-pulse rounded" />
<div className="bg-muted h-3 w-52 animate-pulse rounded" />
</div>
);
}
const approvers = data?.approvers ?? [];
const pending = !!data?.pendingRequestId;
// An agent whose document the server would not name is one that does not exist in this
// organisation — or exists in another, which is deliberately the same answer. There is
// nothing to ask for, and `canRequest` is already false, so the form does not render.
const askable = data?.canRequest && !!targetDocumentId;
return (
<div className={cn('flex flex-col gap-4 px-3 py-6', className)}>
<div className="flex items-start gap-2.5">
<Lock className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">You do not have access to this {noun}.</p>
{approvers.length > 0 ? (
<p className="text-muted-foreground text-xs">
{namesOf(approvers)} can give it to you.
</p>
) : (
// No approver means there is nothing to ask for — a document with no
// manager, or one that is simply not there. Offering a button that
// cannot work would be worse than saying so.
<p className="text-muted-foreground text-xs">
There is nobody to ask — this {noun} may have been deleted.
</p>
)}
</div>
</div>
{pending ? (
<p className="text-muted-foreground text-xs">
You have already asked. You will be notified when someone answers.
</p>
) : askable ? (
<div className="flex max-w-md flex-col gap-3">
<TextareaField
label="Add a note"
optional
hint="Saying why usually gets a faster answer."
rows={3}
value={message}
maxLength={500}
onChange={(e) => setMessage(e.target.value)}
placeholder="Covering for Ravi this week."
/>
{error ? <FormError>{error}</FormError> : null}
<FormActions>
<Button
size="sm"
className="cursor-pointer"
disabled={asking}
onClick={() =>
askForAccess({
documentId: targetDocumentId ?? '',
...(message.trim() ? { message: message.trim() } : {}),
})
}
>
{asking ? 'Asking…' : 'Request access'}
</Button>
</FormActions>
</div>
) : null}
</div>
);
}
/** "Maya", "Maya or Ravi", "Maya, Ravi or 2 others" — a person, not a list of ids. */
function namesOf(approvers: { userId: string; name: string | null }[]): string {
const names = approvers.map((a) => a.name?.trim()).filter((n): n is string => !!n);
if (names.length === 0) return 'Someone in your organisation';
if (names.length === 1) return names[0]!;
if (names.length === 2) return `${names[0]} or ${names[1]}`;
return `${names[0]}, ${names[1]} or ${names.length - 2} other${names.length - 2 === 1 ? '' : 's'}`;
}