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 { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "~/client/components/ui/select"; async function verifyPassword(password: string): Promise { const response = await fetch("/api/v1/auth/verify-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }), }); return response.ok; } 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 { 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) { const errorText = await res.text().catch(() => res.statusText); throw new Error(errorText || `HTTP ${res.status}`); } const data = await res.json(); downloadAsJson(data, filename); } export type ExportEntityType = "volumes" | "repositories" | "notification-destinations" | "backup-schedules" | "full"; type ExportConfig = { endpoint: string; label: string; labelPlural: string; getFilename: (options: ExportOptions) => string; }; const exportConfigs: Record = { 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"; }, }, "notification-destinations": { endpoint: "/api/v1/config/export/notification-destinations", label: "Notification Destination", labelPlural: "Notification Destinations", getFilename: (opts) => { const identifier = opts.id ?? opts.name; return identifier ? `notification-destination-${identifier}-config` : "notification-destinations-config"; }, }, "backup-schedules": { endpoint: "/api/v1/config/export/backup-schedules", 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 { const config = exportConfigs[entityType]; const filename = config.getFilename(options); await exportFromApi(config.endpoint, filename, options); } 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("exclude"); const [isExporting, setIsExporting] = useState(false); const [showPasswordStep, setShowPasswordStep] = useState(false); const [password, setPassword] = useState(""); const [isVerifying, setIsVerifying] = 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 !== "backup-schedules" && entityType !== "volumes"; const entityLabel = isSingleItem ? config.label : config.labelPlural; const requiresPassword = includeRecoveryKey || secretsMode === "cleartext"; const performExport = 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); setShowPasswordStep(false); setPassword(""); } catch (err) { toast.error("Export failed", { description: err instanceof Error ? err.message : String(err), }); } finally { setIsExporting(false); } }; const handleExport = () => { if (requiresPassword) { setShowPasswordStep(true); } else { performExport(); } }; const handlePasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!password) { toast.error("Password is required"); return; } setIsVerifying(true); try { const isValid = await verifyPassword(password); if (!isValid) { toast.error("Incorrect password"); return; } // Password verified, proceed with export await performExport(); } catch { toast.error("Incorrect password"); } finally { setIsVerifying(false); } }; const handleDialogChange = (isOpen: boolean) => { setOpen(isOpen); if (!isOpen) { setShowPasswordStep(false); setPassword(""); } }; const defaultTrigger = variant === "card" ? ( ) : ( ); return ( {trigger ?? defaultTrigger} {showPasswordStep ? (
Confirm Export For security reasons, please enter your password to export {includeRecoveryKey && secretsMode === "cleartext" ? " the recovery key and decrypted secrets." : includeRecoveryKey ? " the recovery key." : " decrypted secrets."}
setPassword(e.target.value)} placeholder="Enter your password" required autoFocus />
) : ( <> Export {entityLabel} {isSingleItem ? `Export the configuration for this ${config.label.toLowerCase()}.` : `Export all ${config.labelPlural.toLowerCase()} configurations.`}
setIncludeIds(checked === true)} />

Include internal database identifiers in the export. Useful for debugging or when IDs are needed for reference.

setIncludeTimestamps(checked === true)} />

Include createdAt and updatedAt timestamps. Disable for cleaner exports when timestamps aren't needed.

setIncludeRuntimeState(checked === true)} />

Include current status, health checks, and last backup information. Usually not needed for migration.

{hasSecrets && ( <>

{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" && ( ⚠️ Secrets will be decrypted and exported as plaintext. Keep this export secure! )}

)} {isFullExport && ( <>
setIncludeRecoveryKey(checked === true)} />

⚠️ Security sensitive: The recovery key is the master encryption key for all repositories. Keep this export secure and never share it.

setIncludePasswordHash(checked === true)} />

Include the hashed admin password for seamless migration. The password is already securely hashed (argon2).

)}
)}
); }