view-as.tsx5.9 KBView on GitHub
'use client';

import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Eye, X } from 'lucide-react';

import { cn } from '@/lib/utils';

/**
 * Looking at Cedar as somebody else — and the reason it is READ-ONLY, structurally.
 *
 * ── THIS IS NOT THE STAFF IMPERSONATION PATH, AND MUST NEVER BECOME IT ──────
 *
 * Cedar staff have `X-Admin-View-User` (apps/server/src/http/app.ts): it swaps the SESSION
 * wholesale, so every write also runs as that person. It is staff-gated, manual, internal,
 * and `cedar-cli --for` depends on it. This is a different mechanism with a different
 * shape, and the two must not be merged: a session swap cannot be made partial, because
 * the session IS the identity every downstream predicate reads.
 *
 * So the customer-facing version substitutes principals on READS ONLY — an `asUserId`
 * parameter the read models accept and the mutations REFUSE. The server enforces that;
 * this module is the other half, and it exists because the server enforcing it is not
 * enough. A screen that lets you press Save while showing somebody else's view produces
 * an edit nobody can explain afterwards — the person who made it was looking at a
 * different document from the one they changed. Read-only is the honest rendering of a
 * read-only capability, so while a perspective is active every mutation affordance is
 * HIDDEN rather than disabled or left to fail at the server.
 *
 * ── HOW A SURFACE OBEYS IT ──────────────────────────────────────────────────
 *
 * `useViewAs().readOnly` — one boolean, consulted where a control that WRITES is rendered.
 * Hidden, not disabled: a disabled Delete still says "this is a thing you do here", and
 * the answer while viewing as someone is that it is not.
 *
 * Any new mutation affordance in a surface that can be viewed-as must consult it. That is
 * a rule a person has to remember, which is why it is written here and why the server
 * refusing `asUserId` on every mutation is the backstop rather than the only guard.
 *
 * Design: apps/server/docs/sharing.md §3.2 J (items 27-29);
 * apps/server/docs/sharing.md "Decisions already taken", item 4.
 */

interface ViewAsValue {
  /** Null when you are looking at your own Cedar, which is nearly always. */
  viewingAsUserId: string | null;
  viewingAsName: string | null;
  /** True exactly when a perspective is active. Read this, not the id. */
  readOnly: boolean;
  enter: (person: { userId: string; name: string | null }) => void;
  exit: () => void;
}

const ViewAsContext = createContext<ViewAsValue>({
  viewingAsUserId: null,
  viewingAsName: null,
  readOnly: false,
  enter: () => undefined,
  exit: () => undefined,
});

export function useViewAs(): ViewAsValue {
  return useContext(ViewAsContext);
}

/**
 * The `asUserId` to send with a READ, or `undefined` to omit the field entirely.
 *
 * A helper rather than `viewingAsUserId ?? undefined` at each call site, because the two
 * are not interchangeable: `null` is a value tRPC will happily serialise and the route
 * would then have to treat as "not asked", and `undefined` is the absence the route
 * actually checks for.
 */
export function useAsUserId(): string | undefined {
  return useViewAs().viewingAsUserId ?? undefined;
}

export function ViewAsProvider({ children }: { children: React.ReactNode }) {
  const [person, setPerson] = useState<{ userId: string; name: string | null } | null>(null);

  const exit = useCallback(() => setPerson(null), []);
  const enter = useCallback((next: { userId: string; name: string | null }) => setPerson(next), []);

  useEffect(() => {
    if (!person) return;
    // Esc leaves, from anywhere. The badge's own ✕ is the discoverable way out; this is
    // the one every reader already tries first when a screen is not theirs.
    const onKey=[redacted] KeyboardEvent) => {
      if (event.key === 'Escape') exit();
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [person, exit]);

  const value = useMemo(
    () => ({
      viewingAsUserId: person?.userId ?? null,
      viewingAsName: person?.name ?? null,
      readOnly: person !== null,
      enter,
      exit,
    }),
    [person, enter, exit],
  );

  return <ViewAsContext.Provider value={value}>{children}</ViewAsContext.Provider>;
}

/**
 * The badge. In the title row, beside the control that entered the perspective.
 *
 * A perspective that can be entered and then forgotten is worse than no perspective at
 * all: every empty list afterwards reads as a bug in Cedar rather than as a fact about
 * the person you are looking at. It used to be a bar across the whole surface, on that
 * argument — but a bar pushes every screen down by 30px to state a fact about ONE row
 * of chrome, and the fact belongs beside the control it came from. A tinted badge
 * carrying a face, a name and an ✕, next to Share, is as hard to miss on a title row as
 * a bar is on a page, and it costs no layout.
 *
 * Escape still leaves, from anywhere (see `ViewAsProvider`).
 */
export function ViewAsBadge({ className }: { className?: string }) {
  const { viewingAsName, viewingAsUserId, exit } = useViewAs();
  if (!viewingAsUserId) return null;

  return (
    <span
      className={cn(
        'bg-selected flex h-7 shrink-0 items-center gap-1.5 rounded-full pl-2 pr-1 text-xs',
        className,
      )}
    >
      <Eye className="text-muted-foreground size-3.5 shrink-0" aria-hidden />
      <span className="max-w-32 truncate font-medium">{viewingAsName ?? 'someone else'}</span>
      <button
        type="button"
        onClick={exit}
        aria-label="Back to my view"
        title="Back to my view"
        className="hover:bg-hover flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors"
      >
        <X className="size-3" aria-hidden />
      </button>
    </span>
  );
}