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