From f4d2e7932a528b5a93b8b8bd9d99b81731e8b1da Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 11 Mar 2026 02:22:35 +0800 Subject: [PATCH] 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. --- src/handlers/ciphers.ts | 23 +++ src/handlers/folders.ts | 24 +++ src/handlers/sends.ts | 33 ++++ src/router.ts | 48 ++++-- src/services/storage.ts | 100 ++++++++++- webapp/src/App.tsx | 144 ++++++++-------- webapp/src/components/VaultPage.tsx | 254 +++++++++++----------------- webapp/src/lib/api.ts | 191 ++++++++++----------- webapp/src/lib/i18n.ts | 24 +-- webapp/src/lib/password-breach.ts | 127 -------------- webapp/src/styles.css | 13 +- 11 files changed, 491 insertions(+), 490 deletions(-) delete mode 100644 webapp/src/lib/password-breach.ts diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 7504802..b871b38 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -518,3 +518,26 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId: return new Response(null, { status: 204 }); } + +// POST /api/ciphers/delete - Bulk soft delete +export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 204 }); +} diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index a569b27..0cfd6da 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -136,3 +136,27 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str return new Response(null, { status: 204 }); } + +// POST /api/folders/delete +export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : []; + if (!ids.length) { + return errorResponse('Folder ids are required', 400); + } + + const revisionDate = await storage.bulkDeleteFolders(ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 204 }); +} diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts index 5119c7d..52517ed 100644 --- a/src/handlers/sends.ts +++ b/src/handlers/sends.ts @@ -1025,6 +1025,39 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin return new Response(null, { status: 200 }); } +// POST /api/sends/delete - Bulk delete +export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + const sends = await storage.getSendsByIds(body.ids, userId); + for (const send of sends) { + if (send.type !== SendType.File) continue; + const data = parseStoredSendData(send); + const fileId = typeof data.id === 'string' ? data.id : null; + if (fileId) { + await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); + } + } + + const revisionDate = await storage.bulkDeleteSends(body.ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 200 }); +} + // PUT /api/sends/:id/remove-password export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise { void request; diff --git a/src/router.ts b/src/router.ts index f5cbb41..486cb36 100644 --- a/src/router.ts +++ b/src/router.ts @@ -34,6 +34,7 @@ import { handleRestoreCipher, handlePartialUpdateCipher, handleBulkMoveCiphers, + handleBulkDeleteCiphers, } from './handlers/ciphers'; // Folder handlers @@ -42,7 +43,8 @@ import { handleGetFolder, handleCreateFolder, handleUpdateFolder, - handleDeleteFolder + handleDeleteFolder, + handleBulkDeleteFolders, } from './handlers/folders'; // Send handlers @@ -55,6 +57,7 @@ import { handleUploadSendFile, handleUpdateSend, handleDeleteSend, + handleBulkDeleteSends, handleRemoveSendPassword, handleRemoveSendAuth, handleAccessSend, @@ -141,6 +144,18 @@ function getNwIconSvg(): string { return `NW`; } +function isImportBypassRequest(request: Request, path: string, method: string): boolean { + if (request.headers.get('X-NodeWarden-Import') !== '1') return false; + + if (method === 'POST') { + if (path === '/api/ciphers/import') return true; + if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true; + if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true; + } + + return false; +} + function handleNwFavicon(): Response { return new Response(getNwIconSvg(), { status: 200, @@ -151,17 +166,6 @@ function handleNwFavicon(): Response { }); } -const BITWARDEN_DEFAULT_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783'; - -function bytesToHex(bytes: Uint8Array): string { - return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); -} - -async function sha256Hex(buffer: ArrayBuffer): Promise { - const digest = await crypto.subtle.digest('SHA-256', buffer); - return bytesToHex(new Uint8Array(digest)); -} - function isValidIconHostname(hostname: string): boolean { if (!hostname) return false; if (hostname.length > 253) return false; @@ -183,7 +187,7 @@ function isValidIconHostname(hostname: string): boolean { }); } -// Icons handler - proxy to Bitwarden's official icon service +// Icons handler - proxy to favicon.im async function handleGetIcon(request: Request, env: Env, hostname: string): Promise { try { void env; @@ -199,8 +203,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom return cached; } - // Use Bitwarden's official icon service - const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`; + const iconUrl = `https://favicon.im/${normalizedHostname}`; const resp = await fetch(iconUrl, { headers: { 'User-Agent': 'NodeWarden/1.0' }, redirect: 'follow', @@ -212,7 +215,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom if (resp.ok) { const body = await resp.arrayBuffer(); - if (body.byteLength === 500 && (await sha256Hex(body)) === BITWARDEN_DEFAULT_ICON_SHA256) { + if (body.byteLength === 0) { return new Response(null, { status: 204 }); } const iconResponse = new Response(body, { @@ -512,7 +515,7 @@ export async function handleRequest(request: Request, env: Env): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ + deletedAt: now, + updatedAt: now, + }); + const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await this.db + .prepare( + `UPDATE ciphers + SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(now, now, patch, userId, ...chunk) + .run(); + } + + return this.updateRevisionDate(userId); + } + async getAllCiphers(userId: string): Promise { const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); return (res.results || []).flatMap(r => { @@ -512,7 +540,7 @@ export class StorageService { folderId, updatedAt: now, }); - const chunkSize = LIMITS.performance.bulkMoveChunkSize; + const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); @@ -562,6 +590,42 @@ export class StorageService { await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); } + async bulkDeleteFolders(ids: string[], userId: string): Promise { + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + const now = new Date().toISOString(); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await this.db + .prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`) + .bind(userId, ...chunk) + .all<{ data: string }>(); + + for (const row of res.results || []) { + let cipher: Cipher; + try { + cipher = JSON.parse(row.data) as Cipher; + } catch { + continue; + } + cipher.folderId = null; + cipher.updatedAt = now; + await this.saveCipher(cipher); + } + + await this.db + .prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .run(); + } + + return this.updateRevisionDate(userId); + } + // Clear folder references from all ciphers owned by the user. // Without this, deleting a folder leaves stale folderId values in cipher JSON. async clearFolderFromCiphers(userId: string, folderId: string): Promise { @@ -928,6 +992,40 @@ export class StorageService { await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run(); } + async getSendsByIds(ids: string[], userId: string): Promise { + if (ids.length === 0) return []; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return []; + const placeholders = uniqueIds.map(() => '?').join(','); + const res = await this.db + .prepare( + `SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date + FROM sends + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(userId, ...uniqueIds) + .all(); + return (res.results || []).map((row) => this.mapSendRow(row)); + } + + async bulkDeleteSends(ids: string[], userId: string): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await this.db + .prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .run(); + } + + return this.updateRevisionDate(userId); + } + async getAllSends(userId: string): Promise { const res = await this.db .prepare( diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index c356b5b..f173912 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -18,15 +18,20 @@ import ImportPage from '@/components/ImportPage'; import TotpCodesPage from '@/components/TotpCodesPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { + buildCipherImportPayload, + bulkDeleteFolders, changeMasterPassword, createFolder, updateFolder, deleteCipherAttachment, deleteFolder, + bulkDeleteCiphers, + bulkDeleteSends, createCipher, createAuthedFetch, createInvite, downloadCipherAttachmentDecrypted, + encryptFolderImportName, exportAdminBackup, importAdminBackup, importCiphers, @@ -450,6 +455,14 @@ export default function App() { ), [session, setupRegistered] ); + const importAuthedFetch = useMemo( + () => async (input: string, init?: RequestInit) => { + const headers = new Headers(init?.headers || {}); + headers.set('X-NodeWarden-Import', '1'); + return authedFetch(input, { ...init, headers }); + }, + [authedFetch] + ); useEffect(() => { let mounted = true; @@ -1208,9 +1221,7 @@ export default function App() { async function bulkDeleteVaultItems(ids: string[]) { try { - for (const id of ids) { - await deleteCipher(authedFetch, id); - } + await bulkDeleteCiphers(authedFetch, ids); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); pushToast('success', t('txt_deleted_selected_items')); } catch (error) { @@ -1287,9 +1298,7 @@ export default function App() { async function bulkDeleteSendItems(ids: string[]) { try { - for (const id of ids) { - await deleteSend(authedFetch, id); - } + await bulkDeleteSends(authedFetch, ids); await sendsQuery.refetch(); pushToast('success', t('txt_deleted_selected_sends')); } catch (error) { @@ -1336,18 +1345,17 @@ export default function App() { } } - function buildImportedCipherMaps( - payloadCiphers: Array>, - createdCipherIdsByIndex: Map - ): { byIndex: Map; bySourceId: Map } { - const byIndex = new Map(createdCipherIdsByIndex); - const bySourceId = new Map(); - for (const [index, id] of createdCipherIdsByIndex.entries()) { - const raw = (payloadCiphers[index] || {}) as Record; - const sourceId = String(raw.id || '').trim(); - if (sourceId) bySourceId.set(sourceId, id); + async function bulkDeleteFoldersAction(ids: string[]) { + const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!folderIds.length) return; + try { + await bulkDeleteFolders(authedFetch, folderIds); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', t('txt_folders_deleted')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); + throw error; } - return { byIndex, bySourceId }; } async function uploadImportedAttachments( @@ -1383,7 +1391,7 @@ export default function App() { const file = new File([fileBytes], name, { type: 'application/octet-stream' }); const cipher = cipherById.get(targetCipherId) || null; try { - await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher); + await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher); imported += 1; } catch (error) { failed.push({ @@ -1426,82 +1434,65 @@ export default function App() { const mode = options.folderMode || 'original'; const targetFolderId = (options.targetFolderId || '').trim() || null; - const folderIdByCipherIndex = new Map(); - let createdFolderCount = 0; + const nextPayload: CiphersImportPayload = { + ciphers: [], + folders: [], + folderRelationships: [], + }; if (mode === 'original') { - const folderIdByImportIndex = new Map(); - const folderIdByLegacyId = new Map(); - const folderIdByName = new Map(); - const createdFolderIdByName = new Map(); + const folderIndexByLegacyId = new Map(); + const folderIndexByName = new Map(); for (let i = 0; i < payload.folders.length; i++) { const folderRaw = (payload.folders[i] || {}) as Record; const name = String(folderRaw.name || '').trim(); if (!name) continue; - let folderId = createdFolderIdByName.get(name) || null; - if (!folderId) { - const created = await createFolder(authedFetch, session, name); - folderId = created.id; - createdFolderIdByName.set(name, folderId); - createdFolderCount += 1; + let folderIndex = folderIndexByName.get(name); + if (folderIndex == null) { + folderIndex = nextPayload.folders.length; + nextPayload.folders.push({ name: await encryptFolderImportName(session, name) }); + folderIndexByName.set(name, folderIndex); } - folderIdByImportIndex.set(i, folderId); - folderIdByName.set(name, folderId); const legacyId = String(folderRaw.id || '').trim(); - if (legacyId) folderIdByLegacyId.set(legacyId, folderId); - } - for (const relation of payload.folderRelationships || []) { - const cipherIndex = Number(relation?.key); - const folderIndex = Number(relation?.value); - if (!Number.isFinite(cipherIndex) || !Number.isFinite(folderIndex)) continue; - const folderId = folderIdByImportIndex.get(folderIndex); - if (folderId) folderIdByCipherIndex.set(cipherIndex, folderId); + if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex); } for (let i = 0; i < payload.ciphers.length; i++) { - if (folderIdByCipherIndex.has(i)) continue; const raw = (payload.ciphers[i] || {}) as Record; - const rawFolderId = String(raw.folderId || '').trim(); - if (rawFolderId && folderIdByLegacyId.has(rawFolderId)) { - folderIdByCipherIndex.set(i, folderIdByLegacyId.get(rawFolderId)!); - continue; + let folderIndex: number | undefined; + for (const relation of payload.folderRelationships || []) { + const cipherIndex = Number(relation?.key); + const relFolderIndex = Number(relation?.value); + if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue; + const importedFolder = payload.folders[relFolderIndex] as Record | undefined; + const importedName = String(importedFolder?.name || '').trim(); + if (importedName) folderIndex = folderIndexByName.get(importedName); + if (folderIndex != null) break; } - const rawFolderName = String(raw.folder || '').trim(); - if (rawFolderName && folderIdByName.has(rawFolderName)) { - folderIdByCipherIndex.set(i, folderIdByName.get(rawFolderName)!); + if (folderIndex == null) { + const rawFolderId = String(raw.folderId || '').trim(); + if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId); + } + if (folderIndex == null) { + const rawFolderName = String(raw.folder || '').trim(); + if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName); + } + if (folderIndex != null) { + nextPayload.folderRelationships.push({ key: i, value: folderIndex }); } - } - } else if (mode === 'target' && targetFolderId) { - for (let i = 0; i < payload.ciphers.length; i++) { - folderIdByCipherIndex.set(i, targetFolderId); } } - - const createdCipherIdsByIndex = new Map(); for (let i = 0; i < payload.ciphers.length; i++) { const raw = (payload.ciphers[i] || {}) as Record; - const draft = importCipherToDraft(raw, null); - const created = await createCipher(authedFetch, session, draft); - createdCipherIdsByIndex.set(i, created.id); + const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null); + nextPayload.ciphers.push(await buildCipherImportPayload(session, draft)); } - - const moveIdsByFolderId = new Map(); - for (const [index, folderId] of folderIdByCipherIndex.entries()) { - const cipherId = createdCipherIdsByIndex.get(index); - if (!cipherId || !folderId) continue; - const group = moveIdsByFolderId.get(folderId) || []; - group.push(cipherId); - moveIdsByFolderId.set(folderId, group); - } - for (const [folderId, ids] of moveIdsByFolderId.entries()) { - await bulkMoveCiphers(authedFetch, ids, folderId); - } - - const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex); - await foldersQuery.refetch(); - await ciphersQuery.refetch(); + const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { + returnCipherMap: attachments.length > 0, + }); + await Promise.all([foldersQuery.refetch(), ciphersQuery.refetch()]); const attachmentSummary = attachments.length - ? await uploadImportedAttachments(attachments, idMaps) + ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) : undefined; - return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary); + return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary); } async function handleImportEncryptedRawAction( @@ -1522,7 +1513,7 @@ export default function App() { for (const raw of nextPayload.ciphers) (raw as Record).folderId = targetFolderId; } - const importedCipherMap = await importCiphers(authedFetch, nextPayload, { + const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { returnCipherMap: attachments.length > 0, }); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); @@ -2070,6 +2061,7 @@ export default function App() { onNotify={pushToast} onCreateFolder={createFolderAction} onDeleteFolder={deleteFolderAction} + onBulkDeleteFolders={bulkDeleteFoldersAction} onDownloadAttachment={downloadVaultAttachment} /> diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 779f058..0ef0c78 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,7 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; -import { checkCipherPasswordsExposed } from '@/lib/password-breach'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { ArrowUpDown, @@ -26,7 +25,6 @@ import { Pencil, Plus, RefreshCw, - ShieldAlert, ShieldUser, Star, StarOff, @@ -53,6 +51,7 @@ interface VaultPageProps { onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onCreateFolder: (name: string) => Promise; onDeleteFolder: (folderId: string) => Promise; + onBulkDeleteFolders: (folderIds: string[]) => Promise; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; } @@ -61,7 +60,6 @@ type VaultSortMode = 'edited' | 'created' | 'name'; type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } - | { kind: 'exposed' } | { kind: 'trash' } | { kind: 'type'; value: TypeFilter } | { kind: 'folder'; folderId: string | null }; @@ -80,9 +78,9 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [ ]; const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; -const VAULT_EXPOSED_IGNORED_STORAGE_KEY = 'nodewarden.vault.exposed-ignored.v1'; -const VAULT_EXPOSED_SIGNATURE_STORAGE_KEY = 'nodewarden.vault.exposed-signature.v1'; const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; +const VAULT_LIST_ROW_HEIGHT = 66; +const VAULT_LIST_OVERSCAN = 10; const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ { value: 'edited', label: t('txt_sort_last_edited') }, { value: 'created', label: t('txt_sort_created') }, @@ -336,11 +334,12 @@ function firstPasskeyCreationTime(cipher: Cipher | null): string | null { const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; +const failedIconHosts = new Set(); function VaultListIcon({ cipher }: { cipher: Cipher }) { const uri = firstCipherUri(cipher); const host = hostFromUri(uri); - const [errored, setErrored] = useState(false); + const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); if (host && !errored) { return ( setErrored(true)} + onError={() => { + failedIconHosts.add(host); + setErrored(true); + }} /> ); } @@ -371,43 +373,12 @@ function openUri(raw: string): void { window.open(url, '_blank', 'noopener'); } -async function computePasswordSignature(ciphers: Cipher[]): Promise { - const parts = ciphers - .filter((cipher) => Number(cipher.type || 1) === 1) - .map((cipher) => `${String(cipher.id || '').trim()}\u0000${String(cipher.login?.decPassword || '')}`) - .sort(); - const bytes = new TextEncoder().encode(parts.join('\u0001')); - const digest = await crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); -} - -function countVisibleExposed(results: Record, ignoredMap: Record): number { - let count = 0; - for (const [cipherId, exposed] of Object.entries(results)) { - if (exposed && !ignoredMap[cipherId]) count++; - } - return count; -} - -function readIgnoredExposedMap(): Record { - try { - const raw = localStorage.getItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as Record; - return parsed && typeof parsed === 'object' ? parsed : {}; - } catch { - return {}; - } -} - export default function VaultPage(props: VaultPageProps) { const [searchInput, setSearchInput] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchComposing, setSearchComposing] = useState(false); const [sortMode, setSortMode] = useState('edited'); const [sortMenuOpen, setSortMenuOpen] = useState(false); - const [exposedStatusMap, setExposedStatusMap] = useState>({}); - const [ignoredExposedMap, setIgnoredExposedMap] = useState>(() => readIgnoredExposedMap()); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); @@ -428,6 +399,7 @@ export default function VaultPage(props: VaultPageProps) { const [createFolderOpen, setCreateFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null); + const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [attachmentQueue, setAttachmentQueue] = useState([]); @@ -442,13 +414,11 @@ export default function VaultPage(props: VaultPageProps) { const createMenuRef = useRef(null); const sortMenuRef = useRef(null); const attachmentInputRef = useRef(null); + const listPanelRef = useRef(null); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); - const hasCompletedAutoExposureCheckRef = useRef(false); - - function isVisibleExposed(cipherId: string): boolean { - return !!exposedStatusMap[cipherId] && !ignoredExposedMap[cipherId]; - } + const [listScrollTop, setListScrollTop] = useState(0); + const [listViewportHeight, setListViewportHeight] = useState(0); useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; @@ -499,57 +469,14 @@ export default function VaultPage(props: VaultPageProps) { }, [sortMode]); useEffect(() => { - try { - localStorage.setItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY, JSON.stringify(ignoredExposedMap)); - } catch { - // ignore storage write failures - } - }, [ignoredExposedMap]); - - useEffect(() => { - if (props.loading) return; - - const loginCiphers = props.ciphers.filter( - (cipher) => Number(cipher.type || 1) === 1 && !!String(cipher.login?.decPassword || '').trim() - ); - - let cancelled = false; - - void (async () => { - try { - const [signature, results] = await Promise.all([ - computePasswordSignature(loginCiphers), - checkCipherPasswordsExposed(loginCiphers), - ]); - if (cancelled) return; - - setExposedStatusMap(results); - - const previousSignature = - typeof localStorage !== 'undefined' - ? String(localStorage.getItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY) || '').trim() - : ''; - - if (typeof localStorage !== 'undefined') { - localStorage.setItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY, signature); - } - - if (hasCompletedAutoExposureCheckRef.current && previousSignature && previousSignature !== signature) { - const count = countVisibleExposed(results, ignoredExposedMap); - if (count > 0) { - props.onNotify('warning', t('txt_exposed_password_check_complete_count', { count })); - } - } - hasCompletedAutoExposureCheckRef.current = true; - } catch { - // Keep exposed-password checks silent in the background. - } - })(); - - return () => { - cancelled = true; - }; - }, [props.ciphers, props.loading]); + const node = listPanelRef.current; + if (!node) return; + const updateSize = () => setListViewportHeight(node.clientHeight || 0); + updateSize(); + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(node); + return () => resizeObserver.disconnect(); + }, []); useEffect(() => { const onPointerDown = (event: Event) => { @@ -627,7 +554,6 @@ export default function VaultPage(props: VaultPageProps) { } else { if (isDeleted) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; - if (sidebarFilter.kind === 'exposed' && !isVisibleExposed(cipher.id)) return false; if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'folder') { if (sidebarFilter.folderId === null) { @@ -663,7 +589,18 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, sidebarFilter, searchQuery, sortMode, exposedStatusMap, ignoredExposedMap]); + }, [props.ciphers, sidebarFilter, searchQuery, sortMode]); + + const sidebarFilterKey = useMemo(() => { + if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; + if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; + return sidebarFilter.kind; + }, [sidebarFilter]); + + useEffect(() => { + setListScrollTop(0); + listPanelRef.current?.scrollTo({ top: 0 }); + }, [searchQuery, sortMode, sidebarFilterKey]); useEffect(() => { if (isCreating) return; @@ -680,8 +617,25 @@ export default function VaultPage(props: VaultPageProps) { () => props.ciphers.find((x) => x.id === selectedCipherId) || null, [props.ciphers, selectedCipherId] ); - const selectedCipherExposed = !!(selectedCipher && exposedStatusMap[selectedCipher.id]); - const selectedCipherIgnored = !!(selectedCipher && ignoredExposedMap[selectedCipher.id]); + const virtualRange = useMemo(() => { + if (!filteredCiphers.length) { + return { start: 0, end: 0, padTop: 0, padBottom: 0 }; + } + const viewport = Math.max(listViewportHeight, VAULT_LIST_ROW_HEIGHT * 8); + const visibleCount = Math.ceil(viewport / VAULT_LIST_ROW_HEIGHT); + const start = Math.max(0, Math.floor(listScrollTop / VAULT_LIST_ROW_HEIGHT) - VAULT_LIST_OVERSCAN); + const end = Math.min(filteredCiphers.length, start + visibleCount + VAULT_LIST_OVERSCAN * 2); + return { + start, + end, + padTop: start * VAULT_LIST_ROW_HEIGHT, + padBottom: Math.max(0, (filteredCiphers.length - end) * VAULT_LIST_ROW_HEIGHT), + }; + }, [filteredCiphers.length, listScrollTop, listViewportHeight]); + const visibleCiphers = useMemo( + () => filteredCiphers.slice(virtualRange.start, virtualRange.end), + [filteredCiphers, virtualRange.start, virtualRange.end] + ); const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); const selectedAttachments = useMemo( () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), @@ -885,26 +839,11 @@ function folderName(id: string | null | undefined): string { if (isCreating) { await props.onCreate(nextDraft, attachmentQueue); } else if (selectedCipher) { - const passwordChanged = - nextDraft.type === 1 && - String(nextDraft.loginPassword || '') !== String(selectedCipher.login?.decPassword || ''); const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]); await props.onUpdate(selectedCipher, nextDraft, { addFiles: attachmentQueue, removeAttachmentIds, }); - if (passwordChanged) { - setExposedStatusMap((prev) => { - const next = { ...prev }; - delete next[selectedCipher.id]; - return next; - }); - setIgnoredExposedMap((prev) => { - const next = { ...prev }; - delete next[selectedCipher.id]; - return next; - }); - } } setIsCreating(false); setIsEditing(false); @@ -971,15 +910,6 @@ function folderName(id: string | null | undefined): string { } } - function toggleIgnoreExposed(cipherId: string): void { - setIgnoredExposedMap((prev) => { - const next = { ...prev }; - if (next[cipherId]) delete next[cipherId]; - else next[cipherId] = true; - return next; - }); - } - async function verifyReprompt(): Promise { if (!selectedCipher) return; if (!repromptPassword) { @@ -1028,6 +958,20 @@ function folderName(id: string | null | undefined): string { } } + async function confirmDeleteAllFolders(): Promise { + if (!props.folders.length) return; + setBusy(true); + try { + await props.onBulkDeleteFolders(props.folders.map((folder) => folder.id)); + if (sidebarFilter.kind === 'folder') { + setSidebarFilter({ kind: 'all' }); + } + setDeleteAllFoldersOpen(false); + } finally { + setBusy(false); + } + } + return ( <>
@@ -1048,9 +992,6 @@ function folderName(id: string | null | undefined): string { - @@ -1078,9 +1019,21 @@ function folderName(id: string | null | undefined): string {
{t('txt_folders')}
- +
+ + +
-
- {filteredCiphers.map((cipher) => ( +
setListScrollTop((event.currentTarget as HTMLDivElement).scrollTop)} + > + {!!filteredCiphers.length && ( +
+ {visibleCiphers.map((cipher) => (
{cipher.decName || t('txt_no_name')} - {isVisibleExposed(cipher.id) ? {t('txt_exposed_short')} : null} {listSubtitle(cipher)}
- ))} + ))} +
+ )} {!filteredCiphers.length &&
{t('txt_no_items')}
}
@@ -1678,25 +1638,6 @@ function folderName(id: string | null | undefined): string {
- {selectedCipherExposed && ( -
- {t('txt_exposed_passwords')} -
- - {selectedCipherIgnored ? t('txt_exposed_ignored') : t('txt_exposed')} - -
-
- -
-
- )} {!!selectedCipher.login.decTotp && (
{t('txt_totp')} @@ -2086,6 +2027,17 @@ function folderName(id: string | null | undefined): string { onCancel={() => setPendingDeleteFolder(null)} /> + void confirmDeleteAllFolders()} + onCancel={() => setDeleteAllFoldersOpen(false)} + /> + { + 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, 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, + ids: string[] +): Promise { + 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, 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, +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 = { - 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, - session: SessionState, - cipher: Cipher, - draft: VaultDraft -): Promise { + draft: VaultDraft, + cipher: Cipher | null +): Promise> { 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 = { - 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> { + return buildCipherPayload(session, draft, null); +} + +export async function createCipher( + authedFetch: (input: string, init?: RequestInit) => Promise, + 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, + session: SessionState, + cipher: Cipher, + draft: VaultDraft +): Promise { + 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, + ids: string[] +): Promise { + 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, 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, + ids: string[] +): Promise { + 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> { const payload: Record = {}; const plainPassword = String(password || '').trim(); diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 2a58239..efcbd0a 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -141,11 +141,6 @@ const messages: Record> = { 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> = { 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> = { 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> = { 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 = { 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 = { 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 = { 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 = { 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 = '密码库数据尚未就绪'; diff --git a/webapp/src/lib/password-breach.ts b/webapp/src/lib/password-breach.ts deleted file mode 100644 index 0892fb2..0000000 --- a/webapp/src/lib/password-breach.ts +++ /dev/null @@ -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 }>(); -const inflightRangeRequests = new Map>>(); - -function normalizeHashHex(value: string): string { - return String(value || '').trim().toUpperCase(); -} - -async function sha1Hex(input: string): Promise { - 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 | 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): 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> { - 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(); - 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> { - const loginCiphers = ciphers.filter((cipher) => { - const password = String(cipher.login?.decPassword || '').trim(); - return cipher.type === 1 && !!cipher.id && !!password; - }); - - const uniquePasswords = new Map(); - 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>(); - await Promise.all( - prefixes.map(async (prefix) => { - rangeMap.set(prefix, await getRangeSuffixes(prefix)); - }) - ); - - const results: Record = {}; - 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; -} diff --git a/webapp/src/styles.css b/webapp/src/styles.css index b97e83d..8fcd30d 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -601,6 +601,12 @@ input[type='file'].input::file-selector-button:hover { margin-bottom: 0; } +.folder-title-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + .folder-add-btn { border: none; background: transparent; @@ -808,9 +814,11 @@ input[type='file'].input::file-selector-button:hover { border-radius: 10px; padding: 10px 12px; display: flex; - align-items: flex-start; + align-items: center; gap: 10px; margin-bottom: 8px; + min-height: 66px; + box-sizing: border-box; } .list-item:hover { @@ -836,7 +844,7 @@ input[type='file'].input::file-selector-button:hover { background: transparent; padding: 0; display: flex; - align-items: flex-start; + align-items: center; gap: 10px; text-align: left; cursor: pointer; @@ -848,7 +856,6 @@ input[type='file'].input::file-selector-button:hover { display: grid; place-items: center; flex-shrink: 0; - margin-top: 1px; } .list-icon {