Merge branch 'fix/step-up-and-session-revocation' into release/v1.39.3

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 20:40:59 +02:00
commit 03ccb21227
96 files changed
+3238 -495

No files matched your search

+23 -9
View File
@@ -39,6 +39,11 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
interface ApiTokenInfo {
id: string;
@@ -138,6 +143,9 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState<string | null>(null);
const [tokenCopied, setTokenCopied] = useState(false);
// The token outlives the session, so minting one asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
@@ -145,13 +153,18 @@ function IngestTokenCard({
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
body: JSON.stringify({ name: newName.trim() }),
});
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
const body = JSON.stringify({ name: newName.trim() });
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
),
);
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
@@ -164,8 +177,8 @@ function IngestTokenCard({
// this specific action when it did not.
setTokenMsg(json.error || copy.createFailed);
}
} catch {
setTokenMsg(t("common.networkError"));
} catch (err) {
setTokenMsg(recentProofErrorMessage(err, t("common.networkError")));
} finally {
setCreating(false);
}
@@ -185,6 +198,7 @@ function IngestTokenCard({
return (
<SettingsCard>
{recentProof.dialog}
<SettingsCardHeader
icon={icon}
title={copy.title}
+48 -30
View File
@@ -46,6 +46,11 @@ 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";
@@ -55,7 +60,9 @@ type ExportFormat = "CSV" | "JSON" | "FHIR";
* one place.
*/
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await apiFetchRaw(url, { credentials: "include" });
const res = await throwIfReproofRequired(
await apiFetchRaw(url, { credentials: "include" }),
);
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
@@ -418,45 +425,55 @@ function FullBackupCard() {
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 {
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await apiFetchRaw("/api/export/encrypted", {
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.status === 401) {
setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded"));
return;
}
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`,
);
}),
);
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
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`,
);
}
}
@@ -550,6 +567,7 @@ function FullBackupCard() {
{error}
</p>
)}
{recentProof.dialog}
</ExportCardShell>
);
}
+25 -10
View File
@@ -50,6 +50,11 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { apiFetchRaw, apiGet, apiPatch } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
interface McpTokenInfo {
id: string;
@@ -289,21 +294,30 @@ function McpTokensCard() {
enabled: isAuthenticated,
});
// A connector token outlives the session, so minting one asks for a fresh
// proof unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setTokenMsg(null);
setNewToken(null);
try {
const res = await apiFetchRaw("/api/mcp/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newName.trim(),
// Closed choice — read-only, or read + the audience-bound write scope.
scope: allowWrite ? "read_write" : "read",
}),
const body = JSON.stringify({
name: newName.trim(),
// Closed choice — read-only, or read + the audience-bound write scope.
scope: allowWrite ? "read_write" : "read",
});
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw("/api/mcp/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
),
);
const json = await res.json();
if (res.ok) {
setNewToken(json.data.token);
@@ -313,8 +327,8 @@ function McpTokensCard() {
} else {
setTokenMsg(json.error || t("common.error"));
}
} catch {
setTokenMsg(t("common.networkError"));
} catch (err) {
setTokenMsg(recentProofErrorMessage(err, t("common.networkError")));
} finally {
setCreating(false);
}
@@ -351,6 +365,7 @@ function McpTokensCard() {
return (
<SettingsCard>
{recentProof.dialog}
<SettingsCardHeader
icon={Key}
title={t("settings.mcp.tokensTitle")}
@@ -0,0 +1,91 @@
/**
* Every web surface that calls a route behind the recent-proof gate asks for
* the proof through `useRecentProof` and renders its dialog, so a person whose
* session is older than five minutes is asked to confirm instead of meeting a
* bare failure. Source-oriented, like the enrollment reauth test next door:
* the repository keeps component tests free of a browser DOM harness. The
* server half is pinned in `tests/integration/recent-proof.test.ts`.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { ApiError } from "@/lib/api/api-fetch";
import {
ReproofCancelledError,
recentProofErrorMessage,
throwIfReproofRequired,
} from "../use-recent-proof";
const SRC = resolve(__dirname, "..", "..", "..", "..");
const read = (file: string) => readFileSync(resolve(SRC, file), "utf8");
describe("throwIfReproofRequired", () => {
it("turns the refusal into an ApiError carrying the methods", async () => {
const res = new Response(
JSON.stringify({
data: null,
error: "Confirm it is you to continue",
meta: { errorCode: "auth.reproof.required", methods: ["password"] },
}),
{ status: 401 },
);
const err = await throwIfReproofRequired(res).catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).meta?.methods).toEqual(["password"]);
});
it("passes every other response through untouched", async () => {
const other = new Response(
JSON.stringify({ meta: { errorCode: "auth.stepup.required" } }),
{ status: 401 },
);
await expect(throwIfReproofRequired(other)).resolves.toBe(other);
const ok = new Response("{}", { status: 200 });
await expect(throwIfReproofRequired(ok)).resolves.toBe(ok);
});
});
describe("recentProofErrorMessage", () => {
it("shows nothing for a cancelled dialog and the sentence for sign-in-again", () => {
expect(recentProofErrorMessage(new ReproofCancelledError(), "x")).toBe(
null,
);
expect(
recentProofErrorMessage(
new ApiError("Sign in again", 401, {
errorCode: "auth.reproof.sign_in_again",
}),
"x",
),
).toBe("Sign in again");
expect(recentProofErrorMessage(new Error("boom"), "fallback")).toBe(
"fallback",
);
});
});
describe.each([
["components/settings/export-section.tsx", "/api/export/full-backup"],
["components/settings/export-section.tsx", "/api/export/encrypted"],
["components/settings/share-link-create-form.tsx", "/api/share-links"],
["components/settings/mcp-section.tsx", "/api/mcp/tokens"],
["components/settings/api-section.tsx", "/api/tokens/measurements"],
["components/admin/backups-section.tsx", "/download"],
["components/admin/backups-section.tsx", "/restore"],
["components/admin/backups-section.tsx", "/api/admin/backups/upload"],
["components/admin/danger-zone-section.tsx", "/api/admin/data"],
["components/admin/user-management-section.tsx", "/reset-password"],
])("%s → %s", (file, endpoint) => {
const source = read(file);
it("calls the gated route", () => {
expect(source).toContain(endpoint);
});
it("runs it through the recent-proof hook and renders the dialog", () => {
expect(source).toContain("useRecentProof()");
expect(source).toMatch(/recentProof\.run\(/);
expect(source).toContain("{recentProof.dialog}");
});
});
@@ -58,7 +58,10 @@ export function ExistingFactorReauthDialog({
pending,
error,
onProof,
description,
}: {
/** Overrides the default "before adding a second factor" sentence. */
description?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
methods: ReauthMethod[];
@@ -134,7 +137,7 @@ export function ExistingFactorReauthDialog({
{t("settings.passkeyReauth.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.passkeyReauth.factorDescription")}
{description ?? t("settings.passkeyReauth.factorDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
@@ -0,0 +1,181 @@
"use client";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
ExistingFactorReauthDialog,
isReproofRequired,
type ExistingFactorProof,
type ReauthMethod,
} from "./existing-factor-reauth-dialog";
const KNOWN_METHODS: readonly ReauthMethod[] = [
"password",
"totp",
"webauthn",
"passkey",
];
/** The proofs a `auth.reproof.required` refusal says this account can give. */
function methodsFrom(err: ApiError): ReauthMethod[] {
const raw = err.meta?.methods;
if (!Array.isArray(raw)) return [];
return raw.filter((m): m is ReauthMethod =>
KNOWN_METHODS.includes(m as ReauthMethod),
);
}
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
export class ReproofCancelledError extends Error {
constructor() {
super("Re-proof cancelled");
this.name = "ReproofCancelledError";
}
}
/**
* The sentence to show for an error `run` rejected with, or null when there is
* nothing to show (the dialog was cancelled). Any other error returns
* `fallback`.
*/
export function recentProofErrorMessage(
err: unknown,
fallback: string,
): string | null {
if (err instanceof ReproofCancelledError) return null;
if (
err instanceof ApiError &&
err.meta?.errorCode === "auth.reproof.sign_in_again"
) {
return err.message;
}
return fallback;
}
/**
* Turn a raw `Response` from a download or upload call site into the
* `ApiError` the hook recognises, when it is the recent-proof refusal. Any
* other response is returned untouched for the caller's own handling.
*/
export async function throwIfReproofRequired(res: Response): Promise<Response> {
if (res.status !== 401) return res;
let body: { error?: unknown; meta?: unknown } | null = null;
try {
body = await res.clone().json();
} catch {
return res;
}
const meta =
body && typeof body.meta === "object" && body.meta !== null
? (body.meta as Record<string, unknown>)
: undefined;
const err = new ApiError(
typeof body?.error === "string" ? body.error : "",
401,
meta,
);
if (isReproofRequired(err)) throw err;
return res;
}
/**
* Run an action that the server may hold back for a fresh proof.
*
* Exports of the whole record, share links, token minting and the admin data
* actions answer 401 `auth.reproof.required` with `meta.methods` when the
* session has not signed in or re-proved within five minutes. `run` catches
* exactly that refusal, opens the re-proof dialog with the listed methods,
* posts the proof to `/api/auth/reproof` and runs the action once more. Every
* other error rejects as usual; a cancelled dialog rejects with
* `ReproofCancelledError`. `recentProofErrorMessage` maps both for display.
*
* An account with nothing to re-prove in place (signed in through single
* sign-on only) gets no dialog: the refusal is rethrown with a message asking
* to sign in again.
*/
export function useRecentProof(): {
run: <T>(action: () => Promise<T>) => Promise<T>;
dialog: ReactNode;
} {
const { t } = useTranslations();
const [methods, setMethods] = useState<ReauthMethod[] | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const waiter = useRef<{
resolve: () => void;
reject: () => void;
} | null>(null);
const run = useCallback(
async <T,>(action: () => Promise<T>): Promise<T> => {
try {
return await action();
} catch (err) {
if (!isReproofRequired(err)) throw err;
const offered = methodsFrom(err as ApiError);
if (offered.length === 0) {
throw new ApiError(t("settings.reproof.signInAgain"), 401, {
errorCode: "auth.reproof.sign_in_again",
});
}
await new Promise<void>((resolve, reject) => {
waiter.current = {
resolve,
reject: () => reject(new ReproofCancelledError()),
};
setError(null);
setMethods(offered);
});
return action();
}
},
[t],
);
const onProof = useCallback(
async (proof: ExistingFactorProof) => {
setPending(true);
setError(null);
try {
await apiPost("/api/auth/reproof", proof);
setMethods(null);
const w = waiter.current;
waiter.current = null;
w?.resolve();
} catch (err) {
setError(
err instanceof ApiError && err.status === 429
? err.message
: t("settings.reproof.failed"),
);
} finally {
setPending(false);
}
},
[t],
);
const dialog = (
<ExistingFactorReauthDialog
key={methods?.join(",") ?? "closed"}
open={methods !== null}
onOpenChange={(open) => {
if (open) return;
setMethods(null);
setError(null);
const w = waiter.current;
waiter.current = null;
w?.reject();
}}
methods={methods ?? ["password"]}
pending={pending}
error={error}
onProof={(proof) => void onProof(proof)}
description={t("settings.reproof.description")}
/>
);
return { run, dialog };
}
@@ -16,6 +16,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -43,6 +44,11 @@ export function SecuritySessionsCard({
const fmt = useFormatters();
const queryClient = useQueryClient();
const [status, setStatus] = useState<string | null>(null);
// "Everywhere" also ends clinician share links unless the person keeps
// them. Revoking is the default: this is often pressed after losing a
// device or a session, and a link made by whoever held it opens the record
// without signing in.
const [endShareLinks, setEndShareLinks] = useState(true);
// Collapsed by default — the list opens only when the user asks for it, so
// the security surface stays calm and skimmable on first paint. UI-only
// state; nothing is persisted across reloads.
@@ -69,7 +75,11 @@ export function SecuritySessionsCard({
const revokeOthers = useMutation({
mutationFn: () =>
apiDelete<{ sessionsRevoked: number }>("/api/auth/me/sessions"),
apiDelete<{ sessionsRevoked: number }>(
endShareLinks
? "/api/auth/me/sessions"
: "/api/auth/me/sessions?keepShareLinks=1",
),
onSuccess: (res) => {
setStatus(
t("settings.security.signOutEverywhereDone", {
@@ -85,7 +95,6 @@ export function SecuritySessionsCard({
});
const sessions = data?.sessions ?? [];
const hasOthers = sessions.some((s) => !s.isCurrent);
return (
<SettingsCard data-slot="settings-security-sessions-card">
@@ -185,7 +194,7 @@ export function SecuritySessionsCard({
</ul>
)}
{hasOthers && (
{data && (
<SettingsCardActions>
<ConfirmButton
slot="sign-out-everywhere"
@@ -196,6 +205,19 @@ export function SecuritySessionsCard({
confirmLabel={t("settings.security.signOutEverywhere")}
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
extra={
<label className="flex items-start gap-2 text-sm">
<Checkbox
data-testid="sign-out-everywhere-share-links"
checked={endShareLinks}
onCheckedChange={(next) => setEndShareLinks(next === true)}
className="mt-0.5"
/>
<span>
{t("settings.security.signOutEverywhereShareLinks")}
</span>
</label>
}
/>
</SettingsCardActions>
)}
@@ -40,6 +40,10 @@ 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;
@@ -240,6 +244,10 @@ export function ShareLinkCreateForm({
};
}, [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
@@ -247,16 +255,16 @@ export function ShareLinkCreateForm({
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
return Promise.reject(new Error("EXPIRY_RANGE"));
}
return apiPost<ShareLinkCreated>(
"/api/share-links",
buildShareLinkCreatePayload({
label,
rangeDays,
expiryDays,
leaves: orderLeaves(selectedLeaves),
documentIds: selectedDocs.map((d) => d.id),
documentOnly,
}),
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) => {
@@ -277,7 +285,7 @@ export function ShareLinkCreateForm({
setFormError(
err.message === "EXPIRY_RANGE"
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
: t("common.error"),
: recentProofErrorMessage(err, t("common.error")),
);
},
});
@@ -294,6 +302,7 @@ export function ShareLinkCreateForm({
return (
<>
{recentProof.dialog}
<form
className="space-y-4"
onSubmit={(e) => {