mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge branch 'feat/v1463-restore' into release/v1.46.3
This commit is contained in:
commit
00d129ad42
28 files changed
+2504
-154
No files matched your search
@@ -29,7 +29,8 @@ import type { BackupRow, BackupsList } from "@/types/backups";
|
||||
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
|
||||
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
|
||||
import { DatabaseDumpRow } from "./backups/database-dump-row";
|
||||
import { BackupUploadCard } from "./backups/backup-upload-card";
|
||||
import { BackupUpload } from "./backups/backup-upload-card";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
import {
|
||||
RestoreRefusalNotice,
|
||||
RestoreSkipReport,
|
||||
@@ -37,7 +38,6 @@ import {
|
||||
} from "./backups/restore-notices";
|
||||
import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions";
|
||||
import { useBackupRestores } from "./backups/use-backup-restores";
|
||||
import { useBackupUpload } from "./backups/use-backup-upload";
|
||||
|
||||
export function BackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -55,8 +55,7 @@ export function BackupsSection() {
|
||||
// a fresh proof unless this session signed in or re-proved within minutes.
|
||||
const recentProof = useRecentProof();
|
||||
const { downloadingId, handleDownload } = useBackupDownload(recentProof);
|
||||
const { upload, fileInputRef, handleFileChange } =
|
||||
useBackupUpload(recentProof);
|
||||
const uploadFlow = useBackupUpload(recentProof);
|
||||
const {
|
||||
restoreJobs,
|
||||
dismissedRestores,
|
||||
@@ -126,11 +125,7 @@ export function BackupsSection() {
|
||||
<BackupPassActivityNotice activity={data.scheduleActivity} />
|
||||
) : null}
|
||||
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
<BackupUpload flow={uploadFlow} />
|
||||
|
||||
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
|
||||
<RestoreJobStatus
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* The choice offered when an uploaded file's account is not on this server.
|
||||
*
|
||||
* SSR markup per project convention, plus the pure rules the dialog submits
|
||||
* by: a new account needs a valid, free username; an existing one needs the
|
||||
* server's yes for exactly that account and its username typed; managed
|
||||
* profiles and accounts pending deletion are never offered; and each refusal
|
||||
* the upload can answer has its own sentence.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
BackupTargetForm,
|
||||
EMPTY_TARGET_DRAFT,
|
||||
pickableAccounts,
|
||||
readyTarget,
|
||||
targetUploadFailureKey,
|
||||
type BackupTargetCheck,
|
||||
type BackupTargetDraft,
|
||||
} from "../backup-target-dialog";
|
||||
|
||||
function user(overrides: Partial<AdminUser>): AdminUser {
|
||||
return {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
mfaEnforced: false,
|
||||
documentQuotaBytes: null,
|
||||
passkeyCount: 0,
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const USERS = [
|
||||
user({ id: "u1", username: "returning-person", email: "r@example.test" }),
|
||||
user({ id: "u2", username: "managed-cafe01", managedProfile: true }),
|
||||
user({
|
||||
id: "u3",
|
||||
username: "leaving",
|
||||
deletion: { requestedAt: "x", purgeAt: null, requestedBy: "self" },
|
||||
}),
|
||||
user({ id: "u4", username: "operator", role: "ADMIN" }),
|
||||
];
|
||||
|
||||
const ELIGIBLE: BackupTargetCheck = {
|
||||
id: "u1",
|
||||
username: "returning-person",
|
||||
eligible: true,
|
||||
reason: null,
|
||||
models: [],
|
||||
};
|
||||
|
||||
function render(
|
||||
draft: BackupTargetDraft,
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown } = {
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={() => {}}
|
||||
disabled={false}
|
||||
users={USERS}
|
||||
check={check}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pickableAccounts", () => {
|
||||
it("offers neither managed profiles nor accounts pending deletion", () => {
|
||||
expect(pickableAccounts(USERS).map((u) => u.username)).toEqual([
|
||||
"returning-person",
|
||||
"operator",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyTarget", () => {
|
||||
it("takes a new account with a valid username nobody has", () => {
|
||||
expect(
|
||||
readyTarget(
|
||||
{ ...EMPTY_TARGET_DRAFT, username: " back-again " },
|
||||
USERS,
|
||||
undefined,
|
||||
),
|
||||
).toEqual({ into: "new", username: "back-again" });
|
||||
});
|
||||
|
||||
it("refuses a new username that is taken, malformed or reads as a managed profile", () => {
|
||||
for (const username of ["operator", "no spaces", "ab", "managed-x1"]) {
|
||||
expect(
|
||||
readyTarget({ ...EMPTY_TARGET_DRAFT, username }, USERS, undefined),
|
||||
username,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an existing account only with the server's yes and its username typed", () => {
|
||||
const draft: BackupTargetDraft = {
|
||||
into: "existing",
|
||||
username: "",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
};
|
||||
expect(readyTarget(draft, USERS, ELIGIBLE)).toEqual({
|
||||
into: "existing",
|
||||
userId: "u1",
|
||||
confirm: "returning-person",
|
||||
});
|
||||
expect(readyTarget(draft, USERS, undefined)).toBeNull();
|
||||
expect(
|
||||
readyTarget(draft, USERS, {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
}),
|
||||
).toBeNull();
|
||||
// A yes for another account does not carry over.
|
||||
expect(readyTarget(draft, USERS, { ...ELIGIBLE, id: "u4" })).toBeNull();
|
||||
expect(
|
||||
readyTarget({ ...draft, confirm: "Returning-Person" }, USERS, ELIGIBLE),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetUploadFailureKey", () => {
|
||||
it("names each refusal the upload answers with, and falls back for the rest", () => {
|
||||
const key = (errorCode?: string) =>
|
||||
targetUploadFailureKey(
|
||||
new ApiError("x", 409, errorCode ? { errorCode } : undefined),
|
||||
);
|
||||
expect(key("backup.target.holds_records")).toBe(
|
||||
"admin.section.backups.target.failureHoldsRecords",
|
||||
);
|
||||
expect(key("backup.target.managed_profile")).toBe(
|
||||
"admin.section.backups.target.failureManagedProfile",
|
||||
);
|
||||
expect(key("backup.target.pending_deletion")).toBe(
|
||||
"admin.section.backups.target.failurePendingDeletion",
|
||||
);
|
||||
expect(key("backup.target.not_found")).toBe(
|
||||
"admin.section.backups.target.failureNotFound",
|
||||
);
|
||||
expect(key("backup.target.unconfirmed")).toBe(
|
||||
"admin.section.backups.target.failureUnconfirmed",
|
||||
);
|
||||
expect(key("backup.target.username_taken")).toBe(
|
||||
"admin.section.backups.target.failureUsernameTaken",
|
||||
);
|
||||
expect(key("backup.owner.exists")).toBe(
|
||||
"admin.section.backups.target.failureOwnerExists",
|
||||
);
|
||||
expect(key()).toBe("admin.section.backups.uploadFailed");
|
||||
expect(targetUploadFailureKey(new Error("offline"))).toBe(
|
||||
"admin.section.backups.uploadFailed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<BackupTargetForm>", () => {
|
||||
it("starts on a new account, with the sign-in note", () => {
|
||||
const html = render(EMPTY_TARGET_DRAFT);
|
||||
expect(html).toContain('data-testid="backup-target-username"');
|
||||
expect(html).toContain("created without a password or email");
|
||||
expect(html).not.toContain('data-testid="backup-target-account"');
|
||||
});
|
||||
|
||||
it("says a taken username is taken", () => {
|
||||
const html = render({ ...EMPTY_TARGET_DRAFT, username: "operator" });
|
||||
expect(html).toContain("This username is already taken.");
|
||||
});
|
||||
|
||||
it("offers only pickable accounts, and asks for the username once the server says yes", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{ data: ELIGIBLE, loading: false, error: null },
|
||||
);
|
||||
expect(html).toContain("returning-person (r@example.test)");
|
||||
expect(html).toContain(">operator<");
|
||||
expect(html).not.toContain("managed-cafe01");
|
||||
expect(html).not.toContain(">leaving<");
|
||||
expect(html).toContain('data-slot="backup-target-eligible"');
|
||||
expect(html).toContain("Type returning-person to confirm");
|
||||
expect(html).toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
|
||||
it("says why an account that holds records cannot take the copy, and asks nothing more", () => {
|
||||
const html = render(
|
||||
{ into: "existing", username: "", userId: "u1", confirm: "" },
|
||||
{
|
||||
data: {
|
||||
...ELIGIBLE,
|
||||
eligible: false,
|
||||
reason: "holds_records",
|
||||
models: ["Measurement"],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="backup-target-refused"');
|
||||
expect(html).toContain("This account already holds records.");
|
||||
expect(html).not.toContain('data-testid="backup-target-confirm"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, UserPlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AdminUser } from "@/components/admin/_shared";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Segmented } from "@/components/ui/segmented";
|
||||
import { ApiError, apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { accountUsernameSchema } from "@/lib/validations/auth";
|
||||
import type { BackupUploadTarget } from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/** What the admin has filled in so far. */
|
||||
export interface BackupTargetDraft {
|
||||
into: "new" | "existing";
|
||||
username: string;
|
||||
userId: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
export const EMPTY_TARGET_DRAFT: BackupTargetDraft = {
|
||||
into: "new",
|
||||
username: "",
|
||||
userId: "",
|
||||
confirm: "",
|
||||
};
|
||||
|
||||
/** The server's answer to "may this account take the copy?". */
|
||||
export interface BackupTargetCheck {
|
||||
id: string;
|
||||
username: string;
|
||||
eligible: boolean;
|
||||
reason: string | null;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts the picker offers: not a managed profile (its Guardians run
|
||||
* it) and not pending deletion. Whether one holds records is the server's to
|
||||
* say, per account, once it is picked.
|
||||
*/
|
||||
export function pickableAccounts(users: readonly AdminUser[]): AdminUser[] {
|
||||
return users.filter((user) => !user.managedProfile && user.deletion === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The target the draft describes, or null while it is not one the upload
|
||||
* would take: a new account needs a valid username nobody has, an existing
|
||||
* one needs the server's yes and its username typed exactly.
|
||||
*/
|
||||
export function readyTarget(
|
||||
draft: BackupTargetDraft,
|
||||
users: readonly AdminUser[],
|
||||
check: BackupTargetCheck | undefined,
|
||||
): BackupUploadTarget | null {
|
||||
if (draft.into === "new") {
|
||||
const username = draft.username.trim();
|
||||
if (!accountUsernameSchema.safeParse(username).success) return null;
|
||||
if (users.some((user) => user.username === username)) return null;
|
||||
return { into: "new", username };
|
||||
}
|
||||
const user = users.find((candidate) => candidate.id === draft.userId);
|
||||
if (!user || !check || check.id !== user.id || !check.eligible) return null;
|
||||
if (draft.confirm.trim() !== user.username) return null;
|
||||
return { into: "existing", userId: user.id, confirm: user.username };
|
||||
}
|
||||
|
||||
/** The sentence for a refusal, by the code the server answered with. */
|
||||
export function targetFailureKey(code: string): string {
|
||||
switch (code) {
|
||||
case "backup.target.holds_records":
|
||||
return "admin.section.backups.target.failureHoldsRecords";
|
||||
case "backup.target.managed_profile":
|
||||
return "admin.section.backups.target.failureManagedProfile";
|
||||
case "backup.target.pending_deletion":
|
||||
return "admin.section.backups.target.failurePendingDeletion";
|
||||
case "backup.target.not_found":
|
||||
return "admin.section.backups.target.failureNotFound";
|
||||
case "backup.target.unconfirmed":
|
||||
return "admin.section.backups.target.failureUnconfirmed";
|
||||
case "backup.target.username_taken":
|
||||
return "admin.section.backups.target.failureUsernameTaken";
|
||||
case "backup.owner.exists":
|
||||
return "admin.section.backups.target.failureOwnerExists";
|
||||
default:
|
||||
return "admin.section.backups.uploadFailed";
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence for a failed upload or check. */
|
||||
export function targetUploadFailureKey(error: unknown): string {
|
||||
return targetFailureKey(
|
||||
error instanceof ApiError && typeof error.meta?.errorCode === "string"
|
||||
? error.meta.errorCode
|
||||
: "",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The form inside the dialog, apart from it so it renders on its own: where
|
||||
* the copy of an account that is no longer on this server goes. A new account
|
||||
* by default, since it cannot hold anything yet; an existing one when the
|
||||
* person has signed up again, checked by the server as soon as it is picked.
|
||||
*/
|
||||
export function BackupTargetForm({
|
||||
draft,
|
||||
onChange,
|
||||
disabled,
|
||||
users,
|
||||
check,
|
||||
}: {
|
||||
draft: BackupTargetDraft;
|
||||
onChange: (next: BackupTargetDraft) => void;
|
||||
disabled: boolean;
|
||||
/** Every account; null while the list loads. */
|
||||
users: readonly AdminUser[] | null;
|
||||
/** The server's check of the picked account. */
|
||||
check: { data?: BackupTargetCheck; loading: boolean; error: unknown };
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const accounts = pickableAccounts(users ?? []);
|
||||
const chosen = accounts.find((user) => user.id === draft.userId);
|
||||
const username = draft.username.trim();
|
||||
const usernameTaken = (users ?? []).some(
|
||||
(user) => user.username === username,
|
||||
);
|
||||
const usernameInvalid =
|
||||
username !== "" && !accountUsernameSchema.safeParse(username).success;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="backup-target-form">
|
||||
<Segmented
|
||||
label={t("admin.section.backups.target.choiceLabel")}
|
||||
slot="backup-target-choice"
|
||||
stretch
|
||||
disabled={disabled}
|
||||
value={draft.into}
|
||||
onChange={(into) => onChange({ ...draft, into })}
|
||||
options={[
|
||||
{ value: "new", label: t("admin.section.backups.target.newAccount") },
|
||||
{
|
||||
value: "existing",
|
||||
label: t("admin.section.backups.target.existingAccount"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.into === "new" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-username">
|
||||
{t("admin.section.backups.target.usernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-username"
|
||||
data-testid="backup-target-username"
|
||||
value={draft.username}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange({ ...draft, username: e.target.value })}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={usernameInvalid || usernameTaken}
|
||||
/>
|
||||
{usernameTaken ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.failureUsernameTaken")}
|
||||
</p>
|
||||
) : usernameInvalid ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("admin.section.backups.target.usernameInvalid")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.newAccountHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-target-account">
|
||||
{t("admin.section.backups.target.accountLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="backup-target-account"
|
||||
data-testid="backup-target-account"
|
||||
value={draft.userId}
|
||||
disabled={disabled || users === null}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, userId: e.target.value, confirm: "" })
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("admin.section.backups.target.accountPlaceholder")}
|
||||
</option>
|
||||
{accounts.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.email
|
||||
? `${user.username} (${user.email})`
|
||||
: user.username}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
{chosen && check.loading ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.section.backups.target.checking")}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.error ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t(targetUploadFailureKey(check.error))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data && !check.data.eligible ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-xs"
|
||||
data-slot="backup-target-refused"
|
||||
>
|
||||
{t(targetFailureKey(`backup.target.${check.data.reason}`))}
|
||||
</p>
|
||||
) : null}
|
||||
{chosen && check.data?.eligible ? (
|
||||
<>
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="backup-target-eligible"
|
||||
>
|
||||
{t("admin.section.backups.target.eligible")}
|
||||
</p>
|
||||
<Label htmlFor="backup-target-confirm">
|
||||
{t("admin.section.backups.target.confirmLabel", {
|
||||
username: chosen.username,
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-target-confirm"
|
||||
data-testid="backup-target-confirm"
|
||||
value={draft.confirm}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...draft, confirm: e.target.value })
|
||||
}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder={chosen.username}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when an upload was refused because the account the file belongs to
|
||||
* is not on this server. Choosing a target sends the file again with it; the
|
||||
* server rewrites the file's owner to that account as it stores it.
|
||||
*/
|
||||
export function BackupTargetDialog({
|
||||
file,
|
||||
pending,
|
||||
error,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
/** The refused file; the dialog is open while there is one. */
|
||||
file: File | null;
|
||||
pending: boolean;
|
||||
/** The last refusal of an upload into a target, shown in the dialog. */
|
||||
error: unknown;
|
||||
onSubmit: (target: BackupUploadTarget) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [draft, setDraft] = useState<BackupTargetDraft>(EMPTY_TARGET_DRAFT);
|
||||
const users = useQuery({
|
||||
queryKey: queryKeys.adminUsers(),
|
||||
queryFn: () => apiGet<AdminUser[]>("/api/admin/users"),
|
||||
enabled: file !== null,
|
||||
});
|
||||
const check = useQuery({
|
||||
queryKey: queryKeys.adminBackupTarget(draft.userId),
|
||||
queryFn: () =>
|
||||
apiGet<BackupTargetCheck>(
|
||||
`/api/admin/backups/targets/${encodeURIComponent(draft.userId)}`,
|
||||
),
|
||||
enabled: file !== null && draft.into === "existing" && draft.userId !== "",
|
||||
retry: false,
|
||||
});
|
||||
const target = readyTarget(draft, users.data ?? [], check.data);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={file !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (next || pending) return;
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent data-slot="backup-target-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.section.backups.target.title")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("admin.section.backups.target.description", {
|
||||
file: file?.name ?? "",
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{file ? (
|
||||
<BackupTargetForm
|
||||
draft={draft}
|
||||
onChange={setDraft}
|
||||
disabled={pending}
|
||||
users={users.isError ? [] : (users.data ?? null)}
|
||||
check={{
|
||||
data: check.data,
|
||||
loading: check.isLoading,
|
||||
error: check.error,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{error && !pending ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(targetUploadFailureKey(error))}
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
data-testid="backup-target-submit"
|
||||
disabled={!target || pending}
|
||||
onClick={() => {
|
||||
if (target) onSubmit(target);
|
||||
}}
|
||||
className="min-h-11"
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{pending
|
||||
? t("admin.section.backups.target.submitting")
|
||||
: t("admin.section.backups.target.submit")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,55 @@
|
||||
import { Loader2, Upload } from "lucide-react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { BackupTargetDialog } from "./backup-target-dialog";
|
||||
import type { useBackupUpload } from "./use-backup-upload";
|
||||
|
||||
/**
|
||||
* The upload card, and for a file whose account is not on this server, the
|
||||
* dialog that sends it again into an account the admin picks. The dialog is
|
||||
* mounted per refused file, so every file starts from a clean form.
|
||||
*/
|
||||
export function BackupUpload({
|
||||
flow,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBackupUpload>;
|
||||
}) {
|
||||
const {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
} = flow;
|
||||
return (
|
||||
<>
|
||||
<BackupUploadCard
|
||||
pending={upload.isPending || uploadInto.isPending}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
{orphan ? (
|
||||
<BackupTargetDialog
|
||||
file={orphan}
|
||||
pending={uploadInto.isPending}
|
||||
error={
|
||||
uploadInto.error instanceof ReproofCancelledError
|
||||
? null
|
||||
: uploadInto.error
|
||||
}
|
||||
onSubmit={(target) => uploadInto.mutate(target)}
|
||||
onClose={closeOrphan}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The upload card: separate from the table so admins can ingest a backup file
|
||||
* independently of any existing rows. The visible button proxies a hidden
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { useRef, useState, type ChangeEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ReproofCancelledError } from "@/components/settings/security-section/use-recent-proof";
|
||||
import { apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiFetch, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
BACKUP_OWNER_MISSING_CODE,
|
||||
backupUploadTargetQuery,
|
||||
type BackupUploadTarget,
|
||||
} from "@/lib/validations/backup-upload-target";
|
||||
|
||||
/**
|
||||
* The upload body for a backup file: the file itself when it is already
|
||||
@@ -28,9 +33,11 @@ async function uploadBody(file: File): Promise<Blob> {
|
||||
* 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.
|
||||
* re-proof sends the file again rather than a spent stream. With a target, the
|
||||
* copy goes into that account instead of the one the file names, which is
|
||||
* how the copy of an account no longer on this server comes back.
|
||||
*/
|
||||
async function uploadOnce(file: File) {
|
||||
async function uploadOnce(file: File, target?: BackupUploadTarget) {
|
||||
const body = await uploadBody(file);
|
||||
return apiFetch<{
|
||||
id: string;
|
||||
@@ -65,12 +72,17 @@ async function uploadOnce(file: File) {
|
||||
customMetricEntries?: number;
|
||||
intradayProfiles?: number;
|
||||
};
|
||||
}>("/api/admin/backups/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
});
|
||||
}>(
|
||||
target
|
||||
? `/api/admin/backups/upload?${backupUploadTargetQuery(target)}`
|
||||
: "/api/admin/backups/upload",
|
||||
{
|
||||
method: "POST",
|
||||
body,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
signal: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export type UploadSummary = Awaited<ReturnType<typeof uploadOnce>>["summary"];
|
||||
@@ -106,9 +118,19 @@ export function uploadedRecordCount(summary: UploadSummary): number {
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether an upload was refused because the file's account is not here. */
|
||||
export function isOwnerMissing(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === BACKUP_OWNER_MISSING_CODE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single-file upload flow: a fresh proof first, then the file, and the
|
||||
* file input reset after either outcome.
|
||||
* file input reset after either outcome. A file whose account is not on this
|
||||
* server is kept (`orphan`) for the target dialog, which sends it again into
|
||||
* the account the admin picks (`uploadInto`).
|
||||
*/
|
||||
export function useBackupUpload(
|
||||
recentProof: ReturnType<typeof useRecentProof>,
|
||||
@@ -118,21 +140,32 @@ export function useBackupUpload(
|
||||
|
||||
// Upload state — single-file flow, drives the file input + button.
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [orphan, setOrphan] = useState<File | null>(null);
|
||||
|
||||
/** A stored copy, said once: for its own account, or for the one chosen. */
|
||||
function announceStored(summary: UploadSummary, username: string | null) {
|
||||
const count = String(uploadedRecordCount(summary));
|
||||
toast.success(
|
||||
username === null
|
||||
? t("admin.section.backups.uploadSuccess", { count })
|
||||
: t("admin.section.backups.target.success", { count, username }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
}
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
// 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 = uploadedRecordCount(data.summary);
|
||||
toast.success(
|
||||
t("admin.section.backups.uploadSuccess", { count: String(total) }),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
|
||||
},
|
||||
onError: (err) => {
|
||||
onSuccess: (data) => announceStored(data.summary, null),
|
||||
onError: (err, file) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
// Not a dead end: the dialog offers an account to restore it into.
|
||||
if (isOwnerMissing(err)) {
|
||||
setOrphan(file);
|
||||
return;
|
||||
}
|
||||
showErrorToast(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
@@ -147,10 +180,39 @@ export function useBackupUpload(
|
||||
},
|
||||
});
|
||||
|
||||
const uploadInto = useMutation({
|
||||
mutationFn: async (target: BackupUploadTarget) => {
|
||||
if (!orphan) throw new Error("No file to upload");
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
return { data: await uploadOnce(orphan, target), target };
|
||||
},
|
||||
onSuccess: ({ data, target }) => {
|
||||
setOrphan(null);
|
||||
announceStored(
|
||||
data.summary,
|
||||
target.into === "new" ? target.username : target.confirm,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
},
|
||||
// A refusal stays in the dialog, beside the choice it refuses.
|
||||
});
|
||||
|
||||
function handleFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) upload.mutate(file);
|
||||
}
|
||||
|
||||
return { upload, fileInputRef, handleFileChange };
|
||||
function closeOrphan() {
|
||||
setOrphan(null);
|
||||
uploadInto.reset();
|
||||
}
|
||||
|
||||
return {
|
||||
upload,
|
||||
fileInputRef,
|
||||
handleFileChange,
|
||||
orphan,
|
||||
uploadInto,
|
||||
closeOrphan,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user