feat(documents): share action on the document detail sheet

Extract the clinician share-link create flow (create form, document
picker mount, one-time QR + passphrase reveal) from the Settings sharing
section into a reusable ShareLinkCreateForm. Settings mounts it unchanged
alongside the active/revoked lists; the document detail sheet adds a
Share action beside Download that opens the same flow in a bottom sheet
with the current document pre-attached and its title pre-filled as the
label. The one-time QR reveal lands in that sheet, scannable on mobile.

No backend change: the shared POST /api/share-links handles the attached
document set as before.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 09:41:47 +02:00
1 parent b00a2206c5
commit d4009a3587
10 files changed
+936 -768

No files matched your search

+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Verknüpfen",
"noConditions": "Mit keiner Erkrankung verknüpft",
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
"uploadedMeta": "Hinzugefügt am {date} · {size}"
"uploadedMeta": "Hinzugefügt am {date} · {size}",
"share": "Teilen"
},
"dropOverlay": {
"title": "Zum Hochladen ablegen",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "{count} Dokument(e) werden im Hintergrund indiziert.",
"indexAllNothing": "Alles ist bereits indiziert.",
"indexAllError": "Indizierung konnte nicht gestartet werden."
},
"share": {
"title": "Dieses Dokument teilen",
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
}
},
"selfDescription": {
+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Link",
"noConditions": "Not linked to a condition",
"previewUnavailable": "No inline preview for this file type",
"uploadedMeta": "Added {date} · {size}"
"uploadedMeta": "Added {date} · {size}",
"share": "Share"
},
"dropOverlay": {
"title": "Drop files to upload",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "Indexing {count} document(s) in the background.",
"indexAllNothing": "Everything is already indexed.",
"indexAllError": "Couldn't start indexing."
},
"share": {
"title": "Share this document",
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
}
},
"selfDescription": {
+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Vincular",
"noConditions": "Sin vincular a una afección",
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
"uploadedMeta": "Añadido el {date} · {size}"
"uploadedMeta": "Añadido el {date} · {size}",
"share": "Compartir"
},
"dropOverlay": {
"title": "Suelta los archivos para subirlos",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "Indexando {count} documento(s) en segundo plano.",
"indexAllNothing": "Todo ya está indexado.",
"indexAllError": "No se pudo iniciar la indexación."
},
"share": {
"title": "Compartir este documento",
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
}
},
"selfDescription": {
+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Lier",
"noConditions": "Lié à aucune affection",
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
"uploadedMeta": "Ajouté le {date} · {size}"
"uploadedMeta": "Ajouté le {date} · {size}",
"share": "Partager"
},
"dropOverlay": {
"title": "Déposez les fichiers pour les téléverser",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "Indexation de {count} document(s) en arrière-plan.",
"indexAllNothing": "Tout est déjà indexé.",
"indexAllError": "Impossible de démarrer l'indexation."
},
"share": {
"title": "Partager ce document",
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
}
},
"selfDescription": {
+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Collega",
"noConditions": "Non collegato a una patologia",
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
"uploadedMeta": "Aggiunto il {date} · {size}"
"uploadedMeta": "Aggiunto il {date} · {size}",
"share": "Condividi"
},
"dropOverlay": {
"title": "Rilascia i file per caricarli",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "Indicizzazione di {count} documento/i in background.",
"indexAllNothing": "Tutto è già indicizzato.",
"indexAllError": "Impossibile avviare l'indicizzazione."
},
"share": {
"title": "Condividi questo documento",
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
}
},
"selfDescription": {
+6 -1
View File
@@ -8140,7 +8140,8 @@
"linkCondition": "Powiąż",
"noConditions": "Niepowiązany ze schorzeniem",
"previewUnavailable": "Brak podglądu dla tego typu pliku",
"uploadedMeta": "Dodano {date} · {size}"
"uploadedMeta": "Dodano {date} · {size}",
"share": "Udostępnij"
},
"dropOverlay": {
"title": "Upuść pliki, aby je przesłać",
@@ -8219,6 +8220,10 @@
"indexAllQueued": "Indeksowanie {count} dokumentu/ów w tle.",
"indexAllNothing": "Wszystko jest już zindeksowane.",
"indexAllError": "Nie udało się uruchomić indeksowania."
},
"share": {
"title": "Udostępnij ten dokument",
"description": "Utwórz link tylko do odczytu dla lekarza z dołączonym tym dokumentem. Link i hasło pojawią się tylko raz."
}
},
"selfDescription": {
+309 -271
View File
@@ -16,7 +16,7 @@
* copy. No share affordance anywhere — deliberately.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react";
import { Check, Download, Pencil, Plus, Share2, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
@@ -59,6 +59,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import { useIndexDocument } from "./use-content-index";
import {
@@ -223,6 +224,7 @@ export function DocumentDetailSheet({
const indexDoc = useIndexDocument();
const [mutationError, setMutationError] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
@@ -242,6 +244,7 @@ export function DocumentDetailSheet({
if (lastDocumentId !== documentId) {
setLastDocumentId(documentId);
setEditingTitle(false);
setShareOpen(false);
setMutationError(null);
setSuggestion(null);
setAppliedFields({ title: false, kind: false, date: false });
@@ -399,280 +402,315 @@ export function DocumentDetailSheet({
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
footer={
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Download — the safe,
// expected action — keeps the trailing edge.
<div className="flex w-full items-center justify-between">
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
<Button variant="outline" asChild data-slot="document-download">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
<>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
footer={
doc ? (
// Delete sits bottom-LEFT, quieted to a text button: the
// bottom-right slot reads as the primary/confirm action, and a
// destructive control there is a footgun. Share + Download — the
// safe, expected actions — keep the trailing edge. On phones their
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
<Button
variant="outline"
asChild
data-slot="document-download"
aria-label={t("documents.detail.download")}
>
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.download")}
</span>
</a>
</Button>
</div>
</div>
) : undefined
}
>
{detail.isPending && open ? (
<div className="space-y-3" data-slot="document-detail-loading">
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-9 w-1/2" />
</div>
) : undefined
}
>
{detail.isPending && open ? (
<div className="space-y-3" data-slot="document-detail-loading">
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-9 w-1/2" />
</div>
) : null}
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
) : null}
{doc ? (
<div className="space-y-6">
{doc.servingClass === "inline" ? (
<InlinePreview
documentId={doc.id}
mimeType={doc.mimeType}
title={title}
/>
) : (
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
{Icon ? (
<Icon className="text-muted-foreground size-8" aria-hidden />
) : null}
<p className="max-w-full truncate text-sm font-medium">
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
)}
<DocumentAiSection
aiEnabled={aiEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
<Select
value={doc.kind}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
documentDate: value === "" ? null : value,
})
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">
{episode.label}
</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
</div>
</div>
) : null}
</ResponsiveSheet>
{doc ? (
<div className="space-y-6">
{doc.servingClass === "inline" ? (
<InlinePreview
documentId={doc.id}
mimeType={doc.mimeType}
title={title}
/>
) : (
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
{Icon ? (
<Icon className="text-muted-foreground size-8" aria-hidden />
) : null}
<p className="max-w-full truncate text-sm font-medium">
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<Button asChild size="sm" className="mt-1">
<a href={originalHref} download={doc.filename ?? undefined}>
<Download className="size-4" aria-hidden />
{t("documents.detail.download")}
</a>
</Button>
</div>
)}
<DocumentAiSection
aiEnabled={aiEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
{mutationError}
</p>
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="document-title-input">
{t("documents.detail.titleLabel")}
</Label>
{editingTitle ? (
<Input
id="document-title-input"
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") commitTitle();
if (e.key === "Escape") setEditingTitle(false);
}}
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : (
<button
type="button"
id="document-title-input"
onClick={() => {
setTitleDraft(doc.title ?? "");
setEditingTitle(true);
}}
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<span
className={cn(
"truncate",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</span>
<Pencil
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden
/>
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
<Select
value={doc.kind}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
>
<SelectTrigger id="document-kind-select" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INBOUND_DOCUMENT_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`documents.kind.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="document-date-field">
{t("documents.detail.dateLabel")}
</Label>
<DateField
id="document-date-field"
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({ documentDate: value === "" ? null : value })
}
aria-label={t("documents.detail.dateLabel")}
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-sm leading-none font-medium">
{t("documents.detail.conditionsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.conditionLinks.map((link) => (
<Link
key={link.episodeId}
href={`/illness/${link.episodeId}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">{link.name}</span>
</Link>
))}
{doc.conditionLinks.length === 0 ? (
<span className="text-muted-foreground text-xs">
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 rounded-full px-2.5 text-xs"
>
<Plus className="size-3" aria-hidden />
{t("documents.detail.linkCondition")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
{episodes.data?.map((episode) => {
const linked = doc.conditionLinks.some(
(l) => l.episodeId === episode.id,
);
return (
<li key={episode.id}>
<button
type="button"
onClick={() => toggleEpisode(episode.id)}
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
aria-pressed={linked}
>
<Check
className={cn(
"size-4 shrink-0",
linked ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="truncate">
{episode.label}
</span>
</button>
</li>
);
})}
</ul>
</PopoverContent>
</Popover>
) : null}
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
</div>
</div>
<DocumentShareSheet
open={shareOpen}
onOpenChange={setShareOpen}
documentId={doc.id}
documentTitle={title}
/>
) : null}
</ResponsiveSheet>
</>
);
}
@@ -0,0 +1,49 @@
"use client";
/**
* The document-launched entry point into the clinician share-link create flow.
* Mounts the shared `ShareLinkCreateForm` inside a `ResponsiveSheet` (bottom
* sheet on phones, dialog on desktop) with the current document pre-attached
* and its title pre-filled as the link label — the owner shares the document
* they were looking at without leaving for Settings, and the one-time QR /
* passphrase reveal lands in the same surface.
*
* The frozen-write-once + ≤50 rules, the EXIF note, and expiry all come from
* the shared form as-is; the same `POST /api/share-links` handles the create.
* The form is mounted only while the sheet is open so each open starts a fresh
* link (the one-time secret is never re-shown for a stale create).
*/
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ShareLinkCreateForm } from "@/components/settings/share-link-create-form";
import { useTranslations } from "@/lib/i18n/context";
export function DocumentShareSheet({
open,
onOpenChange,
documentId,
documentTitle,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
documentId: string;
documentTitle: string;
}) {
const { t } = useTranslations();
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("documents.share.title")}
description={t("documents.share.description")}
contentWidth="2xl"
>
{open ? (
<ShareLinkCreateForm
initialDocuments={[{ id: documentId, title: documentTitle }]}
initialLabel={documentTitle}
/>
) : null}
</ResponsiveSheet>
);
}
@@ -0,0 +1,528 @@
"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/<token>`, optionally
* exposing a scoped read-only FHIR face and a hand-picked, frozen-at-create
* document set.
*
* 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, 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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { 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. */
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;
resourceTypes: string[];
allowFhirApi: boolean;
/** 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;
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();
}
export function ShareLinkCreateForm({
initialDocuments,
initialLabel,
onCreated,
}: {
/** 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;
/** 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(DEFAULT_DAYS);
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
const [allowFhirApi, setAllowFhirApi] = useState(false);
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
"Patient",
"Observation",
]);
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
initialDocuments ?? [],
);
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
const [formError, setFormError] = useState<string | null>(null);
// 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]);
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<ShareLinkCreated>("/api/share-links", {
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
// entirely when nothing is attached (a documents-less share stays the
// default). The server re-validates each id as the caller's own live
// document before minting the link.
...(selectedDocs.length > 0
? { documentIds: selectedDocs.map((d) => d.id) }
: {}),
expiresAt: isoDaysFromNow(expiryDays),
});
},
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 })
: t("common.error"),
);
},
});
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.
}
}
return (
<>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
<Input
id="share-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("settings.sharing.labelPlaceholder")}
maxLength={120}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-expiry">{t("settings.sharing.expiry")}</Label>
<Input
id="share-expiry"
type="number"
min={1}
max={MAX_DAYS}
value={expiryDays}
onChange={(e) =>
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
}
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
</div>
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
{allowFhirApi && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharing.resourceTypes")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{RESOURCE_TYPES.map((type) => (
<label
key={type}
className="flex items-center gap-2 text-sm"
htmlFor={`share-rt-${type}`}
>
<Checkbox
id={`share-rt-${type}`}
checked={resourceTypes.includes(type)}
onCheckedChange={() => toggleResourceType(type)}
/>
<span className="font-mono text-xs">{type}</span>
</label>
))}
</div>
</fieldset>
)}
{/* ── Attach documents ─────────────────────────────────────── */}
<div className="border-border space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("settings.sharing.attachTitle")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachCount", {
count: selectedDocs.length,
max: MAX_DOCUMENTS,
})}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setPickerOpen(true)}
data-testid="share-attach-open"
>
<FileText className="mr-1.5 h-3.5 w-3.5" />
{t("settings.sharing.attachButton")}
</Button>
</div>
{selectedDocs.length > 0 ? (
<ul
className="flex flex-wrap gap-1.5"
data-testid="share-attached-chips"
>
{selectedDocs.map((doc) => (
<li key={doc.id}>
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
<span className="max-w-[12rem] truncate">{doc.title}</span>
<button
type="button"
onClick={() =>
setSelectedDocs((prev) =>
prev.filter((d) => d.id !== doc.id),
)
}
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={t("settings.sharing.attachRemove", {
title: doc.title,
})}
>
<X className="size-3" aria-hidden />
</button>
</span>
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachFrozen")}
</p>
{selectedDocs.length > 0 ? (
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.exifNote")}
</p>
) : null}
</div>
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
</p>
)}
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</form>
<ShareDocumentPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
selected={selectedDocs}
onSelectedChange={setSelectedDocs}
max={MAX_DOCUMENTS}
/>
{created && (
<div
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
{created.documentCount > 0 && (
<p
className="text-foreground text-xs font-medium"
data-testid="share-created-doc-count"
>
{t("settings.sharing.documentCount", {
count: created.documentCount,
})}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.shownOnce")}
</p>
{/* 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 && (
<div
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
data-testid="share-qr-block"
>
<div className="flex items-center gap-1.5">
<ScanLine className="text-foreground size-4" aria-hidden />
<p className="text-foreground text-sm font-medium">
{t("settings.sharing.qrScanTitle")}
</p>
</div>
{/* The QR is a transient secret render, not stored content. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrDataUrl}
alt={t("settings.sharing.qrAlt")}
width={512}
height={512}
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
/>
<p className="text-muted-foreground max-w-xs text-xs">
{t("settings.sharing.qrScanHint")}
</p>
</div>
)}
{/* The link (no passphrase). On its own it cannot open the record. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.linkLabel")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{created.shareUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.shareUrl, "link")}
aria-label={t("settings.sharing.copyLink")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "link" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
{/* The passphrase — the second factor. Shown once; only its hash is
stored, so it cannot be recovered. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.passphraseLabel")}
</p>
<div className="flex items-center gap-2">
<code
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
data-testid="share-passphrase"
>
{created.passphrase}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.passphrase, "passphrase")}
aria-label={t("settings.sharing.copyPassphrase")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "passphrase" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.passphraseHint")}
</p>
</div>
</div>
)}
</>
);
}
+14 -491
View File
@@ -7,32 +7,23 @@
* scope-frozen, read-only view of the owner's own health record at
* `/c/<token>`, 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.
* (the server only stores its hash).
*
* 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.
* 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 `<SettingsCard>` so the surface
* matches every sibling section 1:1.
*/
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Copy,
FileText,
KeyRound,
Loader2,
ScanLine,
Share2,
Trash2,
X,
} from "lucide-react";
import { FileText, KeyRound, Share2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -47,74 +38,17 @@ import {
} 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 {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
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, 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;
/**
* 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). */
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;
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
documentCount: number;
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;
}
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
export function SharingSection() {
// v1.18.7 — the visible heading + subtitle come from the shared settings
@@ -126,61 +60,13 @@ export function SharingSection() {
);
}
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<ResourceType[]>([
"Patient",
"Observation",
]);
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>([]);
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
const [formError, setFormError] = useState<string | null>(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 }) =>
// 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]);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: () =>
@@ -190,52 +76,6 @@ function ShareLinksCard() {
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<ShareLinkCreated>("/api/share-links", {
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
// entirely when nothing is attached (a documents-less share stays the
// default). The server re-validates each id as the caller's own live
// document before minting the link.
...(selectedDocs.length > 0
? { documentIds: selectedDocs.map((d) => d.id) }
: {}),
expiresAt: isoDaysFromNow(expiryDays),
});
},
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() });
},
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: () => {
@@ -243,22 +83,6 @@ function ShareLinksCard() {
},
});
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],
@@ -276,308 +100,7 @@ function ShareLinksCard() {
description={t("settings.sharing.createDescription")}
/>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
createMutation.mutate();
}}
>
<div className="space-y-1.5">
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
<Input
id="share-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("settings.sharing.labelPlaceholder")}
maxLength={120}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
<Input
id="share-range"
type="number"
min={1}
max={3650}
value={rangeDays}
onChange={(e) =>
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.rangeHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-expiry">{t("settings.sharing.expiry")}</Label>
<Input
id="share-expiry"
type="number"
min={1}
max={MAX_DAYS}
value={expiryDays}
onChange={(e) =>
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
}
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
/>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
</div>
<div className="border-border flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5 pr-3">
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
<Switch
id="share-fhir"
checked={allowFhirApi}
onCheckedChange={setAllowFhirApi}
/>
</div>
{allowFhirApi && (
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharing.resourceTypes")}
</legend>
<div className="grid gap-2 sm:grid-cols-2">
{RESOURCE_TYPES.map((type) => (
<label
key={type}
className="flex items-center gap-2 text-sm"
htmlFor={`share-rt-${type}`}
>
<Checkbox
id={`share-rt-${type}`}
checked={resourceTypes.includes(type)}
onCheckedChange={() => toggleResourceType(type)}
/>
<span className="font-mono text-xs">{type}</span>
</label>
))}
</div>
</fieldset>
)}
{/* ── Attach documents ─────────────────────────────────────── */}
<div className="border-border space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("settings.sharing.attachTitle")}
</Label>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachCount", {
count: selectedDocs.length,
max: MAX_DOCUMENTS,
})}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setPickerOpen(true)}
data-testid="share-attach-open"
>
<FileText className="mr-1.5 h-3.5 w-3.5" />
{t("settings.sharing.attachButton")}
</Button>
</div>
{selectedDocs.length > 0 ? (
<ul
className="flex flex-wrap gap-1.5"
data-testid="share-attached-chips"
>
{selectedDocs.map((doc) => (
<li key={doc.id}>
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
<span className="max-w-[12rem] truncate">{doc.title}</span>
<button
type="button"
onClick={() =>
setSelectedDocs((prev) =>
prev.filter((d) => d.id !== doc.id),
)
}
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={t("settings.sharing.attachRemove", {
title: doc.title,
})}
>
<X className="size-3" aria-hidden />
</button>
</span>
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.attachFrozen")}
</p>
{selectedDocs.length > 0 ? (
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.exifNote")}
</p>
) : null}
</div>
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
</p>
)}
<Button
type="submit"
disabled={createMutation.isPending || !label.trim()}
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</form>
<ShareDocumentPicker
open={pickerOpen}
onOpenChange={setPickerOpen}
selected={selectedDocs}
onSelectedChange={setSelectedDocs}
max={MAX_DOCUMENTS}
/>
{created && (
<div
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
{created.documentCount > 0 && (
<p
className="text-foreground text-xs font-medium"
data-testid="share-created-doc-count"
>
{t("settings.sharing.documentCount", {
count: created.documentCount,
})}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.shownOnce")}
</p>
{/* 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 && (
<div
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
data-testid="share-qr-block"
>
<div className="flex items-center gap-1.5">
<ScanLine className="text-foreground size-4" aria-hidden />
<p className="text-foreground text-sm font-medium">
{t("settings.sharing.qrScanTitle")}
</p>
</div>
{/* The QR is a transient secret render, not stored content. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrDataUrl}
alt={t("settings.sharing.qrAlt")}
width={512}
height={512}
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
/>
<p className="text-muted-foreground max-w-xs text-xs">
{t("settings.sharing.qrScanHint")}
</p>
</div>
)}
{/* The link (no passphrase). On its own it cannot open the record. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.linkLabel")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{created.shareUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.shareUrl, "link")}
aria-label={t("settings.sharing.copyLink")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "link" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
{/* The passphrase — the second factor. Shown once; only its hash is
stored, so it cannot be recovered. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.passphraseLabel")}
</p>
<div className="flex items-center gap-2">
<code
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
data-testid="share-passphrase"
>
{created.passphrase}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.passphrase, "passphrase")}
aria-label={t("settings.sharing.copyPassphrase")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "passphrase" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.passphraseHint")}
</p>
</div>
</div>
)}
<ShareLinkCreateForm />
<div className="space-y-2">
<h3 className="text-sm font-medium">