diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bba30590..ec0af97ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dbbe767a7..756279cdb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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. diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index d6c81f481..518853a56 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -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`); diff --git a/messages/de.json b/messages/de.json index b2dde2823..5073c6e7b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index c2be7f586..e85e5c5fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 1976cb245..80d3c7b3c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 5197be627..5bd017305 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index f5793f68e..933289bb6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 0ab99dbfc..fc43f1599 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/package.json b/package.json index d3cc633b4..08a47ab2d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index 2a10e0367..8eae7de8c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 633f51c70..463b78044 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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(null); + const [shareOpen, setShareOpen] = useState(false); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [suggestion, setSuggestion] = useState( @@ -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 ( - - - + <> + + +
+ + +
+ + ) : undefined + } + > + {detail.isPending && open ? ( +
+ + +
- ) : undefined - } - > - {detail.isPending && open ? ( -
- - - -
- ) : null} + ) : null} - {detail.isError ? ( -

- {t("documents.detail.loadError")} -

- ) : null} + {detail.isError ? ( +

+ {t("documents.detail.loadError")} +

+ ) : null} + + {doc ? ( +
+ {doc.servingClass === "inline" ? ( + + ) : ( +
+ {Icon ? ( + + ) : null} +

+ {doc.filename ?? title} +

+

+ {t("documents.detail.previewUnavailable")} ·{" "} + {formatBytes(doc.byteSize, locale)} +

+ +
+ )} + + 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 ? ( +

+ {mutationError} +

+ ) : null} + +
+
+ + {editingTitle ? ( + 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")} + /> + ) : ( + + )} +
+ +
+
+ + +
+
+ + + patch.mutate({ + documentDate: value === "" ? null : value, + }) + } + aria-label={t("documents.detail.dateLabel")} + /> +
+
+ +
+

+ {t("documents.detail.conditionsLabel")} +

+
+ {doc.conditionLinks.map((link) => ( + + {link.name} + + ))} + {doc.conditionLinks.length === 0 ? ( + + {t("documents.detail.noConditions")} + + ) : null} + {(episodes.data?.length ?? 0) > 0 ? ( + + + + + +
    + {episodes.data?.map((episode) => { + const linked = doc.conditionLinks.some( + (l) => l.episodeId === episode.id, + ); + return ( +
  • + +
  • + ); + })} +
+
+
+ ) : null} +
+
+ +

+ {t("documents.detail.uploadedMeta", { + date: format.date(doc.createdAt), + size: formatBytes(doc.byteSize, locale), + })} + {doc.filename ? ` · ${doc.filename}` : ""} +

+
+
+ ) : null} +
{doc ? ( -
- {doc.servingClass === "inline" ? ( - - ) : ( -
- {Icon ? ( - - ) : null} -

- {doc.filename ?? title} -

-

- {t("documents.detail.previewUnavailable")} ·{" "} - {formatBytes(doc.byteSize, locale)} -

- -
- )} - - 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 ? ( -

- {mutationError} -

- ) : null} - -
-
- - {editingTitle ? ( - 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")} - /> - ) : ( - - )} -
- -
-
- - -
-
- - - patch.mutate({ documentDate: value === "" ? null : value }) - } - aria-label={t("documents.detail.dateLabel")} - /> -
-
- -
-

- {t("documents.detail.conditionsLabel")} -

-
- {doc.conditionLinks.map((link) => ( - - {link.name} - - ))} - {doc.conditionLinks.length === 0 ? ( - - {t("documents.detail.noConditions")} - - ) : null} - {(episodes.data?.length ?? 0) > 0 ? ( - - - - - -
    - {episodes.data?.map((episode) => { - const linked = doc.conditionLinks.some( - (l) => l.episodeId === episode.id, - ); - return ( -
  • - -
  • - ); - })} -
-
-
- ) : null} -
-
- -

- {t("documents.detail.uploadedMeta", { - date: format.date(doc.createdAt), - size: formatBytes(doc.byteSize, locale), - })} - {doc.filename ? ` · ${doc.filename}` : ""} -

-
-
+ ) : null} -
+ ); } diff --git a/src/components/documents/document-share-sheet.tsx b/src/components/documents/document-share-sheet.tsx new file mode 100644 index 000000000..321cbe51f --- /dev/null +++ b/src/components/documents/document-share-sheet.tsx @@ -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 ( + + {open ? ( + + ) : null} + + ); +} diff --git a/src/components/settings/__tests__/share-link-create-form.test.tsx b/src/components/settings/__tests__/share-link-create-form.test.tsx new file mode 100644 index 000000000..bbde00093 --- /dev/null +++ b/src/components/settings/__tests__/share-link-create-form.test.tsx @@ -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( + + + , + ); +} + +describe(" — 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"); + }); +}); diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx new file mode 100644 index 000000000..b193c10a0 --- /dev/null +++ b/src/components/settings/share-link-create-form.tsx @@ -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/`, 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([ + "Patient", + "Observation", + ]); + const [selectedDocs, setSelectedDocs] = useState( + initialDocuments ?? [], + ); + const [pickerOpen, setPickerOpen] = useState(false); + const [created, setCreated] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [copied, setCopied] = useState<"link" | "passphrase" | null>(null); + const [formError, setFormError] = useState(null); + + // 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("/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 ( + <> +
{ + e.preventDefault(); + createMutation.mutate(); + }} + > +
+ + setLabel(e.target.value)} + placeholder={t("settings.sharing.labelPlaceholder")} + maxLength={120} + /> +
+ +
+
+ + + setRangeDays(Math.max(1, Number(e.target.value) || 1)) + } + /> +

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

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

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

