Merge pull request #438 from MBombeck/release/v1.27.28

Release v1.27.28 — share a document from the document
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-08 20:48:50 +02:00
commit 6ad2dc858a
16 files changed
+1076 -772

No files matched your search

+6
View File
@@ -2,6 +2,12 @@
## [Unreleased]
## [1.27.28] — 2026-07-08 — Share a document from the document
### Added
- A document now carries its own Share action: open a document and tap Share to create a clinician share link with that document already attached — the picker, expiry, EXIF note, and the one-time QR (with the passphrase in the link) all appear in the same sheet, so sharing no longer requires a detour through Settings. The Settings → Sharing flow is unchanged.
## [1.27.27] — 2026-07-08 — Reorderable hero rings
### Changed
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.27.27
version: 1.27.28
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+45
View File
@@ -30,6 +30,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureShareDocFixture,
ensureVaultFixture,
MRT_DOC_ID,
SHARE_DOC_PREFIX,
SHARE_JPEG_DOC_ID,
SHARE_PDF_DOC_ID,
@@ -233,6 +234,50 @@ test.describe("clinician document sharing", () => {
await clinician.context().close();
});
test("the document detail Share action opens the create flow with that document pre-attached", async ({
page,
}) => {
// Opens two stacked sheets (detail → share) and drives a real create with
// the one-time QR render — heavier than the default 30s budget on a loaded
// runner. Runs on the desktop and 390px mobile projects.
test.slow();
// Deep-link straight to the document detail sheet.
await page.goto(`/documents?doc=${MRT_DOC_ID}`);
const detail = page.getByRole("dialog").filter({ hasText: "MRT Knie" });
await expect(detail).toBeVisible();
// The Share action sits beside Download in the footer.
await detail.getByRole("button", { name: "Share" }).click();
// The share flow opens with the document already attached and its title
// pre-filled as the link label — no context switch to Settings.
const shareSheet = page
.getByRole("dialog")
.filter({ hasText: "Share this document" });
await expect(shareSheet).toBeVisible();
await expect(shareSheet.locator("#share-label")).toHaveValue("MRT Knie");
const chips = shareSheet
.getByTestId("share-attached-chips")
.getByRole("listitem");
await expect(chips).toHaveCount(1);
await expect(chips.first()).toContainText("MRT Knie");
// Create the link — the one-time reveal (with the scannable QR) lands in
// the same sheet, carrying the one attached document.
await shareSheet.getByRole("button", { name: "Create link" }).click();
const reveal = shareSheet.getByTestId("share-token-reveal");
await expect(reveal).toBeVisible();
await expect(
shareSheet.getByTestId("share-created-doc-count"),
).toContainText("1");
const qrImg = shareSheet.getByTestId("share-qr-block").getByRole("img");
await expect(qrImg).toBeVisible();
const box = await qrImg.boundingBox();
expect(box, "QR image has a rendered box").not.toBeNull();
expect(box!.width).toBeGreaterThanOrEqual(160);
});
test("picker enforces the document cap at the limit", async ({ page }) => {
await page.goto("/settings/sharing");
await page.locator("#share-label").fill(`${SHARE_DOC_PREFIX} cap`);
+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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.27.27",
"version": "1.27.28",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.27.27";
/* @sw-version-fallback */ "v1.27.28";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+311 -272
View File
@@ -13,10 +13,11 @@
* errors surface inline (`role="alert"`), never as a QueryErrorCard.
* Delete soft-deletes with an Undo toast (restore endpoint); a restore
* refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated
* copy. No share affordance anywhere — deliberately.
* copy. A Share action in the footer opens the clinician share-link create
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
*/
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 +60,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 +225,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 +245,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 +403,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,84 @@
/**
* The shared clinician share-link CREATE flow. It is mounted from two places
* — Settings → Sharing and the document detail sheet's Share action — so the
* test pins the two behaviours the document entry point relies on:
* 1. Mounted bare (the Settings default) it renders the create form with the
* 90-day-capped expiry, the attach affordance, the frozen-set warning,
* and no pre-attached document.
* 2. Mounted with `initialDocuments` + `initialLabel` (the document flow) it
* pre-fills the label and seeds the attached-document chip, so sharing a
* document lands on a create form already scoped to it.
*
* SSR-static render only (no jsdom), matching the sibling settings tests. The
* TanStack hooks are mocked; the QR (`qrcode`) only ever renders in an effect
* after a create, so it never runs under `renderToStaticMarkup`.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@tanstack/react-query", () => ({
// The always-mounted ShareDocumentPicker reads via useQuery; the form
// itself creates via useMutation.
useQuery: () => ({ data: undefined, isPending: false, isError: false }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { ShareLinkCreateForm } from "../share-link-create-form";
import type { PickedDocument } from "../share-document-picker";
function render(
props: {
initialDocuments?: PickedDocument[];
initialLabel?: string;
} = {},
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<ShareLinkCreateForm {...props} />
</I18nProvider>,
);
}
describe("<ShareLinkCreateForm> — shared create flow", () => {
it("renders the create form with the 90-day-capped expiry and attach affordance", () => {
const html = render();
expect(html).toContain('max="90"');
expect(html).toContain("Create link");
expect(html).toContain('data-testid="share-attach-open"');
expect(html).toContain("Choose documents");
});
it("surfaces the frozen-set warning so the write-once contract is explicit", () => {
const html = render();
expect(html).toContain("fixed once you create the link");
});
it("does not reveal a token before a create succeeds", () => {
const html = render();
expect(html).not.toContain('data-testid="share-token-reveal"');
});
it("mounts bare with no pre-attached document (the Settings default)", () => {
const html = render();
expect(html).not.toContain('data-testid="share-attached-chips"');
expect(html).toContain("0 of 50 selected");
});
it("seeds the label and the attached-document chip from the document flow", () => {
const html = render({
initialDocuments: [{ id: "doc-1", title: "Blood panel 2026" }],
initialLabel: "Blood panel 2026",
});
// Label pre-filled with the document title.
expect(html).toContain('value="Blood panel 2026"');
// The document rides in as a removable chip, and the count reflects it.
expect(html).toContain('data-testid="share-attached-chips"');
expect(html).toContain("Blood panel 2026");
expect(html).toContain("1 of 50 selected");
// A seeded set surfaces the EXIF note the picker path also shows.
expect(html).toContain("Camera metadata");
});
});
@@ -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">