zerobyte/app/client/components/export-dialog.tsx

260 lines
8 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 { type ReactNode, useState } from "react";
import { toast } from "sonner";
import { exportFullConfigMutation } 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";
type SecretsMode = "exclude" | "encrypted" | "cleartext";
const DEFAULT_EXPORT_FILENAME = "zerobyte-full-config";
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);
}
type BaseExportDialogProps = {
/** Optional custom filename (without extension). Defaults to `zerobyte-full-config`. */
filename?: string;
};
type ExportDialogWithTrigger = BaseExportDialogProps & {
/** Custom trigger element. When provided, variant/size/triggerLabel/showIcon are ignored. */
trigger: 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({
filename = DEFAULT_EXPORT_FILENAME,
trigger,
variant = "outline",
size = "default",
triggerLabel,
showIcon = true,
}: ExportDialogProps) {
const [open, setOpen] = useState(false);
const [includeMetadata, setIncludeMetadata] = useState(false);
const [includeRecoveryKey, setIncludeRecoveryKey] = useState(false);
const [includePasswordHash, setIncludePasswordHash] = useState(false);
const [secretsMode, setSecretsMode] = useState<SecretsMode>("exclude");
const [password, setPassword] = useState("");
const handleExportSuccess = (data: unknown) => {
downloadAsJson(data, filename);
toast.success("Configuration 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 exportMutation = useMutation({
...exportFullConfigMutation(),
onSuccess: handleExportSuccess,
onError: handleExportError,
});
const handleExport = (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
exportMutation.mutate({
body: {
password,
includeMetadata,
secretsMode,
includeRecoveryKey,
includePasswordHash,
},
});
};
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 Config"}
</span>
</button>
) : (
<Button variant={variant} size={size}>
{showIcon && <Download className="h-4 w-4 mr-2" />}
{triggerLabel ?? "Export Config"}
</Button>
);
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogTrigger asChild>{trigger ?? defaultTrigger}</DialogTrigger>
<DialogContent>
<form onSubmit={handleExport}>
<DialogHeader>
<DialogTitle>Export Full Configuration</DialogTitle>
<DialogDescription>
Export the complete Zerobyte configuration.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center space-x-3">
<Checkbox
id="includeMetadata"
checked={includeMetadata}
onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
/>
<Label htmlFor="includeMetadata" className="cursor-pointer">
Include metadata
</Label>
</div>
<p className="text-xs text-muted-foreground ml-7">
Include database IDs, timestamps, and runtime state (status, health checks, last backup info).
</p>
<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>
<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 user passwords for seamless migration. The passwords are 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>
);
}