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, cipherTypeKey, cipherTypeLabel, createEmptyDraft, creationTimeValue, draftFromCipher, buildCipherDuplicateSignature, firstCipherUri, firstPasskeyCreationTime, isCipherVisibleInArchive, isCipherVisibleInNormalVault, isCipherVisibleInTrash, sortTimeValue, type SidebarFilter, type VaultSortMode, } from '@/components/vault/vault-page-helpers'; import { calcTotpNow } 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; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise; onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDelete: (cipher: Cipher) => Promise; onArchive: (cipher: Cipher) => Promise; onUnarchive: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkPermanentDelete: (ids: string[]) => Promise; onBulkRestore: (ids: string[]) => Promise; onBulkArchive: (ids: string[]) => Promise; onBulkUnarchive: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onCreateFolder: (name: string) => Promise; onRenameFolder: (folderId: string, name: string) => Promise; onDeleteFolder: (folderId: string) => Promise; onBulkDeleteFolders: (folderIds: string[]) => Promise; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; 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('edited'); const [sortMenuOpen, setSortMenuOpen] = useState(false); const [folderSortMode, setFolderSortMode] = useState('name'); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); const [showPassword, setShowPassword] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); const [isCreating, setIsCreating] = useState(false); const [draft, setDraft] = useState(null); const [fieldModalOpen, setFieldModalOpen] = useState(false); const [fieldType, setFieldType] = useState(0); const [fieldLabel, setFieldLabel] = useState(''); const [fieldValue, setFieldValue] = useState(''); const [localError, setLocalError] = useState(''); const [pendingArchive, setPendingArchive] = useState(null); const [pendingDelete, setPendingDelete] = useState(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(null); const [renameFolderName, setRenameFolderName] = 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([]); const [removedAttachmentIds, setRemovedAttachmentIds] = useState>({}); const [busy, setBusy] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false); const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState(null); const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout); const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list'); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const createMenuRef = useRef(null); const sortMenuRef = useRef(null); const folderSortMenuRef = useRef(null); const attachmentInputRef = useRef(null); const listPanelRef = useRef(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(); for (const cipher of props.ciphers) { const name = String(cipher.decName || cipher.name || ''); const username = String(cipher.login?.decUsername || ''); const uri = firstCipherUri(cipher); meta.set(cipher.id, { name, searchText: `${name}\n${username}\n${uri}`.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(); for (const cipher of props.ciphers) map.set(cipher.id, cipher); return map; }, [props.ciphers]); const folderById = useMemo(() => { const map = new Map(); 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(); const counts = new Map(); for (const cipher of props.ciphers) { if (!isCipherVisibleInNormalVault(cipher)) continue; const signature = buildCipherDuplicateSignature(cipher); byId.set(cipher.id, signature); counts.set(signature, (counts.get(signature) || 0) + 1); } return { byId, counts }; }, [props.ciphers, sidebarFilter.kind]); 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' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 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); }); next.sort((a, b) => { 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(); 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}`; return sidebarFilter.kind; }, [sidebarFilter]); 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 (isCreating) return; if (!filteredCiphers.length) { if (selectedCipherId) setSelectedCipherId(''); return; } if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) { setSelectedCipherId(filteredCiphers[0].id); } }, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]); 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); } 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): 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 { 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 { 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): 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 { 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'); } finally { setBusy(false); } } async function deleteSelected(): Promise { if (!pendingDelete) return; setBusy(true); try { await props.onDelete(pendingDelete); setPendingDelete(null); cancelEdit(); if (isMobileLayout) setMobilePanel('list'); } finally { setBusy(false); } } async function confirmBulkDelete(): Promise { 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); } finally { setBusy(false); } } async function confirmBulkMove(): Promise { 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); } finally { setBusy(false); } } async function syncVault(): Promise { setBusy(true); try { await props.onRefresh(); } finally { setBusy(false); } } async function verifyReprompt(): Promise { 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 { if (!newFolderName.trim()) { props.onNotify('error', t('txt_folder_name_is_required')); return; } setBusy(true); try { await props.onCreateFolder(newFolderName); setCreateFolderOpen(false); setNewFolderName(''); } finally { setBusy(false); } } async function confirmDeleteFolder(): Promise { if (!pendingDeleteFolder) return; setBusy(true); try { await props.onDeleteFolder(pendingDeleteFolder.id); if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) { setSidebarFilter({ kind: 'all' }); } setPendingDeleteFolder(null); } finally { setBusy(false); } } async function confirmRenameFolder(): Promise { 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(''); } finally { setBusy(false); } } async function confirmBulkRestore(): Promise { const ids = Object.entries(selectedMap) .filter(([, selected]) => selected) .map(([id]) => id); if (!ids.length) return; setBusy(true); try { await props.onBulkRestore(ids); setSelectedMap({}); } finally { setBusy(false); } } async function confirmArchiveSelected(): Promise { if (!pendingArchive) return; setBusy(true); try { await props.onArchive(pendingArchive); setPendingArchive(null); if (isMobileLayout && selectedCipherId === pendingArchive.id) { setMobilePanel('list'); } } finally { setBusy(false); } } async function handleUnarchiveSelected(cipher: Cipher): Promise { setBusy(true); try { await props.onBulkUnarchive([cipher.id]); setSelectedMap((prev) => { const next = { ...prev }; delete next[cipher.id]; return next; }); } finally { setBusy(false); } } async function confirmBulkArchive(): Promise { 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); } finally { setBusy(false); } } async function confirmBulkUnarchive(): Promise { const ids = Object.entries(selectedMap) .filter(([, selected]) => selected) .map(([id]) => id); if (!ids.length) return; setBusy(true); try { await props.onBulkUnarchive(ids); setSelectedMap({}); } finally { setBusy(false); } } 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); } } 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(() => { const map: Record = {}; const seen = new Set(); for (const cipher of filteredCiphers) { const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); if (seen.has(signature)) { map[cipher.id] = true; continue; } seen.add(signature); } setSelectedMap(map); }, [filteredCiphers, duplicateSignatureInfo]); const handleSelectAll = useCallback(() => { const map: Record = {}; 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 ( <>
{isMobileLayout && (
)}
{isMobileLayout && mobilePanel !== 'list' && (
)} {isEditing && draft && (
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)} />
)} {!isEditing && selectedCipher && (
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} onArchive={(cipher) => setPendingArchive(cipher)} onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)} />
)} {!isEditing && !selectedCipher && ( props.loading ? : props.error ? (
{props.error}
) :
{t('txt_select_an_item')}
)}
{ 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)} /> ); }