mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
1104 lines
40 KiB
TypeScript
1104 lines
40 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
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,
|
|
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;
|
|
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>;
|
|
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;
|
|
}
|
|
|
|
|
|
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 [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
|
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<{ code: string; remain: number } | 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 attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
|
const listPanelRef = useRef<HTMLDivElement | null>(null);
|
|
const sshSeedTicketRef = useRef(0);
|
|
const sshFingerprintTicketRef = 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(() => {
|
|
const onToggleSidebar = () => {
|
|
setMobileSidebarOpen((open) => !open);
|
|
};
|
|
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
|
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
|
}, []);
|
|
|
|
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(() => {
|
|
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(() => {
|
|
setRepromptApprovedCipherId(null);
|
|
setRepromptPassword('');
|
|
setRepromptOpen(false);
|
|
}, [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 duplicateSignatureCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
for (const cipher of props.ciphers) {
|
|
if (!isCipherVisibleInNormalVault(cipher)) continue;
|
|
const signature = buildCipherDuplicateSignature(cipher);
|
|
counts.set(signature, (counts.get(signature) || 0) + 1);
|
|
}
|
|
return counts;
|
|
}, [props.ciphers]);
|
|
|
|
const filteredCiphers = useMemo(() => {
|
|
const next = props.ciphers.filter((cipher) => {
|
|
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' && (duplicateSignatureCounts.get(buildCipherDuplicateSignature(cipher)) || 0) < 2) {
|
|
return false;
|
|
}
|
|
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
|
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
|
if (sidebarFilter.kind === 'folder') {
|
|
if (sidebarFilter.folderId === null) {
|
|
if (cipher.folderId) return false;
|
|
} else if (cipher.folderId !== sidebarFilter.folderId) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
if (!searchQuery) return true;
|
|
const name = (cipher.decName || '').toLowerCase();
|
|
const username = (cipher.login?.decUsername || '').toLowerCase();
|
|
const uri = firstCipherUri(cipher).toLowerCase();
|
|
return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery);
|
|
});
|
|
|
|
next.sort((a, b) => {
|
|
if (sortMode === 'edited') {
|
|
const diff = sortTimeValue(b) - sortTimeValue(a);
|
|
if (diff !== 0) return diff;
|
|
} else if (sortMode === 'created') {
|
|
const diff = creationTimeValue(b) - creationTimeValue(a);
|
|
if (diff !== 0) return diff;
|
|
} else {
|
|
const nameDiff = String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || ''), undefined, {
|
|
sensitivity: 'base',
|
|
numeric: true,
|
|
});
|
|
if (nameDiff !== 0) return nameDiff;
|
|
}
|
|
|
|
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
});
|
|
|
|
return next;
|
|
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, duplicateSignatureCounts]);
|
|
|
|
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);
|
|
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 || !filteredCiphers.some((x) => x.id === selectedCipherId)) {
|
|
setSelectedCipherId(filteredCiphers[0].id);
|
|
}
|
|
}, [filteredCiphers, selectedCipherId, isCreating]);
|
|
|
|
const selectedCipher = useMemo(
|
|
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
|
[props.ciphers, 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;
|
|
|
|
function folderName(id: string | null | undefined): string {
|
|
if (!id) return t('txt_no_folder');
|
|
const folder = props.folders.find((x) => x.id === id);
|
|
return folder?.decName || folder?.name || id;
|
|
}
|
|
|
|
function listSubtitle(cipher: Cipher): string {
|
|
if (Number(cipher.type || 1) === 1) {
|
|
return cipher.login?.decUsername || firstCipherUri(cipher) || '';
|
|
}
|
|
return cipherTypeLabel(Number(cipher.type || 1));
|
|
}
|
|
|
|
function startCreate(type: number): void {
|
|
setDraft(createEmptyDraft(type));
|
|
setIsCreating(true);
|
|
setIsEditing(true);
|
|
setCreateMenuOpen(false);
|
|
setSelectedCipherId('');
|
|
setShowPassword(false);
|
|
setLocalError('');
|
|
setAttachmentQueue([]);
|
|
setRemovedAttachmentIds({});
|
|
if (isMobileLayout) setMobilePanel('edit');
|
|
setMobileSidebarOpen(false);
|
|
if (type === 5) void seedSshDefaults();
|
|
}
|
|
|
|
function startEdit(): void {
|
|
if (!selectedCipher) return;
|
|
setDraft(draftFromCipher(selectedCipher));
|
|
setIsCreating(false);
|
|
setIsEditing(true);
|
|
setShowPassword(false);
|
|
setLocalError('');
|
|
setAttachmentQueue([]);
|
|
setRemovedAttachmentIds({});
|
|
if (isMobileLayout) setMobilePanel('edit');
|
|
setMobileSidebarOpen(false);
|
|
}
|
|
|
|
function cancelEdit(): void {
|
|
const returnToDetail = isMobileLayout && !isCreating && !!selectedCipher;
|
|
setDraft(null);
|
|
setIsEditing(false);
|
|
setIsCreating(false);
|
|
setLocalError('');
|
|
setAttachmentQueue([]);
|
|
setRemovedAttachmentIds({});
|
|
setPendingDeletePasskeyIndex(null);
|
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
|
}
|
|
|
|
function updateDraft(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');
|
|
} 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');
|
|
} 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);
|
|
} 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);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function syncVault(): Promise<void> {
|
|
setBusy(true);
|
|
try {
|
|
await props.onRefresh();
|
|
} 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('');
|
|
} 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);
|
|
} 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('');
|
|
} 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({});
|
|
} 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');
|
|
}
|
|
} 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;
|
|
});
|
|
} 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);
|
|
} 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({});
|
|
} 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);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
|
{isMobileLayout && (
|
|
<div
|
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
|
onClick={() => {
|
|
if (!mobileSidebarOpen) return;
|
|
setMobileSidebarOpen(false);
|
|
}}
|
|
/>
|
|
)}
|
|
<VaultSidebar
|
|
folders={props.folders}
|
|
sidebarFilter={sidebarFilter}
|
|
busy={busy}
|
|
isMobileLayout={isMobileLayout}
|
|
mobileSidebarOpen={mobileSidebarOpen}
|
|
onCloseMobileSidebar={() => setMobileSidebarOpen(false)}
|
|
onChangeFilter={setSidebarFilter}
|
|
onOpenDeleteAllFolders={() => setDeleteAllFoldersOpen(true)}
|
|
onOpenCreateFolder={() => setCreateFolderOpen(true)}
|
|
onOpenRenameFolder={(folder) => {
|
|
setPendingRenameFolder(folder);
|
|
setRenameFolderName(folder.decName || folder.name || '');
|
|
}}
|
|
onOpenDeleteFolder={setPendingDeleteFolder}
|
|
/>
|
|
|
|
<VaultListPanel
|
|
busy={busy}
|
|
loading={props.loading}
|
|
searchInput={searchInput}
|
|
sortMode={sortMode}
|
|
sortMenuOpen={sortMenuOpen}
|
|
selectedCount={selectedCount}
|
|
totalCipherCount={totalCipherCount}
|
|
filteredCiphers={filteredCiphers}
|
|
visibleCiphers={visibleCiphers}
|
|
virtualRange={virtualRange}
|
|
selectedCipherId={selectedCipherId}
|
|
selectedMap={selectedMap}
|
|
sidebarFilter={sidebarFilter}
|
|
createMenuOpen={createMenuOpen}
|
|
createMenuRef={createMenuRef}
|
|
sortMenuRef={sortMenuRef}
|
|
listPanelRef={listPanelRef}
|
|
onSearchInput={setSearchInput}
|
|
onClearSearch={() => setSearchInput('')}
|
|
onSearchCompositionStart={() => setSearchComposing(true)}
|
|
onSearchCompositionEnd={(value) => {
|
|
setSearchComposing(false);
|
|
setSearchInput(value);
|
|
}}
|
|
onToggleSortMenu={() => setSortMenuOpen((open) => !open)}
|
|
onSelectSortMode={(value) => {
|
|
setSortMode(value);
|
|
setSortMenuOpen(false);
|
|
}}
|
|
onSyncVault={() => void syncVault()}
|
|
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={() => {
|
|
const map: Record<string, boolean> = {};
|
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
|
setSelectedMap(map);
|
|
}}
|
|
onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)}
|
|
onStartCreate={startCreate}
|
|
onBulkRestore={() => void confirmBulkRestore()}
|
|
onBulkArchive={() => setBulkArchiveOpen(true)}
|
|
onBulkUnarchive={() => void confirmBulkUnarchive()}
|
|
onOpenMove={() => {
|
|
setMoveFolderId('__none__');
|
|
setMoveOpen(true);
|
|
}}
|
|
onClearSelection={() => setSelectedMap({})}
|
|
onScroll={setListScrollTop}
|
|
onToggleSelected={(cipherId, checked) =>
|
|
setSelectedMap((prev) => ({
|
|
...prev,
|
|
[cipherId]: checked,
|
|
}))
|
|
}
|
|
onSelectCipher={(cipherId) => {
|
|
if (isEditing || isCreating) {
|
|
cancelEdit();
|
|
}
|
|
setSelectedCipherId(cipherId);
|
|
setRepromptApprovedCipherId(null);
|
|
if (isMobileLayout) setMobilePanel('detail');
|
|
setMobileSidebarOpen(false);
|
|
}}
|
|
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}
|
|
onArchive={(cipher) => setPendingArchive(cipher)}
|
|
onUnarchive={(cipher) => void handleUnarchiveSelected(cipher)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!isEditing && !selectedCipher && <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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|