Files
nodewarden/webapp/src/components/VaultPage.tsx
T
ph4nt0mer df6b0b9767 fix: keep duplicate group indices unique when selecting duplicates (#351)
The duplicate group index was capped with '% 64' to limit color slots, but
the same index is used as the group identity in 'select unique items from
duplicates'. With more than 64 duplicate groups the indices wrap around, so
later groups had every item selected (nothing kept). Drop the modulo so each
group keeps a unique index; colors still differ via the golden-angle hue.
2026-08-30 01:54:15 +08:00

1468 lines
54 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
import LoadingState from '@/components/LoadingState';
import VaultDialogs from '@/components/vault/VaultDialogs';
import VaultDetailView from '@/components/vault/VaultDetailView';
import VaultEditor from '@/components/vault/VaultEditor';
import VaultListPanel from '@/components/vault/VaultListPanel';
import VaultSidebar from '@/components/vault/VaultSidebar';
import {
MOBILE_LAYOUT_QUERY,
VAULT_LIST_OVERSCAN,
VAULT_LIST_ROW_HEIGHT,
cardListSubtitle,
FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY,
bankAccountListSubtitle,
cipherTypeKey,
cipherTypeLabel,
createEmptyDraft,
creationTimeValue,
draftFromCipher,
driversLicenseListSubtitle,
buildCipherDuplicateSignatures,
firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive,
isCipherVisibleInNormalVault,
isCipherVisibleInTrash,
passportListSubtitle,
sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
import { ChevronLeft } from 'lucide-preact';
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
import { t } from '@/lib/i18n';
interface VaultPageProps {
ciphers: Cipher[];
folders: Folder[];
loading: boolean;
error: string;
emailForReprompt: string;
onRefresh: () => Promise<void>;
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
onDelete: (cipher: Cipher) => Promise<void>;
onArchive: (cipher: Cipher) => Promise<void>;
onUnarchive: (cipher: Cipher) => Promise<void>;
onRestore: (ids: string[]) => Promise<void>;
onBulkDelete: (ids: string[]) => Promise<void>;
onBulkPermanentDelete: (ids: string[]) => Promise<void>;
onBulkRestore: (ids: string[]) => Promise<void>;
onBulkArchive: (ids: string[]) => Promise<void>;
onBulkUnarchive: (ids: string[]) => Promise<void>;
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
onCreateFolder: (name: string) => Promise<void>;
onRenameFolder: (folderId: string, name: string) => Promise<void>;
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
uploadingAttachmentName: string;
attachmentUploadPercent: number | null;
mobileSidebarToggleKey: number;
}
export default function VaultPage(props: VaultPageProps) {
const getInitialIsMobileLayout = () =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
: false;
const [searchInput, setSearchInput] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [searchComposing, setSearchComposing] = useState(false);
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
const pendingFocusCipherIdRef = useRef<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [createMenuOpen, setCreateMenuOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [draft, setDraft] = useState<VaultDraft | null>(null);
const [fieldModalOpen, setFieldModalOpen] = useState(false);
const [fieldType, setFieldType] = useState<CustomFieldType>(0);
const [fieldLabel, setFieldLabel] = useState('');
const [fieldValue, setFieldValue] = useState('');
const [localError, setLocalError] = useState('');
const [pendingArchive, setPendingArchive] = useState<Cipher | null>(null);
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(null);
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [moveFolderId, setMoveFolderId] = useState('__none__');
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [pendingRenameFolder, setPendingRenameFolder] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
const [busy, setBusy] = useState(false);
const [repromptOpen, setRepromptOpen] = useState(false);
const [repromptPassword, setRepromptPassword] = useState('');
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const sortMenuRef = useRef<HTMLDivElement | null>(null);
const folderSortMenuRef = useRef<HTMLDivElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const listPanelRef = useRef<HTMLDivElement | null>(null);
const mobileSidebarToggleKeyRef = useRef(props.mobileSidebarToggleKey);
const sshSeedTicketRef = useRef(0);
const sshFingerprintTicketRef = useRef(0);
const listScrollBucketRef = useRef(0);
const [listScrollTop, setListScrollTop] = useState(0);
const [listViewportHeight, setListViewportHeight] = useState(0);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
const sync = () => setIsMobileLayout(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
if (props.mobileSidebarToggleKey === mobileSidebarToggleKeyRef.current) return;
mobileSidebarToggleKeyRef.current = props.mobileSidebarToggleKey;
setMobileSidebarOpen((open) => !open);
}, [props.mobileSidebarToggleKey]);
useEffect(() => {
const onQuickAdd = () => {
startCreate(1);
};
window.addEventListener('nodewarden:add-item', onQuickAdd);
return () => window.removeEventListener('nodewarden:add-item', onQuickAdd);
}, []);
useEffect(() => {
try {
const saved = String(localStorage.getItem(VAULT_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
setSortMode(saved);
}
} catch {
// ignore storage read failures
}
}, []);
useEffect(() => {
try {
localStorage.setItem(VAULT_SORT_STORAGE_KEY, sortMode);
} catch {
// ignore storage write failures
}
}, [sortMode]);
useEffect(() => {
try {
const saved = String(localStorage.getItem(FOLDER_SORT_STORAGE_KEY) || '').trim() as VaultSortMode;
if (saved === 'edited' || saved === 'created' || saved === 'name') {
setFolderSortMode(saved);
}
} catch {
// ignore storage read failures
}
}, []);
useEffect(() => {
try {
localStorage.setItem(FOLDER_SORT_STORAGE_KEY, folderSortMode);
} catch {
// ignore storage write failures
}
}, [folderSortMode]);
useEffect(() => {
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) => {
if (!createMenuOpen) return;
const target = event.target as Node | null;
if (createMenuRef.current && target && !createMenuRef.current.contains(target)) {
setCreateMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setCreateMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [createMenuOpen]);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!sortMenuOpen) return;
const target = event.target as Node | null;
if (sortMenuRef.current && target && !sortMenuRef.current.contains(target)) {
setSortMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSortMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [sortMenuOpen]);
useEffect(() => {
const onPointerDown = (event: Event) => {
if (!folderSortMenuOpen) return;
const target = event.target as Node | null;
if (folderSortMenuRef.current && target && !folderSortMenuRef.current.contains(target)) {
setFolderSortMenuOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setFolderSortMenuOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [folderSortMenuOpen]);
useEffect(() => {
setRepromptApprovedCipherId(null);
setRepromptPassword('');
setRepromptOpen(false);
setShowPassword(false);
setHiddenFieldVisibleMap({});
}, [selectedCipherId]);
useEffect(() => {
if (!isMobileLayout) {
setMobilePanel('list');
setMobileSidebarOpen(false);
return;
}
if (isEditing) {
setMobilePanel('edit');
} else if (!selectedCipherId) {
setMobilePanel('list');
}
}, [isMobileLayout, isEditing, selectedCipherId]);
useEffect(() => {
if (searchComposing) return;
const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90);
return () => window.clearTimeout(timer);
}, [searchInput, searchComposing]);
useEffect(() => {
if (!isEditing || !draft || draft.type !== 5) return;
void recalculateSshFingerprint(draft.sshPublicKey);
}, [isEditing, draft?.id, draft?.type]);
const cipherMetaById = useMemo(() => {
const meta = new Map<string, {
name: string;
searchText: string;
firstUri: string;
typeKey: string;
sortTime: number;
creationTime: number;
}>();
for (const cipher of props.ciphers) {
const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher);
const typedText = [
cipher.bankAccount?.decBankName,
cipher.bankAccount?.decNameOnAccount,
cipher.bankAccount?.decAccountNumber,
cipher.driversLicense?.decLicenseNumber,
cipher.driversLicense?.decFirstName,
cipher.driversLicense?.decLastName,
cipher.passport?.decPassportNumber,
cipher.passport?.decGivenName,
cipher.passport?.decSurname,
].filter(Boolean).join('\n');
const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, {
name,
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}\n${typedText}`.toLowerCase(),
firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher),
creationTime: creationTimeValue(cipher),
});
}
return meta;
}, [props.ciphers]);
const cipherById = useMemo(() => {
const map = new Map<string, Cipher>();
for (const cipher of props.ciphers) map.set(cipher.id, cipher);
return map;
}, [props.ciphers]);
const folderById = useMemo(() => {
const map = new Map<string, Folder>();
for (const folder of props.folders) map.set(folder.id, folder);
return map;
}, [props.folders]);
const nameCollator = useMemo(
() => new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }),
[]
);
const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string[]>();
const counts = new Map<string, number>();
for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue;
const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signatures);
for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1);
}
}
return { byId, counts };
}, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
// Keep indices unique (no modulo): they are used both for group colors and
// as the group identity when selecting duplicate items to delete.
groupIndexByKey.set(groupKey, index);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => {
const meta = cipherMetaById.get(cipher.id);
if (sidebarFilter.kind === 'trash') {
if (!isCipherVisibleInTrash(cipher)) return false;
} else if (sidebarFilter.kind === 'archive') {
if (!isCipherVisibleInArchive(cipher)) return false;
} else {
if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates') {
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false;
}
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') {
if (sidebarFilter.folderId === null) {
if (cipher.folderId) return false;
} else if (cipher.folderId !== sidebarFilter.folderId) {
return false;
}
}
}
if (!searchQuery) return true;
return !!meta?.searchText.includes(searchQuery);
});
// Pre-compute group min name for duplicates group ordering
const groupMinName = new Map<string, string>();
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
for (const cipher of next) {
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
if (!gk) continue;
const name = cipherMetaById.get(cipher.id)?.name || '';
const cur = groupMinName.get(gk);
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
}
}
next.sort((a, b) => {
// Duplicates view: group by color, sort A-Z within each group
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
.sort()[0] || '';
const gA = gk(a.id), gB = gk(b.id);
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
) || (gA < gB ? -1 : 1);
return nameCollator.compare(
cipherMetaById.get(a.id)?.name || '',
cipherMetaById.get(b.id)?.name || ''
) || String(a.id || '').localeCompare(String(b.id || ''));
}
const metaA = cipherMetaById.get(a.id);
const metaB = cipherMetaById.get(b.id);
if (sortMode === 'edited') {
const diff = (metaB?.sortTime || 0) - (metaA?.sortTime || 0);
if (diff !== 0) return diff;
} else if (sortMode === 'created') {
const diff = (metaB?.creationTime || 0) - (metaA?.creationTime || 0);
if (diff !== 0) return diff;
} else {
const nameDiff = nameCollator.compare(metaA?.name || '', metaB?.name || '');
if (nameDiff !== 0) return nameDiff;
}
return String(a.id || '').localeCompare(String(b.id || ''));
});
return next;
}, [props.ciphers, cipherMetaById, sidebarFilter, searchQuery, sortMode, duplicateSignatureInfo, nameCollator]);
const filteredCipherIds = useMemo(() => {
const ids = new Set<string>();
for (const cipher of filteredCiphers) ids.add(cipher.id);
return ids;
}, [filteredCiphers]);
const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind;
}, [sidebarFilter, duplicateMode]);
useEffect(() => {
setListScrollTop(0);
listScrollBucketRef.current = 0;
listPanelRef.current?.scrollTo({ top: 0 });
}, [searchQuery, sortMode, sidebarFilterKey]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates' && sortMode !== 'name') {
setSortMode('name');
}
}, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => {
if (typeof window === 'undefined') return;
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
if (!focusId) return;
pendingFocusCipherIdRef.current = focusId;
}, []);
useEffect(() => {
const focusId = pendingFocusCipherIdRef.current;
if (!focusId) return;
const cipher = cipherById.get(focusId);
if (!cipher) {
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
return;
}
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
? { kind: 'trash' }
: isCipherVisibleInArchive(cipher)
? { kind: 'archive' }
: { kind: 'all' };
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
setSearchInput('');
setSearchQuery('');
setIsEditing(false);
setIsCreating(false);
setDraft(null);
}, [cipherById, props.ciphers.length, props.loading]);
useEffect(() => {
if (isCreating) return;
const focusId = pendingFocusCipherIdRef.current;
if (focusId) {
if (!filteredCipherIds.has(focusId)) return;
setSelectedCipherId(focusId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
pendingFocusCipherIdRef.current = null;
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
const url = new URL(window.location.href);
if (url.searchParams.has('cipher')) {
url.searchParams.delete('cipher');
const next = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState(null, '', next || '/vault');
}
}
return;
}
if (!filteredCiphers.length) {
if (selectedCipherId) setSelectedCipherId('');
return;
}
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
setSelectedCipherId(filteredCiphers[0].id);
}
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
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 selectedAttachments = useMemo(
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
[selectedCipher]
);
const editExistingAttachments = useMemo(
() =>
selectedAttachments.filter((attachment) => {
const id = String(attachment?.id || '').trim();
return !!id;
}),
[selectedAttachments]
);
const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]);
useEffect(() => {
const raw = selectedCipher?.login?.decTotp || '';
if (!raw) {
setTotpLive(null);
return;
}
let stopped = false;
let timer = 0;
const tick = async () => {
try {
const now = await calcTotpNow(raw);
if (!stopped) setTotpLive(now);
} catch {
if (!stopped) setTotpLive(null);
}
};
void tick();
timer = window.setInterval(() => void tick(), 1000);
return () => {
stopped = true;
window.clearInterval(timer);
};
}, [selectedCipher?.id, selectedCipher?.login?.decTotp]);
const selectedCount = useMemo(
() => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0),
[selectedMap]
);
const totalCipherCount = filteredCiphers.length;
const folderName = useCallback((id: string | null | undefined): string => {
if (!id) return t('txt_no_folder');
const folder = folderById.get(id);
return folder?.decName || folder?.name || id;
}, [folderById]);
const listSubtitle = useCallback((cipher: Cipher): string => {
if (Number(cipher.type || 1) === 1) {
return cipher.login?.decUsername || cipherMetaById.get(cipher.id)?.firstUri || '';
}
if (Number(cipher.type || 1) === 3) {
return cardListSubtitle(cipher);
}
if (Number(cipher.type || 1) === 6) return bankAccountListSubtitle(cipher);
if (Number(cipher.type || 1) === 7) return driversLicenseListSubtitle(cipher);
if (Number(cipher.type || 1) === 8) return passportListSubtitle(cipher);
return cipherTypeLabel(Number(cipher.type || 1));
}, [cipherMetaById]);
const handleListScroll = useCallback((top: number): void => {
const bucket = Math.floor(Math.max(0, top) / VAULT_LIST_ROW_HEIGHT);
if (bucket === listScrollBucketRef.current) return;
listScrollBucketRef.current = bucket;
setListScrollTop(top);
}, []);
const startCreate = useCallback((type: number): void => {
setDraft(createEmptyDraft(type));
setIsCreating(true);
setIsEditing(true);
setCreateMenuOpen(false);
setSelectedCipherId('');
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
if (type === 5) void seedSshDefaults();
}, [isMobileLayout]);
const startEdit = useCallback((): void => {
if (!selectedCipher) return;
setDraft(draftFromCipher(selectedCipher));
setIsCreating(false);
setIsEditing(true);
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('edit');
setMobileSidebarOpen(false);
}, [selectedCipher, isMobileLayout]);
const cancelEdit = useCallback((): void => {
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
setDraft(null);
setIsEditing(false);
setIsCreating(false);
setShowPassword(false);
setHiddenFieldVisibleMap({});
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
setPendingDeletePasskeyIndex(null);
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
}, [isMobileLayout, isCreating, selectedCipher]);
const updateDraft = useCallback((patch: Partial<VaultDraft>): void => {
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
}, []);
function confirmDeleteLoginPasskey(): void {
if (pendingDeletePasskeyIndex == null) return;
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
};
});
setPendingDeletePasskeyIndex(null);
}
async function seedSshDefaults(force = false): Promise<void> {
const ticket = ++sshSeedTicketRef.current;
try {
const generated = await generateDefaultSshKeyMaterial();
if (ticket !== sshSeedTicketRef.current) return;
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
if (!force && (prev.sshPrivateKey.trim() || prev.sshPublicKey.trim())) return prev;
return {
...prev,
sshPrivateKey: generated.privateKey,
sshPublicKey: generated.publicKey,
sshFingerprint: generated.fingerprint,
};
});
} catch {
// Browser may not support Ed25519 generation; user can still paste keys manually.
}
}
async function recalculateSshFingerprint(publicKey: string): Promise<void> {
const ticket = ++sshFingerprintTicketRef.current;
const fingerprint = await computeSshFingerprint(publicKey);
if (ticket !== sshFingerprintTicketRef.current) return;
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
if (prev.sshPublicKey !== publicKey) return prev;
if (prev.sshFingerprint === fingerprint) return prev;
return { ...prev, sshFingerprint: fingerprint };
});
}
function updateSshPublicKey(nextValue: string): void {
setDraft((prev) => {
if (!prev || prev.type !== 5) return prev;
return { ...prev, sshPublicKey: nextValue };
});
void recalculateSshFingerprint(nextValue);
}
function updateDraftCustomFields(nextFields: VaultDraftField[]): void {
setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev));
}
function patchDraftCustomField(index: number, patch: Partial<VaultDraftField>): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.customFields];
next[index] = { ...next[index], ...patch };
return { ...prev, customFields: next };
});
}
function updateDraftLoginUri(index: number, value: string): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = { ...(next[index] || { uri: '', match: null }), uri: value };
return { ...prev, loginUris: next };
});
}
function updateDraftLoginUriMatch(index: number, value: number | null): void {
setDraft((prev) => {
if (!prev) return prev;
const next = [...prev.loginUris];
next[index] = { ...(next[index] || { uri: '', match: null }), match: value };
return { ...prev, loginUris: next };
});
}
function reorderDraftLoginUri(fromIndex: number, toIndex: number): void {
setDraft((prev) => {
if (!prev) return prev;
if (fromIndex < 0 || toIndex < 0 || fromIndex >= prev.loginUris.length || toIndex >= prev.loginUris.length || fromIndex === toIndex) {
return prev;
}
const next = [...prev.loginUris];
const [moved] = next.splice(fromIndex, 1);
if (!moved) return prev;
next.splice(toIndex, 0, moved);
return { ...prev, loginUris: next };
});
}
function queueAttachmentFiles(list: FileList | null): void {
if (!list || !list.length) return;
const next = Array.from(list).filter((file) => file && file.size >= 0);
if (!next.length) return;
setAttachmentQueue((prev) => [...prev, ...next]);
}
function removeQueuedAttachment(index: number): void {
setAttachmentQueue((prev) => prev.filter((_, i) => i !== index));
}
function toggleExistingAttachmentRemoval(attachmentId: string): void {
const id = String(attachmentId || '').trim();
if (!id) return;
setRemovedAttachmentIds((prev) => {
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
}
async function saveDraft(): Promise<void> {
if (!draft) return;
let nextDraft = draft;
if (nextDraft.type === 5) {
const computedFingerprint = await computeSshFingerprint(nextDraft.sshPublicKey);
if (computedFingerprint !== nextDraft.sshFingerprint) {
nextDraft = { ...nextDraft, sshFingerprint: computedFingerprint };
setDraft(nextDraft);
}
}
if (!nextDraft.name.trim()) {
setLocalError(t('txt_item_name_is_required'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
}
setIsCreating(false);
setIsEditing(false);
setDraft(null);
setLocalError('');
setAttachmentQueue([]);
setRemovedAttachmentIds({});
if (isMobileLayout) setMobilePanel('detail');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function deleteSelected(): Promise<void> {
if (!pendingDelete) return;
setBusy(true);
try {
await props.onDelete(pendingDelete);
setPendingDelete(null);
cancelEdit();
if (isMobileLayout) setMobilePanel('list');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function handleRestoreSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onRestore([cipher.id]);
if (isMobileLayout && selectedCipherId === cipher.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmBulkDelete(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
if (sidebarFilter.kind === 'trash') {
await props.onBulkPermanentDelete(ids);
} else {
await props.onBulkDelete(ids);
}
setSelectedMap({});
setBulkDeleteOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmBulkMove(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
const folderId = moveFolderId === '__none__' ? null : moveFolderId;
setBusy(true);
try {
await props.onBulkMove(ids, folderId);
setSelectedMap({});
setMoveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function syncVault(): Promise<void> {
setBusy(true);
try {
await props.onRefresh();
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function verifyReprompt(): Promise<void> {
if (!selectedCipher) return;
if (!repromptPassword) {
props.onNotify('error', t('txt_master_password_is_required_2'));
return;
}
setBusy(true);
try {
await props.onVerifyMasterPassword(props.emailForReprompt, repromptPassword);
setRepromptApprovedCipherId(selectedCipher.id);
setRepromptOpen(false);
setRepromptPassword('');
} catch (error) {
props.onNotify('error', error instanceof Error ? error.message : t('txt_unlock_failed'));
} finally {
setBusy(false);
}
}
async function confirmCreateFolder(): Promise<void> {
if (!newFolderName.trim()) {
props.onNotify('error', t('txt_folder_name_is_required'));
return;
}
setBusy(true);
try {
await props.onCreateFolder(newFolderName);
setCreateFolderOpen(false);
setNewFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmDeleteFolder(): Promise<void> {
if (!pendingDeleteFolder) return;
setBusy(true);
try {
await props.onDeleteFolder(pendingDeleteFolder.id);
if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) {
setSidebarFilter({ kind: 'all' });
}
setPendingDeleteFolder(null);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmRenameFolder(): Promise<void> {
if (!pendingRenameFolder) return;
const nextName = renameFolderName.trim();
if (!nextName) {
props.onNotify('error', t('txt_folder_name_is_required'));
return;
}
setBusy(true);
try {
await props.onRenameFolder(pendingRenameFolder.id, nextName);
setPendingRenameFolder(null);
setRenameFolderName('');
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmBulkRestore(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkRestore(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmArchiveSelected(): Promise<void> {
if (!pendingArchive) return;
setBusy(true);
try {
await props.onArchive(pendingArchive);
setPendingArchive(null);
if (isMobileLayout && selectedCipherId === pendingArchive.id) {
setMobilePanel('list');
}
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function handleUnarchiveSelected(cipher: Cipher): Promise<void> {
setBusy(true);
try {
await props.onBulkUnarchive([cipher.id]);
setSelectedMap((prev) => {
const next = { ...prev };
delete next[cipher.id];
return next;
});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmBulkArchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkArchive(ids);
setSelectedMap({});
setBulkArchiveOpen(false);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmBulkUnarchive(): Promise<void> {
const ids = Object.entries(selectedMap)
.filter(([, selected]) => selected)
.map(([id]) => id);
if (!ids.length) return;
setBusy(true);
try {
await props.onBulkUnarchive(ids);
setSelectedMap({});
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
async function confirmDeleteAllFolders(): Promise<void> {
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);
} catch {
// The action layer already shows the user-facing error toast.
} finally {
setBusy(false);
}
}
const handleClearSearch = useCallback(() => setSearchInput(''), []);
const handleSearchCompositionStart = useCallback(() => setSearchComposing(true), []);
const handleSearchCompositionEnd = useCallback((value: string) => {
setSearchComposing(false);
setSearchInput(value);
}, []);
const handleToggleSortMenu = useCallback(() => setSortMenuOpen((open) => !open), []);
const handleSelectSortMode = useCallback((value: VaultSortMode) => {
setSortMode(value);
setSortMenuOpen(false);
}, []);
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {};
const seen = new Set<string>();
for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) {
map[cipher.id] = true;
continue;
}
seen.add(signature);
}
setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectUniqueFromDuplicates = useCallback(() => {
const map: Record<string, boolean> = {};
const seen = new Set<number>();
for (const cipher of filteredCiphers) {
const groupIndex = duplicateGroupIndexById.get(cipher.id);
if (groupIndex === undefined) continue;
if (seen.has(groupIndex)) {
map[cipher.id] = true;
} else {
seen.add(groupIndex);
}
}
setSelectedMap(map);
}, [filteredCiphers, duplicateGroupIndexById]);
const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true;
setSelectedMap(map);
}, [filteredCiphers]);
const handleToggleCreateMenu = useCallback(() => setCreateMenuOpen((open) => !open), []);
const handleBulkRestore = useCallback(() => { void confirmBulkRestore(); }, [selectedMap, props.onBulkRestore]);
const handleBulkArchive = useCallback(() => setBulkArchiveOpen(true), []);
const handleBulkUnarchive = useCallback(() => { void confirmBulkUnarchive(); }, [selectedMap, props.onBulkUnarchive]);
const handleOpenMove = useCallback(() => {
setMoveFolderId('__none__');
setMoveOpen(true);
}, []);
const handleClearSelection = useCallback(() => setSelectedMap({}), []);
const handleToggleSelected = useCallback((cipherId: string, checked: boolean) =>
setSelectedMap((prev) => {
if (checked) return { ...prev, [cipherId]: true };
if (!prev[cipherId]) return prev;
const next = { ...prev };
delete next[cipherId];
return next;
})
, []);
const handleSelectCipher = useCallback((cipherId: string) => {
if (isEditing || isCreating) {
cancelEdit();
}
setSelectedCipherId(cipherId);
setRepromptApprovedCipherId(null);
setShowPassword(false);
setHiddenFieldVisibleMap({});
if (isMobileLayout) setMobilePanel('detail');
setMobileSidebarOpen(false);
}, [isEditing, isCreating, cancelEdit, isMobileLayout]);
const handleCloseMobileSidebar = useCallback(() => setMobileSidebarOpen(false), []);
const handleOpenDeleteAllFolders = useCallback(() => setDeleteAllFoldersOpen(true), []);
const handleOpenCreateFolder = useCallback(() => setCreateFolderOpen(true), []);
const handleOpenRenameFolder = useCallback((folder: Folder) => {
setPendingRenameFolder(folder);
setRenameFolderName(folder.decName || folder.name || '');
}, []);
const handleToggleFolderSortMenu = useCallback(() => setFolderSortMenuOpen((open) => !open), []);
const handleSelectFolderSortMode = useCallback((value: VaultSortMode) => {
setFolderSortMode(value);
setFolderSortMenuOpen(false);
}, []);
const handleMobileSidebarMaskClick = useCallback(() => {
if (!mobileSidebarOpen) return;
setMobileSidebarOpen(false);
}, [mobileSidebarOpen]);
return (
<>
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
{isMobileLayout && (
<div
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
onClick={handleMobileSidebarMaskClick}
/>
)}
<VaultSidebar
folders={props.folders}
sidebarFilter={sidebarFilter}
busy={busy}
isMobileLayout={isMobileLayout}
mobileSidebarOpen={mobileSidebarOpen}
folderSortMode={folderSortMode}
folderSortMenuOpen={folderSortMenuOpen}
folderSortMenuRef={folderSortMenuRef}
onCloseMobileSidebar={handleCloseMobileSidebar}
onChangeFilter={setSidebarFilter}
onOpenDeleteAllFolders={handleOpenDeleteAllFolders}
onOpenCreateFolder={handleOpenCreateFolder}
onOpenRenameFolder={handleOpenRenameFolder}
onOpenDeleteFolder={setPendingDeleteFolder}
onToggleFolderSortMenu={handleToggleFolderSortMenu}
onSelectFolderSortMode={handleSelectFolderSortMode}
/>
<VaultListPanel
busy={busy}
loading={props.loading}
error={props.error}
folders={props.folders}
searchInput={searchInput}
sortMode={sortMode}
sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount}
totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange}
selectedCipherId={selectedCipherId}
selectedMap={selectedMap}
sidebarFilter={sidebarFilter}
isMobileLayout={isMobileLayout}
mobileFabVisible={!isMobileLayout || mobilePanel === 'list'}
createMenuOpen={createMenuOpen}
createMenuRef={createMenuRef}
sortMenuRef={sortMenuRef}
listPanelRef={listPanelRef}
onSearchInput={setSearchInput}
onClearSearch={handleClearSearch}
onSearchCompositionStart={handleSearchCompositionStart}
onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onChangeFilter={setSidebarFilter}
onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates}
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
onSelectAll={handleSelectAll}
onToggleCreateMenu={handleToggleCreateMenu}
onStartCreate={startCreate}
onBulkRestore={handleBulkRestore}
onBulkArchive={handleBulkArchive}
onBulkUnarchive={handleBulkUnarchive}
onOpenMove={handleOpenMove}
onClearSelection={handleClearSelection}
onScroll={handleListScroll}
onToggleSelected={handleToggleSelected}
onSelectCipher={handleSelectCipher}
listSubtitle={listSubtitle}
/>
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
{isMobileLayout && mobilePanel !== 'list' && (
<div className="mobile-panel-head">
<button
type="button"
className="btn btn-secondary small mobile-panel-back"
onClick={() => {
if (isEditing) cancelEdit();
else setMobilePanel('list');
}}
>
<ChevronLeft size={14} className="btn-icon" />
{t('txt_back')}
</button>
</div>
)}
{isEditing && draft && (
<div key={`editor-${draft.id || selectedCipher?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
<VaultEditor
draft={draft}
isCreating={isCreating}
busy={busy}
folders={props.folders}
selectedCipher={selectedCipher}
editExistingAttachments={editExistingAttachments}
removedAttachmentIds={removedAttachmentIds}
removedAttachmentCount={removedAttachmentCount}
attachmentQueue={attachmentQueue}
attachmentInputRef={attachmentInputRef}
localError={localError}
onUpdateDraft={updateDraft}
onSeedSshDefaults={(force) => void seedSshDefaults(force)}
onUpdateSshPublicKey={updateSshPublicKey}
onUpdateDraftLoginUri={updateDraftLoginUri}
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
onReorderDraftLoginUri={reorderDraftLoginUri}
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
onQueueAttachmentFiles={queueAttachmentFiles}
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
onRemoveQueuedAttachment={removeQueuedAttachment}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
uploadingAttachmentName={props.uploadingAttachmentName}
attachmentUploadPercent={props.attachmentUploadPercent}
onPatchDraftCustomField={patchDraftCustomField}
onUpdateDraftCustomFields={updateDraftCustomFields}
onOpenFieldModal={() => setFieldModalOpen(true)}
onSave={() => void saveDraft()}
onCancel={cancelEdit}
onDeleteSelected={() => selectedCipher && setPendingDelete(selectedCipher)}
/>
</div>
)}
{!isEditing && selectedCipher && (
<div key={`detail-${selectedCipher.id}`} className="detail-switch-stage">
<VaultDetailView
selectedCipher={selectedCipher}
repromptApprovedCipherId={repromptApprovedCipherId}
showPassword={showPassword}
totpLive={totpLive}
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
folderName={folderName}
onOpenReprompt={() => setRepromptOpen(true)}
onToggleShowPassword={() => setShowPassword((value) => !value)}
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit}
onDelete={setPendingDelete}
onRestore={(cipher) => void handleRestoreSelected(cipher)}
onArchive={(cipher) => setPendingArchive(cipher)}
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
/>
</div>
)}
{!isEditing && !selectedCipher && (
props.loading
? <LoadingState card lines={5} />
: props.error
? (
<div className="empty card vault-error-state">
<strong>{props.error}</strong>
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={handleSyncVault}>
{t('txt_retry_sync')}
</button>
</div>
)
: <div className="empty card">{t('txt_select_an_item')}</div>
)}
</section>
</div>
<VaultDialogs
busy={busy}
fieldModalOpen={fieldModalOpen}
fieldType={fieldType}
fieldLabel={fieldLabel}
fieldValue={fieldValue}
archiveConfirmOpen={!!pendingArchive}
bulkArchiveOpen={bulkArchiveOpen}
pendingDeleteOpen={!!pendingDelete}
bulkDeleteOpen={bulkDeleteOpen}
sidebarTrashMode={sidebarFilter.kind === 'trash'}
selectedCount={selectedCount}
moveOpen={moveOpen}
moveFolderId={moveFolderId}
folders={props.folders}
createFolderOpen={createFolderOpen}
newFolderName={newFolderName}
renameFolderOpen={!!pendingRenameFolder}
renameFolderName={renameFolderName}
pendingDeleteFolder={pendingDeleteFolder}
deleteAllFoldersOpen={deleteAllFoldersOpen}
repromptOpen={repromptOpen}
repromptPassword={repromptPassword}
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
onConfirmAddField={() => {
if (!draft) return;
if (!fieldLabel.trim()) {
setLocalError(t('txt_field_label_is_required'));
return;
}
updateDraftCustomFields([
...draft.customFields,
{
type: fieldType,
label: fieldLabel.trim(),
value: fieldType === 2 ? (fieldValue === 'true' ? 'true' : 'false') : fieldValue,
},
]);
setFieldModalOpen(false);
setFieldType(0);
setFieldLabel('');
setFieldValue('');
setLocalError('');
}}
onCancelFieldModal={() => {
setFieldModalOpen(false);
setFieldType(0);
setFieldLabel('');
setFieldValue('');
}}
onFieldTypeChange={setFieldType}
onFieldLabelChange={setFieldLabel}
onFieldValueChange={setFieldValue}
onConfirmArchive={() => void confirmArchiveSelected()}
onCancelArchive={() => setPendingArchive(null)}
onConfirmBulkArchive={() => void confirmBulkArchive()}
onCancelBulkArchive={() => setBulkArchiveOpen(false)}
onConfirmDelete={() => void deleteSelected()}
onCancelDelete={() => setPendingDelete(null)}
onConfirmBulkDelete={() => void confirmBulkDelete()}
onCancelBulkDelete={() => setBulkDeleteOpen(false)}
onConfirmMove={() => void confirmBulkMove()}
onCancelMove={() => setMoveOpen(false)}
onMoveFolderIdChange={setMoveFolderId}
onConfirmCreateFolder={() => void confirmCreateFolder()}
onCancelCreateFolder={() => {
setCreateFolderOpen(false);
setNewFolderName('');
}}
onNewFolderNameChange={setNewFolderName}
onConfirmRenameFolder={() => void confirmRenameFolder()}
onCancelRenameFolder={() => {
setPendingRenameFolder(null);
setRenameFolderName('');
}}
onRenameFolderNameChange={setRenameFolderName}
onConfirmDeleteFolder={() => void confirmDeleteFolder()}
onCancelDeleteFolder={() => setPendingDeleteFolder(null)}
onConfirmDeleteAllFolders={() => void confirmDeleteAllFolders()}
onCancelDeleteAllFolders={() => setDeleteAllFoldersOpen(false)}
onConfirmReprompt={() => void verifyReprompt()}
onCancelReprompt={() => {
setRepromptOpen(false);
setRepromptPassword('');
}}
onRepromptPasswordChange={setRepromptPassword}
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
/>
</>
);
}