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 { 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 = { 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 { 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("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" ? (
{triggerLabel ?? `Export ${isSingleItem ? "config" : "configs"}`}
) : ( ); return ( {trigger ?? defaultTrigger} 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).

)}
); } export function ExportCard({ entityType, ...props }: Omit) { const config = exportConfigs[entityType]; return ( ); }