feat: add duplicate handling features and UI elements for cipher management

This commit is contained in:
shuaiplus
2026-03-18 01:39:35 +08:00
parent 9280f6916e
commit 3204eeb9ab
5 changed files with 131 additions and 2 deletions
@@ -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) {
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
{props.sidebarFilter.kind === 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
)}
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
@@ -133,7 +139,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
</button>
)}
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && (
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
</button>
@@ -1,4 +1,5 @@
import {
Copy,
CreditCard,
Folder as FolderIcon,
FolderPlus,
@@ -50,6 +51,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'duplicates' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'duplicates' })}>
<Copy size={14} className="tree-icon" /> <span className="tree-label">{t('txt_duplicates')}</span>
</button>
</div>
<div className="sidebar-block">
@@ -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,