Files
HealthLog/src/components/settings/export-section.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

576 lines
17 KiB
TypeScript

"use client";
/**
* v1.4.16 phase B7 — Settings → Export.
*
* Single surface that consolidates every "give me my data out" path in
* HealthLog. Replaces the old `<ExportCard>` inside `<AdvancedSection>`
* (CSV/JSON/Doctor-report buttons crammed onto one row) with one card
* per export type, each with its own filter inputs and a clear
* "Download" / "Generate" button.
*
* 1. Measurements CSV — optional `since`/`until`
* 2. Medications CSV — optional intake-history toggle
* 3. Mood CSV — optional `since`/`until`
* 4. Full JSON Backup — single-file user-scoped dump
*
* v1.18.0 (S5) — the full health-record export (PDF + FHIR R4 + zip
* package) moved out to its own top-level "Gesundheitsakte" section. This
* page now keeps only the generic data export/import paths.
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
import { useState } from "react";
import {
Download,
FileJson,
FileSpreadsheet,
FileText,
Loader2,
Pill,
Waves,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SectionHeading } from "@/components/ui/section-heading";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { cn } from "@/lib/utils";
import { ImportPanel } from "@/components/settings/import-panel";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
type ExportFormat = "CSV" | "JSON" | "FHIR";
/**
* Trigger a browser download for a given URL by spinning up an anchor
* element. Used by every CSV/JSON card so the implementation stays in
* one place.
*/
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await throwIfReproofRequired(
await apiFetchRaw(url, { credentials: "include" }),
);
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
export function ExportSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible page heading + subtitle now come from the
// shared `<SettingsSectionFrame>` in the route; the inner `<h2>` keeps its
// own "other options" subsection label.
return (
<div className="space-y-6">
{/* v1.18.0 (S5) — the full health-record export moved to its own
top-level "Gesundheitsakte" section. This page keeps the generic
CSV/JSON data-out paths and the import surface. */}
<section
aria-labelledby="settings-section-export-other-title"
className="space-y-4"
>
<SectionHeading
icon={Download}
id="settings-section-export-other-title"
title={t("settings.sections.export.otherOptionsHeading")}
/>
<div className="grid gap-4 md:grid-cols-2">
<MeasurementsCsvCard />
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
</div>
</section>
{/* R28 / issue #281 — the import surface for the Apple Health
`export.zip` and the generic JSON paths. The export routes had
backends with no UI until now. */}
<ImportPanel />
</div>
);
}
// ─────────────────────────── Card primitives ───────────────────────────
interface ExportCardShellProps {
testId: string;
icon: LucideIcon;
title: string;
description: string;
format: ExportFormat;
children?: React.ReactNode;
/** The card's action row — the "Download" button, and nothing after it. */
footer: React.ReactNode;
/** Extra grid-position classes applied to the outer card div. */
outerClassName?: string;
}
function ExportCardShell({
testId,
icon: Icon,
title,
description,
format,
children,
footer,
outerClassName,
}: ExportCardShellProps) {
return (
<SettingsCard
data-testid={testId}
className={cn("flex h-full flex-col", outerClassName)}
>
<SettingsCardHeader
icon={Icon}
title={title}
description={description}
status={
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase">
{format}
</span>
}
/>
{children && <div className="space-y-3">{children}</div>}
<SettingsCardActions>{footer}</SettingsCardActions>
</SettingsCard>
);
}
// ─────────────────────────── CSV cards ───────────────────────────
interface DateRangeFieldsProps {
since: string;
until: string;
setSince: (v: string) => void;
setUntil: (v: string) => void;
idPrefix: string;
}
function DateRangeFields({
since,
until,
setSince,
setUntil,
idPrefix,
}: DateRangeFieldsProps) {
const { t } = useTranslations();
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-since`} className="text-xs">
{t("settings.sections.export.filters.since")}
</Label>
<DateField
id={`${idPrefix}-since`}
value={since}
onChange={setSince}
max={until || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-until`} className="text-xs">
{t("settings.sections.export.filters.until")}
</Label>
<DateField
id={`${idPrefix}-until`}
value={until}
onChange={setUntil}
min={since || undefined}
/>
</div>
</div>
);
}
function buildQueryString(base: Record<string, string | undefined>): string {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(base)) {
if (v) params.set(k, v);
}
const q = params.toString();
return q ? `?${q}` : "";
}
interface CsvCardProps {
testId: string;
actionTestId: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
endpoint: string;
filenamePrefix: string;
}
function CsvCard({
testId,
actionTestId,
icon,
titleKey,
descriptionKey,
endpoint,
filenamePrefix,
}: CsvCardProps) {
const { t } = useTranslations();
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDownload() {
setBusy(true);
setError(null);
try {
const query = buildQueryString({
since: since || undefined,
until: until || undefined,
});
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId={testId}
icon={icon}
title={t(titleKey)}
description={t(descriptionKey)}
format="CSV"
footer={
<Button
data-testid={actionTestId}
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
<DateRangeFields
idPrefix={testId}
since={since}
until={until}
setSince={setSince}
setUntil={setUntil}
/>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
function MeasurementsCsvCard() {
return (
<CsvCard
testId="export-card-measurements-csv"
actionTestId="export-action-measurements-csv"
icon={FileSpreadsheet}
titleKey="settings.sections.export.cards.measurementsCsv.title"
descriptionKey="settings.sections.export.cards.measurementsCsv.description"
endpoint="/api/export/measurements"
filenamePrefix="healthlog-measurements"
/>
);
}
function MedicationsCsvCard() {
// The intake-history toggle is local state on this card — we read it
// when the user clicks Download and append `&intake=true` to the URL.
const { t } = useTranslations();
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [includeIntake, setIncludeIntake] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDownload() {
setBusy(true);
setError(null);
try {
const query = buildQueryString({
since: since || undefined,
until: until || undefined,
intake: includeIntake ? "true" : "false",
});
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications${query}`,
`healthlog-medications-${stamp}.csv`,
);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-medications-csv"
icon={Pill}
title={t("settings.sections.export.cards.medicationsCsv.title")}
description={t(
"settings.sections.export.cards.medicationsCsv.description",
)}
format="CSV"
footer={
<Button
data-testid="export-action-medications-csv"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Download className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
<DateRangeFields
idPrefix="export-medications-csv"
since={since}
until={until}
setSince={setSince}
setUntil={setUntil}
/>
<label
htmlFor="export-medications-include-intake"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-medications-include-intake"
data-testid="export-medications-include-intake"
checked={includeIntake}
onCheckedChange={setIncludeIntake}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.medicationsCsv.includeIntake")}
</span>
</label>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
function MoodCsvCard() {
return (
<CsvCard
testId="export-card-mood-csv"
actionTestId="export-action-mood-csv"
icon={Waves}
titleKey="settings.sections.export.cards.moodCsv.title"
descriptionKey="settings.sections.export.cards.moodCsv.description"
endpoint="/api/export/mood"
filenamePrefix="healthlog-mood"
/>
);
}
const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
function FullBackupCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.23 — opt-in passphrase encryption. When on, the download POSTs to
// /api/export/encrypted with the passphrase and saves an opaque `.hlx`
// archive instead of plaintext JSON.
const [encrypt, setEncrypt] = useState(false);
const [passphrase, setPassphrase] = useState("");
const passphraseTooShort =
encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH;
// The whole record leaves the server here, so it asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleDownload() {
setBusy(true);
setError(null);
try {
await recentProof.run(downloadOnce);
} catch (err) {
setError(
recentProofErrorMessage(
err,
t("settings.sections.export.downloadFailed"),
),
);
} finally {
setBusy(false);
}
}
async function downloadOnce() {
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await throwIfReproofRequired(
await apiFetchRaw("/api/export/encrypted", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
}),
);
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = `healthlog-backup-${stamp}.hlx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} else {
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
}
}
return (
<ExportCardShell
testId="export-card-full-backup"
icon={FileJson}
title={t("settings.sections.export.cards.fullBackup.title")}
description={t("settings.sections.export.cards.fullBackup.description")}
format="JSON"
footer={
<Button
data-testid="export-action-full-backup"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy || passphraseTooShort}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileText className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{/* What is in the file, and the shape it has. This used to be the
card's description — twelve enumerated record types in a muted
`text-xs` meta slot, which is neither the tier nor the length that
slot is for. */}
<p className="text-sm">
{t("settings.sections.export.cards.fullBackup.contents")}{" "}
{t("settings.sections.export.cards.fullBackup.detail")}
</p>
{/* v1.28 backup-completeness — the paragraph above lists what's
included; this line honestly discloses what isn't (document
binaries, workout GPS/sample series) rather than implying
"everything" is in the file. Always visible, not gated on the
encrypt toggle. */}
<p
data-testid="export-full-backup-scope-note"
className="text-muted-foreground text-xs"
>
{t("settings.sections.export.cards.fullBackup.scopeNote")}
</p>
<label
htmlFor="export-full-backup-encrypt"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-full-backup-encrypt"
data-testid="export-full-backup-encrypt"
checked={encrypt}
onCheckedChange={(v) => {
setEncrypt(v);
setError(null);
if (!v) setPassphrase("");
}}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.fullBackup.encryptToggle")}
</span>
</label>
{encrypt && (
<div className="space-y-2">
<PasswordInput
data-testid="export-full-backup-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t(
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
)}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
className="h-9 text-sm"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
</p>
<p className="text-warning text-xs">
{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}
</p>
</div>
)}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
{recentProof.dialog}
</ExportCardShell>
);
}
// ─────────────────────────── Cycle export (gated) ───────────────────────────