Refactor VaultPage component: remove exposed password checks, add bulk delete functionality for folders, and improve list rendering performance

- Removed password breach checking logic and related state management from VaultPage.
- Introduced bulk delete functionality for folders with a confirmation dialog.
- Enhanced list rendering with virtualization to improve performance.
- Updated styles for folder actions and list items for better UI consistency.
- Removed unused password breach library and related translations.
This commit is contained in:
shuaiplus
2026-03-11 02:22:35 +08:00
parent bc5efbf2fd
commit f4d2e7932a
11 changed files with 491 additions and 490 deletions
+92 -99
View File
@@ -373,6 +373,13 @@ export async function createFolder(
return { id: body.id, name: body.name ?? null };
}
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
return encryptBw(new TextEncoder().encode(name), enc, mac);
}
export async function deleteFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
folderId: string
@@ -385,6 +392,18 @@ export async function deleteFolder(
if (!resp.ok) throw new Error('Delete folder failed');
}
export async function bulkDeleteFolders(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/folders/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete folders failed');
}
export async function updateFolder(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
@@ -1010,111 +1029,21 @@ async function getCipherKeys(cipher: Cipher | null, userEnc: Uint8Array, userMac
return { enc: userEnc, mac: userMac, key: null };
}
export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
async function buildCipherPayload(
session: SessionState,
draft: VaultDraft
): Promise<{ id: string }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const enc = base64ToBytes(session.symEncKey);
const mac = base64ToBytes(session.symMacKey);
const type = Number(draft.type || 1);
const payload: Record<string, unknown> = {
type,
favorite: !!draft.favorite,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, enc, mac),
notes: await encryptTextValue(draft.notes, enc, mac),
login: null,
card: null,
identity: null,
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], enc, mac),
};
if (type === 1) {
payload.login = {
username: await encryptTextValue(draft.loginUsername, enc, mac),
password: await encryptTextValue(draft.loginPassword, enc, mac),
totp: await encryptTextValue(draft.loginTotp, enc, mac),
fido2Credentials: await normalizeFido2Credentials(draft.loginFido2Credentials, enc, mac),
uris: await encryptUris(draft.loginUris || [], enc, mac),
};
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, enc, mac),
number: await encryptTextValue(draft.cardNumber, enc, mac),
brand: await encryptTextValue(draft.cardBrand, enc, mac),
expMonth: await encryptTextValue(draft.cardExpMonth, enc, mac),
expYear: await encryptTextValue(draft.cardExpYear, enc, mac),
code: await encryptTextValue(draft.cardCode, enc, mac),
};
} else if (type === 4) {
payload.identity = {
title: await encryptTextValue(draft.identTitle, enc, mac),
firstName: await encryptTextValue(draft.identFirstName, enc, mac),
middleName: await encryptTextValue(draft.identMiddleName, enc, mac),
lastName: await encryptTextValue(draft.identLastName, enc, mac),
username: await encryptTextValue(draft.identUsername, enc, mac),
company: await encryptTextValue(draft.identCompany, enc, mac),
ssn: await encryptTextValue(draft.identSsn, enc, mac),
passportNumber: await encryptTextValue(draft.identPassportNumber, enc, mac),
licenseNumber: await encryptTextValue(draft.identLicenseNumber, enc, mac),
email: await encryptTextValue(draft.identEmail, enc, mac),
phone: await encryptTextValue(draft.identPhone, enc, mac),
address1: await encryptTextValue(draft.identAddress1, enc, mac),
address2: await encryptTextValue(draft.identAddress2, enc, mac),
address3: await encryptTextValue(draft.identAddress3, enc, mac),
city: await encryptTextValue(draft.identCity, enc, mac),
state: await encryptTextValue(draft.identState, enc, mac),
postalCode: await encryptTextValue(draft.identPostalCode, enc, mac),
country: await encryptTextValue(draft.identCountry, enc, mac),
};
} else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, enc, mac);
payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
const resp = await authedFetch('/api/ciphers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
}
export async function updateCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
draft: VaultDraft,
cipher: Cipher | null
): Promise<Record<string, unknown>> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher.type || 1);
const type = Number(draft.type || cipher?.type || 1);
const payload: Record<string, unknown> = {
id: cipher.id,
type,
key: keys.key,
folderId: asNullable(draft.folderId),
favorite: !!draft.favorite,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
@@ -1126,11 +1055,16 @@ export async function updateCipher(
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
};
if (cipher?.id) {
payload.id = cipher.id;
payload.key = keys.key;
}
if (type === 1) {
const existingFido2 =
cipher.login && Array.isArray((cipher.login as any).fido2Credentials)
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: null;
: draft.loginFido2Credentials;
payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
@@ -1174,13 +1108,48 @@ export async function updateCipher(
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
};
} else if (type === 2) {
payload.secureNote = { type: 0 };
}
return payload;
}
export async function buildCipherImportPayload(
session: SessionState,
draft: VaultDraft
): Promise<Record<string, unknown>> {
return buildCipherPayload(session, draft, null);
}
export async function createCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
draft: VaultDraft
): Promise<{ id: string }> {
const payload = await buildCipherPayload(session, draft, null);
const resp = await authedFetch('/api/ciphers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Create item failed');
const body = await parseJson<{ id?: string }>(resp);
if (!body?.id) throw new Error('Create item failed');
return { id: body.id };
}
export async function updateCipher(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
session: SessionState,
cipher: Cipher,
draft: VaultDraft
): Promise<void> {
const payload = await buildCipherPayload(session, draft, cipher);
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -1197,6 +1166,18 @@ export async function deleteCipher(
if (!resp.ok) throw new Error('Delete item failed');
}
export async function bulkDeleteCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/ciphers/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete failed');
}
export async function bulkMoveCiphers(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[],
@@ -1431,6 +1412,18 @@ export async function deleteSend(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
}
export async function bulkDeleteSends(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
ids: string[]
): Promise<void> {
const resp = await authedFetch('/api/sends/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!resp.ok) throw new Error('Bulk delete sends failed');
}
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
const payload: Record<string, unknown> = {};
const plainPassword = String(password || '').trim();
+8 -16
View File
@@ -141,11 +141,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
txt_expiration_date: "Expiration Date",
txt_expiration_days_0_never: "Expiration Days (0 = never)",
txt_exposed: "Exposed",
txt_exposed_password_check_complete_count: "{count} exposed password(s) found",
txt_exposed_ignored: "Exposed (Ignored)",
txt_exposed_passwords: "Exposed Passwords",
txt_exposed_short: "Exposed",
txt_expires_at: "Expires At",
txt_expires_at_value: "Expires at: {value}",
txt_expiry: "Expiry",
@@ -256,7 +251,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_no: "No",
txt_no_devices_found: "No devices found.",
txt_no_folder: "No Folder",
txt_no_exposed_passwords_found: "No exposed passwords found",
txt_no_items: "No items",
txt_no_username: "(No username)",
txt_no_verification_codes: "No verification codes",
@@ -300,7 +294,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_regenerate: "Regenerate",
txt_registration_succeeded_please_sign_in: "Registration succeeded. Please sign in.",
txt_remove: "Remove",
txt_ignore: "Ignore",
txt_remove_device: "Remove device",
txt_remove_device_2: "Remove Device",
txt_remove_all_devices: "Remove all devices",
@@ -392,7 +385,6 @@ const messages: Record<Locale, Record<string, string>> = {
txt_unlock_item: "Unlock Item",
txt_unlock_send: "Unlock Send",
txt_unlock_vault: "Unlock Vault",
txt_unignore: "Unignore",
txt_unlocked: "Unlocked",
txt_all_devices_removed: "All devices removed",
txt_remove_device_failed: "Failed to remove device",
@@ -454,7 +446,6 @@ const zhCNOverrides: Record<string, string> = {
txt_back_to_login: '返回登录',
txt_unlock: '解锁',
txt_unlock_vault: '解锁密码库',
txt_unignore: '取消忽略',
txt_master_password: '主密码',
txt_email: '邮箱',
txt_name: '名称',
@@ -481,7 +472,6 @@ const zhCNOverrides: Record<string, string> = {
txt_copy: '复制',
txt_code_copied: '验证码已复制',
txt_copy_link: '复制链接',
txt_ignore: '忽略',
txt_select_all: '全选',
txt_delete_selected: '删除所选',
txt_all_items: '所有项目',
@@ -490,7 +480,6 @@ const zhCNOverrides: Record<string, string> = {
txt_folder: '文件夹',
txt_folders: '文件夹',
txt_no_folder: '无文件夹',
txt_no_exposed_passwords_found: '未发现已泄露密码',
txt_no_items: '没有项目',
txt_no_username: '无用户名',
txt_no_verification_codes: '没有验证码',
@@ -498,11 +487,6 @@ const zhCNOverrides: Record<string, string> = {
txt_select_an_item: '请选择一个项目',
txt_login: '登录',
txt_card: '银行卡',
txt_exposed: '已泄露',
txt_exposed_password_check_complete_count: '发现 {count} 个已泄露密码',
txt_exposed_ignored: '已泄露(已忽略)',
txt_exposed_passwords: '是否泄露',
txt_exposed_short: '泄露',
txt_identity: '身份',
txt_note: '笔记',
txt_secure_note: '安全笔记',
@@ -883,9 +867,13 @@ 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_delete_all_folders = 'Delete All Folders';
messages.en.txt_delete_all_folders_message = 'Delete all folders? 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_folders_deleted = 'Folders deleted';
messages.en.txt_delete_folder_failed = 'Delete folder failed';
messages.en.txt_delete_all_folders_failed = 'Delete all folders 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';
@@ -945,9 +933,13 @@ zhCNOverrides.txt_new_type_header = '新建{type}';
zhCNOverrides.txt_edit_type_header = '编辑{type}';
zhCNOverrides.txt_delete_folder = '删除文件夹';
zhCNOverrides.txt_delete_folder_message = '删除文件夹「{name}」?其中的项目将移至无文件夹。';
zhCNOverrides.txt_delete_all_folders = '删除全部文件夹';
zhCNOverrides.txt_delete_all_folders_message = '确认删除全部文件夹吗?其中的项目将移至无文件夹。';
zhCNOverrides.txt_folder_not_found = '文件夹不存在';
zhCNOverrides.txt_folder_deleted = '文件夹已删除';
zhCNOverrides.txt_folders_deleted = '文件夹已删除';
zhCNOverrides.txt_delete_folder_failed = '删除文件夹失败';
zhCNOverrides.txt_delete_all_folders_failed = '删除全部文件夹失败';
zhCNOverrides.txt_other = '其他';
zhCNOverrides.txt_vault_key_unavailable = '账户密钥不可用,请先解锁密码库后重试。';
zhCNOverrides.txt_vault_not_ready = '密码库数据尚未就绪';
-127
View File
@@ -1,127 +0,0 @@
import type { Cipher } from './types';
const HIBP_RANGE_ENDPOINT = 'https://api.pwnedpasswords.com/range/';
const RANGE_CACHE_PREFIX = 'nodewarden.password-breach.range.v1.';
const RANGE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const inMemoryRangeCache = new Map<string, { expiresAt: number; suffixes: Set<string> }>();
const inflightRangeRequests = new Map<string, Promise<Set<string>>>();
function normalizeHashHex(value: string): string {
return String(value || '').trim().toUpperCase();
}
async function sha1Hex(input: string): Promise<string> {
const bytes = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-1', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase();
}
function readCachedSuffixes(prefix: string): Set<string> | null {
const now = Date.now();
const memory = inMemoryRangeCache.get(prefix);
if (memory && memory.expiresAt > now) return new Set(memory.suffixes);
if (memory) inMemoryRangeCache.delete(prefix);
if (typeof sessionStorage === 'undefined') return null;
const raw = sessionStorage.getItem(`${RANGE_CACHE_PREFIX}${prefix}`);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { expiresAt?: number; suffixes?: string[] };
if (!parsed.expiresAt || parsed.expiresAt <= now || !Array.isArray(parsed.suffixes)) {
sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`);
return null;
}
const suffixes = new Set(parsed.suffixes.map(normalizeHashHex));
inMemoryRangeCache.set(prefix, { expiresAt: parsed.expiresAt, suffixes });
return new Set(suffixes);
} catch {
sessionStorage.removeItem(`${RANGE_CACHE_PREFIX}${prefix}`);
return null;
}
}
function writeCachedSuffixes(prefix: string, suffixes: Set<string>): void {
const expiresAt = Date.now() + RANGE_CACHE_TTL_MS;
inMemoryRangeCache.set(prefix, { expiresAt, suffixes: new Set(suffixes) });
if (typeof sessionStorage === 'undefined') return;
sessionStorage.setItem(
`${RANGE_CACHE_PREFIX}${prefix}`,
JSON.stringify({ expiresAt, suffixes: Array.from(suffixes) })
);
}
async function getRangeSuffixes(prefix: string): Promise<Set<string>> {
const normalizedPrefix = normalizeHashHex(prefix).slice(0, 5);
if (!/^[A-F0-9]{5}$/.test(normalizedPrefix)) throw new Error('Invalid password hash prefix');
const cached = readCachedSuffixes(normalizedPrefix);
if (cached) return cached;
const inflight = inflightRangeRequests.get(normalizedPrefix);
if (inflight) return inflight;
const request = (async () => {
const response = await fetch(`${HIBP_RANGE_ENDPOINT}${normalizedPrefix}`, {
method: 'GET',
headers: {
Accept: 'text/plain',
'Add-Padding': 'true',
},
cache: 'no-store',
});
if (!response.ok) throw new Error('Failed to check exposed passwords');
const body = await response.text();
const suffixes = new Set<string>();
for (const line of body.split(/\r?\n/)) {
const [suffix] = line.split(':', 1);
const normalizedSuffix = normalizeHashHex(suffix || '');
if (/^[A-F0-9]{35}$/.test(normalizedSuffix)) suffixes.add(normalizedSuffix);
}
writeCachedSuffixes(normalizedPrefix, suffixes);
return suffixes;
})();
inflightRangeRequests.set(normalizedPrefix, request);
try {
return await request;
} finally {
inflightRangeRequests.delete(normalizedPrefix);
}
}
export async function checkCipherPasswordsExposed(ciphers: Cipher[]): Promise<Record<string, boolean>> {
const loginCiphers = ciphers.filter((cipher) => {
const password = String(cipher.login?.decPassword || '').trim();
return cipher.type === 1 && !!cipher.id && !!password;
});
const uniquePasswords = new Map<string, string>();
for (const cipher of loginCiphers) {
const password = String(cipher.login?.decPassword || '');
if (!uniquePasswords.has(password)) {
uniquePasswords.set(password, await sha1Hex(password));
}
}
const prefixes = Array.from(new Set(Array.from(uniquePasswords.values(), (hash) => hash.slice(0, 5))));
const rangeMap = new Map<string, Set<string>>();
await Promise.all(
prefixes.map(async (prefix) => {
rangeMap.set(prefix, await getRangeSuffixes(prefix));
})
);
const results: Record<string, boolean> = {};
for (const cipher of loginCiphers) {
const password = String(cipher.login?.decPassword || '');
const hash = uniquePasswords.get(password);
if (!hash) continue;
const prefix = hash.slice(0, 5);
const suffix = hash.slice(5);
results[cipher.id] = !!rangeMap.get(prefix)?.has(suffix);
}
return results;
}