mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: improve error handling and localization for vault operations and import/export processes
This commit is contained in:
+26
-23
@@ -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 || '' : '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user