zerobyte/app/client/components/export-dialog.tsx
Jakub Trávník 9f1a7694a3 fix: Entity lookup and frontend mutation patterns for config export
- Fix volumes/repositories export to prefer name lookup over ID
- Fallback to list+filter when only ID provided (services expect name)
- Refactor ExportDialog to use generated mutations instead of manual fetch
- Remove standalone /auth/verify-password endpoint (integrated into exports)
2025-12-03 00:12:34 +01:00

442 lines
13 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMutation } from "@tanstack/react-query";
import { Download } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
exportBackupSchedulesMutation,
exportFullConfigMutation,
exportNotificationDestinationsMutation,
exportRepositoriesMutation,
exportVolumesMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Checkbox } from "~/client/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/client/components/ui/select";
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
function downloadAsJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${filename}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full";
type ExportConfig = {
label: string;
labelPlural: string;
getFilename: (id?: string | number, name?: string) => string;
};
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
volumes: {
label: "Volume",
labelPlural: "Volumes",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `volume-${identifier}-config` : "volumes-config";
},
},
repositories: {
label: "Repository",
labelPlural: "Repositories",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `repository-${identifier}-config` : "repositories-config";
},
},
"notification-destinations": {
label: "Notification Destination",
labelPlural: "Notification Destinations",
getFilename: (id, name) => {
const identifier = id ?? name;
return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config";
},
},
"backup-schedules": {
label: "Backup Schedule",
labelPlural: "Backup Schedules",
getFilename: (id) => (id ? `backup-schedule-${id}-config` : "backup-schedules-config"),
},
full: {
label: "Full Config",
labelPlural: "Full Config",
getFilename: () => "zerobyte-full-config",
},
};
type BaseExportDialogProps = {
entityType: ExportEntityType;
name?: string;
id?: string | number;
};
type ExportDialogWithTrigger = BaseExportDialogProps & {
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
trigger: React.ReactNode;
variant?: never;
size?: never;
triggerLabel?: never;
showIcon?: never;
};
type ExportDialogWithDefaultTrigger = BaseExportDialogProps & {
trigger?: never;
variant?: "default" | "outline" | "ghost" | "card";
size?: "default" | "sm" | "lg" | "icon";
triggerLabel?: string;
showIcon?: boolean;
};
type ExportDialogProps = ExportDialogWithTrigger | ExportDialogWithDefaultTrigger;
export function ExportDialog({
entityType,
name,
id,
trigger,
variant = "outline",
size = "default",
triggerLabel,
showIcon = true,
}: ExportDialogProps) {
const [open, setOpen] = useState(false);
const [includeIds, setIncludeIds] = useState(true);
const [includeTimestamps, setIncludeTimestamps] = useState(true);
const [includeRuntimeState, setIncludeRuntimeState] = useState(false);
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [password, setPassword] = useState("");
const config = exportConfigs[entityType];
const isSingleItem = !!(name || id);
const isFullExport = entityType === "full";
// TODO: Volumes will have encrypted secrets (e.g., SMB/NFS credentials) in a future PR
const hasSecrets = entityType !== "backup-schedules" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural;
const filename = config.getFilename(id, name);
const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename);
toast.success(`${entityLabel} exported successfully`);
setOpen(false);
setPassword("");
};
const handleExportError = (error: unknown) => {
const message = error && typeof error === "object" && "error" in error
? (error as { error: string }).error
: "Unknown error";
toast.error("Export failed", {
description: message,
});
};
const fullExport = useMutation({
...exportFullConfigMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const volumesExport = useMutation({
...exportVolumesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const repositoriesExport = useMutation({
...exportRepositoriesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const notificationsExport = useMutation({
...exportNotificationDestinationsMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const backupSchedulesExport = useMutation({
...exportBackupSchedulesMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const getMutation = () => {
switch (entityType) {
case "full":
return fullExport;
case "volumes":
return volumesExport;
case "repositories":
return repositoriesExport;
case "notification-destinations":
return notificationsExport;
case "backup-schedules":
return backupSchedulesExport;
}
};
const exportMutation = getMutation();
const handleExport = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
const baseBody = {
password,
includeIds,
includeTimestamps,
includeRuntimeState,
secretsMode: hasSecrets ? secretsMode : undefined,
};
switch (entityType) {
case "full":
fullExport.mutate({
body: {
...baseBody,
includeRecoveryKey,
includePasswordHash,
},
});
break;
case "volumes":
volumesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "repositories":
repositoriesExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "notification-destinations":
notificationsExport.mutate({
body: {
...baseBody,
id: id as number | string | undefined,
name,
},
});
break;
case "backup-schedules":
backupSchedulesExport.mutate({
body: {
...baseBody,
id: typeof id === "number" ? id : undefined,
},
});
break;
}
};
const handleDialogChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setPassword("");
}
};
const defaultTrigger =
variant === "card" ? (
<button
type="button"
className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full border-0 bg-transparent p-0 hover:opacity-80 transition-opacity"
>
<Download className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
</span>
</button>
) : (
<Button variant={variant} size={size}>
{showIcon && <Download className="h-4 w-4 mr-2" />}
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
</Button>
);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{isFullExport
? "Export the complete Zerobyte configuration including all volumes, repositories, backup schedules, and notifications."
: isSingleItem
? `Export the configuration for this ${config.label.toLowerCase()}.`
: `Export all ${config.labelPlural.toLowerCase()} configurations.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeIds"
checked={includeIds}
onCheckedChange={(checked) => setIncludeIds(checked === true)}
/>
<Label htmlFor="includeIds" className="cursor-pointer">
Include database IDs
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeTimestamps"
checked={includeTimestamps}
onCheckedChange={(checked) => setIncludeTimestamps(checked === true)}
/>
<Label htmlFor="includeTimestamps" className="cursor-pointer">
Include timestamps
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includeRuntimeState"
checked={includeRuntimeState}
onCheckedChange={(checked) => setIncludeRuntimeState(checked === true)}
/>
<Label htmlFor="includeRuntimeState" className="cursor-pointer">
Include runtime state
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include current status, health checks, and last backup information. Usually not needed for migration.
</p>
{hasSecrets && (
<>
<div className="flex items-center justify-between pt-2 border-t">
<Label className="cursor-pointer">Secrets handling</Label>
<Select value={secretsMode} onValueChange={(v) => setSecretsMode(v as SecretsMode)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exclude">Exclude</SelectItem>
<SelectItem value="encrypted">Keep encrypted</SelectItem>
<SelectItem value="cleartext">Decrypt</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{secretsMode === "exclude" && "Sensitive fields (passwords, API keys, webhooks) will be removed from the export."}
{secretsMode === "encrypted" && "Secrets will be exported in encrypted form. Requires the same recovery key to decrypt on import."}
{secretsMode === "cleartext" && (
<span className="text-yellow-600">
Secrets will be decrypted and exported as plaintext. Keep this export secure!
</span>
)}
</p>
</>
)}
{isFullExport && (
<>
<div className="flex items-center space-x-3 pt-2 border-t">
<Checkbox
id="includeRecoveryKey"
checked={includeRecoveryKey}
onCheckedChange={(checked) => setIncludeRecoveryKey(checked === true)}
/>
<Label htmlFor="includeRecoveryKey" className="cursor-pointer">
Include recovery key
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
<span className="text-yellow-600 font-medium"> Security sensitive:</span> The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.
</p>
<div className="flex items-center space-x-3">
<Checkbox
id="includePasswordHash"
checked={includePasswordHash}
onCheckedChange={(checked) => setIncludePasswordHash(checked === true)}
/>
<Label htmlFor="includePasswordHash" className="cursor-pointer">
Include password hash
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).
</p>
</>
)}
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="export-password">Your Password</Label>
<Input
id="export-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password to export"
required
/>
<p className="text-xs text-muted-foreground">
Password is required to verify your identity before exporting configuration.
</p>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={exportMutation.isPending}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}