mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
5783acacbb
commit
d75a538614
7 files changed
+261
-60
No files matched your search
+18
-2
@@ -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": {
|
||||
|
||||
+18
-2
@@ -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": {
|
||||
|
||||
+18
-2
@@ -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": {
|
||||
|
||||
+18
-2
@@ -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": {
|
||||
|
||||
+18
-2
@@ -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": {
|
||||
|
||||
+18
-2
@@ -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": {
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
* the share-links card. Cards paint through `<SettingsCard>` 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<string | null>(null);
|
||||
const [copied, setCopied] = 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;
|
||||
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<ShareLinkSummary & { token: string }>(
|
||||
"/api/share-links",
|
||||
{
|
||||
label: trimmed,
|
||||
rangeStart: isoDaysFromNow(-rangeDays),
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
},
|
||||
);
|
||||
return apiPost<ShareLinkCreated>("/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() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{newToken && (
|
||||
{created && (
|
||||
<div
|
||||
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
|
||||
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>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.tokenOnce")}
|
||||
{t("settings.sharing.shownOnce")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={copyToken}
|
||||
aria-label={t("settings.sharing.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
|
||||
{/* QR — carries the passphrase in the URL fragment, so scanning it
|
||||
opens the record. Shown exactly once with the passphrase text. */}
|
||||
{qrDataUrl && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.qrLabel")}
|
||||
</p>
|
||||
{/* 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={220}
|
||||
height={220}
|
||||
className="bg-background rounded border p-2"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -373,6 +468,16 @@ function ShareLinksCard() {
|
||||
<Badge className="bg-success/15 text-success text-[10px]">
|
||||
{t("settings.sharing.statusActive")}
|
||||
</Badge>
|
||||
{link.protected && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 text-[10px]"
|
||||
data-testid="share-protected-badge"
|
||||
>
|
||||
<KeyRound className="h-2.5 w-2.5" />
|
||||
{t("settings.sharing.protected")}
|
||||
</Badge>
|
||||
)}
|
||||
{link.allowFhirApi && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
FHIR
|
||||
|
||||
Reference in new issue
Block a user