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

+77 -49
View File
@@ -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
+20 -9
View File
@@ -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")}