mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add export and import functionality for Bitwarden and NodeWarden formats
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON). - Added support for attachments in ciphers and introduced new types for handling attachments. - Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON. - Updated internationalization strings for attachment-related features. - Improved UI styles for attachment management and import summary display.
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { FileUp } from 'lucide-preact';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import {
|
||||
type EncryptedJsonMode,
|
||||
EXPORT_FORMATS,
|
||||
type ExportDownloadPayload,
|
||||
type ExportFormatId,
|
||||
type ExportRequest,
|
||||
} from '@/lib/export-formats';
|
||||
import {
|
||||
getFileAcceptBySource,
|
||||
IMPORT_SOURCES,
|
||||
@@ -17,18 +25,36 @@ import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Folder } from '@/lib/types';
|
||||
|
||||
configureZipJs({ useWebWorkers: false });
|
||||
|
||||
export interface ImportAttachmentFile {
|
||||
sourceCipherId: string | null;
|
||||
sourceCipherIndex: number | null;
|
||||
fileName: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
interface ImportPageProps {
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||
) => Promise<void>;
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
onImportEncryptedRaw: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }
|
||||
) => Promise<void>;
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
accountKeys?: { encB64: string; macB64: string } | null;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
folders: Folder[];
|
||||
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
|
||||
}
|
||||
|
||||
export interface ImportResultSummary {
|
||||
totalItems: number;
|
||||
folderCount: number;
|
||||
typeCounts: Array<{ label: string; count: number }>;
|
||||
}
|
||||
|
||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
@@ -45,6 +71,8 @@ interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||
'bitwarden_json',
|
||||
'bitwarden_csv',
|
||||
'bitwarden_zip',
|
||||
'nodewarden_json',
|
||||
'onepassword_1pux',
|
||||
'onepassword_1pif',
|
||||
'onepassword_mac_csv',
|
||||
@@ -80,7 +108,7 @@ async function derivePasswordProtectedFileKey(
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
const kdfType = Number(parsed.kdfType);
|
||||
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
throw new Error('Invalid password-protected export file.');
|
||||
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||
}
|
||||
|
||||
let keyMaterial: Uint8Array;
|
||||
@@ -113,11 +141,11 @@ async function derivePasswordProtectedFileKey(
|
||||
|
||||
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
||||
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
|
||||
throw new Error('Invalid password-protected export file.');
|
||||
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||
}
|
||||
const pass = String(password || '').trim();
|
||||
if (!pass) {
|
||||
throw new Error('Please enter file password.');
|
||||
throw new Error(t('txt_import_file_password_required'));
|
||||
}
|
||||
|
||||
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
||||
@@ -131,7 +159,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected
|
||||
try {
|
||||
return JSON.parse(plainJson);
|
||||
} catch {
|
||||
throw new Error('Failed to decrypt import file.');
|
||||
throw new Error(t('txt_import_decrypt_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +170,7 @@ function isZipPayload(bytes: Uint8Array): boolean {
|
||||
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
const unzipped = unzipSync(bytes);
|
||||
const fileNames = Object.keys(unzipped);
|
||||
if (!fileNames.length) throw new Error('Empty zip archive.');
|
||||
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
|
||||
for (const p of preferred) {
|
||||
@@ -152,7 +180,7 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||
|
||||
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
|
||||
if (firstJson) return strFromU8(unzipped[firstJson]);
|
||||
throw new Error('No importable JSON data found in zip archive.');
|
||||
throw new Error(t('txt_import_no_json_found_in_zip'));
|
||||
}
|
||||
|
||||
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||
@@ -164,21 +192,128 @@ async function readImportText(file: File, source: ImportSourceId): Promise<strin
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders }: ImportPageProps) {
|
||||
interface PendingPasswordImportContext {
|
||||
parsed: BitwardenPasswordProtectedInput;
|
||||
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
|
||||
attachments: ImportAttachmentFile[];
|
||||
}
|
||||
|
||||
class ZipNeedsPasswordError extends Error {}
|
||||
class ZipInvalidPasswordError extends Error {}
|
||||
|
||||
function looksLikeZipPasswordError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
|
||||
if (!message) return false;
|
||||
return message.includes('password') || message.includes('encrypted');
|
||||
}
|
||||
|
||||
async function readBitwardenZipPayload(
|
||||
file: File,
|
||||
passwordRaw: string
|
||||
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||
const password = String(passwordRaw || '').trim();
|
||||
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||
try {
|
||||
const entries = await reader.getEntries();
|
||||
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||
|
||||
let jsonText = '';
|
||||
const attachments: ImportAttachmentFile[] = [];
|
||||
const options = password ? { password } : undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.directory) continue;
|
||||
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||
if (!name) continue;
|
||||
|
||||
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'data.json') {
|
||||
jsonText = new TextDecoder().decode(bytes);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||
if (!attachmentMatch) continue;
|
||||
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||
attachments.push({
|
||||
sourceCipherId,
|
||||
sourceCipherIndex: null,
|
||||
fileName,
|
||||
bytes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
|
||||
return { jsonText, attachments };
|
||||
} catch (error) {
|
||||
if (looksLikeZipPasswordError(error)) {
|
||||
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
|
||||
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
|
||||
}
|
||||
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out: ImportAttachmentFile[] = [];
|
||||
for (const entry of raw) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const row = entry as Record<string, unknown>;
|
||||
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
|
||||
const base64 = String(row.data || '').trim();
|
||||
if (!base64) continue;
|
||||
try {
|
||||
const bytes = base64ToBytes(base64);
|
||||
const sourceCipherId = String(row.cipherId || '').trim() || null;
|
||||
const indexRaw = Number(row.cipherIndex);
|
||||
out.push({
|
||||
sourceCipherId,
|
||||
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
|
||||
fileName,
|
||||
bytes,
|
||||
});
|
||||
} catch {
|
||||
// skip malformed attachment row
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
|
||||
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [importPassword, setImportPassword] = useState('');
|
||||
const [pendingPasswordImport, setPendingPasswordImport] = useState<BitwardenPasswordProtectedInput | null>(null);
|
||||
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
|
||||
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
|
||||
const [zipImportPassword, setZipImportPassword] = useState('');
|
||||
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
|
||||
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
|
||||
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
|
||||
const [targetFolderId, setTargetFolderId] = useState('');
|
||||
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
|
||||
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
|
||||
const [exportPassword, setExportPassword] = useState('');
|
||||
const [zipPassword, setZipPassword] = useState('');
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||
|
||||
async function runBitwardenJsonImport(parsed: unknown): Promise<void> {
|
||||
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||
if (isRecord(parsed) && parsed.encrypted === true) {
|
||||
const accountEncrypted = parsed as BitwardenJsonInput;
|
||||
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||
@@ -193,16 +328,53 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
} catch {
|
||||
throw new Error('This encrypted export belongs to another account.');
|
||||
}
|
||||
await onImportEncryptedRaw(normalizeBitwardenEncryptedAccountImport(accountEncrypted), {
|
||||
return onImportEncryptedRaw(
|
||||
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
},
|
||||
attachments
|
||||
);
|
||||
}
|
||||
return onImport(
|
||||
normalizeBitwardenImport(parsed),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
return;
|
||||
},
|
||||
attachments
|
||||
);
|
||||
}
|
||||
|
||||
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
|
||||
if (!isRecord(parsed)) return [];
|
||||
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
|
||||
if (direct.length) return direct;
|
||||
|
||||
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
|
||||
if (!encryptedPayload) return [];
|
||||
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||
throw new Error('Vault key unavailable. Please unlock vault and try again.');
|
||||
}
|
||||
await onImport(normalizeBitwardenImport(parsed), {
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
const accountEnc = base64ToBytes(accountKeys.encB64);
|
||||
const accountMac = base64ToBytes(accountKeys.macB64);
|
||||
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
|
||||
const unpacked = JSON.parse(plain) as Record<string, unknown>;
|
||||
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
|
||||
}
|
||||
|
||||
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||
const bundled = await extractNodeWardenAttachments(parsed);
|
||||
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
|
||||
}
|
||||
|
||||
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
|
||||
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
|
||||
if (ctx.source === 'nodewarden_json') {
|
||||
return runNodeWardenJsonImport(parsed, ctx.attachments);
|
||||
}
|
||||
return runBitwardenJsonImport(parsed, ctx.attachments);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -213,31 +385,77 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (source === 'bitwarden_zip') {
|
||||
try {
|
||||
const bundle = await readBitwardenZipPayload(file, '');
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bundle.jsonText);
|
||||
} catch {
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source: 'bitwarden_zip',
|
||||
attachments: bundle.attachments,
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof ZipNeedsPasswordError) {
|
||||
setPendingZipFile(file);
|
||||
setZipImportPassword('');
|
||||
setZipPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const text = await readImportText(file, source);
|
||||
if (source === 'bitwarden_json') {
|
||||
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON file');
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport(parsed);
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source,
|
||||
attachments: [],
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
await runBitwardenJsonImport(parsed);
|
||||
const summary =
|
||||
source === 'nodewarden_json'
|
||||
? await runNodeWardenJsonImport(parsed)
|
||||
: await runBitwardenJsonImport(parsed);
|
||||
setImportSummary(summary);
|
||||
} else {
|
||||
await onImport(parseImportPayloadBySource(source, text), {
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
});
|
||||
const summary = await onImport(
|
||||
parseImportPayloadBySource(source, text),
|
||||
{
|
||||
folderMode,
|
||||
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||
},
|
||||
[]
|
||||
);
|
||||
setImportSummary(summary);
|
||||
}
|
||||
setFile(null);
|
||||
onNotify('success', 'Import completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Import failed';
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -248,31 +466,130 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
if (!pendingPasswordImport) return;
|
||||
setIsPasswordSubmitting(true);
|
||||
try {
|
||||
const parsed = await decryptPasswordProtectedExport(pendingPasswordImport, importPassword);
|
||||
await runBitwardenJsonImport(parsed);
|
||||
const summary = await processPasswordProtectedImport(pendingPasswordImport);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
setImportPassword('');
|
||||
setPendingPasswordImport(null);
|
||||
setPasswordDialogOpen(false);
|
||||
onNotify('success', 'Import completed');
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Import failed';
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsPasswordSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleZipPasswordImportConfirm() {
|
||||
if (!pendingZipFile) return;
|
||||
setIsZipPasswordSubmitting(true);
|
||||
try {
|
||||
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bundle.jsonText);
|
||||
} catch {
|
||||
throw new Error(t('txt_import_invalid_json_file'));
|
||||
}
|
||||
if (isPasswordProtectedExport(parsed)) {
|
||||
setPendingPasswordImport({
|
||||
parsed,
|
||||
source: 'bitwarden_zip',
|
||||
attachments: bundle.attachments,
|
||||
});
|
||||
setImportPassword('');
|
||||
setPasswordDialogOpen(true);
|
||||
} else {
|
||||
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||
setImportSummary(summary);
|
||||
setFile(null);
|
||||
}
|
||||
setZipPasswordDialogOpen(false);
|
||||
setPendingZipFile(null);
|
||||
setZipImportPassword('');
|
||||
} catch (error) {
|
||||
if (error instanceof ZipInvalidPasswordError) {
|
||||
onNotify('error', t('txt_import_invalid_zip_password'));
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsZipPasswordSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const exportNeedsMode =
|
||||
exportFormat === 'bitwarden_encrypted_json' ||
|
||||
exportFormat === 'bitwarden_encrypted_json_zip' ||
|
||||
exportFormat === 'nodewarden_encrypted_json';
|
||||
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
|
||||
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
|
||||
|
||||
async function runExportWithMasterPassword(masterPassword: string) {
|
||||
const filePassword = exportPassword.trim();
|
||||
const zipPass = zipPassword.trim();
|
||||
if (exportNeedsFilePassword && !filePassword) {
|
||||
onNotify('error', t('txt_import_file_password_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const payload = await onExport({
|
||||
format: exportFormat,
|
||||
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
||||
filePassword,
|
||||
zipPassword: exportIsZip ? zipPass : '',
|
||||
masterPassword,
|
||||
});
|
||||
const blobBytes = Uint8Array.from(payload.bytes);
|
||||
const blob = new Blob([blobBytes], { type: payload.mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = payload.fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
onNotify('success', t('txt_export_completed'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
||||
onNotify('error', message);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportConfirmPassword() {
|
||||
const masterPassword = String(exportAuthPassword || '').trim();
|
||||
if (!masterPassword) {
|
||||
onNotify('error', t('txt_master_password_is_required'));
|
||||
return;
|
||||
}
|
||||
await runExportWithMasterPassword(masterPassword);
|
||||
if (!isExporting) {
|
||||
setExportAuthPassword('');
|
||||
setExportAuthDialogOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
setExportAuthPassword('');
|
||||
setExportAuthDialogOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>Import</h3>
|
||||
<h3>{t('txt_import')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
Import vault data into your current account.
|
||||
{t('txt_import_vault_data_hint')}
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>Format</span>
|
||||
<span>{t('txt_format')}</span>
|
||||
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
|
||||
{commonSources.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
@@ -293,7 +610,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</label>
|
||||
|
||||
<label className="field field-span-2">
|
||||
<span>Source file</span>
|
||||
<span>{t('txt_source_file')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
@@ -306,23 +623,23 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</label>
|
||||
|
||||
<label className="field field-span-2">
|
||||
<span>Folder handling</span>
|
||||
<span>{t('txt_folder_handling')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={folderMode}
|
||||
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
||||
>
|
||||
<option value="original">Original path from import file</option>
|
||||
<option value="none">No folder</option>
|
||||
<option value="target">One selected folder</option>
|
||||
<option value="original">{t('txt_import_folder_mode_original')}</option>
|
||||
<option value="none">{t('txt_import_folder_mode_none')}</option>
|
||||
<option value="target">{t('txt_import_folder_mode_target')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{folderMode === 'target' && (
|
||||
<label className="field field-span-2">
|
||||
<span>Target folder</span>
|
||||
<span>{t('txt_target_folder')}</span>
|
||||
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
|
||||
<option value="">-- Select folder --</option>
|
||||
<option value="">{t('txt_select_folder_placeholder')}</option>
|
||||
{folders
|
||||
.slice()
|
||||
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
|
||||
@@ -343,16 +660,112 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : 'Import'}
|
||||
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>{t('txt_export')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_export_vault_data_hint')}
|
||||
</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={exportFormat}
|
||||
onChange={(e) => {
|
||||
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
|
||||
setExportFormat(next);
|
||||
}}
|
||||
>
|
||||
{EXPORT_FORMATS.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{exportNeedsMode && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_encrypted_mode')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={encryptedJsonMode}
|
||||
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
|
||||
>
|
||||
<option value="account">{t('txt_account_verification')}</option>
|
||||
<option value="password">{t('txt_password_verification')}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{exportNeedsFilePassword && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_file_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={exportPassword}
|
||||
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{exportIsZip && (
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_zip_password_optional')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={zipPassword}
|
||||
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
|
||||
<Download size={15} className="btn-icon" />
|
||||
{isExporting ? t('txt_loading') : t('txt_export')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={exportAuthDialogOpen}
|
||||
title={t('txt_export')}
|
||||
message={t('txt_enter_master_password_to_view_this_item')}
|
||||
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handleExportConfirmPassword()}
|
||||
onCancel={() => {
|
||||
if (isExporting) return;
|
||||
setExportAuthDialogOpen(false);
|
||||
setExportAuthPassword('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={exportAuthPassword}
|
||||
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={passwordDialogOpen}
|
||||
title="Import encrypted file"
|
||||
message="This Bitwarden export is password-protected. Enter the export file password to continue."
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : 'Import'}
|
||||
title={t('txt_import_encrypted_file_title')}
|
||||
message={t('txt_import_encrypted_file_message')}
|
||||
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handlePasswordImportConfirm()}
|
||||
@@ -364,7 +777,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>File password</span>
|
||||
<span>{t('txt_file_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
@@ -373,6 +786,74 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={zipPasswordDialogOpen}
|
||||
title={t('txt_import_encrypted_zip_title')}
|
||||
message={t('txt_import_encrypted_zip_message')}
|
||||
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||
onCancel={() => {
|
||||
if (isZipPasswordSubmitting) return;
|
||||
setZipPasswordDialogOpen(false);
|
||||
setZipImportPassword('');
|
||||
setPendingZipFile(null);
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_zip_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={zipImportPassword}
|
||||
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
{importSummary && (
|
||||
<div className="dialog-mask">
|
||||
<section className="dialog-card import-summary-dialog">
|
||||
<button
|
||||
type="button"
|
||||
className="import-summary-close"
|
||||
onClick={() => setImportSummary(null)}
|
||||
aria-label={t('txt_close')}
|
||||
>
|
||||
X
|
||||
</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>
|
||||
<div className="import-summary-table-wrap">
|
||||
<table className="import-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('txt_type')}</th>
|
||||
<th>{t('txt_total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importSummary.typeCounts.map((row) => (
|
||||
<tr key={row.label}>
|
||||
<td>{row.label}</td>
|
||||
<td>{row.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td>{t('txt_folder')}</td>
|
||||
<td>{importSummary.folderCount}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user