FEAT: Implement SQLite-based data export/import with incremental merge

Replace JSON-based backup system with SQLite export/import functionality:

**Export Features:**
- Generate SQLite database files with complete user data
- Export all tables: SSH hosts, credentials, file manager data, settings, alerts
- Include OIDC configuration and system settings (admin only)
- Password authentication required for data decryption
- Direct browser download instead of file path display

**Import Features:**
- Incremental import with duplicate detection and skipping
- Smart conflict resolution by key combinations:
  - SSH hosts: ip + port + username
  - Credentials: name + username
  - File manager: path + name
- Re-encrypt imported data to current user's keys
- Admin-only settings import (including OIDC config)
- Detailed import statistics with category breakdown

**Removed:**
- Database backup functionality (redundant with export)
- JSON export format
- File path-based workflows

**Security:**
- Password verification for all operations
- SQLite file format validation
- Proper error handling and logging
- Admin permission checks for settings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ZacharyZcR
2025-09-24 03:09:53 +08:00
parent b655b2fe0c
commit 0506536893
2 changed files with 800 additions and 231 deletions

View File

@@ -30,8 +30,6 @@ import {
Lock,
Download,
Upload,
HardDrive,
FileArchive,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -99,10 +97,10 @@ export function AdminSettings({
// Database migration state
const [exportLoading, setExportLoading] = React.useState(false);
const [importLoading, setImportLoading] = React.useState(false);
const [backupLoading, setBackupLoading] = React.useState(false);
const [importFile, setImportFile] = React.useState<File | null>(null);
const [exportPath, setExportPath] = React.useState<string>("");
const [backupPath, setBackupPath] = React.useState<string>("");
const [exportPassword, setExportPassword] = React.useState("");
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
const [importPassword, setImportPassword] = React.useState("");
React.useEffect(() => {
const jwt = getCookie("jwt");
@@ -282,6 +280,16 @@ export function AdminSettings({
// Database export/import handlers
const handleExportDatabase = async () => {
if (!showPasswordInput) {
setShowPasswordInput(true);
return;
}
if (!exportPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setExportLoading(true);
try {
const jwt = getCookie("jwt");
@@ -295,15 +303,34 @@ export function AdminSettings({
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
body: JSON.stringify({ password: exportPassword }),
});
if (response.ok) {
const result = await response.json();
setExportPath(result.exportPath);
// Handle file download
const blob = await response.blob();
const contentDisposition = response.headers.get('content-disposition');
const filename = contentDisposition?.match(/filename="([^"]+)"/)?.[1] || 'termix-export.sqlite';
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("admin.databaseExportedSuccessfully"));
setExportPassword("");
setShowPasswordInput(false);
} else {
throw new Error("Export failed");
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseExportFailed"));
}
}
} catch (err) {
toast.error(t("admin.databaseExportFailed"));
@@ -318,6 +345,11 @@ export function AdminSettings({
return;
}
if (!importPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setImportLoading(true);
try {
const jwt = getCookie("jwt");
@@ -328,7 +360,7 @@ export function AdminSettings({
// Create FormData for file upload
const formData = new FormData();
formData.append("file", importFile);
formData.append("backupCurrent", "true");
formData.append("password", importPassword);
const response = await fetch(apiUrl, {
method: "POST",
@@ -341,16 +373,34 @@ export function AdminSettings({
if (response.ok) {
const result = await response.json();
if (result.success) {
toast.success(t("admin.databaseImportedSuccessfully"));
const summary = result.summary;
const imported = summary.sshHostsImported + summary.sshCredentialsImported + summary.fileManagerItemsImported + summary.dismissedAlertsImported + (summary.settingsImported || 0);
const skipped = summary.skippedItems;
const details = [];
if (summary.sshHostsImported > 0) details.push(`${summary.sshHostsImported} SSH hosts`);
if (summary.sshCredentialsImported > 0) details.push(`${summary.sshCredentialsImported} credentials`);
if (summary.fileManagerItemsImported > 0) details.push(`${summary.fileManagerItemsImported} file manager items`);
if (summary.dismissedAlertsImported > 0) details.push(`${summary.dismissedAlertsImported} alerts`);
if (summary.settingsImported > 0) details.push(`${summary.settingsImported} settings`);
toast.success(
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(', ')})` : ''}, ${skipped} items skipped`
);
setImportFile(null);
// Status refresh not needed in v2 system
setImportPassword("");
} else {
toast.error(
`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`,
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
);
}
} else {
throw new Error("Import failed");
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseImportFailed"));
}
}
} catch (err) {
toast.error(t("admin.databaseImportFailed"));
@@ -359,36 +409,6 @@ export function AdminSettings({
}
};
const handleCreateBackup = async () => {
setBackupLoading(true);
try {
const jwt = getCookie("jwt");
const apiUrl = isElectron()
? `${(window as any).configuredServerUrl}/database/backup`
: "http://localhost:8081/database/backup";
const response = await fetch(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (response.ok) {
const result = await response.json();
setBackupPath(result.backupPath);
toast.success(t("admin.encryptedBackupCreatedSuccessfully"));
} else {
throw new Error("Backup failed");
}
} catch (err) {
toast.error(t("admin.backupCreationFailed"));
} finally {
setBackupLoading(false);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
@@ -844,27 +864,56 @@ export function AdminSettings({
</div>
</div>
{/* Practical functions - export/import/backup */}
<div className="grid gap-3 md:grid-cols-3">
{/* Data management functions - export/import */}
<div className="grid gap-3 md:grid-cols-2">
<div className="p-4 border rounded bg-card">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.export")}</h4>
</div>
<p className="text-xs text-muted-foreground">
Export SSH hosts and credentials as SQLite file
</p>
{showPasswordInput && (
<div className="space-y-2">
<Label htmlFor="export-password">Password</Label>
<PasswordInput
id="export-password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleExportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleExportDatabase}
disabled={exportLoading}
className="w-full"
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
{exportLoading
? t("admin.exporting")
: showPasswordInput
? t("admin.confirmExport")
: t("admin.export")
}
</Button>
{exportPath && (
<div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">
{exportPath}
</div>
</div>
{showPasswordInput && (
<Button
variant="outline"
onClick={() => {
setShowPasswordInput(false);
setExportPassword("");
}}
className="w-full"
>
Cancel
</Button>
)}
</div>
</div>
@@ -875,44 +924,40 @@ export function AdminSettings({
<Upload className="h-4 w-4 text-green-500" />
<h4 className="font-medium">{t("admin.import")}</h4>
</div>
<p className="text-xs text-muted-foreground">
Import SQLite file with incremental merge (skips duplicates)
</p>
<input
type="file"
accept=".sqlite,.termix-export.sqlite,.db"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2"
/>
{importFile && (
<div className="space-y-2">
<Label htmlFor="import-password">Password</Label>
<PasswordInput
id="import-password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleImportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleImportDatabase}
disabled={importLoading || !importFile}
disabled={importLoading || !importFile || !importPassword.trim()}
className="w-full"
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
</div>
</div>
<div className="p-4 border rounded bg-card">
<div className="space-y-3">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-purple-500" />
<h4 className="font-medium">{t("admin.backup")}</h4>
</div>
<Button
onClick={handleCreateBackup}
disabled={backupLoading}
className="w-full"
>
{backupLoading ? t("admin.creatingBackup") : t("admin.createBackup")}
</Button>
{backupPath && (
<div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">
{backupPath}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</TabsContent>