refactor(backup): one success line for a stored upload

Both upload paths now announce a stored copy through one function, so
the upload hook keeps the single success affordance it is pinned at,
and the upload card and target dialog are wired in their own component
rather than in the backups section, which stays under its size line.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 20:37:11 +01:00
1 parent 8ed6eb5793
commit 46350b6461
3 files changed
+68 -49

No files matched your search

+3 -34
View File
@@ -15,10 +15,7 @@ import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react";
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
ReproofCancelledError,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -32,8 +29,7 @@ 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 { BackupTargetDialog } from "./backups/backup-target-dialog";
import { BackupUploadCard } from "./backups/backup-upload-card";
import { BackupUpload } from "./backups/backup-upload-card";
import {
RestoreRefusalNotice,
RestoreSkipReport,
@@ -41,7 +37,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();
@@ -59,14 +54,6 @@ 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,
orphan,
uploadInto,
closeOrphan,
} = useBackupUpload(recentProof);
const {
restoreJobs,
dismissedRestores,
@@ -136,25 +123,7 @@ export function BackupsSection() {
<BackupPassActivityNotice activity={data.scheduleActivity} />
) : null}
<BackupUploadCard
pending={upload.isPending || uploadInto.isPending}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
/>
{/* Mounted per refused file, so every file starts from a clean form. */}
{orphan ? (
<BackupTargetDialog
file={orphan}
pending={uploadInto.isPending}
error={
uploadInto.error instanceof ReproofCancelledError
? null
: uploadInto.error
}
onSubmit={(target) => uploadInto.mutate(target)}
onClose={closeOrphan}
/>
) : null}
<BackupUpload recentProof={recentProof} />
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
<RestoreJobStatus
@@ -1,9 +1,58 @@
import { Loader2, Upload } from "lucide-react";
import type { ChangeEvent, RefObject } from "react";
import {
ReproofCancelledError,
type useRecentProof,
} 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 { 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({
recentProof,
}: {
recentProof: ReturnType<typeof useRecentProof>;
}) {
const {
upload,
fileInputRef,
handleFileChange,
orphan,
uploadInto,
closeOrphan,
} = useBackupUpload(recentProof);
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
@@ -141,19 +141,24 @@ 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() });
},
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.
@@ -182,15 +187,11 @@ export function useBackupUpload(
return { data: await uploadOnce(orphan, target), target };
},
onSuccess: ({ data, target }) => {
const total = uploadedRecordCount(data.summary);
toast.success(
t("admin.section.backups.target.success", {
count: String(total),
username: target.into === "new" ? target.username : target.confirm,
}),
);
setOrphan(null);
queryClient.invalidateQueries({ queryKey: queryKeys.adminBackups() });
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.