"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). * * The create form + one-time reveal live in the shared `ShareLinkCreateForm` * (mounted here and from the document detail sheet's Share action); this * section owns the create card chrome plus the active/revoked lists and the * revoke action. 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 { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FileText, KeyRound, Link2, List, 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 { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { MAX_DAYS, ShareLinkCreateForm, type ShareLinkSummary, } from "@/components/settings/share-link-create-form"; 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 } from "@/lib/api/api-fetch"; 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 (
); } /** * Carry a closed link's window and lifetime into a new create form. * * The window is measured from the frozen `rangeStart` to now (a rolling link * has no end), the lifetime from when it was created to when it would have * expired. Both are clamped into the form's own bounds so a very old link * cannot seed an out-of-range value. */ function prefillFrom(link: ShareLinkSummary) { const day = 86_400_000; const rangeDays = Math.max( 1, Math.round((Date.now() - new Date(link.rangeStart).getTime()) / day), ); const expiryDays = Math.min( MAX_DAYS, Math.max( 1, Math.round( (new Date(link.expiresAt).getTime() - new Date(link.createdAt).getTime()) / day, ), ), ); return { key: Date.now(), label: link.label, rangeDays, expiryDays }; } function ShareLinksCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); const [showRevoked, setShowRevoked] = useState(false); // A re-mint carries the closed link's label, window and lifetime across, so // the only thing left to decide is the scope it never had. Keyed so the form // remounts and picks the values up as its initial state. const [prefill, setPrefill] = useState<{ key: number; label: string; rangeDays: number; expiryDays: number; } | null>(null); const { data: links, isError, isLoading, refetch, } = useQuery({ queryKey: queryKeys.shareLinks(), queryFn: () => apiGet<{ shareLinks: ShareLinkSummary[] }>("/api/share-links").then( (data) => data.shareLinks, ), enabled: isAuthenticated, }); const revokeMutation = useMutation({ mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); }, }); const activeLinks = useMemo( () => (links ?? []).filter((l) => l.active), [links], ); const inactiveLinks = useMemo( () => (links ?? []).filter((l) => !l.active), [links], ); // Two cards: making a link and the links already out are separate jobs, and // a growing list under the form pushed the create action out of reach. return ( <>

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

{isError ? ( // A read failure must not read as "no active links" on a clinical // sharing surface — that is the §6 fall-through this card must avoid. refetch()} /> ) : isLoading ? (
) : ( <>
{activeLinks.length === 0 ? ( ) : (
    {activeLinks.map((link) => (
  • {link.label}

    {t("settings.sharing.statusActive")} {link.protected && ( {t("settings.sharing.protected")} )} {link.documentCount > 0 && ( {link.documentCount} )}

    {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)}` : ""}

    {/* §12 — a right-aligned row action, not a full-width destructive strip stacked once per link (which reads as an alarm wall on a list of shares). */} {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.needsReselection ? t("settings.sharing.statusNeedsReselect") : link.revokedAt ? t("settings.sharing.statusRevoked") : t("settings.sharing.statusExpired")}
    {/* A link whose frozen scope predates the selection model. It served a scope nobody chose, so it was closed; the owner re-mints above with the label, window and expiry still in front of them. */} {link.needsReselection ? (

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

    ) : null}

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

  • ))}
)}
)} )}
); }