"use client"; /** * v1.11.0 — Settings → Sharing (Epic C, C7). * * The OWNER surface for clinician share links. A share link is a time-boxed, * scope-frozen, read-only view of the owner's own health record at * `/c/`, optionally exposing a scoped read-only FHIR face. The raw * `hls_` token is shown EXACTLY ONCE on create — the list never carries it * (the server only stores its hash), so the copy-on-create card is the single * chance to capture it. * * Client island: the create form, the active/revoked lists, and the revoke * action all need state and mutate via the C4 lifecycle API * (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key * comes from the centralised factory. No markdown anywhere — every value * renders as escaped React text. * * v1.18.7 — restored as a first-class Settings section. The visible heading + * subtitle now come from the shared shell chrome in the route; this body is * the share-links card. Cards paint through `` so the surface * matches every sibling section 1:1. */ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch"; /** The FHIR resource types a share link may serve — mirrors C4's enum. */ const RESOURCE_TYPES = [ "Patient", "Observation", "MedicationStatement", "MedicationAdministration", ] as const; type ResourceType = (typeof RESOURCE_TYPES)[number]; /** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */ const MAX_DAYS = 90; const DEFAULT_DAYS = 30; /** Owner-facing shape returned by `GET /api/share-links` (never the token). */ interface ShareLinkSummary { id: string; label: string; rangeStart: string; rangeEnd: string | null; resourceTypes: string[]; allowFhirApi: boolean; /** v1.18.7 — whether a passphrase second factor guards this link. */ protected: 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=`). */ interface ShareLinkCreated extends ShareLinkSummary { token: string; passphrase: string; shareUrl: string; qrUrl: string; } export function SharingSection() { // v1.18.7 — the visible heading + subtitle come from the shared settings // shell chrome in the route; this body is the share-links card only. return (
); } function isoDaysFromNow(days: number): string { return new Date(Date.now() + days * 86_400_000).toISOString(); } function ShareLinksCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const [label, setLabel] = useState(""); const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS); const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS); const [allowFhirApi, setAllowFhirApi] = useState(false); const [resourceTypes, setResourceTypes] = useState([ "Patient", "Observation", ]); const [created, setCreated] = useState(null); const [qrDataUrl, setQrDataUrl] = useState(null); const [copied, setCopied] = useState<"link" | "passphrase" | null>(null); const [formError, setFormError] = useState(null); const [showRevoked, setShowRevoked] = useState(false); // 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 }) => QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }), ) .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]); const { data: links } = useQuery({ queryKey: queryKeys.shareLinks(), queryFn: () => apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then( (data) => data.shareLinks, ), enabled: isAuthenticated, }); const createMutation = useMutation({ mutationFn: () => { const trimmed = label.trim(); // 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")); } return apiPost("/api/share-links", { label: trimmed, rangeStart: isoDaysFromNow(-rangeDays), rangeEnd: null, resourceTypes, allowFhirApi, expiresAt: isoDaysFromNow(expiryDays), }); }, onSuccess: (result) => { // Clear any stale QR before the effect renders the new one. setQrDataUrl(null); setCreated(result); setCopied(null); setLabel(""); setFormError(null); queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); }, onError: (err: Error) => { setCreated(null); setFormError( err.message === "EXPIRY_RANGE" ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) : t("common.error"), ); }, }); const revokeMutation = useMutation({ mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); }, }); function toggleResourceType(type: ResourceType) { setResourceTypes((prev) => prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type], ); } 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. } } const activeLinks = useMemo( () => (links ?? []).filter((l) => l.active), [links], ); const inactiveLinks = useMemo( () => (links ?? []).filter((l) => !l.active), [links], ); return (
{ e.preventDefault(); createMutation.mutate(); }} >
setLabel(e.target.value)} placeholder={t("settings.sharing.labelPlaceholder")} maxLength={120} />
setRangeDays(Math.max(1, Number(e.target.value) || 1)) } />

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

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

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

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

{allowFhirApi && (
{t("settings.sharing.resourceTypes")}
{RESOURCE_TYPES.map((type) => ( ))}
)} {formError && (

{formError}

)}
{created && (

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

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

{/* QR — carries the passphrase in the URL fragment, so scanning it opens the record. Shown exactly once with the passphrase text. */} {qrDataUrl && (

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

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

)}

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

{activeLinks.length === 0 ? (

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

) : (
    {activeLinks.map((link) => (
  • {link.label}

    {t("settings.sharing.statusActive")} {link.protected && ( {t("settings.sharing.protected")} )} {link.allowFhirApi && ( FHIR )}

    {t("settings.sharing.created")}: {" "} {formatDate(link.createdAt)}

    {t("settings.sharing.expires")}: {" "} {formatDateTime(link.expiresAt)}

    {t("settings.sharing.accessCount")}: {" "} {link.accessCount} {link.lastAccessAt ? ` · ${formatDateTime(link.lastAccessAt)}` : ""}

    {t("settings.sharing.revoke")} {t("settings.sharing.revokeDescription")} {t("common.cancel")} revokeMutation.mutate(link.id)} > {t("settings.sharing.revoke")}
  • ))}
)}
{inactiveLinks.length > 0 && (
{showRevoked && (
    {inactiveLinks.map((link) => (
  • {link.label}

    {link.revokedAt ? t("settings.sharing.statusRevoked") : t("settings.sharing.statusExpired")}

    {t("settings.sharing.accessCount")}: {" "} {link.accessCount}

  • ))}
)}
)}
); }