import { useCallback, useState, type ReactNode } from "react"; import { Eye, EyeOff, Loader2, ShieldAlert } from "lucide-react"; import { cn } from "../lib/utils"; /** * Viewer for a server-produced redacted document. Renders text inline and each * redacted span as a masked chip; clicking a chip asks the server to reveal that * one span. The original plaintext is NEVER in the document the client holds — * the chip carries only an id + kind; `onReveal` round-trips to the server, where * `@tangle-network/agent-app/redact`'s `revealSpan` runs the authorization check * and writes the audit trail. So authz + audit are server-truth; this is display. * * Structural types (no `@tangle-network/agent-app` dependency) — the viewer needs * only `{ id, kind }` per span; the cipher stays server-side. */ export type RedactedDocSegment = | { type: "text"; text: string } | { type: "redacted"; id: string; kind: string }; export interface RedactedDocumentData { segments: RedactedDocSegment[]; } export interface RevealResult { ok: boolean; value?: string; /** e.g. `forbidden` | `not_found` when `ok` is false. */ reason?: string; } export interface RedactedDocumentProps { document: RedactedDocumentData; /** Reveal one span by id. Wire to a server route that calls agent-app's * `revealSpan` (authz + audit happen there). Resolves with the original. */ onReveal: (spanId: string) => Promise; /** Display label for a redaction kind (default: the kind, upper-cased). */ labelForKind?: (kind: string) => string; className?: string; } type ChipState = | { status: "masked" } | { status: "loading" } | { status: "revealed"; value: string } | { status: "denied"; reason?: string }; const defaultLabel = (kind: string) => kind.replace(/[-_]/g, " ").toUpperCase(); function RedactedChip({ kind, label, onReveal, }: { kind: string; label: string; onReveal: () => Promise; }) { const [state, setState] = useState({ status: "masked" }); const reveal = useCallback(async () => { setState({ status: "loading" }); try { const r = await onReveal(); setState( r.ok && r.value !== undefined ? { status: "revealed", value: r.value } : { status: "denied", reason: r.reason }, ); } catch { setState({ status: "denied", reason: "error" }); } }, [onReveal]); if (state.status === "revealed") { return ( ); } if (state.status === "denied") { return ( {label} ); } return ( ); } export function RedactedDocument({ document, onReveal, labelForKind = defaultLabel, className, }: RedactedDocumentProps): ReactNode { return (
{document.segments.map((seg, i) => seg.type === "text" ? ( {seg.text} ) : ( onReveal(seg.id)} /> ), )}
); }