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 380cd34474
commit 1e34a96c57
4 changed files with 71 additions and 34 deletions
+26 -23
View File
@@ -110,24 +110,27 @@ function summarizeImportResult(
ciphers: Array<Record<string, unknown>>,
folderCount: number
): ImportResultSummary {
const counter = new Map<string, number>();
const typeLabel = (type: number): string => {
if (type === 1) return '登录';
if (type === 2) return '安全备注';
if (type === 3) return '卡片';
if (type === 4) return '身份';
if (type === 5) return 'SSH 密钥';
return '其他';
if (type === 1) return t('txt_login');
if (type === 2) return t('txt_secure_note');
if (type === 3) return t('txt_card');
if (type === 4) return t('txt_identity');
if (type === 5) return t('txt_ssh_key');
return t('txt_other');
};
const counter = new Map<number, number>();
for (const raw of ciphers) {
const t = Number(raw?.type || 1) || 1;
const label = typeLabel(t);
counter.set(label, (counter.get(label) || 0) + 1);
const cipherType = Number(raw?.type || 1) || 1;
counter.set(cipherType, (counter.get(cipherType) || 0) + 1);
}
const order = ['登录', '安全备注', '卡片', '身份', 'SSH 密钥', '其他'];
const order = [1, 2, 3, 4, 5];
const seen = new Set<number>(order);
const typeCounts = order
.filter((label) => (counter.get(label) || 0) > 0)
.map((label) => ({ label, count: counter.get(label) || 0 }));
.filter((type) => (counter.get(type) || 0) > 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 {
totalItems: ciphers.length,
folderCount: Math.max(0, folderCount),
@@ -1075,7 +1078,7 @@ export default function App() {
return;
}
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 foldersQuery.refetch();
pushToast('success', t('txt_folder_created'));
@@ -1088,15 +1091,15 @@ export default function App() {
async function deleteFolderAction(folderId: string) {
const id = String(folderId || '').trim();
if (!id) {
pushToast('error', 'Folder not found');
pushToast('error', t('txt_folder_not_found'));
return;
}
try {
await deleteFolder(authedFetch, id);
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
pushToast('success', 'Folder deleted');
pushToast('success', t('txt_folder_deleted'));
} 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;
}
}
@@ -1120,7 +1123,7 @@ export default function App() {
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
): Promise<void> {
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 cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
@@ -1145,7 +1148,7 @@ export default function App() {
}
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();
@@ -1172,7 +1175,7 @@ export default function App() {
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
attachments: ImportAttachmentFile[] = []
): 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 targetFolderId = (options.targetFolderId || '').trim() || null;
@@ -1284,7 +1287,7 @@ export default function App() {
}
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();
if (!masterPassword) throw new Error(t('txt_master_password_is_required'));
const email = String(profile?.email || session.email || '').trim().toLowerCase();
@@ -1294,7 +1297,7 @@ export default function App() {
const rawFolders = foldersQuery.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 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 || '' : '';
+7 -7
View File
@@ -118,7 +118,7 @@ async function derivePasswordProtectedFileKey(
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('Invalid Argon2id parameters in export file.');
throw new Error(t('txt_invalid_argon2id_params'));
}
const memoryKiB = Math.floor(memoryMiB * 1024);
const maxmem = memoryKiB * 1024 + 1024 * 1024;
@@ -131,7 +131,7 @@ async function derivePasswordProtectedFileKey(
asyncTick: 10,
});
} 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);
@@ -152,7 +152,7 @@ async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtected
try {
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
} 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);
@@ -317,16 +317,16 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
if (isRecord(parsed) && parsed.encrypted === true) {
const accountEncrypted = parsed as BitwardenJsonInput;
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();
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 accountMacKey = base64ToBytes(accountKeys.macB64);
try {
await decryptStr(validation, accountEncKey, accountMacKey);
} catch {
throw new Error('This encrypted export belongs to another account.');
throw new Error(t('txt_export_belongs_to_another_account'));
}
return onImportEncryptedRaw(
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
@@ -355,7 +355,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
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.');
throw new Error(t('txt_vault_key_unavailable'));
}
const accountEnc = base64ToBytes(accountKeys.encB64);
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="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
type="button"
className={`btn btn-secondary small ${draft.favorite ? 'star-on' : ''}`}
@@ -1738,8 +1738,8 @@ function folderName(id: string | null | undefined): string {
<ConfirmDialog
open={!!pendingDeleteFolder}
title={`${t('txt_delete')} ${t('txt_folder')}`}
message={`Delete folder "${pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || ''}"? Items inside will move to ${t('txt_no_folder')}.`}
title={t('txt_delete_folder')}
message={t('txt_delete_folder_message', { name: pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || '' })}
confirmText={t('txt_delete')}
cancelText={t('txt_cancel')}
danger
+35 -1
View File
@@ -373,7 +373,7 @@ const messages: Record<Locale, Record<string, string>> = {
const zhCNOverrides: Record<string, string> = {
nav_my_vault: '我的保险库',
nav_sends: 'Send',
nav_admin_panel: '管理面板',
nav_admin_panel: '用户管理',
nav_account_settings: '账户设置',
nav_device_management: '设备管理',
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_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_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_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_compat_title = '跨客户端兼容';
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 };