+
+
+ +
+
+ +

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

+
+ +
+ + {allowFhirApi && ( +
+ + {t("settings.sharing.resourceTypes")} + +
+ {RESOURCE_TYPES.map((type) => ( + + ))} +
+
+ )} + + {/* ── Attach documents ─────────────────────────────────────── */} +
+
+
+ +

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

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

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

+ {selectedDocs.length > 0 ? ( +

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

+ ) : null} +
+ + {formError && ( +

+ {formError} +

+ )} + + +
+ + + + {created && ( +
+

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

+ {created.documentCount > 0 && ( +

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

+ )} +

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

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

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

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

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

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

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

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

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

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

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

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

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

+ )} +

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

+
+
+ )} + + ); +} diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index a18b43295..80efc0cd8 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -7,32 +7,23 @@ * scope-frozen, read-only view of the owner's own health record at * `/c/`, optionally exposing a scoped read-only FHIR face. The raw * `hls_` token is shown EXACTLY ONCE on create — the list never carries it - * (the server only stores its hash), so the copy-on-create card is the single - * chance to capture it. + * (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 `` 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([ - "Patient", - "Observation", - ]); - const [selectedDocs, setSelectedDocs] = useState([]); - const [pickerOpen, setPickerOpen] = useState(false); - const [created, setCreated] = useState(null); - const [qrDataUrl, setQrDataUrl] = useState(null); - const [copied, setCopied] = useState<"link" | "passphrase" | null>(null); - const [formError, setFormError] = useState(null); const [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("/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")} /> -
{ - e.preventDefault(); - createMutation.mutate(); - }} - > -
- - setLabel(e.target.value)} - placeholder={t("settings.sharing.labelPlaceholder")} - maxLength={120} - /> -
- -
-
- - - setRangeDays(Math.max(1, Number(e.target.value) || 1)) - } - /> -

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

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

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

-
-
- -
-
- -

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

-
- -
- - {allowFhirApi && ( -
- - {t("settings.sharing.resourceTypes")} - -
- {RESOURCE_TYPES.map((type) => ( - - ))} -
-
- )} - - {/* ── Attach documents ─────────────────────────────────────── */} -
-
-
- -

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

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

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

- {selectedDocs.length > 0 ? ( -

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

- ) : null} -
- - {formError && ( -

- {formError} -

- )} - - -
- - - - {created && ( -
-

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

- {created.documentCount > 0 && ( -

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

- )} -

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

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

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

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

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

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

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

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

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

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

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

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

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

- )} -

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

-
-
- )} +