zerobyte/app/client/components/export-dialog.tsx
2025-12-01 11:31:19 +01:00

348 lines
11 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 { Download } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
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 { Label } from "~/client/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/client/components/ui/select";
export type SecretsMode = "exclude" | "encrypted" | "cleartext";
export type ExportOptions = {
includeIds?: boolean;
includeTimestamps?: boolean;
includeRuntimeState?: boolean;
includeRecoveryKey?: boolean;
includePasswordHash?: boolean;
secretsMode?: SecretsMode;
name?: string;
id?: string | number;
};
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);
}
async function exportFromApi(endpoint: string, filename: string, options: ExportOptions = {}): Promise<void> {
const params = new URLSearchParams();
if (options.includeIds === false) params.set("includeIds", "false");
if (options.includeTimestamps === false) params.set("includeTimestamps", "false");
if (options.includeRuntimeState === true) params.set("includeRuntimeState", "true");
if (options.includeRecoveryKey === true) params.set("includeRecoveryKey", "true");
if (options.includePasswordHash === true) params.set("includePasswordHash", "true");
if (options.secretsMode && options.secretsMode !== "exclude") params.set("secretsMode", options.secretsMode);
if (options.id !== undefined) params.set("id", String(options.id));
if (options.name) params.set("name", options.name);
const url = params.toString() ? `${endpoint}?${params}` : endpoint;
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`Export failed: ${res.statusText}`);
}
const data = await res.json();
downloadAsJson(data, filename);
}
export type ExportEntityType = "volumes" | "repositories" | "notifications" | "backups" | "full";
type ExportConfig = {
endpoint: string;
label: string;
labelPlural: string;
getFilename: (options: ExportOptions) => string;
};
const exportConfigs: Record<ExportEntityType, ExportConfig> = {
volumes: {
endpoint: "/api/v1/config/export/volumes",
label: "Volume",
labelPlural: "Volumes",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
return identifier ? `volume-${identifier}-config` : "volumes-config";
},
},
repositories: {
endpoint: "/api/v1/config/export/repositories",
label: "Repository",
labelPlural: "Repositories",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
return identifier ? `repository-${identifier}-config` : "repositories-config";
},
},
notifications: {
endpoint: "/api/v1/config/export/notifications",
label: "Notification",
labelPlural: "Notifications",
getFilename: (opts) => {
const identifier = opts.id ?? opts.name;
return identifier ? `notification-${identifier}-config` : "notifications-config";
},
},
backups: {
endpoint: "/api/v1/config/export/backups",
label: "Backup Schedule",
labelPlural: "Backup Schedules",
getFilename: (opts) => (opts.id ? `backup-schedule-${opts.id}-config` : "backup-schedules-config"),
},
full: {
endpoint: "/api/v1/config/export",
label: "Full Config",
labelPlural: "Full Config",
getFilename: () => "zerobyte-full-config",
},
};
export async function exportConfig(
entityType: ExportEntityType,
options: ExportOptions = {}
): Promise<void> {
const config = exportConfigs[entityType];
const filename = config.getFilename(options);
await exportFromApi(config.endpoint, filename, options);
}
type ExportDialogProps = {
entityType: ExportEntityType;
name?: string;
id?: string | number;
trigger?: React.ReactNode;
variant?: "default" | "outline" | "ghost" | "card";
size?: "default" | "sm" | "lg" | "icon";
triggerLabel?: string;
showIcon?: boolean;
};
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 [isExporting, setIsExporting] = useState(false);
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 !== "backups" && entityType !== "volumes";
const entityLabel = isSingleItem ? config.label : config.labelPlural;
const handleExport = async () => {
setIsExporting(true);
try {
await exportConfig(entityType, {
includeIds,
includeTimestamps,
includeRuntimeState,
includeRecoveryKey: isFullExport ? includeRecoveryKey : undefined,
includePasswordHash: isFullExport ? includePasswordHash : undefined,
secretsMode: hasSecrets ? secretsMode : undefined,
name,
id,
});
toast.success(`${entityLabel} exported successfully`);
setOpen(false);
} catch (err) {
toast.error("Export failed", {
description: err instanceof Error ? err.message : String(err),
});
} finally {
setIsExporting(false);
}
};
const defaultTrigger =
variant === "card" ? (
<div className="flex flex-col items-center justify-center gap-2 cursor-pointer h-full w-full">
<Download className="h-8 w-8 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
</span>
</div>
) : (
<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={setOpen}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Export {entityLabel}</DialogTitle>
<DialogDescription>
{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>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleExport} loading={isExporting}>
<Download className="h-4 w-4 mr-2" />
Export
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function ExportCard({ entityType, ...props }: Omit<ExportDialogProps, "variant" | "trigger">) {
const config = exportConfigs[entityType];
return (
<ExportDialog
entityType={entityType}
variant="card"
triggerLabel={props.triggerLabel ?? `Export ${props.id || props.name ? "config" : "configs"}`}
{...props}
/>
);
}