From fdc0f83a4791e8eaa8a8447d20e10cf1f1ca365b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 21:15:19 +0200 Subject: [PATCH] feat(documents): share document picker, clinician document list, mobile QR --- messages/de.json | 25 +- messages/en.json | 25 +- messages/es.json | 25 +- messages/fr.json | 25 +- messages/it.json | 25 +- messages/pl.json | 25 +- src/app/c/[token]/page.tsx | 5 +- src/components/clinician/clinician-view.tsx | 126 ++++++++++ .../settings/share-document-picker.tsx | 215 ++++++++++++++++++ src/components/settings/sharing-section.tsx | 160 ++++++++++++- .../__tests__/share-view-data.test.ts | 2 + src/lib/clinician-share/share-view-data.ts | 8 + 12 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 src/components/settings/share-document-picker.tsx diff --git a/messages/de.json b/messages/de.json index 91fab32a0..2e132c202 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5119,7 +5119,23 @@ "passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.", "copyLink": "Link kopieren", "copyPassphrase": "Passphrase kopieren", - "protected": "Passphrase" + "protected": "Passphrase", + "attachTitle": "Dokumente anhängen", + "attachButton": "Dokumente auswählen", + "attachCount": "{count} von {max} ausgewählt", + "attachFrozen": "Die Dokumentenauswahl ist nach dem Erstellen des Links fest — Dokumente lassen sich danach weder hinzufügen noch entfernen.", + "attachRemove": "{title} entfernen", + "exifNote": "Kamera-Metadaten (EXIF/GPS) werden beim Teilen aus JPEG-, PNG- und WebP-Bildern entfernt. PDF-, TIFF-, HEIC- und Office-Dateien werden unverändert übernommen.", + "documentCount": "Angehängte Dokumente: {count}", + "qrScanTitle": "Zum Öffnen auf einem anderen Gerät scannen", + "qrScanHint": "Richte eine Handykamera auf den Code, um die geteilte Akte zu öffnen — die Passphrase ist bereits enthalten.", + "pickerTitle": "Dokumente anhängen", + "pickerDescription": "Durchsuche deinen Tresor und wähle Dokumente zum Teilen. Bis zu {max}.", + "pickerSearch": "Dokumente suchen", + "pickerEmpty": "Keine Dokumente gefunden.", + "pickerDone": "Fertig", + "pickerSelectedCount": "{count} von {max} ausgewählt", + "pickerMaxReached": "Maximum von {max} Dokumenten erreicht." }, "genderOther": "Divers", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "Diese geteilte Ansicht konnte nicht geladen werden", "description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.", "retry": "Erneut versuchen" + }, + "documents": { + "title": "Dokumente", + "untitled": "Unbenanntes Dokument", + "download": "Herunterladen", + "attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).", + "exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt." } }, "cycle": { diff --git a/messages/en.json b/messages/en.json index 3eddae1fa..fe0c3632b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5119,7 +5119,23 @@ "passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.", "copyLink": "Copy link", "copyPassphrase": "Copy passphrase", - "protected": "Passphrase" + "protected": "Passphrase", + "attachTitle": "Attach documents", + "attachButton": "Choose documents", + "attachCount": "{count} of {max} selected", + "attachFrozen": "The document set is fixed once you create the link — documents can't be added or removed afterwards.", + "attachRemove": "Remove {title}", + "exifNote": "Camera metadata (EXIF/GPS) is removed from JPEG, PNG and WebP images when shared. PDF, TIFF, HEIC and Office files pass through unchanged.", + "documentCount": "Attached documents: {count}", + "qrScanTitle": "Scan to open on another device", + "qrScanHint": "Point a phone camera at the code to open the shared record — the passphrase is already included.", + "pickerTitle": "Attach documents", + "pickerDescription": "Search your vault and select documents to share. Up to {max}.", + "pickerSearch": "Search documents", + "pickerEmpty": "No documents found.", + "pickerDone": "Done", + "pickerSelectedCount": "{count} of {max} selected", + "pickerMaxReached": "You've reached the maximum of {max} documents." }, "genderOther": "Other", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "This shared view could not be loaded", "description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.", "retry": "Try again" + }, + "documents": { + "title": "Documents", + "untitled": "Untitled document", + "download": "Download", + "attachmentHint": "This file downloads to your device (no in-page preview).", + "exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images." } }, "cycle": { diff --git a/messages/es.json b/messages/es.json index c11a90160..a9fff8d79 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5119,7 +5119,23 @@ "passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.", "copyLink": "Copiar enlace", "copyPassphrase": "Copiar frase de acceso", - "protected": "Frase de acceso" + "protected": "Frase de acceso", + "attachTitle": "Adjuntar documentos", + "attachButton": "Elegir documentos", + "attachCount": "{count} de {max} seleccionados", + "attachFrozen": "El conjunto de documentos queda fijo al crear el enlace: después no se pueden añadir ni quitar documentos.", + "attachRemove": "Quitar {title}", + "exifNote": "Los metadatos de la cámara (EXIF/GPS) se eliminan de las imágenes JPEG, PNG y WebP al compartir. Los archivos PDF, TIFF, HEIC y Office se comparten sin cambios.", + "documentCount": "Documentos adjuntos: {count}", + "qrScanTitle": "Escanéalo para abrirlo en otro dispositivo", + "qrScanHint": "Apunta la cámara del teléfono al código para abrir el registro compartido: la frase de acceso ya está incluida.", + "pickerTitle": "Adjuntar documentos", + "pickerDescription": "Busca en tu archivo y selecciona documentos para compartir. Hasta {max}.", + "pickerSearch": "Buscar documentos", + "pickerEmpty": "No se encontraron documentos.", + "pickerDone": "Listo", + "pickerSelectedCount": "{count} de {max} seleccionados", + "pickerMaxReached": "Has alcanzado el máximo de {max} documentos." }, "genderOther": "Otro", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "No se pudo cargar esta vista compartida", "description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.", "retry": "Intentar de nuevo" + }, + "documents": { + "title": "Documentos", + "untitled": "Documento sin título", + "download": "Descargar", + "attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).", + "exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas." } }, "cycle": { diff --git a/messages/fr.json b/messages/fr.json index 5b1e1aa2c..1d27244f7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5119,7 +5119,23 @@ "passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.", "copyLink": "Copier le lien", "copyPassphrase": "Copier la phrase secrète", - "protected": "Phrase secrète" + "protected": "Phrase secrète", + "attachTitle": "Joindre des documents", + "attachButton": "Choisir des documents", + "attachCount": "{count} sur {max} sélectionnés", + "attachFrozen": "La sélection de documents est figée à la création du lien — impossible d'ajouter ou de retirer des documents ensuite.", + "attachRemove": "Retirer {title}", + "exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) sont supprimées des images JPEG, PNG et WebP lors du partage. Les fichiers PDF, TIFF, HEIC et Office sont transmis tels quels.", + "documentCount": "Documents joints : {count}", + "qrScanTitle": "Scannez pour ouvrir sur un autre appareil", + "qrScanHint": "Pointez l'appareil photo d'un téléphone vers le code pour ouvrir le dossier partagé — la phrase secrète est déjà incluse.", + "pickerTitle": "Joindre des documents", + "pickerDescription": "Recherchez dans votre coffre et sélectionnez les documents à partager. Jusqu'à {max}.", + "pickerSearch": "Rechercher des documents", + "pickerEmpty": "Aucun document trouvé.", + "pickerDone": "Terminé", + "pickerSelectedCount": "{count} sur {max} sélectionnés", + "pickerMaxReached": "Vous avez atteint le maximum de {max} documents." }, "genderOther": "Autre", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "Impossible de charger cette vue partagée", "description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.", "retry": "Réessayer" + }, + "documents": { + "title": "Documents", + "untitled": "Document sans titre", + "download": "Télécharger", + "attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).", + "exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées." } }, "cycle": { diff --git a/messages/it.json b/messages/it.json index 8039367fe..ca480036c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5119,7 +5119,23 @@ "passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.", "copyLink": "Copia link", "copyPassphrase": "Copia passphrase", - "protected": "Passphrase" + "protected": "Passphrase", + "attachTitle": "Allega documenti", + "attachButton": "Scegli documenti", + "attachCount": "{count} di {max} selezionati", + "attachFrozen": "La selezione dei documenti è fissata alla creazione del link: dopo non è possibile aggiungere o rimuovere documenti.", + "attachRemove": "Rimuovi {title}", + "exifNote": "I metadati della fotocamera (EXIF/GPS) vengono rimossi dalle immagini JPEG, PNG e WebP durante la condivisione. I file PDF, TIFF, HEIC e Office vengono condivisi invariati.", + "documentCount": "Documenti allegati: {count}", + "qrScanTitle": "Scansiona per aprire su un altro dispositivo", + "qrScanHint": "Inquadra il codice con la fotocamera del telefono per aprire la cartella condivisa: la passphrase è già inclusa.", + "pickerTitle": "Allega documenti", + "pickerDescription": "Cerca nel tuo archivio e seleziona i documenti da condividere. Fino a {max}.", + "pickerSearch": "Cerca documenti", + "pickerEmpty": "Nessun documento trovato.", + "pickerDone": "Fatto", + "pickerSelectedCount": "{count} di {max} selezionati", + "pickerMaxReached": "Hai raggiunto il massimo di {max} documenti." }, "genderOther": "Altro", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "Impossibile caricare questa vista condivisa", "description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.", "retry": "Riprova" + }, + "documents": { + "title": "Documenti", + "untitled": "Documento senza titolo", + "download": "Scarica", + "attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).", + "exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise." } }, "cycle": { diff --git a/messages/pl.json b/messages/pl.json index a7802c80b..dafd3fa69 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5119,7 +5119,23 @@ "passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.", "copyLink": "Kopiuj link", "copyPassphrase": "Kopiuj hasło", - "protected": "Hasło" + "protected": "Hasło", + "attachTitle": "Dołącz dokumenty", + "attachButton": "Wybierz dokumenty", + "attachCount": "Wybrano {count} z {max}", + "attachFrozen": "Zestaw dokumentów jest ustalany przy tworzeniu linku — później nie można dodawać ani usuwać dokumentów.", + "attachRemove": "Usuń {title}", + "exifNote": "Metadane aparatu (EXIF/GPS) są usuwane z obrazów JPEG, PNG i WebP podczas udostępniania. Pliki PDF, TIFF, HEIC i Office są przekazywane bez zmian.", + "documentCount": "Dołączone dokumenty: {count}", + "qrScanTitle": "Zeskanuj, aby otworzyć na innym urządzeniu", + "qrScanHint": "Skieruj aparat telefonu na kod, aby otworzyć udostępnioną dokumentację — hasło jest już dołączone.", + "pickerTitle": "Dołącz dokumenty", + "pickerDescription": "Przeszukaj swój sejf i wybierz dokumenty do udostępnienia. Maksymalnie {max}.", + "pickerSearch": "Szukaj dokumentów", + "pickerEmpty": "Nie znaleziono dokumentów.", + "pickerDone": "Gotowe", + "pickerSelectedCount": "Wybrano {count} z {max}", + "pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów." }, "genderOther": "Inna", "cycleTracking": { @@ -6591,6 +6607,13 @@ "title": "Nie udało się załadować udostępnionego widoku", "description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.", "retry": "Spróbuj ponownie" + }, + "documents": { + "title": "Dokumenty", + "untitled": "Dokument bez tytułu", + "download": "Pobierz", + "attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).", + "exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP." } }, "cycle": { diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 986836f8a..8900ba51f 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -103,7 +103,7 @@ export default async function ClinicianSharePage({ const context = await resolveShareToken(token); if (!context) notFound(); - const [{ report, sections }, locale] = await Promise.all([ + const [{ report, sections, documents }, locale] = await Promise.all([ loadShareViewData(context), resolveLocale(), ]); @@ -116,6 +116,9 @@ export default async function ClinicianSharePage({ expiresAt={context.expiresAt.toISOString()} report={report} sections={sections} + documents={documents} + token={token} + locale={locale} /> ); } diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 9340fc763..81ea6eb28 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -10,8 +10,14 @@ * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a * clinical assessment / not a diagnosis" disclaimer. */ +import { Download } from "lucide-react"; + +import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta"; +import { formatBytes } from "@/components/documents/vault-utils"; +import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys"; import type { DoctorReportData } from "@/lib/doctor-report-data"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; type Translate = ( @@ -27,6 +33,16 @@ interface ClinicianViewProps { expiresAt: string; report: DoctorReportData; sections: DoctorReportPrefs; + /** + * The frozen document set on this link (metadata only — never bytes). Each + * entry points at the token-scoped serve route (`/c//d/`), the + * one decrypt path. Empty when the owner shared no documents. + */ + documents?: ShareViewDocument[]; + /** The raw share token from the path — used to build serve-route URLs. */ + token?: string; + /** Viewer locale (byte formatting only). Defaults to English. */ + locale?: string; } /** Human-readable display per persisted wellness-score type (i18n key suffix). */ @@ -74,12 +90,102 @@ function Section({ ); } +/** + * One shared document. Class A (inline) renders a preview straight at the + * token-scoped serve route — an `` for raster images, a framed PDF + * otherwise; the proxy's share-serve CSP (`default-src 'none'; + * frame-ancestors 'self'`) fences the framed context. Class B (attachment) + * offers a plain download link — the serve route answers it as an opaque + * `application/octet-stream` attachment. No bytes reach this component; only + * the metadata and the URL. + */ +function DocumentEntry({ + t, + doc, + token, + locale, +}: { + t: Translate; + doc: ShareViewDocument; + token: string; + locale: string; +}) { + const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`; + const title = doc.title ?? t("clinicianView.documents.untitled"); + const Icon = + DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined; + const meta = [ + doc.documentDate, + formatBytes(doc.byteSize, locale), + ].filter(Boolean) as string[]; + const isImage = doc.mimeType.startsWith("image/"); + const isInline = doc.servingClass === "inline"; + + return ( +
  • +
    +
    + {Icon ? ( + + ) : null} +
    +

    {title}

    + {meta.length > 0 ? ( +

    + {meta.join(" · ")} +

    + ) : null} +
    +
    + {/* Download is always available (inline or attachment) so the recipient + can keep a copy; the anchor hits the same token-scoped serve route. */} + + + {t("clinicianView.documents.download")} + +
    + + {isInline ? ( +
    + {isImage ? ( + // The served image is the frozen original with EXIF/GPS stripped on + // egress; it renders inline within the Class A carve-out. + // eslint-disable-next-line @next/next/no-img-element + {title} + ) : ( +