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