From d75a5386147b3b19aaa1354ac54b527444ea169b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 19 Jun 2026 22:03:53 +0200 Subject: [PATCH] Show the QR, passphrase, and protected badge in the owner UI On a successful create the Sharing section now renders the QR code (from the #k= deep link), the passphrase as copyable text, and the link, with a clear shown-once notice. The active-link list carries a small passphrase indicator. Add the new strings (QR label, passphrase label, copy buttons, hints, protected badge, and the public unlock prompt) across all six locale bundles. --- messages/de.json | 20 +- messages/en.json | 20 +- messages/es.json | 20 +- messages/fr.json | 20 +- messages/it.json | 20 +- messages/pl.json | 20 +- src/components/settings/sharing-section.tsx | 201 +++++++++++++++----- 7 files changed, 261 insertions(+), 60 deletions(-) diff --git a/messages/de.json b/messages/de.json index a70f807a6..366e5ba2e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4676,7 +4676,16 @@ "statusActive": "Aktiv", "statusRevoked": "Widerrufen", "statusExpired": "Abgelaufen", - "inactiveTitle": "Widerrufen und abgelaufen ({count})" + "inactiveTitle": "Widerrufen und abgelaufen ({count})", + "shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.", + "qrLabel": "QR-Code", + "qrAlt": "QR-Code mit Freigabelink und Passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "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" }, "genderOther": "Divers", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Belastungswert", "score": "Wellness-Wert" }, - "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker." + "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.", + "unlock": { + "title": "Passphrase eingeben", + "description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.", + "label": "Passphrase", + "error": "Diese Passphrase ist nicht korrekt.", + "submit": "Entsperren" + } }, "cycle": { "symptomCategory": { diff --git a/messages/en.json b/messages/en.json index 551a46efe..45e6a3f98 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4676,7 +4676,16 @@ "statusActive": "Active", "statusRevoked": "Revoked", "statusExpired": "Expired", - "inactiveTitle": "Revoked and expired ({count})" + "inactiveTitle": "Revoked and expired ({count})", + "shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.", + "qrLabel": "QR code", + "qrAlt": "QR code containing the share link and passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.", + "copyLink": "Copy link", + "copyPassphrase": "Copy passphrase", + "protected": "Passphrase" }, "genderOther": "Other", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Strain score", "score": "Wellness score" }, - "footer": "Shared from HealthLog — a self-hosted personal health tracker." + "footer": "Shared from HealthLog — a self-hosted personal health tracker.", + "unlock": { + "title": "Enter passphrase", + "description": "This shared health record is protected. Enter the passphrase you were given to view it.", + "label": "Passphrase", + "error": "That passphrase is not correct.", + "submit": "Unlock" + } }, "cycle": { "symptomCategory": { diff --git a/messages/es.json b/messages/es.json index c4c7cc722..983170479 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4676,7 +4676,16 @@ "statusActive": "Activo", "statusRevoked": "Revocado", "statusExpired": "Caducado", - "inactiveTitle": "Revocados y caducados ({count})" + "inactiveTitle": "Revocados y caducados ({count})", + "shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.", + "qrLabel": "Código QR", + "qrAlt": "Código QR con el enlace para compartir y la frase de acceso", + "linkLabel": "Enlace", + "passphraseLabel": "Frase de acceso", + "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" }, "genderOther": "Otro", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Puntuación de esfuerzo", "score": "Puntuación de bienestar" }, - "footer": "Compartido desde HealthLog — un registro de salud autoalojado." + "footer": "Compartido desde HealthLog — un registro de salud autoalojado.", + "unlock": { + "title": "Introduzca la frase de acceso", + "description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.", + "label": "Frase de acceso", + "error": "Esa frase de acceso no es correcta.", + "submit": "Desbloquear" + } }, "cycle": { "symptomCategory": { diff --git a/messages/fr.json b/messages/fr.json index 532b2cbf9..b79b3ce16 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4676,7 +4676,16 @@ "statusActive": "Actif", "statusRevoked": "Révoqué", "statusExpired": "Expiré", - "inactiveTitle": "Révoqués et expirés ({count})" + "inactiveTitle": "Révoqués et expirés ({count})", + "shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.", + "qrLabel": "Code QR", + "qrAlt": "Code QR contenant le lien de partage et la phrase secrète", + "linkLabel": "Lien", + "passphraseLabel": "Phrase secrète", + "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" }, "genderOther": "Autre", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Score de charge", "score": "Score de bien-être" }, - "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé." + "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.", + "unlock": { + "title": "Saisir la phrase secrète", + "description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.", + "label": "Phrase secrète", + "error": "Cette phrase secrète est incorrecte.", + "submit": "Déverrouiller" + } }, "cycle": { "symptomCategory": { diff --git a/messages/it.json b/messages/it.json index 0e28f4af2..09a66cf1a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4676,7 +4676,16 @@ "statusActive": "Attivo", "statusRevoked": "Revocato", "statusExpired": "Scaduto", - "inactiveTitle": "Revocati e scaduti ({count})" + "inactiveTitle": "Revocati e scaduti ({count})", + "shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.", + "qrLabel": "Codice QR", + "qrAlt": "Codice QR contenente il link di condivisione e la passphrase", + "linkLabel": "Link", + "passphraseLabel": "Passphrase", + "passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.", + "copyLink": "Copia link", + "copyPassphrase": "Copia passphrase", + "protected": "Passphrase" }, "genderOther": "Altro", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Punteggio di sforzo", "score": "Punteggio di benessere" }, - "footer": "Condiviso da HealthLog — un tracker della salute self-hosted." + "footer": "Condiviso da HealthLog — un tracker della salute self-hosted.", + "unlock": { + "title": "Inserisci la passphrase", + "description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.", + "label": "Passphrase", + "error": "La passphrase non è corretta.", + "submit": "Sblocca" + } }, "cycle": { "symptomCategory": { diff --git a/messages/pl.json b/messages/pl.json index b528246d6..77f4a881d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4676,7 +4676,16 @@ "statusActive": "Aktywny", "statusRevoked": "Unieważniony", "statusExpired": "Wygasły", - "inactiveTitle": "Unieważnione i wygasłe ({count})" + "inactiveTitle": "Unieważnione i wygasłe ({count})", + "shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.", + "qrLabel": "Kod QR", + "qrAlt": "Kod QR zawierający link do udostępniania i hasło", + "linkLabel": "Link", + "passphraseLabel": "Hasło", + "passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.", + "copyLink": "Kopiuj link", + "copyPassphrase": "Kopiuj hasło", + "protected": "Hasło" }, "genderOther": "Inna", "cycleTracking": { @@ -5963,7 +5972,14 @@ "strain": "Wynik obciążenia", "score": "Wynik dobrostanu" }, - "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia." + "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.", + "unlock": { + "title": "Wprowadź hasło", + "description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.", + "label": "Hasło", + "error": "To hasło jest nieprawidłowe.", + "submit": "Odblokuj" + } }, "cycle": { "symptomCategory": { diff --git a/src/components/settings/sharing-section.tsx b/src/components/settings/sharing-section.tsx index 65f86d9cf..6a1efd874 100644 --- a/src/components/settings/sharing-section.tsx +++ b/src/components/settings/sharing-section.tsx @@ -21,9 +21,10 @@ * the share-links card. Cards paint through `` so the surface * matches every sibling section 1:1. */ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Copy, Loader2, Share2, Trash2 } from "lucide-react"; +import QRCode from "qrcode"; +import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react"; import { AlertDialog, @@ -71,6 +72,8 @@ interface ShareLinkSummary { rangeEnd: string | null; resourceTypes: string[]; allowFhirApi: boolean; + /** v1.18.7 — whether a passphrase second factor guards this link. */ + protected: boolean; expiresAt: string; createdAt: string; revokedAt: string | null; @@ -79,6 +82,18 @@ interface ShareLinkSummary { 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; +} + export function SharingSection() { // v1.18.7 — the visible heading + subtitle come from the shared settings // shell chrome in the route; this body is the share-links card only. @@ -106,11 +121,34 @@ function ShareLinksCard() { "Patient", "Observation", ]); - const [newToken, setNewToken] = useState(null); - const [copied, setCopied] = 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; + QRCode.toDataURL(qrUrl, { margin: 1, width: 220 }) + .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: () => @@ -128,27 +166,26 @@ function ShareLinksCard() { 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, - expiresAt: isoDaysFromNow(expiryDays), - }, - ); + return apiPost("/api/share-links", { + label: trimmed, + rangeStart: isoDaysFromNow(-rangeDays), + rangeEnd: null, + resourceTypes, + allowFhirApi, + expiresAt: isoDaysFromNow(expiryDays), + }); }, - onSuccess: (created) => { - setNewToken(created.token); - setCopied(false); + onSuccess: (result) => { + // Clear any stale QR before the effect renders the new one. + setQrDataUrl(null); + setCreated(result); + setCopied(null); setLabel(""); setFormError(null); queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() }); }, onError: (err: Error) => { - setNewToken(null); + setCreated(null); setFormError( err.message === "EXPIRY_RANGE" ? t("settings.sharing.expiryInvalid", { max: MAX_DAYS }) @@ -170,15 +207,13 @@ function ShareLinksCard() { ); } - async function copyToken() { - if (!newToken) return; + async function copyValue(value: string, which: "link" | "passphrase") { try { - const origin = window.location.origin; - await navigator.clipboard.writeText(`${origin}/c/${newToken}`); - setCopied(true); + await navigator.clipboard.writeText(value); + setCopied(which); } catch { - // Clipboard can be unavailable (insecure context); the token stays - // visible in the card so the owner can copy it by hand. + // Clipboard can be unavailable (insecure context); the values stay + // visible in the card so the owner can copy them by hand. } } @@ -313,37 +348,97 @@ function ShareLinksCard() { - {newToken && ( + {created && (

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

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

-
- - {`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`} - - -
- {copied && ( -

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

+ + {/* QR — carries the passphrase in the URL fragment, so scanning it + opens the record. Shown exactly once with the passphrase text. */} + {qrDataUrl && ( +
+

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

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

+
)} @@ -373,6 +468,16 @@ function ShareLinksCard() { {t("settings.sharing.statusActive")} + {link.protected && ( + + + {t("settings.sharing.protected")} + + )} {link.allowFhirApi && ( FHIR