mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups. - Introduced user interface components for backup management in the web app. - Enhanced import/export logic to handle attachments and provide detailed summaries. - Updated localization files to include new strings related to backup features. - Improved styling for backup-related UI elements.
This commit is contained in:
@@ -1,18 +1,139 @@
|
||||
import { Cloud } from 'lucide-preact';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export default function HelpPage() {
|
||||
interface HelpPageProps {
|
||||
onExport: () => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
export default function HelpPage(props: HelpPageProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||
|
||||
function isReplaceRequiredError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? String(error.message || '') : '';
|
||||
return message.toLowerCase().includes('fresh instance');
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
await props.onExport();
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport(replaceExisting: boolean) {
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalError('');
|
||||
setImporting(true);
|
||||
try {
|
||||
await props.onImport(selectedFile, replaceExisting);
|
||||
props.onNotify('success', t('txt_backup_import_success_relogin'));
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setConfirmReplaceOpen(false);
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_import_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
await runImport(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>{t('backup_strategy_title')}</h3>
|
||||
<div className="empty" style={{ minHeight: 180 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
|
||||
<div>{t('backup_strategy_under_construction')}</div>
|
||||
<div className="stack backup-page">
|
||||
<div className="import-export-panels">
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_export')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p className="backup-inline-note">{t('txt_backup_export_description')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={exporting || importing} onClick={() => void handleExport()}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_import')}</h3>
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_import_description')}</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_file')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
disabled={importing || exporting}
|
||||
onChange={(event) => {
|
||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="backup-file-meta">
|
||||
{selectedFile ? (
|
||||
<span>{t('txt_backup_selected_file_name', { name: selectedFile.name })}</span>
|
||||
) : (
|
||||
<span>{t('txt_backup_no_file_selected')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_restore_note')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={importing || exporting} onClick={() => void handleImport()}>
|
||||
<FileUp size={14} className="btn-icon" />
|
||||
{importing ? t('txt_backup_importing') : t('txt_backup_import')}
|
||||
</button>
|
||||
</div>
|
||||
{localError && <div className="local-error">{localError}</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runImport(true)}
|
||||
onCancel={() => setConfirmReplaceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import {
|
||||
@@ -55,6 +55,9 @@ export interface ImportResultSummary {
|
||||
totalItems: number;
|
||||
folderCount: number;
|
||||
typeCounts: Array<{ label: string; count: number }>;
|
||||
attachmentCount: number;
|
||||
importedAttachmentCount: number;
|
||||
failedAttachments: Array<{ fileName: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
@@ -582,46 +585,10 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
return (
|
||||
<div className="import-export-page">
|
||||
<section className="card import-export-hero">
|
||||
<h3>{t('txt_import_export_title')}</h3>
|
||||
<p className="import-export-hero-sub">{t('txt_import_export_feature_intro')}</p>
|
||||
<div className="import-export-feature-grid">
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<Archive size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_bw_zip_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_bw_zip_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<FileJson size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_nodewarden_json_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_nodewarden_json_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<ArrowLeftRight size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_compat_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_compat_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="import-export-panels">
|
||||
<section className="card import-export-panel">
|
||||
<h3>{t('txt_import')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_import_vault_data_hint')}
|
||||
</p>
|
||||
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
@@ -702,9 +669,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
<section className="card import-export-panel">
|
||||
<h3>{t('txt_export')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_export_vault_data_hint')}
|
||||
</p>
|
||||
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
@@ -862,6 +827,29 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</button>
|
||||
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||
{importSummary.attachmentCount > 0 && (
|
||||
<div className="dialog-message">
|
||||
{t('txt_import_attachment_summary', {
|
||||
imported: String(importSummary.importedAttachmentCount),
|
||||
total: String(importSummary.attachmentCount),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{importSummary.failedAttachments.length > 0 && (
|
||||
<div className="import-summary-failed-list">
|
||||
<div className="import-summary-failed-title">
|
||||
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
|
||||
</div>
|
||||
<ul>
|
||||
{importSummary.failedAttachments.map((row, index) => (
|
||||
<li key={`${row.fileName}-${index}`}>
|
||||
<strong>{row.fileName}</strong>
|
||||
{`: ${row.reason}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="import-summary-table-wrap">
|
||||
<table className="import-summary-table">
|
||||
<thead>
|
||||
|
||||
Reference in New Issue
Block a user