feat: improve error handling and localization for vault operations and import/export processes

This commit is contained in:
shuaiplus
2026-03-05 02:55:59 +08:00
parent e12ab2b334
commit dab0961a63
4 changed files with 71 additions and 34 deletions
+26 -23
View File
@@ -110,24 +110,27 @@ function summarizeImportResult(
ciphers: Array<Record<string, unknown>>, ciphers: Array<Record<string, unknown>>,
folderCount: number folderCount: number
): ImportResultSummary { ): ImportResultSummary {
const counter = new Map<string, number>();
const typeLabel = (type: number): string => { const typeLabel = (type: number): string => {
if (type === 1) return '登录'; if (type === 1) return t('txt_login');
if (type === 2) return '安全备注'; if (type === 2) return t('txt_secure_note');
if (type === 3) return '卡片'; if (type === 3) return t('txt_card');
if (type === 4) return '身份'; if (type === 4) return t('txt_identity');
if (type === 5) return 'SSH 密钥'; if (type === 5) return t('txt_ssh_key');
return '其他'; return t('txt_other');
}; };
const counter = new Map<number, number>();
for (const raw of ciphers) { for (const raw of ciphers) {
const t = Number(raw?.type || 1) || 1; const cipherType = Number(raw?.type || 1) || 1;
const label = typeLabel(t); counter.set(cipherType, (counter.get(cipherType) || 0) + 1);
counter.set(label, (counter.get(label) || 0) + 1);
} }
const order = ['登录', '安全备注', '卡片', '身份', 'SSH 密钥', '其他']; const order = [1, 2, 3, 4, 5];
const seen = new Set<number>(order);
const typeCounts = order const typeCounts = order
.filter((label) => (counter.get(label) || 0) > 0) .filter((type) => (counter.get(type) || 0) > 0)
.map((label) => ({ label, count: counter.get(label) || 0 })); .map((type) => ({ label: typeLabel(type), count: counter.get(type) || 0 }));
for (const [type, count] of counter.entries()) {
if (!seen.has(type) && count > 0) typeCounts.push({ label: typeLabel(type), count });
}
return { return {
totalItems: ciphers.length, totalItems: ciphers.length,
folderCount: Math.max(0, folderCount), folderCount: Math.max(0, folderCount),
@@ -1075,7 +1078,7 @@ export default function App() {
return; return;
} }
try { try {
if (!session) throw new Error('Vault key unavailable'); if (!session) throw new Error(t('txt_vault_key_unavailable'));
await createFolder(authedFetch, session, folderName); await createFolder(authedFetch, session, folderName);
await foldersQuery.refetch(); await foldersQuery.refetch();
pushToast('success', t('txt_folder_created')); pushToast('success', t('txt_folder_created'));
@@ -1088,15 +1091,15 @@ export default function App() {
async function deleteFolderAction(folderId: string) { async function deleteFolderAction(folderId: string) {
const id = String(folderId || '').trim(); const id = String(folderId || '').trim();
if (!id) { if (!id) {
pushToast('error', 'Folder not found'); pushToast('error', t('txt_folder_not_found'));
return; return;
} }
try { try {
await deleteFolder(authedFetch, id); await deleteFolder(authedFetch, id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Folder deleted'); pushToast('success', t('txt_folder_deleted'));
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Delete folder failed'); pushToast('error', error instanceof Error ? error.message : t('txt_delete_folder_failed'));
throw error; throw error;
} }
} }
@@ -1120,7 +1123,7 @@ export default function App() {
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> } idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
): Promise<void> { ): Promise<void> {
if (!attachments.length) return; if (!attachments.length) return;
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
const initialCiphers = (await ciphersQuery.refetch()).data || []; const initialCiphers = (await ciphersQuery.refetch()).data || [];
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher])); const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
@@ -1145,7 +1148,7 @@ export default function App() {
} }
if (unresolved.length) { if (unresolved.length) {
throw new Error(`Failed to map ${unresolved.length} attachment(s) to imported items.`); throw new Error(t('txt_failed_to_map_attachments', { count: unresolved.length }));
} }
await ciphersQuery.refetch(); await ciphersQuery.refetch();
@@ -1172,7 +1175,7 @@ export default function App() {
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments: ImportAttachmentFile[] = [] attachments: ImportAttachmentFile[] = []
): Promise<ImportResultSummary> { ): Promise<ImportResultSummary> {
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
const mode = options.folderMode || 'original'; const mode = options.folderMode || 'original';
const targetFolderId = (options.targetFolderId || '').trim() || null; const targetFolderId = (options.targetFolderId || '').trim() || null;
@@ -1284,7 +1287,7 @@ export default function App() {
} }
async function handleExportAction(request: ExportRequest) { async function handleExportAction(request: ExportRequest) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error('Vault key unavailable'); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
const masterPassword = String(request.masterPassword || '').trim(); const masterPassword = String(request.masterPassword || '').trim();
if (!masterPassword) throw new Error(t('txt_master_password_is_required')); if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
const email = String(profile?.email || session.email || '').trim().toLowerCase(); const email = String(profile?.email || session.email || '').trim().toLowerCase();
@@ -1294,7 +1297,7 @@ export default function App() {
const rawFolders = foldersQuery.data || []; const rawFolders = foldersQuery.data || [];
const rawCiphers = ciphersQuery.data || []; const rawCiphers = ciphersQuery.data || [];
if (!rawFolders || !rawCiphers) throw new Error('Vault is not ready yet'); if (!rawFolders || !rawCiphers) throw new Error(t('txt_vault_not_ready'));
let plainJsonCache: string | null = null; let plainJsonCache: string | null = null;
let plainJsonDocCache: Record<string, unknown> | null = null; let plainJsonDocCache: Record<string, unknown> | null = null;
@@ -1512,7 +1515,7 @@ export default function App() {
}; };
} }
throw new Error('Unsupported export format'); throw new Error(t('txt_unsupported_export_format'));
} }
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
+7 -7
View File
@@ -118,7 +118,7 @@ async function derivePasswordProtectedFileKey(
const memoryMiB = Number(parsed.kdfMemory || 0); const memoryMiB = Number(parsed.kdfMemory || 0);
const parallelism = Number(parsed.kdfParallelism || 0); const parallelism = Number(parsed.kdfParallelism || 0);
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) { if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
throw new Error('Invalid Argon2id parameters in export file.'); throw new Error(t('txt_invalid_argon2id_params'));
} }
const memoryKiB = Math.floor(memoryMiB * 1024); const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024; const maxmem = memoryKiB * 1024 + 1024 * 1024;
@@ -131,7 +131,7 @@ async function derivePasswordProtectedFileKey(
asyncTick: 10, asyncTick: 10,
}); });
} else { } else {
throw new Error(`Unsupported kdfType: ${kdfType}`); throw new Error(t('txt_unsupported_kdf_type', { type: String(kdfType) }));
} }
const enc = await hkdfExpand(keyMaterial, 'enc', 32); const enc = await hkdfExpand(keyMaterial, 'enc', 32);
@@ -152,7 +152,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected
try { try {
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac); await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
} catch { } catch {
throw new Error('Invalid file password.'); throw new Error(t('txt_invalid_file_password'));
} }
const plainJson = await decryptStr(parsed.data, key.enc, key.mac); const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
@@ -317,16 +317,16 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
if (isRecord(parsed) && parsed.encrypted === true) { if (isRecord(parsed) && parsed.encrypted === true) {
const accountEncrypted = parsed as BitwardenJsonInput; const accountEncrypted = parsed as BitwardenJsonInput;
if (!accountKeys?.encB64 || !accountKeys?.macB64) { if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.'); throw new Error(t('txt_vault_key_unavailable'));
} }
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim(); const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
if (!validation) throw new Error('Invalid encrypted export file.'); if (!validation) throw new Error(t('txt_invalid_encrypted_export'));
const accountEncKey = base64ToBytes(accountKeys.encB64); const accountEncKey = base64ToBytes(accountKeys.encB64);
const accountMacKey = base64ToBytes(accountKeys.macB64); const accountMacKey = base64ToBytes(accountKeys.macB64);
try { try {
await decryptStr(validation, accountEncKey, accountMacKey); await decryptStr(validation, accountEncKey, accountMacKey);
} catch { } catch {
throw new Error('This encrypted export belongs to another account.'); throw new Error(t('txt_export_belongs_to_another_account'));
} }
return onImportEncryptedRaw( return onImportEncryptedRaw(
normalizeBitwardenEncryptedAccountImport(accountEncrypted), normalizeBitwardenEncryptedAccountImport(accountEncrypted),
@@ -355,7 +355,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim(); const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
if (!encryptedPayload) return []; if (!encryptedPayload) return [];
if (!accountKeys?.encB64 || !accountKeys?.macB64) { if (!accountKeys?.encB64 || !accountKeys?.macB64) {
throw new Error('Vault key unavailable. Please unlock vault and try again.'); throw new Error(t('txt_vault_key_unavailable'));
} }
const accountEnc = base64ToBytes(accountKeys.encB64); const accountEnc = base64ToBytes(accountKeys.encB64);
const accountMac = base64ToBytes(accountKeys.macB64); const accountMac = base64ToBytes(accountKeys.macB64);
+3 -3
View File
@@ -963,7 +963,7 @@ function folderName(id: string | null | undefined): string {
<> <>
<div className="card"> <div className="card">
<div className="section-head"> <div className="section-head">
<h3 className="detail-title">{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}</h3> <h3 className="detail-title">{isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(draft.type) })}</h3>
<button <button
type="button" type="button"
className={`btn btn-secondary small ${draft.favorite ? 'star-on' : ''}`} className={`btn btn-secondary small ${draft.favorite ? 'star-on' : ''}`}
@@ -1738,8 +1738,8 @@ function folderName(id: string | null | undefined): string {
<ConfirmDialog <ConfirmDialog
open={!!pendingDeleteFolder} open={!!pendingDeleteFolder}
title={`${t('txt_delete')} ${t('txt_folder')}`} title={t('txt_delete_folder')}
message={`Delete folder "${pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || ''}"? Items inside will move to ${t('txt_no_folder')}.`} message={t('txt_delete_folder_message', { name: pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || '' })}
confirmText={t('txt_delete')} confirmText={t('txt_delete')}
cancelText={t('txt_cancel')} cancelText={t('txt_cancel')}
danger danger
+35 -1
View File
@@ -373,7 +373,7 @@ const messages: Record<Locale, Record<string, string>> = {
const zhCNOverrides: Record<string, string> = { const zhCNOverrides: Record<string, string> = {
nav_my_vault: '我的保险库', nav_my_vault: '我的保险库',
nav_sends: 'Send', nav_sends: 'Send',
nav_admin_panel: '管理面板', nav_admin_panel: '用户管理',
nav_account_settings: '账户设置', nav_account_settings: '账户设置',
nav_device_management: '设备管理', nav_device_management: '设备管理',
nav_backup_strategy: '备份策略', nav_backup_strategy: '备份策略',
@@ -786,6 +786,23 @@ messages.en.txt_import_encrypted_file_title = 'Import encrypted file';
messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.'; messages.en.txt_import_encrypted_file_message = 'This Bitwarden export is password-protected. Enter the export file password to continue.';
messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP'; messages.en.txt_import_encrypted_zip_title = 'Import encrypted ZIP';
messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.'; messages.en.txt_import_encrypted_zip_message = 'This ZIP archive is password-protected. Enter the ZIP password to continue.';
messages.en.txt_new_type_header = 'New {type}';
messages.en.txt_edit_type_header = 'Edit {type}';
messages.en.txt_delete_folder = 'Delete Folder';
messages.en.txt_delete_folder_message = 'Delete folder "{name}"? Items inside will move to No Folder.';
messages.en.txt_folder_not_found = 'Folder not found';
messages.en.txt_folder_deleted = 'Folder deleted';
messages.en.txt_delete_folder_failed = 'Delete folder failed';
messages.en.txt_other = 'Other';
messages.en.txt_vault_key_unavailable = 'Vault key unavailable. Please unlock vault and try again.';
messages.en.txt_vault_not_ready = 'Vault is not ready yet';
messages.en.txt_unsupported_export_format = 'Unsupported export format';
messages.en.txt_invalid_encrypted_export = 'Invalid encrypted export file.';
messages.en.txt_export_belongs_to_another_account = 'This encrypted export belongs to another account.';
messages.en.txt_invalid_argon2id_params = 'Invalid Argon2id parameters in export file.';
messages.en.txt_unsupported_kdf_type = 'Unsupported kdfType: {type}';
messages.en.txt_invalid_file_password = 'Invalid file password.';
messages.en.txt_failed_to_map_attachments = 'Failed to map {count} attachment(s) to imported items.';
zhCNOverrides.txt_import = '导入'; zhCNOverrides.txt_import = '导入';
zhCNOverrides.txt_export = '导出'; zhCNOverrides.txt_export = '导出';
@@ -834,6 +851,23 @@ zhCNOverrides.txt_import_export_feature_nodewarden_json_title = 'NodeWarden 密
zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。'; zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。';
zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容'; zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容';
zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。'; zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。';
zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_type_header = '编辑{type}';
zhCNOverrides.txt_delete_folder = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
zhCNOverrides.txt_other = '其他';
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁保险库后重试。';
zhCNOverrides.txt_vault_not_ready = '保险库数据尚未就绪';
zhCNOverrides.txt_unsupported_export_format = '不支持的导出格式';
zhCNOverrides.txt_invalid_encrypted_export = '加密导出文件无效。';
zhCNOverrides.txt_export_belongs_to_another_account = '此加密导出文件属于另一个账号。';
zhCNOverrides.txt_invalid_argon2id_params = '导出文件中的 Argon2id 参数无效。';
zhCNOverrides.txt_unsupported_kdf_type = '不支持的 KDF 类型:{type}';
zhCNOverrides.txt_invalid_file_password = '文件密码错误。';
zhCNOverrides.txt_failed_to_map_attachments = '无法将 {count} 个附件匹配到导入项目。';
messages['zh-CN'] = { ...messages.en, ...zhCNOverrides }; messages['zh-CN'] = { ...messages.en, ...zhCNOverrides };