mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
commit
03ccb21227
96 files changed
+3238
-495
No files matched your search
@@ -59,6 +59,11 @@ import type {
|
||||
} from "@/lib/export/restore-skips";
|
||||
import type { BackupRestoreJobView } from "@/lib/jobs/backup-restore";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import {
|
||||
isActiveRestore,
|
||||
RestoreJobStatus,
|
||||
@@ -702,6 +707,57 @@ function ScheduleHealthNotice({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a backup file to the admin upload route. Sent as the raw body,
|
||||
* compressed on the way unless it already is: the server reads it as a stream,
|
||||
* and a large export (hundreds of MB of JSON) shrinks about tenfold. No request
|
||||
* timeout, because a file of that size takes longer to check and store than
|
||||
* the default allows. The body is built here, per call, so a retry after a
|
||||
* re-proof sends the file again rather than a spent stream.
|
||||
*/
|
||||
async function uploadOnce(file: File) {
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
valid: true;
|
||||
summary: {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
// Optional for the same reason as the keys below: a file written
|
||||
// before side effects rode the wire carries no key at all.
|
||||
medicationSideEffects?: number;
|
||||
cycles?: number;
|
||||
cycleDayLogs?: number;
|
||||
// v1.28 backup-completeness — optional so an older-schema upload
|
||||
// (pre-v1.28) still totals correctly without these keys.
|
||||
labResults?: number;
|
||||
biomarkers?: number;
|
||||
illnessEpisodes?: number;
|
||||
illnessDayLogs?: number;
|
||||
allergies?: number;
|
||||
familyHistory?: number;
|
||||
workouts?: number;
|
||||
documents?: number;
|
||||
// Optional for the same reason: a file written before each of these
|
||||
// rode the wire carries no key, and an absent key must total as
|
||||
// nothing rather than break the count.
|
||||
nutrientDays?: number;
|
||||
healthProfile?: number;
|
||||
healthProfileFactRevisions?: number;
|
||||
customMetrics?: number;
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -744,52 +800,14 @@ export function BackupsSection() {
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Download, upload and restore reach every account's data, so each asks for
|
||||
// a fresh proof unless this session signed in or re-proved within minutes.
|
||||
const recentProof = useRecentProof();
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
// Sent as the raw body, compressed on the way unless it already is: the
|
||||
// server reads it as a stream, and a large export (hundreds of MB of
|
||||
// JSON) shrinks about tenfold. No request timeout, because a file of
|
||||
// that size takes longer to check and store than the default allows.
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
valid: true;
|
||||
summary: {
|
||||
measurements: number;
|
||||
medications: number;
|
||||
intakeEvents: number;
|
||||
moodEntries: number;
|
||||
// Optional for the same reason as the keys below: a file written
|
||||
// before side effects rode the wire carries no key at all.
|
||||
medicationSideEffects?: number;
|
||||
cycles?: number;
|
||||
cycleDayLogs?: number;
|
||||
// v1.28 backup-completeness — optional so an older-schema upload
|
||||
// (pre-v1.28) still totals correctly without these keys.
|
||||
labResults?: number;
|
||||
biomarkers?: number;
|
||||
illnessEpisodes?: number;
|
||||
illnessDayLogs?: number;
|
||||
allergies?: number;
|
||||
familyHistory?: number;
|
||||
workouts?: number;
|
||||
documents?: number;
|
||||
// Optional for the same reason: a file written before each of these
|
||||
// rode the wire carries no key, and an absent key must total as
|
||||
// nothing rather than break the count.
|
||||
nutrientDays?: number;
|
||||
healthProfile?: number;
|
||||
healthProfileFactRevisions?: number;
|
||||
customMetrics?: number;
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
// Ask first, so a large file is not sent once only to be refused.
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return uploadOnce(file);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const total =
|
||||
@@ -824,6 +842,7 @@ export function BackupsSection() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -938,10 +957,12 @@ export function BackupsSection() {
|
||||
// restore. Include the row id so two different backups can both be
|
||||
// restored independently in the same minute.
|
||||
const idempotencyKey = `restore-${row.id}-${randomId()}`;
|
||||
return apiPost<{ jobId: string; status: "queued" }>(
|
||||
`/api/admin/backups/${row.id}/restore`,
|
||||
{ confirm: "RESTORE", restoreInstanceSettings },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
return recentProof.run(() =>
|
||||
apiPost<{ jobId: string; status: "queued" }>(
|
||||
`/api/admin/backups/${row.id}/restore`,
|
||||
{ confirm: "RESTORE", restoreInstanceSettings },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -955,6 +976,7 @@ export function BackupsSection() {
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "backup.restore.active"
|
||||
@@ -979,7 +1001,11 @@ export function BackupsSection() {
|
||||
try {
|
||||
// apiFetchRaw: the download needs the raw Response for the blob +
|
||||
// the Content-Disposition filename header.
|
||||
const res = await apiFetchRaw(`/api/admin/backups/${row.id}/download`);
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw(`/api/admin/backups/${row.id}/download`),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
@@ -1002,6 +1028,7 @@ export function BackupsSection() {
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("admin.section.backups.downloadStarted"));
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -1014,6 +1041,7 @@ export function BackupsSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
|
||||
"Run now" button follows. */}
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -23,6 +23,10 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
|
||||
/**
|
||||
* The literal an admin must type to arm the wipe. Mirrors the Backups
|
||||
@@ -43,6 +47,10 @@ export function DangerZoneSection() {
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === WIPE_CONFIRM_TOKEN;
|
||||
|
||||
// Wiping every account asks for a fresh proof unless this session signed
|
||||
// in or re-proved within five minutes.
|
||||
const recentProof = useRecentProof();
|
||||
|
||||
const wipeAllData = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Idempotency-Key prevents a double-submit from re-running the
|
||||
@@ -52,14 +60,16 @@ export function DangerZoneSection() {
|
||||
// used to return nine fixed names, which is how nobody noticed the
|
||||
// wipe had stopped covering the schema: the result line read the same
|
||||
// whether it had cleared nine tables or ninety.
|
||||
return apiDelete<{
|
||||
cleared: boolean;
|
||||
deletedRows: number;
|
||||
models: Record<string, number>;
|
||||
}>(
|
||||
"/api/admin/data",
|
||||
{ confirm: WIPE_CONFIRM_TOKEN },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
return recentProof.run(() =>
|
||||
apiDelete<{
|
||||
cleared: boolean;
|
||||
deletedRows: number;
|
||||
models: Record<string, number>;
|
||||
}>(
|
||||
"/api/admin/data",
|
||||
{ confirm: WIPE_CONFIRM_TOKEN },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -72,7 +82,7 @@ export function DangerZoneSection() {
|
||||
);
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setWipeMsg(err.message);
|
||||
setWipeMsg(recentProofErrorMessage(err, err.message));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,6 +94,7 @@ export function DangerZoneSection() {
|
||||
// are alarm enough. The header keeps the `AlertTriangle` icon so the
|
||||
// section still flags its intent.
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
icon={AlertTriangle}
|
||||
title={t("admin.deleteAllData")}
|
||||
|
||||
@@ -38,6 +38,10 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
|
||||
/**
|
||||
* Filter values for the v1.5 users sub-route. The User model does NOT
|
||||
@@ -103,16 +107,22 @@ export function UserManagementSection() {
|
||||
},
|
||||
});
|
||||
|
||||
// Setting another account's password asks for a fresh proof unless this
|
||||
// session signed in or re-proved within five minutes.
|
||||
const recentProof = useRecentProof();
|
||||
|
||||
const resetPw = useMutation({
|
||||
mutationFn: async ({ id, password }: { id: string; password: string }) => {
|
||||
await apiPost(`/api/admin/users/${id}/reset-password`, { password });
|
||||
await recentProof.run(() =>
|
||||
apiPost(`/api/admin/users/${id}/reset-password`, { password }),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setResetMsg(t("admin.passwordReset"));
|
||||
setResetPassword("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setResetMsg(err.message);
|
||||
setResetMsg(recentProofErrorMessage(err, err.message));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -265,6 +275,7 @@ export function UserManagementSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("admin.userManagement")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -52,6 +52,8 @@ interface ConfirmCopy {
|
||||
pending?: boolean;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
/** A control that belongs with the question, such as an opt-out checkbox. */
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
@@ -61,6 +63,7 @@ function ConfirmBody({
|
||||
onConfirm,
|
||||
pending = false,
|
||||
slot,
|
||||
extra,
|
||||
}: ConfirmCopy) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -70,6 +73,7 @@ function ConfirmBody({
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{extra}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
@@ -102,6 +106,7 @@ export function ConfirmButton({
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
extra,
|
||||
}: ConfirmCopy & {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
@@ -143,6 +148,7 @@ export function ConfirmButton({
|
||||
onConfirm={onConfirm}
|
||||
pending={pending}
|
||||
slot={slot}
|
||||
extra={extra}
|
||||
/>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user