Merge branch 'feat/v1463-restore' into release/v1.46.3

This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:46:23 +01:00
commit 00d129ad42
28 files changed
+2504 -154

No files matched your search

+4 -9
View File
@@ -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,
};
}