mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(documents): share action on the document detail sheet
Extract the clinician share-link create flow (create form, document picker mount, one-time QR + passphrase reveal) from the Settings sharing section into a reusable ShareLinkCreateForm. Settings mounts it unchanged alongside the active/revoked lists; the document detail sheet adds a Share action beside Download that opens the same flow in a bottom sheet with the current document pre-attached and its title pre-filled as the label. The one-time QR reveal lands in that sheet, scannable on mobile. No backend change: the shared POST /api/share-links handles the attached document set as before.
This commit is contained in:
1 parent
b00a2206c5
commit
d4009a3587
10 files changed
+936
-768
No files matched your search
+6
-1
@@ -8140,7 +8140,8 @@
|
||||
"linkCondition": "Verknüpfen",
|
||||
"noConditions": "Mit keiner Erkrankung verknüpft",
|
||||
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
|
||||
"uploadedMeta": "Hinzugefügt am {date} · {size}"
|
||||
"uploadedMeta": "Hinzugefügt am {date} · {size}",
|
||||
"share": "Teilen"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Zum Hochladen ablegen",
|
||||
@@ -8219,6 +8220,10 @@
|
||||
"indexAllQueued": "{count} Dokument(e) werden im Hintergrund indiziert.",
|
||||
"indexAllNothing": "Alles ist bereits indiziert.",
|
||||
"indexAllError": "Indizierung konnte nicht gestartet werden."
|
||||
},
|
||||
"share": {
|
||||
"title": "Dieses Dokument teilen",
|
||||
"description": "Erstelle einen schreibgeschützten Ärzte-Link mit diesem angehängten Dokument. Link und Passphrase erscheinen nur einmal."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+6
-1
@@ -8140,7 +8140,8 @@
|
||||
"linkCondition": "Link",
|
||||
"noConditions": "Not linked to a condition",
|
||||
"previewUnavailable": "No inline preview for this file type",
|
||||
"uploadedMeta": "Added {date} · {size}"
|
||||
"uploadedMeta": "Added {date} · {size}",
|
||||
"share": "Share"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Drop files to upload",
|
||||
@@ -8219,6 +8220,10 @@
|
||||
"indexAllQueued": "Indexing {count} document(s) in the background.",
|
||||
"indexAllNothing": "Everything is already indexed.",
|
||||
"indexAllError": "Couldn't start indexing."
|
||||
},
|
||||
"share": {
|
||||
"title": "Share this document",
|
||||
"description": "Create a read-only clinician link with this document attached. The link and its passphrase appear once."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+6
-1
@@ -8140,7 +8140,8 @@
|
||||
"linkCondition": "Vincular",
|
||||
"noConditions": "Sin vincular a una afección",
|
||||
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
|
||||
"uploadedMeta": "Añadido el {date} · {size}"
|
||||
"uploadedMeta": "Añadido el {date} · {size}",
|
||||
"share": "Compartir"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Suelta los archivos para subirlos",
|
||||
@@ -8219,6 +8220,10 @@
|
||||
"indexAllQueued": "Indexando {count} documento(s) en segundo plano.",
|
||||
"indexAllNothing": "Todo ya está indexado.",
|
||||
"indexAllError": "No se pudo iniciar la indexación."
|
||||
},
|
||||
"share": {
|
||||
"title": "Compartir este documento",
|
||||
"description": "Crea un enlace de solo lectura para profesionales con este documento adjunto. El enlace y su frase de contraseña aparecen una sola vez."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+6
-1
@@ -8140,7 +8140,8 @@
|
||||
"linkCondition": "Lier",
|
||||
"noConditions": "Lié à aucune affection",
|
||||
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
|
||||
"uploadedMeta": "Ajouté le {date} · {size}"
|
||||
"uploadedMeta": "Ajouté le {date} · {size}",
|
||||
"share": "Partager"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Déposez les fichiers pour les téléverser",
|
||||
@@ -8219,6 +8220,10 @@
|
||||
"indexAllQueued": "Indexation de {count} document(s) en arrière-plan.",
|
||||
"indexAllNothing": "Tout est déjà indexé.",
|
||||
"indexAllError": "Impossible de démarrer l'indexation."
|
||||
},
|
||||
"share": {
|
||||
"title": "Partager ce document",
|
||||
"description": "Créez un lien clinicien en lecture seule avec ce document joint. Le lien et sa phrase secrète n’apparaissent qu’une seule fois."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+6
-1
@@ -8140,7 +8140,8 @@
|
||||
"linkCondition": "Collega",
|
||||
"noConditions": "Non collegato a una patologia",
|
||||
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
|
||||
"uploadedMeta": "Aggiunto il {date} · {size}"
|
||||
"uploadedMeta": "Aggiunto il {date} · {size}",
|
||||
"share": "Condividi"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Rilascia i file per caricarli",
|
||||
@@ -8219,6 +8220,10 @@
|
||||
"indexAllQueued": "Indicizzazione di {count} documento/i in background.",
|
||||
"indexAllNothing": "Tutto è già indicizzato.",
|
||||
"indexAllError": "Impossibile avviare l'indicizzazione."
|
||||
},
|
||||
"share": {
|
||||
"title": "Condividi questo documento",
|
||||
"description": "Crea un link di sola lettura per il medico con questo documento allegato. Il link e la relativa passphrase compaiono una sola volta."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+6
-1
@@ -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": {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* copy. No share affordance anywhere — deliberately.
|
||||
*/
|
||||
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 +59,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 +224,7 @@ export function DocumentDetailSheet({
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
|
||||
@@ -242,6 +244,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 +402,315 @@ export function DocumentDetailSheet({
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={t("documents.detail.title")}
|
||||
contentWidth="3xl"
|
||||
footer={
|
||||
doc ? (
|
||||
// Delete sits bottom-LEFT, quieted to a text button: the
|
||||
// bottom-right slot reads as the primary/confirm action, and a
|
||||
// destructive control there is a footgun. Download — the safe,
|
||||
// expected action — keeps the trailing edge.
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => remove.mutate(doc.id)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.detail.delete")}
|
||||
</Button>
|
||||
<Button variant="outline" asChild data-slot="document-download">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
<>
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={t("documents.detail.title")}
|
||||
contentWidth="3xl"
|
||||
footer={
|
||||
doc ? (
|
||||
// Delete sits bottom-LEFT, quieted to a text button: the
|
||||
// bottom-right slot reads as the primary/confirm action, and a
|
||||
// destructive control there is a footgun. Share + Download — the
|
||||
// safe, expected actions — keep the trailing edge. On phones their
|
||||
// labels collapse to icon-only (aria-labelled) so three controls
|
||||
// never overflow the sheet at 360 px; the labels return at `sm+`.
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => remove.mutate(doc.id)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.detail.delete")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShareOpen(true)}
|
||||
data-slot="document-share"
|
||||
aria-label={t("documents.detail.share")}
|
||||
>
|
||||
<Share2 className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.share")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="document-download"
|
||||
aria-label={t("documents.detail.download")}
|
||||
>
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.download")}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{detail.isPending && open ? (
|
||||
<div className="space-y-3" data-slot="document-detail-loading">
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-2/3" />
|
||||
<Skeleton className="h-9 w-1/2" />
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{detail.isPending && open ? (
|
||||
<div className="space-y-3" data-slot="document-detail-loading">
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-2/3" />
|
||||
<Skeleton className="h-9 w-1/2" />
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{detail.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.detail.loadError")}
|
||||
</p>
|
||||
) : null}
|
||||
{detail.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.detail.loadError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{doc ? (
|
||||
<div className="space-y-6">
|
||||
{doc.servingClass === "inline" ? (
|
||||
<InlinePreview
|
||||
documentId={doc.id}
|
||||
mimeType={doc.mimeType}
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
|
||||
{Icon ? (
|
||||
<Icon className="text-muted-foreground size-8" aria-hidden />
|
||||
) : null}
|
||||
<p className="max-w-full truncate text-sm font-medium">
|
||||
{doc.filename ?? title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
suggestion={suggestion}
|
||||
suggestionKindLabel={suggestionKindLabel}
|
||||
suggestionDateLabel={suggestionDateLabel}
|
||||
appliedFields={appliedFields}
|
||||
onUseTitle={applyTitle}
|
||||
onUseKind={applyKind}
|
||||
onUseDate={applyDate}
|
||||
onDismissSuggestion={() => setSuggestion(null)}
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
summary.reset();
|
||||
}}
|
||||
hasContentIndex={doc.hasContentIndex}
|
||||
indexPending={indexDoc.isPending}
|
||||
onIndex={runIndex}
|
||||
/>
|
||||
|
||||
{mutationError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-title-input">
|
||||
{t("documents.detail.titleLabel")}
|
||||
</Label>
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
id="document-title-input"
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitTitle();
|
||||
if (e.key === "Escape") setEditingTitle(false);
|
||||
}}
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
id="document-title-input"
|
||||
onClick={() => {
|
||||
setTitleDraft(doc.title ?? "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
doc.title === null && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{doc.title ?? t("documents.detail.titlePlaceholder")}
|
||||
</span>
|
||||
<Pencil
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-kind-select">
|
||||
{t("documents.detail.kindLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={doc.kind}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="document-kind-select" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INBOUND_DOCUMENT_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-date-field">
|
||||
{t("documents.detail.dateLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({
|
||||
documentDate: value === "" ? null : value,
|
||||
})
|
||||
}
|
||||
aria-label={t("documents.detail.dateLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.conditionsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.conditionLinks.map((link) => (
|
||||
<Link
|
||||
key={link.episodeId}
|
||||
href={`/illness/${link.episodeId}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{doc.conditionLinks.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{(episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-2.5 text-xs"
|
||||
>
|
||||
<Plus className="size-3" aria-hidden />
|
||||
{t("documents.detail.linkCondition")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-2">
|
||||
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
{episodes.data?.map((episode) => {
|
||||
const linked = doc.conditionLinks.some(
|
||||
(l) => l.episodeId === episode.id,
|
||||
);
|
||||
return (
|
||||
<li key={episode.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEpisode(episode.id)}
|
||||
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-pressed={linked}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
linked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{episode.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.uploadedMeta", {
|
||||
date: format.date(doc.createdAt),
|
||||
size: formatBytes(doc.byteSize, locale),
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{doc ? (
|
||||
<div className="space-y-6">
|
||||
{doc.servingClass === "inline" ? (
|
||||
<InlinePreview
|
||||
documentId={doc.id}
|
||||
mimeType={doc.mimeType}
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
|
||||
{Icon ? (
|
||||
<Icon className="text-muted-foreground size-8" aria-hidden />
|
||||
) : null}
|
||||
<p className="max-w-full truncate text-sm font-medium">
|
||||
{doc.filename ?? title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
suggestion={suggestion}
|
||||
suggestionKindLabel={suggestionKindLabel}
|
||||
suggestionDateLabel={suggestionDateLabel}
|
||||
appliedFields={appliedFields}
|
||||
onUseTitle={applyTitle}
|
||||
onUseKind={applyKind}
|
||||
onUseDate={applyDate}
|
||||
onDismissSuggestion={() => setSuggestion(null)}
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
summary.reset();
|
||||
}}
|
||||
hasContentIndex={doc.hasContentIndex}
|
||||
indexPending={indexDoc.isPending}
|
||||
onIndex={runIndex}
|
||||
/>
|
||||
|
||||
{mutationError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-title-input">
|
||||
{t("documents.detail.titleLabel")}
|
||||
</Label>
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
id="document-title-input"
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitTitle();
|
||||
if (e.key === "Escape") setEditingTitle(false);
|
||||
}}
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
id="document-title-input"
|
||||
onClick={() => {
|
||||
setTitleDraft(doc.title ?? "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
doc.title === null && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{doc.title ?? t("documents.detail.titlePlaceholder")}
|
||||
</span>
|
||||
<Pencil
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-kind-select">
|
||||
{t("documents.detail.kindLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={doc.kind}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="document-kind-select" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INBOUND_DOCUMENT_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-date-field">
|
||||
{t("documents.detail.dateLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({ documentDate: value === "" ? null : value })
|
||||
}
|
||||
aria-label={t("documents.detail.dateLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.conditionsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.conditionLinks.map((link) => (
|
||||
<Link
|
||||
key={link.episodeId}
|
||||
href={`/illness/${link.episodeId}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{doc.conditionLinks.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{(episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-2.5 text-xs"
|
||||
>
|
||||
<Plus className="size-3" aria-hidden />
|
||||
{t("documents.detail.linkCondition")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-2">
|
||||
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
{episodes.data?.map((episode) => {
|
||||
const linked = doc.conditionLinks.some(
|
||||
(l) => l.episodeId === episode.id,
|
||||
);
|
||||
return (
|
||||
<li key={episode.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEpisode(episode.id)}
|
||||
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-pressed={linked}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
linked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{episode.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.uploadedMeta", {
|
||||
date: format.date(doc.createdAt),
|
||||
size: formatBytes(doc.byteSize, locale),
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DocumentShareSheet
|
||||
open={shareOpen}
|
||||
onOpenChange={setShareOpen}
|
||||
documentId={doc.id}
|
||||
documentTitle={title}
|
||||
/>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document-launched entry point into the clinician share-link create flow.
|
||||
* Mounts the shared `ShareLinkCreateForm` inside a `ResponsiveSheet` (bottom
|
||||
* sheet on phones, dialog on desktop) with the current document pre-attached
|
||||
* and its title pre-filled as the link label — the owner shares the document
|
||||
* they were looking at without leaving for Settings, and the one-time QR /
|
||||
* passphrase reveal lands in the same surface.
|
||||
*
|
||||
* The frozen-write-once + ≤50 rules, the EXIF note, and expiry all come from
|
||||
* the shared form as-is; the same `POST /api/share-links` handles the create.
|
||||
* The form is mounted only while the sheet is open so each open starts a fresh
|
||||
* link (the one-time secret is never re-shown for a stale create).
|
||||
*/
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ShareLinkCreateForm } from "@/components/settings/share-link-create-form";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export function DocumentShareSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
documentId,
|
||||
documentTitle,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("documents.share.title")}
|
||||
description={t("documents.share.description")}
|
||||
contentWidth="2xl"
|
||||
>
|
||||
{open ? (
|
||||
<ShareLinkCreateForm
|
||||
initialDocuments={[{ id: documentId, title: documentTitle }]}
|
||||
initialLabel={documentTitle}
|
||||
/>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The clinician share-link CREATE flow, extracted so both surfaces mount the
|
||||
* exact same form: Settings → Sharing (the owner section) and the document
|
||||
* detail sheet's "Share" action. A share link is a time-boxed, scope-frozen,
|
||||
* read-only view of the owner's own record at `/c/<token>`, optionally
|
||||
* exposing a scoped read-only FHIR face and a hand-picked, frozen-at-create
|
||||
* document set.
|
||||
*
|
||||
* The raw `hls_` token, the passphrase, and the `#k=` QR are returned EXACTLY
|
||||
* ONCE on create (the server stores only hashes); this component is the single
|
||||
* chance to capture them. Reads unwrap `(await res.json()).data`; the query key
|
||||
* comes from the centralised factory. No markdown anywhere — every value
|
||||
* renders as escaped React text.
|
||||
*
|
||||
* The caller owns the chrome (a `SettingsCard` in Settings, a `ResponsiveSheet`
|
||||
* in the document flow); this component renders only the form, the picker, and
|
||||
* the one-time reveal. Pass `initialDocuments` to pre-attach a document (the
|
||||
* document-launched flow seeds the document the user was looking at) and
|
||||
* `initialLabel` to pre-fill the label.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
ShareDocumentPicker,
|
||||
type PickedDocument,
|
||||
} from "@/components/settings/share-document-picker";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
|
||||
export const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum documents per share — mirrors `SHARE_LINK_MAX_DOCUMENTS` on the
|
||||
* server. Kept as a local literal (not imported from the validations module)
|
||||
* because that module pulls the Prisma client into scope and would drag the DB
|
||||
* into the client bundle; the server re-enforces the cap on create regardless.
|
||||
*/
|
||||
const MAX_DOCUMENTS = 50;
|
||||
|
||||
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
|
||||
export interface ShareLinkSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
/** v1.18.7 — whether a passphrase second factor guards this link. */
|
||||
protected: boolean;
|
||||
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
|
||||
documentCount: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
lastAccessAt: string | null;
|
||||
accessCount: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 — the create response. The token, passphrase, and URLs are returned
|
||||
* EXACTLY ONCE here; the list query never carries them (the server stores only
|
||||
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
|
||||
*/
|
||||
export interface ShareLinkCreated extends ShareLinkSummary {
|
||||
token: string;
|
||||
passphrase: string;
|
||||
shareUrl: string;
|
||||
qrUrl: string;
|
||||
}
|
||||
|
||||
function isoDaysFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
export function ShareLinkCreateForm({
|
||||
initialDocuments,
|
||||
initialLabel,
|
||||
onCreated,
|
||||
}: {
|
||||
/** Documents to pre-attach — the document-launched flow seeds the one doc. */
|
||||
initialDocuments?: PickedDocument[];
|
||||
/** Optional pre-filled label (e.g. the document title). */
|
||||
initialLabel?: string;
|
||||
/** Fired after a link is minted, so an outer surface can react if needed. */
|
||||
onCreated?: (created: ShareLinkCreated) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [label, setLabel] = useState(initialLabel ?? "");
|
||||
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
|
||||
const [allowFhirApi, setAllowFhirApi] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
|
||||
initialDocuments ?? [],
|
||||
);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Render the QR from the `#k=` deep link once a link is created. The fragment
|
||||
// carries the passphrase, so the QR alone opens the record — it is shown
|
||||
// exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so
|
||||
// a re-create swaps it; the effect only WRITES on async completion (never a
|
||||
// synchronous reset), so it does not trigger a cascading render.
|
||||
const qrUrl = created?.qrUrl ?? null;
|
||||
useEffect(() => {
|
||||
if (!qrUrl) return;
|
||||
let cancelled = false;
|
||||
// Dynamic import so `qrcode` code-splits out of the settings chunk — the
|
||||
// QR is only ever rendered after a link is created, so the library never
|
||||
// needs to ship eagerly.
|
||||
import("qrcode")
|
||||
.then(({ default: QRCode }) =>
|
||||
// Render at a high pixel density (the display size is capped in CSS) so
|
||||
// the code stays crisp when scaled up for on-the-spot phone scanning.
|
||||
QRCode.toDataURL(qrUrl, { margin: 2, width: 512 }),
|
||||
)
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
})
|
||||
.catch(() => {
|
||||
/* clipboard/QR can fail in some contexts; the link + passphrase text
|
||||
are still shown so the owner can share them by hand */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [qrUrl]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const trimmed = label.trim();
|
||||
// Surface the same expiry-bound the server enforces before the round
|
||||
// trip, so the validation feedback is immediate.
|
||||
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
|
||||
return Promise.reject(new Error("EXPIRY_RANGE"));
|
||||
}
|
||||
return apiPost<ShareLinkCreated>("/api/share-links", {
|
||||
label: trimmed,
|
||||
rangeStart: isoDaysFromNow(-rangeDays),
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
|
||||
// entirely when nothing is attached (a documents-less share stays the
|
||||
// default). The server re-validates each id as the caller's own live
|
||||
// document before minting the link.
|
||||
...(selectedDocs.length > 0
|
||||
? { documentIds: selectedDocs.map((d) => d.id) }
|
||||
: {}),
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Clear any stale QR before the effect renders the new one.
|
||||
setQrDataUrl(null);
|
||||
setCreated(result);
|
||||
setCopied(null);
|
||||
setLabel("");
|
||||
// The document set is frozen onto the link now — reset the picker so the
|
||||
// next link starts clean.
|
||||
setSelectedDocs([]);
|
||||
setFormError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
onCreated?.(result);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCreated(null);
|
||||
setFormError(
|
||||
err.message === "EXPIRY_RANGE"
|
||||
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
|
||||
: t("common.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function toggleResourceType(type: ResourceType) {
|
||||
setResourceTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
async function copyValue(value: string, which: "link" | "passphrase") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(which);
|
||||
} catch {
|
||||
// Clipboard can be unavailable (insecure context); the values stay
|
||||
// visible in the card so the owner can copy them by hand.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
|
||||
<Input
|
||||
id="share-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("settings.sharing.labelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
|
||||
<Input
|
||||
id="share-range"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={rangeDays}
|
||||
onChange={(e) =>
|
||||
setRangeDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.rangeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-expiry">{t("settings.sharing.expiry")}</Label>
|
||||
<Input
|
||||
id="share-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
value={expiryDays}
|
||||
onChange={(e) =>
|
||||
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5 pr-3">
|
||||
<Label htmlFor="share-fhir" className="text-sm font-medium">
|
||||
{t("settings.sharing.fhirApi")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.fhirApiHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-fhir"
|
||||
checked={allowFhirApi}
|
||||
onCheckedChange={setAllowFhirApi}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allowFhirApi && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharing.resourceTypes")}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{RESOURCE_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
htmlFor={`share-rt-${type}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`share-rt-${type}`}
|
||||
checked={resourceTypes.includes(type)}
|
||||
onCheckedChange={() => toggleResourceType(type)}
|
||||
/>
|
||||
<span className="font-mono text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ── Attach documents ─────────────────────────────────────── */}
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("settings.sharing.attachTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachCount", {
|
||||
count: selectedDocs.length,
|
||||
max: MAX_DOCUMENTS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
data-testid="share-attach-open"
|
||||
>
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.attachButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedDocs.length > 0 ? (
|
||||
<ul
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="share-attached-chips"
|
||||
>
|
||||
{selectedDocs.map((doc) => (
|
||||
<li key={doc.id}>
|
||||
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
|
||||
<span className="max-w-[12rem] truncate">{doc.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedDocs((prev) =>
|
||||
prev.filter((d) => d.id !== doc.id),
|
||||
)
|
||||
}
|
||||
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-label={t("settings.sharing.attachRemove", {
|
||||
title: doc.title,
|
||||
})}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachFrozen")}
|
||||
</p>
|
||||
{selectedDocs.length > 0 ? (
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.exifNote")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<ShareDocumentPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selected={selectedDocs}
|
||||
onSelectedChange={setSelectedDocs}
|
||||
max={MAX_DOCUMENTS}
|
||||
/>
|
||||
|
||||
{created && (
|
||||
<div
|
||||
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
|
||||
data-testid="share-token-reveal"
|
||||
>
|
||||
<p className="text-success font-medium">
|
||||
{t("settings.sharing.tokenCreated")}
|
||||
</p>
|
||||
{created.documentCount > 0 && (
|
||||
<p
|
||||
className="text-foreground text-xs font-medium"
|
||||
data-testid="share-created-doc-count"
|
||||
>
|
||||
{t("settings.sharing.documentCount", {
|
||||
count: created.documentCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.shownOnce")}
|
||||
</p>
|
||||
|
||||
{/* QR — carries the passphrase in the URL fragment, so scanning it
|
||||
opens the record (and its shared documents). Promoted as the
|
||||
primary mobile affordance: a clinician scans it on the spot to
|
||||
open the record on their own device. Shown exactly once. The
|
||||
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
|
||||
so the code stays scannable in the dark theme too. */}
|
||||
{qrDataUrl && (
|
||||
<div
|
||||
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
|
||||
data-testid="share-qr-block"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ScanLine className="text-foreground size-4" aria-hidden />
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("settings.sharing.qrScanTitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* The QR is a transient secret render, not stored content. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={t("settings.sharing.qrAlt")}
|
||||
width={512}
|
||||
height={512}
|
||||
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
|
||||
/>
|
||||
<p className="text-muted-foreground max-w-xs text-xs">
|
||||
{t("settings.sharing.qrScanHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The link (no passphrase). On its own it cannot open the record. */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.linkLabel")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{created.shareUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => copyValue(created.shareUrl, "link")}
|
||||
aria-label={t("settings.sharing.copyLink")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "link" && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The passphrase — the second factor. Shown once; only its hash is
|
||||
stored, so it cannot be recovered. */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.passphraseLabel")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
|
||||
data-testid="share-passphrase"
|
||||
>
|
||||
{created.passphrase}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => copyValue(created.passphrase, "passphrase")}
|
||||
aria-label={t("settings.sharing.copyPassphrase")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "passphrase" && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.passphraseHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,32 +7,23 @@
|
||||
* scope-frozen, read-only view of the owner's own health record at
|
||||
* `/c/<token>`, optionally exposing a scoped read-only FHIR face. The raw
|
||||
* `hls_` token is shown EXACTLY ONCE on create — the list never carries it
|
||||
* (the server only stores its hash), so the copy-on-create card is the single
|
||||
* chance to capture it.
|
||||
* (the server only stores its hash).
|
||||
*
|
||||
* Client island: the create form, the active/revoked lists, and the revoke
|
||||
* action all need state and mutate via the C4 lifecycle API
|
||||
* (`/api/share-links`). Reads unwrap `(await res.json()).data`; the query key
|
||||
* comes from the centralised factory. No markdown anywhere — every value
|
||||
* renders as escaped React text.
|
||||
* The create form + one-time reveal live in the shared `ShareLinkCreateForm`
|
||||
* (mounted here and from the document detail sheet's Share action); this
|
||||
* section owns the create card chrome plus the active/revoked lists and the
|
||||
* revoke action. Reads unwrap `(await res.json()).data`; the query key comes
|
||||
* from the centralised factory. No markdown anywhere — every value renders as
|
||||
* escaped React text.
|
||||
*
|
||||
* v1.18.7 — restored as a first-class Settings section. The visible heading +
|
||||
* subtitle now come from the shared shell chrome in the route; this body is
|
||||
* the share-links card. Cards paint through `<SettingsCard>` so the surface
|
||||
* matches every sibling section 1:1.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Copy,
|
||||
FileText,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
ScanLine,
|
||||
Share2,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FileText, KeyRound, Share2, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -47,74 +38,17 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
ShareDocumentPicker,
|
||||
type PickedDocument,
|
||||
} from "@/components/settings/share-document-picker";
|
||||
ShareLinkCreateForm,
|
||||
type ShareLinkSummary,
|
||||
} from "@/components/settings/share-link-create-form";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
|
||||
/** The FHIR resource types a share link may serve — mirrors C4's enum. */
|
||||
const RESOURCE_TYPES = [
|
||||
"Patient",
|
||||
"Observation",
|
||||
"MedicationStatement",
|
||||
"MedicationAdministration",
|
||||
] as const;
|
||||
type ResourceType = (typeof RESOURCE_TYPES)[number];
|
||||
|
||||
/** Maximum lifetime, in days — mirrors `SHARE_LINK_MAX_DAYS` on the server. */
|
||||
const MAX_DAYS = 90;
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum documents per share — mirrors `SHARE_LINK_MAX_DOCUMENTS` on the
|
||||
* server. Kept as a local literal (not imported from the validations module)
|
||||
* because that module pulls the Prisma client into scope and would drag the DB
|
||||
* into the client bundle; the server re-enforces the cap on create regardless.
|
||||
*/
|
||||
const MAX_DOCUMENTS = 50;
|
||||
|
||||
/** Owner-facing shape returned by `GET /api/share-links` (never the token). */
|
||||
interface ShareLinkSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: string | null;
|
||||
resourceTypes: string[];
|
||||
allowFhirApi: boolean;
|
||||
/** v1.18.7 — whether a passphrase second factor guards this link. */
|
||||
protected: boolean;
|
||||
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
|
||||
documentCount: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
lastAccessAt: string | null;
|
||||
accessCount: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 — the create response. The token, passphrase, and URLs are returned
|
||||
* EXACTLY ONCE here; the list query never carries them (the server stores only
|
||||
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
|
||||
*/
|
||||
interface ShareLinkCreated extends ShareLinkSummary {
|
||||
token: string;
|
||||
passphrase: string;
|
||||
shareUrl: string;
|
||||
qrUrl: string;
|
||||
}
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
export function SharingSection() {
|
||||
// v1.18.7 — the visible heading + subtitle come from the shared settings
|
||||
@@ -126,61 +60,13 @@ export function SharingSection() {
|
||||
);
|
||||
}
|
||||
|
||||
function isoDaysFromNow(days: number): string {
|
||||
return new Date(Date.now() + days * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
function ShareLinksCard() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const [rangeDays, setRangeDays] = useState(DEFAULT_DAYS);
|
||||
const [expiryDays, setExpiryDays] = useState(DEFAULT_DAYS);
|
||||
const [allowFhirApi, setAllowFhirApi] = useState(false);
|
||||
const [resourceTypes, setResourceTypes] = useState<ResourceType[]>([
|
||||
"Patient",
|
||||
"Observation",
|
||||
]);
|
||||
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>([]);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showRevoked, setShowRevoked] = useState(false);
|
||||
|
||||
// Render the QR from the `#k=` deep link once a link is created. The fragment
|
||||
// carries the passphrase, so the QR alone opens the record — it is shown
|
||||
// exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so
|
||||
// a re-create swaps it; the effect only WRITES on async completion (never a
|
||||
// synchronous reset), so it does not trigger a cascading render.
|
||||
const qrUrl = created?.qrUrl ?? null;
|
||||
useEffect(() => {
|
||||
if (!qrUrl) return;
|
||||
let cancelled = false;
|
||||
// Dynamic import so `qrcode` code-splits out of the settings chunk — the
|
||||
// QR is only ever rendered after a link is created, so the library never
|
||||
// needs to ship eagerly.
|
||||
import("qrcode")
|
||||
.then(({ default: QRCode }) =>
|
||||
// Render at a high pixel density (the display size is capped in CSS) so
|
||||
// the code stays crisp when scaled up for on-the-spot phone scanning.
|
||||
QRCode.toDataURL(qrUrl, { margin: 2, width: 512 }),
|
||||
)
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url);
|
||||
})
|
||||
.catch(() => {
|
||||
/* clipboard/QR can fail in some contexts; the link + passphrase text
|
||||
are still shown so the owner can share them by hand */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [qrUrl]);
|
||||
|
||||
const { data: links } = useQuery({
|
||||
queryKey: queryKeys.shareLinks(),
|
||||
queryFn: () =>
|
||||
@@ -190,52 +76,6 @@ function ShareLinksCard() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const trimmed = label.trim();
|
||||
// Surface the same expiry-bound the server enforces before the round
|
||||
// trip, so the validation feedback is immediate.
|
||||
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
|
||||
return Promise.reject(new Error("EXPIRY_RANGE"));
|
||||
}
|
||||
return apiPost<ShareLinkCreated>("/api/share-links", {
|
||||
label: trimmed,
|
||||
rangeStart: isoDaysFromNow(-rangeDays),
|
||||
rangeEnd: null,
|
||||
resourceTypes,
|
||||
allowFhirApi,
|
||||
// v1.28 — the hand-picked, frozen-at-create document set. Omit the key
|
||||
// entirely when nothing is attached (a documents-less share stays the
|
||||
// default). The server re-validates each id as the caller's own live
|
||||
// document before minting the link.
|
||||
...(selectedDocs.length > 0
|
||||
? { documentIds: selectedDocs.map((d) => d.id) }
|
||||
: {}),
|
||||
expiresAt: isoDaysFromNow(expiryDays),
|
||||
});
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Clear any stale QR before the effect renders the new one.
|
||||
setQrDataUrl(null);
|
||||
setCreated(result);
|
||||
setCopied(null);
|
||||
setLabel("");
|
||||
// The document set is frozen onto the link now — reset the picker so the
|
||||
// next link starts clean.
|
||||
setSelectedDocs([]);
|
||||
setFormError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setCreated(null);
|
||||
setFormError(
|
||||
err.message === "EXPIRY_RANGE"
|
||||
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
|
||||
: t("common.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`),
|
||||
onSuccess: () => {
|
||||
@@ -243,22 +83,6 @@ function ShareLinksCard() {
|
||||
},
|
||||
});
|
||||
|
||||
function toggleResourceType(type: ResourceType) {
|
||||
setResourceTypes((prev) =>
|
||||
prev.includes(type) ? prev.filter((r) => r !== type) : [...prev, type],
|
||||
);
|
||||
}
|
||||
|
||||
async function copyValue(value: string, which: "link" | "passphrase") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(which);
|
||||
} catch {
|
||||
// Clipboard can be unavailable (insecure context); the values stay
|
||||
// visible in the card so the owner can copy them by hand.
|
||||
}
|
||||
}
|
||||
|
||||
const activeLinks = useMemo(
|
||||
() => (links ?? []).filter((l) => l.active),
|
||||
[links],
|
||||
@@ -276,308 +100,7 @@ function ShareLinksCard() {
|
||||
description={t("settings.sharing.createDescription")}
|
||||
/>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-label">{t("settings.sharing.label")}</Label>
|
||||
<Input
|
||||
id="share-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("settings.sharing.labelPlaceholder")}
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-range">{t("settings.sharing.range")}</Label>
|
||||
<Input
|
||||
id="share-range"
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={rangeDays}
|
||||
onChange={(e) =>
|
||||
setRangeDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.rangeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="share-expiry">{t("settings.sharing.expiry")}</Label>
|
||||
<Input
|
||||
id="share-expiry"
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_DAYS}
|
||||
value={expiryDays}
|
||||
onChange={(e) =>
|
||||
setExpiryDays(Math.max(1, Number(e.target.value) || 1))
|
||||
}
|
||||
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
|
||||
/>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5 pr-3">
|
||||
<Label htmlFor="share-fhir" className="text-sm font-medium">
|
||||
{t("settings.sharing.fhirApi")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.fhirApiHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-fhir"
|
||||
checked={allowFhirApi}
|
||||
onCheckedChange={setAllowFhirApi}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allowFhirApi && (
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium">
|
||||
{t("settings.sharing.resourceTypes")}
|
||||
</legend>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{RESOURCE_TYPES.map((type) => (
|
||||
<label
|
||||
key={type}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
htmlFor={`share-rt-${type}`}
|
||||
>
|
||||
<Checkbox
|
||||
id={`share-rt-${type}`}
|
||||
checked={resourceTypes.includes(type)}
|
||||
onCheckedChange={() => toggleResourceType(type)}
|
||||
/>
|
||||
<span className="font-mono text-xs">{type}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ── Attach documents ─────────────────────────────────────── */}
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("settings.sharing.attachTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachCount", {
|
||||
count: selectedDocs.length,
|
||||
max: MAX_DOCUMENTS,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
data-testid="share-attach-open"
|
||||
>
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("settings.sharing.attachButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selectedDocs.length > 0 ? (
|
||||
<ul
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="share-attached-chips"
|
||||
>
|
||||
{selectedDocs.map((doc) => (
|
||||
<li key={doc.id}>
|
||||
<span className="bg-muted inline-flex max-w-full items-center gap-1 rounded-full py-1 pr-1 pl-2.5 text-xs">
|
||||
<span className="max-w-[12rem] truncate">{doc.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedDocs((prev) =>
|
||||
prev.filter((d) => d.id !== doc.id),
|
||||
)
|
||||
}
|
||||
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-label={t("settings.sharing.attachRemove", {
|
||||
title: doc.title,
|
||||
})}
|
||||
>
|
||||
<X className="size-3" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.attachFrozen")}
|
||||
</p>
|
||||
{selectedDocs.length > 0 ? (
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.exifNote")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.sharing.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<ShareDocumentPicker
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
selected={selectedDocs}
|
||||
onSelectedChange={setSelectedDocs}
|
||||
max={MAX_DOCUMENTS}
|
||||
/>
|
||||
|
||||
{created && (
|
||||
<div
|
||||
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
|
||||
data-testid="share-token-reveal"
|
||||
>
|
||||
<p className="text-success font-medium">
|
||||
{t("settings.sharing.tokenCreated")}
|
||||
</p>
|
||||
{created.documentCount > 0 && (
|
||||
<p
|
||||
className="text-foreground text-xs font-medium"
|
||||
data-testid="share-created-doc-count"
|
||||
>
|
||||
{t("settings.sharing.documentCount", {
|
||||
count: created.documentCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.shownOnce")}
|
||||
</p>
|
||||
|
||||
{/* QR — carries the passphrase in the URL fragment, so scanning it
|
||||
opens the record (and its shared documents). Promoted as the
|
||||
primary mobile affordance: a clinician scans it on the spot to
|
||||
open the record on their own device. Shown exactly once. The
|
||||
white quiet-zone is the documented QR exemption (UI-STANDARDS §4)
|
||||
so the code stays scannable in the dark theme too. */}
|
||||
{qrDataUrl && (
|
||||
<div
|
||||
className="border-border bg-background flex flex-col items-center gap-2 rounded-lg border p-4 text-center"
|
||||
data-testid="share-qr-block"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ScanLine className="text-foreground size-4" aria-hidden />
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("settings.sharing.qrScanTitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* The QR is a transient secret render, not stored content. */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={t("settings.sharing.qrAlt")}
|
||||
width={512}
|
||||
height={512}
|
||||
className="h-auto w-full max-w-[15rem] rounded-md border bg-white p-2 sm:max-w-[13rem]"
|
||||
/>
|
||||
<p className="text-muted-foreground max-w-xs text-xs">
|
||||
{t("settings.sharing.qrScanHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The link (no passphrase). On its own it cannot open the record. */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.linkLabel")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
|
||||
{created.shareUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => copyValue(created.shareUrl, "link")}
|
||||
aria-label={t("settings.sharing.copyLink")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "link" && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The passphrase — the second factor. Shown once; only its hash is
|
||||
stored, so it cannot be recovered. */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-[11px] font-medium">
|
||||
{t("settings.sharing.passphraseLabel")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
|
||||
data-testid="share-passphrase"
|
||||
>
|
||||
{created.passphrase}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
|
||||
onClick={() => copyValue(created.passphrase, "passphrase")}
|
||||
aria-label={t("settings.sharing.copyPassphrase")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "passphrase" && (
|
||||
<p className="text-success text-[11px]">
|
||||
{t("settings.sharing.copied")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-[11px]">
|
||||
{t("settings.sharing.passphraseHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ShareLinkCreateForm />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
|
||||
Reference in new issue
Block a user