mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
feat(documents): view and download the original inbound document
The inbound-document store keeps the uploaded doctor letter encrypted at rest (InboundDocument.contentEncrypted, AES-256-GCM), but there was no way to read the original back — the decrypt helper had been dropped as unused. - Restore decryptDocumentFromBytes in the document store, fail-closed on a bad / missing key id. - Add GET /api/documents/inbound/[id]/original: owner-scoped (userId-narrowed query), module-gated, returns the decrypted bytes with the stored MIME type. A PDF / image is served inline so the browser can render it; other types download as an attachment. The filename is sanitised before it reaches the Content-Disposition header. The bytes are PHI — never logged, never served cross-user, and a decrypt error fails closed (500) rather than returning ciphertext. Cache-Control is private, no-store. - Surface a 'View original' action in the document review screen. - Document the route in the OpenAPI registry and regenerate the spec. - Cover the route with a test: owner-scoped success, attachment fallback, 404 for another user's document, and fail-closed on decrypt error. i18n: viewOriginal across all six locales.
This commit is contained in:
1 parent
6e204d054a
commit
08314c7c09
12 files changed
+418
-10
No files matched your search
@@ -12,7 +12,13 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, FileScan, Loader2, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
FileScan,
|
||||
Loader2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -283,14 +289,29 @@ function DocumentReview({
|
||||
<CardTitle className="text-base">
|
||||
{t("documents.review.title")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => discard.mutate()}
|
||||
disabled={discard.isPending}
|
||||
>
|
||||
{t("documents.review.discard")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* View the original uploaded document. The route is same-origin and
|
||||
cookie-authenticated, so a new tab carries the session; a PDF /
|
||||
image renders inline, other types download. */}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a
|
||||
href={`/api/documents/inbound/${documentId}/original`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" aria-hidden />
|
||||
{t("documents.review.viewOriginal")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => discard.mutate()}
|
||||
disabled={discard.isPending}
|
||||
>
|
||||
{t("documents.review.discard")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{pending.length === 0 ? (
|
||||
|
||||
Reference in new issue
Block a user