mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add duplicate handling features and UI elements for cipher management
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
creationTimeValue,
|
creationTimeValue,
|
||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
|
buildCipherDuplicateSignature,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
firstPasskeyCreationTime,
|
firstPasskeyCreationTime,
|
||||||
sortTimeValue,
|
sortTimeValue,
|
||||||
@@ -223,6 +224,17 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
void recalculateSshFingerprint(draft.sshPublicKey);
|
void recalculateSshFingerprint(draft.sshPublicKey);
|
||||||
}, [isEditing, draft?.id, draft?.type]);
|
}, [isEditing, draft?.id, draft?.type]);
|
||||||
|
|
||||||
|
const duplicateSignatureCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
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 filteredCiphers = useMemo(() => {
|
||||||
const next = props.ciphers.filter((cipher) => {
|
const next = props.ciphers.filter((cipher) => {
|
||||||
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt);
|
||||||
@@ -230,6 +242,9 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
if (!isDeleted) return false;
|
if (!isDeleted) return false;
|
||||||
} else {
|
} else {
|
||||||
if (isDeleted) return false;
|
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 === 'favorite' && !cipher.favorite) return false;
|
||||||
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
||||||
if (sidebarFilter.kind === 'folder') {
|
if (sidebarFilter.kind === 'folder') {
|
||||||
@@ -266,7 +281,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode]);
|
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
|
||||||
|
|
||||||
const sidebarFilterKey = useMemo(() => {
|
const sidebarFilterKey = useMemo(() => {
|
||||||
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
|
||||||
@@ -279,6 +294,12 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
listPanelRef.current?.scrollTo({ top: 0 });
|
listPanelRef.current?.scrollTo({ top: 0 });
|
||||||
}, [searchQuery, sortMode, sidebarFilterKey]);
|
}, [searchQuery, sortMode, sidebarFilterKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarFilter.kind === 'duplicates' && sortMode !== 'name') {
|
||||||
|
setSortMode('name');
|
||||||
|
}
|
||||||
|
}, [sidebarFilter.kind, sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreating) return;
|
if (isCreating) return;
|
||||||
if (!filteredCiphers.length) {
|
if (!filteredCiphers.length) {
|
||||||
@@ -716,6 +737,19 @@ function folderName(id: string | null | undefined): string {
|
|||||||
}}
|
}}
|
||||||
onSyncVault={() => void syncVault()}
|
onSyncVault={() => void syncVault()}
|
||||||
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
|
onOpenBulkDelete={() => setBulkDeleteOpen(true)}
|
||||||
|
onSelectDuplicates={() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const cipher of filteredCiphers) {
|
||||||
|
const signature = buildCipherDuplicateSignature(cipher);
|
||||||
|
if (seen.has(signature)) {
|
||||||
|
map[cipher.id] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(signature);
|
||||||
|
}
|
||||||
|
setSelectedMap(map);
|
||||||
|
}}
|
||||||
onSelectAll={() => {
|
onSelectAll={() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface VaultListPanelProps {
|
|||||||
onSelectSortMode: (value: VaultSortMode) => void;
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
|
onSelectDuplicates: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onToggleCreateMenu: () => void;
|
onToggleCreateMenu: () => void;
|
||||||
onStartCreate: (type: number) => 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}>
|
<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')}
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
</button>
|
</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}>
|
<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')}
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
</button>
|
</button>
|
||||||
@@ -133,7 +139,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
</button>
|
</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}>
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
Copy,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Folder as FolderIcon,
|
Folder as FolderIcon,
|
||||||
FolderPlus,
|
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' })}>
|
<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>
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type SidebarFilter =
|
|||||||
| { kind: 'all' }
|
| { kind: 'all' }
|
||||||
| { kind: 'favorite' }
|
| { kind: 'favorite' }
|
||||||
| { kind: 'trash' }
|
| { kind: 'trash' }
|
||||||
|
| { kind: 'duplicates' }
|
||||||
| { kind: 'type'; value: TypeFilter }
|
| { kind: 'type'; value: TypeFilter }
|
||||||
| { kind: 'folder'; folderId: string | null };
|
| { kind: 'folder'; folderId: string | null };
|
||||||
|
|
||||||
@@ -124,6 +125,86 @@ export function websiteIconUrl(host: string): string {
|
|||||||
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
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 {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_failed_to_open_send: "Failed to open send",
|
txt_failed_to_open_send: "Failed to open send",
|
||||||
txt_favorite: "Favorite",
|
txt_favorite: "Favorite",
|
||||||
txt_favorites: "Favorites",
|
txt_favorites: "Favorites",
|
||||||
|
txt_duplicates: "Duplicates",
|
||||||
txt_field: "Field",
|
txt_field: "Field",
|
||||||
txt_field_label: "Field Label",
|
txt_field_label: "Field Label",
|
||||||
txt_field_label_is_required: "Field label is required.",
|
txt_field_label_is_required: "Field label is required.",
|
||||||
@@ -510,6 +511,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_security_code: "Security Code",
|
txt_security_code: "Security Code",
|
||||||
txt_security_code_cvv: "Security Code (CVV)",
|
txt_security_code_cvv: "Security Code (CVV)",
|
||||||
txt_select_all: "Select All",
|
txt_select_all: "Select All",
|
||||||
|
txt_select_duplicate_items: "Select Duplicates",
|
||||||
txt_select_an_item: "Select an item",
|
txt_select_an_item: "Select an item",
|
||||||
txt_send_created: "Send created",
|
txt_send_created: "Send created",
|
||||||
txt_send_deleted: "Send deleted",
|
txt_send_deleted: "Send deleted",
|
||||||
@@ -819,9 +821,11 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_code_copied: '验证码已复制',
|
txt_code_copied: '验证码已复制',
|
||||||
txt_copy_link: '复制链接',
|
txt_copy_link: '复制链接',
|
||||||
txt_select_all: '全选',
|
txt_select_all: '全选',
|
||||||
|
txt_select_duplicate_items: '选择重复项',
|
||||||
txt_delete_selected: '删除所选',
|
txt_delete_selected: '删除所选',
|
||||||
txt_all_items: '所有项目',
|
txt_all_items: '所有项目',
|
||||||
txt_favorites: '收藏',
|
txt_favorites: '收藏',
|
||||||
|
txt_duplicates: '重复项',
|
||||||
txt_trash: '回收站',
|
txt_trash: '回收站',
|
||||||
txt_folder: '文件夹',
|
txt_folder: '文件夹',
|
||||||
txt_folders: '文件夹',
|
txt_folders: '文件夹',
|
||||||
|
|||||||
Reference in New Issue
Block a user