import { useState } from 'preact/hooks'; import { argon2idAsync } from '@noble/hashes/argon2.js'; import { createPortal } from 'preact/compat'; import { strFromU8, unzipSync } from 'fflate'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { Download, FileUp } from 'lucide-preact'; import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { type EncryptedJsonMode, EXPORT_FORMATS, type ExportFormatId, type ExportRequest, } from '@/lib/export-formats'; import { parseImportPayloadBySource, } from '@/lib/import-formats'; import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources'; import { type BitwardenJsonInput, normalizeBitwardenEncryptedAccountImport, normalizeBitwardenImport, } from '@/lib/import-formats-bitwarden'; 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 }, attachments?: ImportAttachmentFile[] ) => Promise; onImportEncryptedRaw: ( payload: CiphersImportPayload, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, attachments?: ImportAttachmentFile[] ) => Promise; accountKeys?: { encB64: string; macB64: string } | null; onNotify: (type: 'success' | 'error', text: string) => void; folders: Folder[]; onExport: (request: ExportRequest) => Promise; } 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 { encrypted: true; passwordProtected: true; salt?: string; kdfIterations?: number; kdfMemory?: number; kdfParallelism?: number; kdfType?: number; data?: string; } const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [ 'bitwarden_json', 'bitwarden_csv', 'bitwarden_zip', 'nodewarden_json', 'onepassword_1pux', 'onepassword_1pif', 'onepassword_mac_csv', 'onepassword_win_csv', 'protonpass_json', 'chrome', 'edge', 'brave', 'opera', 'vivaldi', 'firefox_csv', 'safari_csv', 'lastpass', 'dashlane_csv', 'dashlane_json', 'keepass_xml', 'keepassx_csv', ]; function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput { return isRecord(value) && value.encrypted === true && value.passwordProtected === true; } async function derivePasswordProtectedFileKey( parsed: BitwardenPasswordProtectedInput, password: string ): Promise<{ enc: Uint8Array; mac: Uint8Array }> { const salt = String(parsed.salt || '').trim(); const iterations = Number(parsed.kdfIterations || 0); const kdfType = Number(parsed.kdfType); if (!salt || !Number.isFinite(iterations) || iterations <= 0) { throw new Error(t('txt_import_invalid_password_protected_file')); } let keyMaterial: Uint8Array; if (kdfType === 0) { keyMaterial = await pbkdf2(password, salt, iterations, 32); } else if (kdfType === 1) { const memoryMiB = Number(parsed.kdfMemory || 0); const parallelism = Number(parsed.kdfParallelism || 0); if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) { throw new Error(t('txt_invalid_argon2id_params')); } const memoryKiB = Math.floor(memoryMiB * 1024); const maxmem = memoryKiB * 1024 + 1024 * 1024; keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), { t: Math.floor(iterations), m: memoryKiB, p: Math.floor(parallelism), dkLen: 32, maxmem, asyncTick: 10, }); } else { throw new Error(t('txt_unsupported_kdf_type', { type: String(kdfType) })); } const enc = await hkdfExpand(keyMaterial, 'enc', 32); const mac = await hkdfExpand(keyMaterial, 'mac', 32); return { enc, mac }; } async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise { if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) { throw new Error(t('txt_import_invalid_password_protected_file')); } const pass = String(password || '').trim(); if (!pass) { throw new Error(t('txt_import_file_password_required')); } const key = await derivePasswordProtectedFileKey(parsed, pass); try { await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac); } catch { throw new Error(t('txt_invalid_file_password')); } const plainJson = await decryptStr(parsed.data, key.enc, key.mac); try { return JSON.parse(plainJson); } catch { throw new Error(t('txt_import_decrypt_failed')); } } function isZipPayload(bytes: Uint8Array): boolean { return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; } function readZipText(bytes: Uint8Array, source: ImportSourceId): string { const unzipped = unzipSync(bytes); const fileNames = Object.keys(unzipped); 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) { const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase())); if (hit) return strFromU8(unzipped[hit]); } const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data')); if (firstJson) return strFromU8(unzipped[firstJson]); throw new Error(t('txt_import_no_json_found_in_zip')); } async function readImportText(file: File, source: ImportSourceId): Promise { if (source !== 'onepassword_1pux' && source !== 'protonpass_json') { return file.text(); } const bytes = new Uint8Array(await file.arrayBuffer()); if (isZipPayload(bytes)) return readZipText(bytes, source); return new TextDecoder().decode(bytes); } 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; 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('bitwarden_json'); const [file, setFile] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [importPassword, setImportPassword] = useState(''); const [pendingPasswordImport, setPendingPasswordImport] = useState(null); const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false); const [zipImportPassword, setZipImportPassword] = useState(''); const [pendingZipFile, setPendingZipFile] = useState(null); const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false); const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original'); const [targetFolderId, setTargetFolderId] = useState(''); const [exportFormat, setExportFormat] = useState('bitwarden_json'); const [encryptedJsonMode, setEncryptedJsonMode] = useState('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(null); useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null); const commonSourceSet = new Set(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, attachments: ImportAttachmentFile[] = []): Promise { if (isRecord(parsed) && parsed.encrypted === true) { const accountEncrypted = parsed as BitwardenJsonInput; if (!accountKeys?.encB64 || !accountKeys?.macB64) { throw new Error(t('txt_vault_key_unavailable')); } const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim(); if (!validation) throw new Error(t('txt_invalid_encrypted_export')); const accountEncKey = base64ToBytes(accountKeys.encB64); const accountMacKey = base64ToBytes(accountKeys.macB64); try { await decryptStr(validation, accountEncKey, accountMacKey); } catch { throw new Error(t('txt_export_belongs_to_another_account')); } return onImportEncryptedRaw( normalizeBitwardenEncryptedAccountImport(accountEncrypted), { folderMode, targetFolderId: folderMode === 'target' ? targetFolderId || null : null, }, attachments ); } return onImport( normalizeBitwardenImport(parsed), { folderMode, targetFolderId: folderMode === 'target' ? targetFolderId || null : null, }, attachments ); } async function extractNodeWardenAttachments(parsed: unknown): Promise { 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(t('txt_vault_key_unavailable')); } const accountEnc = base64ToBytes(accountKeys.encB64); const accountMac = base64ToBytes(accountKeys.macB64); const plain = await decryptStr(encryptedPayload, accountEnc, accountMac); const unpacked = JSON.parse(plain) as Record; return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments); } async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise { const bundled = await extractNodeWardenAttachments(parsed); return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]); } async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise { 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() { if (!file) { onNotify('error', t('txt_please_select_a_file')); return; } 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' || source === 'nodewarden_json') { let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new Error(t('txt_import_invalid_json_file')); } if (isPasswordProtectedExport(parsed)) { setPendingPasswordImport({ parsed, source, attachments: [], }); setImportPassword(''); setPasswordDialogOpen(true); return; } const summary = source === 'nodewarden_json' ? await runNodeWardenJsonImport(parsed) : await runBitwardenJsonImport(parsed); setImportSummary(summary); } else { const summary = await onImport( parseImportPayloadBySource(source, text), { folderMode, targetFolderId: folderMode === 'target' ? targetFolderId || null : null, }, [] ); setImportSummary(summary); } setFile(null); } catch (error) { const message = error instanceof Error ? error.message : t('txt_import_failed'); onNotify('error', message); } finally { setIsSubmitting(false); } } async function handlePasswordImportConfirm() { if (isPasswordSubmitting) return; if (!pendingPasswordImport) return; setIsPasswordSubmitting(true); try { const summary = await processPasswordProtectedImport(pendingPasswordImport); setImportSummary(summary); setFile(null); setImportPassword(''); setPendingPasswordImport(null); setPasswordDialogOpen(false); } catch (error) { const message = error instanceof Error ? error.message : t('txt_import_failed'); onNotify('error', message); } finally { setIsPasswordSubmitting(false); } } async function handleZipPasswordImportConfirm() { if (isZipPasswordSubmitting) return; 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 { await onExport({ format: exportFormat, encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined, filePassword, zipPassword: exportIsZip ? zipPass : '', masterPassword, }); 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() { if (isExporting) return; 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 (

{t('txt_import')}

{t('txt_import_vault_data_hint')}

{folderMode === 'target' && ( )}

{t('txt_export')}

{t('txt_export_vault_data_hint')}

{exportNeedsMode && ( )} {exportNeedsFilePassword && ( )} {exportIsZip && ( )}
void handleExportConfirmPassword()} onCancel={() => { if (isExporting) return; setExportAuthDialogOpen(false); setExportAuthPassword(''); }} > void handlePasswordImportConfirm()} onCancel={() => { if (isPasswordSubmitting) return; setPasswordDialogOpen(false); setImportPassword(''); setPendingPasswordImport(null); }} > void handleZipPasswordImportConfirm()} onCancel={() => { if (isZipPasswordSubmitting) return; setZipPasswordDialogOpen(false); setZipImportPassword(''); setPendingZipFile(null); }} > {importSummary && typeof document !== 'undefined' ? createPortal((
{ if (event.target !== event.currentTarget) return; setImportSummary(null); }} >

{t('txt_import_success')}

{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}
{importSummary.attachmentCount > 0 && (
{t('txt_import_attachment_summary', { imported: String(importSummary.importedAttachmentCount), total: String(importSummary.attachmentCount), })}
)} {importSummary.failedAttachments.length > 0 && (
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
    {importSummary.failedAttachments.map((row, index) => (
  • {row.fileName} {`: ${row.reason}`}
  • ))}
)}
{importSummary.typeCounts.map((row) => ( ))}
{t('txt_type')} {t('txt_total')}
{row.label} {row.count}
{t('txt_folder')} {importSummary.folderCount}
), document.body) : null}
); }