From 3204eeb9abe4139d6256f3670bddfa455f8da9f9 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Mar 2026 01:39:35 +0800 Subject: [PATCH] feat: add duplicate handling features and UI elements for cipher management --- webapp/src/components/VaultPage.tsx | 36 ++++++++- .../src/components/vault/VaultListPanel.tsx | 8 +- webapp/src/components/vault/VaultSidebar.tsx | 4 + .../components/vault/vault-page-helpers.tsx | 81 +++++++++++++++++++ webapp/src/lib/i18n.ts | 4 + 5 files changed, 131 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index af8e951..064cb2a 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -14,6 +14,7 @@ import { createEmptyDraft, creationTimeValue, draftFromCipher, + buildCipherDuplicateSignature, firstCipherUri, firstPasskeyCreationTime, sortTimeValue, @@ -223,6 +224,17 @@ export default function VaultPage(props: VaultPageProps) { void recalculateSshFingerprint(draft.sshPublicKey); }, [isEditing, draft?.id, draft?.type]); + const duplicateSignatureCounts = useMemo(() => { + const counts = new Map(); + for (const cipher of props.ciphers) { + const isDeleted = !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt); + if (isDeleted) continue; + const signature = buildCipherDuplicateSignature(cipher); + counts.set(signature, (counts.get(signature) || 0) + 1); + } + return counts; + }, [props.ciphers]); + const filteredCiphers = useMemo(() => { const next = props.ciphers.filter((cipher) => { const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt); @@ -230,6 +242,9 @@ export default function VaultPage(props: VaultPageProps) { if (!isDeleted) return false; } else { if (isDeleted) return false; + if (sidebarFilter.kind === 'duplicates' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) { + return false; + } if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'folder') { @@ -266,7 +281,7 @@ export default function VaultPage(props: VaultPageProps) { }); return next; - }, [props.ciphers, sidebarFilter, searchQuery, sortMode]); + }, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]); const sidebarFilterKey = useMemo(() => { if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; @@ -279,6 +294,12 @@ export default function VaultPage(props: VaultPageProps) { listPanelRef.current?.scrollTo({ top: 0 }); }, [searchQuery, sortMode, sidebarFilterKey]); + useEffect(() => { + if (sidebarFilter.kind === 'duplicates' && sortMode !== 'name') { + setSortMode('name'); + } + }, [sidebarFilter.kind, sortMode]); + useEffect(() => { if (isCreating) return; if (!filteredCiphers.length) { @@ -716,6 +737,19 @@ function folderName(id: string | null | undefined): string { }} onSyncVault={() => void syncVault()} onOpenBulkDelete={() => setBulkDeleteOpen(true)} + onSelectDuplicates={() => { + const map: Record = {}; + const seen = new Set(); + for (const cipher of filteredCiphers) { + const signature = buildCipherDuplicateSignature(cipher); + if (seen.has(signature)) { + map[cipher.id] = true; + continue; + } + seen.add(signature); + } + setSelectedMap(map); + }} onSelectAll={() => { const map: Record = {}; for (const cipher of filteredCiphers) map[cipher.id] = true; diff --git a/webapp/src/components/vault/VaultListPanel.tsx b/webapp/src/components/vault/VaultListPanel.tsx index 33a17af..436ce37 100644 --- a/webapp/src/components/vault/VaultListPanel.tsx +++ b/webapp/src/components/vault/VaultListPanel.tsx @@ -43,6 +43,7 @@ interface VaultListPanelProps { onSelectSortMode: (value: VaultSortMode) => void; onSyncVault: () => void; onOpenBulkDelete: () => void; + onSelectDuplicates: () => void; onSelectAll: () => void; onToggleCreateMenu: () => void; onStartCreate: (type: number) => void; @@ -104,6 +105,11 @@ export default function VaultListPanel(props: VaultListPanelProps) { + {props.sidebarFilter.kind === 'duplicates' && ( + + )} @@ -133,7 +139,7 @@ export default function VaultListPanel(props: VaultListPanelProps) { {t('txt_restore')} )} - {props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && ( + {props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && ( diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx index 492f78a..06ddfad 100644 --- a/webapp/src/components/vault/VaultSidebar.tsx +++ b/webapp/src/components/vault/VaultSidebar.tsx @@ -1,4 +1,5 @@ import { + Copy, CreditCard, Folder as FolderIcon, FolderPlus, @@ -50,6 +51,9 @@ export default function VaultSidebar(props: VaultSidebarProps) { +
diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 947c32e..c15d278 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -17,6 +17,7 @@ export type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } | { kind: 'trash' } + | { kind: 'duplicates' } | { kind: 'type'; value: TypeFilter } | { kind: 'folder'; folderId: string | null }; @@ -124,6 +125,86 @@ export function websiteIconUrl(host: string): string { return `/icons/${encodeURIComponent(host)}/icon.png`; } +function valueOrFallback(value: string | null | undefined): string { + return String(value || ''); +} + +export function buildCipherDuplicateSignature(cipher: Cipher): string { + const normalized = { + type: Number(cipher.type || 1), + folderId: cipher.folderId || null, + favorite: !!cipher.favorite, + reprompt: Number(cipher.reprompt || 0), + name: valueOrFallback(cipher.decName ?? cipher.name), + notes: valueOrFallback(cipher.decNotes ?? cipher.notes), + login: cipher.login + ? { + username: valueOrFallback(cipher.login.decUsername ?? cipher.login.username), + password: valueOrFallback(cipher.login.decPassword ?? cipher.login.password), + totp: valueOrFallback(cipher.login.decTotp ?? cipher.login.totp), + uris: (cipher.login.uris || []).map((uri) => ({ + uri: valueOrFallback(uri.decUri ?? uri.uri), + match: uri.match ?? null, + })), + fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({ + creationDate: valueOrFallback(credential.creationDate), + })), + } + : null, + card: cipher.card + ? { + cardholderName: valueOrFallback(cipher.card.decCardholderName ?? cipher.card.cardholderName), + number: valueOrFallback(cipher.card.decNumber ?? cipher.card.number), + brand: valueOrFallback(cipher.card.decBrand ?? cipher.card.brand), + expMonth: valueOrFallback(cipher.card.decExpMonth ?? cipher.card.expMonth), + expYear: valueOrFallback(cipher.card.decExpYear ?? cipher.card.expYear), + code: valueOrFallback(cipher.card.decCode ?? cipher.card.code), + } + : null, + identity: cipher.identity + ? { + title: valueOrFallback(cipher.identity.decTitle ?? cipher.identity.title), + firstName: valueOrFallback(cipher.identity.decFirstName ?? cipher.identity.firstName), + middleName: valueOrFallback(cipher.identity.decMiddleName ?? cipher.identity.middleName), + lastName: valueOrFallback(cipher.identity.decLastName ?? cipher.identity.lastName), + username: valueOrFallback(cipher.identity.decUsername ?? cipher.identity.username), + company: valueOrFallback(cipher.identity.decCompany ?? cipher.identity.company), + ssn: valueOrFallback(cipher.identity.decSsn ?? cipher.identity.ssn), + passportNumber: valueOrFallback(cipher.identity.decPassportNumber ?? cipher.identity.passportNumber), + licenseNumber: valueOrFallback(cipher.identity.decLicenseNumber ?? cipher.identity.licenseNumber), + email: valueOrFallback(cipher.identity.decEmail ?? cipher.identity.email), + phone: valueOrFallback(cipher.identity.decPhone ?? cipher.identity.phone), + address1: valueOrFallback(cipher.identity.decAddress1 ?? cipher.identity.address1), + address2: valueOrFallback(cipher.identity.decAddress2 ?? cipher.identity.address2), + address3: valueOrFallback(cipher.identity.decAddress3 ?? cipher.identity.address3), + city: valueOrFallback(cipher.identity.decCity ?? cipher.identity.city), + state: valueOrFallback(cipher.identity.decState ?? cipher.identity.state), + postalCode: valueOrFallback(cipher.identity.decPostalCode ?? cipher.identity.postalCode), + country: valueOrFallback(cipher.identity.decCountry ?? cipher.identity.country), + } + : null, + sshKey: cipher.sshKey + ? { + privateKey: valueOrFallback(cipher.sshKey.decPrivateKey ?? cipher.sshKey.privateKey), + publicKey: valueOrFallback(cipher.sshKey.decPublicKey ?? cipher.sshKey.publicKey), + fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint), + } + : null, + secureNoteType: cipher.secureNote?.type ?? null, + fields: (cipher.fields || []).map((field) => ({ + type: field.type ?? null, + name: valueOrFallback(field.decName ?? field.name), + value: valueOrFallback(field.decValue ?? field.value), + linkedId: field.linkedId ?? null, + })), + passwordHistory: (cipher.passwordHistory || []).map((entry) => ({ + password: valueOrFallback(entry.password), + lastUsedDate: valueOrFallback(entry.lastUsedDate), + })), + }; + return JSON.stringify(normalized); +} + export function createEmptyDraft(type: number): VaultDraft { return { type, diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index ff5fa4d..845ba50 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -319,6 +319,7 @@ const messages: Record> = { txt_failed_to_open_send: "Failed to open send", txt_favorite: "Favorite", txt_favorites: "Favorites", + txt_duplicates: "Duplicates", txt_field: "Field", txt_field_label: "Field Label", txt_field_label_is_required: "Field label is required.", @@ -510,6 +511,7 @@ const messages: Record> = { txt_security_code: "Security Code", txt_security_code_cvv: "Security Code (CVV)", txt_select_all: "Select All", + txt_select_duplicate_items: "Select Duplicates", txt_select_an_item: "Select an item", txt_send_created: "Send created", txt_send_deleted: "Send deleted", @@ -819,9 +821,11 @@ const zhCNOverrides: Record = { txt_code_copied: '验证码已复制', txt_copy_link: '复制链接', txt_select_all: '全选', + txt_select_duplicate_items: '选择重复项', txt_delete_selected: '删除所选', txt_all_items: '所有项目', txt_favorites: '收藏', + txt_duplicates: '重复项', txt_trash: '回收站', txt_folder: '文件夹', txt_folders: '文件夹',