"use client"; /** * The clinician share-link CREATE flow, extracted so both surfaces mount the * exact same form: Settings → Sharing (the owner section) and the document * detail sheet's "Share" action. A share link is a time-boxed, scope-frozen, * read-only view of the owner's own record at `/c/`, plus a hand-picked, * frozen-at-create document set. It serves a rendered page and those documents * and nothing else. A FHIR toggle and a resource-type picker were removed * that configured an endpoint no route ever served. * * The raw `hls_` token, the passphrase, and the `#k=` QR are returned EXACTLY * ONCE on create (the server stores only hashes); this component is the single * chance to capture them. Reads unwrap `(await res.json()).data`; the query key * comes from the centralised factory. No markdown anywhere — every value * renders as escaped React text. * * The caller owns the chrome (a `SettingsCard` in Settings, a `ResponsiveSheet` * in the document flow); this component renders only the form, the picker, and * the one-time reveal. Pass `initialDocuments` to pre-attach a document (the * document-launched flow seeds the document the user was looking at) and * `initialLabel` to pre-fill the label. */ import { useEffect, useId, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ShareDocumentPicker, type PickedDocument, } from "@/components/settings/share-document-picker"; import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker"; import { ScopeSummary } from "@/components/settings/report-selection/scope-summary"; import type { ReportLeafId } from "@/lib/report-selection/catalogue"; import { orderLeaves } from "@/lib/report-selection/selection"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; import { recentProofErrorMessage, useRecentProof, } from "@/components/settings/security-section/use-recent-proof"; /** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */ export const MAX_DAYS = 90; const DEFAULT_DAYS = 30; /** * Maximum documents per share — mirrors `SHARE_LINK_MAX_DOCUMENTS` on the * server. Kept as a local literal (not imported from the validations module) * because that module pulls the Prisma client into scope and would drag the DB * into the client bundle; the server re-enforces the cap on create regardless. */ const MAX_DOCUMENTS = 50; /** Owner-facing shape returned by `GET /api/share-links` (never the token). */ export interface ShareLinkSummary { id: string; label: string; rangeStart: string; rangeEnd: string | null; /** v1.18.7 — whether a passphrase second factor guards this link. */ protected: boolean; /** v1.28 — size of the frozen document set (never the ids, never bytes). */ documentCount: number; /** * Whether this link's frozen scope predates the selection model. Such links * were revoked on upgrade — their scope was "everything except mood and * cycle", which nobody chose — and the sharing list says so, so the owner * re-mints rather than wondering why a working link stopped. */ needsReselection: boolean; expiresAt: string; createdAt: string; revokedAt: string | null; lastAccessAt: string | null; accessCount: number; active: boolean; } /** * v1.18.7 — the create response. The token, passphrase, and URLs are returned * EXACTLY ONCE here; the list query never carries them (the server stores only * hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`). */ export interface ShareLinkCreated extends ShareLinkSummary { token: string; passphrase: string; shareUrl: string; qrUrl: string; } function isoDaysFromNow(days: number): string { return new Date(Date.now() + days * 86_400_000).toISOString(); } /** The create body posted to `POST /api/share-links`. */ export interface ShareLinkCreatePayload { label: string; rangeStart: string; rangeEnd: null; selection?: { v: 2; leaves: readonly string[] }; documentIds?: string[]; documentOnly?: boolean; expiresAt: string; } /** * The one leaf a share link may never carry — mirrored from the server, which * refuses it with a 422. The picker hides it rather than rendering a control * that cannot be honoured. */ const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"]; /** * Build the create payload from the resolved form state. Extracted as a pure * function so the two surfaces' scope contract is unit-testable without a DOM. * * The `documentOnly` branch is the privacy-load-bearing one: a document-launched * share posts NO report scope at all plus `documentOnly: true`, * and ALWAYS carries the picked document ids — * so the created link serves the document(s) and nothing else. The record share * (Settings → Sharing) keeps its full scope and only attaches documents when * the owner picked some. */ export function buildShareLinkCreatePayload(input: { label: string; rangeDays: number; expiryDays: number; leaves: readonly ReportLeafId[]; documentIds: string[]; documentOnly: boolean; }): ShareLinkCreatePayload { const base = { label: input.label.trim(), rangeStart: isoDaysFromNow(-input.rangeDays), rangeEnd: null as null, expiresAt: isoDaysFromNow(input.expiryDays), }; if (input.documentOnly) { return { ...base, documentOnly: true, // The launched document(s) always ride along — a documents-only share // with no document would be meaningless (the server 422s it). documentIds: input.documentIds, }; } return { ...base, selection: { v: 2, leaves: input.leaves }, // Omit the key entirely when nothing is attached (a documents-less record // share stays the default). The server re-validates each id as the caller's // own live document before minting the link. ...(input.documentIds.length > 0 ? { documentIds: input.documentIds } : {}), }; } export function ShareLinkCreateForm({ documentOnly = false, initialDocuments, initialLabel, initialRangeDays, initialExpiryDays, onCreated, }: { /** * Documents-only mode (the document-launched flow). Hides the record-scope * control — the history-range selector — and posts an empty report scope so * the minted link serves ONLY the attached document(s). Keeps the document * picker, label, expiry, and one-time passphrase reveal. */ documentOnly?: boolean; /** Documents to pre-attach — the document-launched flow seeds the one doc. */ initialDocuments?: PickedDocument[]; /** Optional pre-filled label (e.g. the document title). */ initialLabel?: string; /** * Optional pre-filled window and lifetime, in days. The sharing list passes * these when the owner re-mints a link the selection upgrade closed, so the * only decision left in front of them is the one that link never carried: * what it should contain. */ initialRangeDays?: number; initialExpiryDays?: number; /** Fired after a link is minted, so an outer surface can react if needed. */ onCreated?: (created: ShareLinkCreated) => void; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [label, setLabel] = useState(initialLabel ?? ""); const [rangeDays, setRangeDays] = useState(initialRangeDays ?? DEFAULT_DAYS); const [expiryDays, setExpiryDays] = useState( initialExpiryDays ?? DEFAULT_DAYS, ); const [selectedDocs, setSelectedDocs] = useState( initialDocuments ?? [], ); // A new link starts with an empty scope. The picker's standard-report button // fills it in one click, minus the leaf a link may not carry — but a link is // handed to another person, so nothing rides on it that was not ticked. const [selectedLeaves, setSelectedLeaves] = useState< ReadonlySet >(() => new Set()); const [pickerOpen, setPickerOpen] = useState(false); const [created, setCreated] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(null); const [copied, setCopied] = useState<"link" | "passphrase" | null>(null); const [formError, setFormError] = useState(null); const scopeSummaryId = useId(); // Render the QR from the `#k=` deep link once a link is created. The fragment // carries the passphrase, so the QR alone opens the record — it is shown // exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so // a re-create swaps it; the effect only WRITES on async completion (never a // synchronous reset), so it does not trigger a cascading render. const qrUrl = created?.qrUrl ?? null; useEffect(() => { if (!qrUrl) return; let cancelled = false; // Dynamic import so `qrcode` code-splits out of the settings chunk — the // QR is only ever rendered after a link is created, so the library never // needs to ship eagerly. import("qrcode") .then(({ default: QRCode }) => // Render at a high pixel density (the display size is capped in CSS) so // the code stays crisp when scaled up for on-the-spot phone scanning. QRCode.toDataURL(qrUrl, { margin: 2, width: 512 }), ) .then((url) => { if (!cancelled) setQrDataUrl(url); }) .catch(() => { /* clipboard/QR can fail in some contexts; the link + passphrase text are still shown so the owner can share them by hand */ }); return () => { cancelled = true; }; }, [qrUrl]); // A share link opens the record without signing in, so making one asks for // a fresh proof unless the session signed in or re-proved within minutes. const recentProof = useRecentProof(); const createMutation = useMutation({ mutationFn: () => { // Surface the same expiry-bound the server enforces before the round // trip, so the validation feedback is immediate. if (expiryDays < 1 || expiryDays > MAX_DAYS) { return Promise.reject(new Error("EXPIRY_RANGE")); } const payload = buildShareLinkCreatePayload({ label, rangeDays, expiryDays, leaves: orderLeaves(selectedLeaves), documentIds: selectedDocs.map((d) => d.id), documentOnly, }); return recentProof.run(() => apiPost("/api/share-links", payload), ); }, onSuccess: (result) => { // Clear any stale QR before the effect renders the new one. setQrDataUrl(null); setCreated(result); setCopied(null); setLabel(""); // The document set is frozen onto the link now — reset the picker so the // next link starts clean. setSelectedDocs([]); setFormError(null); queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); onCreated?.(result); }, onError: (err: Error) => { setCreated(null); setFormError( err.message === "EXPIRY_RANGE" ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) : recentProofErrorMessage(err, t("common.error")), ); }, }); async function copyValue(value: string, which: "link" | "passphrase") { try { await navigator.clipboard.writeText(value); setCopied(which); } catch { // Clipboard can be unavailable (insecure context); the values stay // visible in the card so the owner can copy them by hand. } } return ( <> {recentProof.dialog}
{ e.preventDefault(); createMutation.mutate(); }} >
setLabel(e.target.value)} placeholder={t("settings.sharing.labelPlaceholder")} maxLength={120} />
{/* The history-range window only scopes the report — meaningless for a documents-only share, so it is hidden there. */} {!documentOnly ? (
setRangeDays(Math.max(1, Number(e.target.value) || 1)) } />

{t("settings.sharing.rangeHint")}

) : null}
setExpiryDays(Math.max(1, Number(e.target.value) || 1)) } aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS} />

