mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +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")}
|
||||
|
||||
Reference in new issue
Block a user