Files
HealthLog/src/components/settings/share-link-create-form.tsx
T
Marc-André Bombeck 0fc83cb0a1 fix(auth): ask for a fresh proof before the record or a credential to it leaves
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
2026-09-26 19:54:38 +02:00

614 lines
23 KiB
TypeScript

"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>`, plus a hand-picked,
* frozen-at-create document set. It serves a rendered page and those documents
* and nothing else. A FHIR toggle and a resource-type picker were removed
* that configured an endpoint no route ever served.
*
* 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, useId, 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 { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ShareDocumentPicker,
type PickedDocument,
} from "@/components/settings/share-document-picker";
import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker";
import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { orderLeaves } from "@/lib/report-selection/selection";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
/** 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;
/** 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;
/**
* Whether this link's frozen scope predates the selection model. Such links
* were revoked on upgrade — their scope was "everything except mood and
* cycle", which nobody chose — and the sharing list says so, so the owner
* re-mints rather than wondering why a working link stopped.
*/
needsReselection: boolean;
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();
}
/** The create body posted to `POST /api/share-links`. */
export interface ShareLinkCreatePayload {
label: string;
rangeStart: string;
rangeEnd: null;
selection?: { v: 2; leaves: readonly string[] };
documentIds?: string[];
documentOnly?: boolean;
expiresAt: string;
}
/**
* The one leaf a share link may never carry — mirrored from the server, which
* refuses it with a 422. The picker hides it rather than rendering a control
* that cannot be honoured.
*/
const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"];
/**
* Build the create payload from the resolved form state. Extracted as a pure
* function so the two surfaces' scope contract is unit-testable without a DOM.
*
* The `documentOnly` branch is the privacy-load-bearing one: a document-launched
* share posts NO report scope at all plus `documentOnly: true`,
* and ALWAYS carries the picked document ids —
* so the created link serves the document(s) and nothing else. The record share
* (Settings → Sharing) keeps its full scope and only attaches documents when
* the owner picked some.
*/
export function buildShareLinkCreatePayload(input: {
label: string;
rangeDays: number;
expiryDays: number;
leaves: readonly ReportLeafId[];
documentIds: string[];
documentOnly: boolean;
}): ShareLinkCreatePayload {
const base = {
label: input.label.trim(),
rangeStart: isoDaysFromNow(-input.rangeDays),
rangeEnd: null as null,
expiresAt: isoDaysFromNow(input.expiryDays),
};
if (input.documentOnly) {
return {
...base,
documentOnly: true,
// The launched document(s) always ride along — a documents-only share
// with no document would be meaningless (the server 422s it).
documentIds: input.documentIds,
};
}
return {
...base,
selection: { v: 2, leaves: input.leaves },
// Omit the key entirely when nothing is attached (a documents-less record
// share stays the default). The server re-validates each id as the caller's
// own live document before minting the link.
...(input.documentIds.length > 0 ? { documentIds: input.documentIds } : {}),
};
}
export function ShareLinkCreateForm({
documentOnly = false,
initialDocuments,
initialLabel,
initialRangeDays,
initialExpiryDays,
onCreated,
}: {
/**
* Documents-only mode (the document-launched flow). Hides the record-scope
* control — the history-range selector — and posts an empty report scope so
* the minted link serves ONLY the attached document(s). Keeps the document
* picker, label, expiry, and one-time passphrase reveal.
*/
documentOnly?: boolean;
/** 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;
/**
* Optional pre-filled window and lifetime, in days. The sharing list passes
* these when the owner re-mints a link the selection upgrade closed, so the
* only decision left in front of them is the one that link never carried:
* what it should contain.
*/
initialRangeDays?: number;
initialExpiryDays?: number;
/** 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(initialRangeDays ?? DEFAULT_DAYS);
const [expiryDays, setExpiryDays] = useState(
initialExpiryDays ?? DEFAULT_DAYS,
);
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
initialDocuments ?? [],
);
// A new link starts with an empty scope. The picker's standard-report button
// fills it in one click, minus the leaf a link may not carry — but a link is
// handed to another person, so nothing rides on it that was not ticked.
const [selectedLeaves, setSelectedLeaves] = useState<
ReadonlySet<ReportLeafId>
>(() => new Set<ReportLeafId>());
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 scopeSummaryId = useId();
// 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]);
// A share link opens the record without signing in, so making one asks for
// a fresh proof unless the session signed in or re-proved within minutes.
const recentProof = useRecentProof();
const createMutation = useMutation({
mutationFn: () => {
// 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"));
}
const payload = buildShareLinkCreatePayload({
label,
rangeDays,
expiryDays,
leaves: orderLeaves(selectedLeaves),
documentIds: selectedDocs.map((d) => d.id),
documentOnly,
});
return recentProof.run(() =>
apiPost<ShareLinkCreated>("/api/share-links", payload),
);
},
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 })
: recentProofErrorMessage(err, t("common.error")),
);
},
});
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 (
<>
{recentProof.dialog}
<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">
{/* The history-range window only scopes the report — meaningless for
a documents-only share, so it is hidden there. */}
{!documentOnly ? (
<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-xs">
{t("settings.sharing.rangeHint")}
</p>
</div>
) : null}
<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-xs">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
</div>
{/* ── What the link contains ───────────────────────────────── */}
{!documentOnly ? (
<div className="space-y-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{t("settings.sharing.scopeTitle")}
</Label>
<p className="text-muted-foreground text-xs">
{t("settings.sharing.scopeInsuranceExcluded")}
</p>
</div>
<ReportScopePicker
surface="share"
selected={selectedLeaves}
onChange={setSelectedLeaves}
hiddenLeaves={SHARE_HIDDEN_LEAVES}
/>
<ScopeSummary
t={t}
id={scopeSummaryId}
surface="share"
selected={selectedLeaves}
/>
</div>
) : null}
{/* ── 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-xs">
{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-xs">
{t("settings.sharing.attachFrozen")}
</p>
{selectedDocs.length > 0 ? (
<p className="text-muted-foreground text-xs">
{t("settings.sharing.exifNote")}
</p>
) : null}
</div>
{formError && (
<p role="alert" className="text-destructive text-sm">
{formError}
</p>
)}
{/* A record link with an empty scope cannot be minted: it would serve
a page with nothing on it. The scope line above says so and says
what to do about it, and describes the button so the disabled state
is not silent for a screen reader. */}
<SettingsCardActions>
<Button
type="submit"
size="sm"
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
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>
</SettingsCardActions>
</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-xs">
{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-xs 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-xs">
{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-xs 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-xs">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-xs">
{t("settings.sharing.passphraseHint")}
</p>
</div>
</div>
)}
</>
);
}