{t("settings.sharing.expiryHint", { max: MAX_DAYS })}

{/* ── What the link contains ───────────────────────────────── */} {!documentOnly ? (

{t("settings.sharing.scopeInsuranceExcluded")}

) : null} {/* ── Attach documents ─────────────────────────────────────── */}

{t("settings.sharing.attachCount", { count: selectedDocs.length, max: MAX_DOCUMENTS, })}

{selectedDocs.length > 0 ? (
    {selectedDocs.map((doc) => (
  • {doc.title}
  • ))}
) : null}

{t("settings.sharing.attachFrozen")}

{selectedDocs.length > 0 ? (

{t("settings.sharing.exifNote")}

) : null}
{formError && (

{formError}

)} {/* A record link with an empty scope cannot be minted: it would serve a page with nothing on it. The scope line above says so and says what to do about it, and describes the button so the disabled state is not silent for a screen reader. */}
{created && (

{t("settings.sharing.tokenCreated")}

{created.documentCount > 0 && (

{t("settings.sharing.documentCount", { count: created.documentCount, })}

)}

{t("settings.sharing.shownOnce")}

{/* QR — carries the passphrase in the URL fragment, so scanning it opens the record (and its shared documents). Promoted as the primary mobile affordance: a clinician scans it on the spot to open the record on their own device. Shown exactly once. The white quiet-zone is the documented QR exemption (UI-STANDARDS §4) so the code stays scannable in the dark theme too. */} {qrDataUrl && (

{t("settings.sharing.qrScanTitle")}

{/* The QR is a transient secret render, not stored content. */} {/* eslint-disable-next-line @next/next/no-img-element */} {t("settings.sharing.qrAlt")}

{t("settings.sharing.qrScanHint")}

)} {/* The link (no passphrase). On its own it cannot open the record. */}

{t("settings.sharing.linkLabel")}

{created.shareUrl}
{copied === "link" && (

{t("settings.sharing.copied")}

)}
{/* The passphrase — the second factor. Shown once; only its hash is stored, so it cannot be recovered. */}

{t("settings.sharing.passphraseLabel")}

{created.passphrase}
{copied === "passphrase" && (

{t("settings.sharing.copied")}

)}

{t("settings.sharing.passphraseHint")}

)} ); }