import type { RefObject } from 'preact'; import { createPortal } from 'preact/compat'; import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import jsQR from 'jsqr'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { normalizeTotpInput } from '@/lib/crypto'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; import { cardBrand } from '@/lib/import-format-shared'; import { CARD_BRAND_OPTIONS, CardBrandIcon, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, formatHistoryTime, getCreateTypeOptions, getWebsiteMatchOptions, normalizeCardBrand, toBooleanFieldValue, } from '@/components/vault/vault-page-helpers'; interface VaultEditorProps { draft: VaultDraft; isCreating: boolean; busy: boolean; folders: Folder[]; selectedCipher: Cipher | null; editExistingAttachments: Array; removedAttachmentIds: Record; removedAttachmentCount: number; attachmentQueue: File[]; attachmentInputRef: RefObject; localError: string; downloadingAttachmentKey: string; attachmentDownloadPercent: number | null; uploadingAttachmentName: string; attachmentUploadPercent: number | null; onUpdateDraft: (patch: Partial) => void; onSeedSshDefaults: (force?: boolean) => void; onUpdateSshPublicKey: (value: string) => void; onUpdateDraftLoginUri: (index: number, value: string) => void; onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void; onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void; onRequestDeleteLoginPasskey: (index: number) => void; onQueueAttachmentFiles: (list: FileList | null) => void; onToggleExistingAttachmentRemoval: (attachmentId: string) => void; onRemoveQueuedAttachment: (index: number) => void; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void; onPatchDraftCustomField: (index: number, patch: Partial) => void; onUpdateDraftCustomFields: (fields: VaultDraftField[]) => void; onOpenFieldModal: () => void; onSave: () => void; onCancel: () => void; onDeleteSelected: () => void; } interface WebsiteRowProps { uriEntry: VaultDraft['loginUris'][number]; index: number; canRemove: boolean; canMoveUp: boolean; canMoveDown: boolean; onUpdateUri: (index: number, value: string) => void; onUpdateMatch: (index: number, value: number | null) => void; onMove: (fromIndex: number, toIndex: number) => void; onRemove: (index: number) => void; } const TOTP_QR_IMAGE_MAX_BYTES = 8 * 1024 * 1024; function WebsiteRow(props: WebsiteRowProps) { const websiteMatchOptions = getWebsiteMatchOptions(); return (
props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)} /> {props.canRemove && ( )}
); } export default function VaultEditor(props: VaultEditorProps) { const createTypeOptions = getCreateTypeOptions(); const normalizedDraftCardBrand = normalizeCardBrand(props.draft.cardBrand); const cardBrandOptions = normalizedDraftCardBrand && !CARD_BRAND_OPTIONS.includes(normalizedDraftCardBrand as any) ? [...CARD_BRAND_OPTIONS, normalizedDraftCardBrand] : CARD_BRAND_OPTIONS; const totpQrVideoRef = useRef(null); const totpQrFileRef = useRef(null); const totpQrStreamRef = useRef(null); const totpQrFrameRef = useRef(null); const [totpQrOpen, setTotpQrOpen] = useState(false); const [totpQrStatus, setTotpQrStatus] = useState(''); const [totpQrBusy, setTotpQrBusy] = useState(false); useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false)); const stopTotpQrScanner = () => { if (totpQrFrameRef.current != null) { window.cancelAnimationFrame(totpQrFrameRef.current); totpQrFrameRef.current = null; } if (totpQrStreamRef.current) { for (const track of totpQrStreamRef.current.getTracks()) track.stop(); totpQrStreamRef.current = null; } if (totpQrVideoRef.current) { totpQrVideoRef.current.srcObject = null; } }; const applyTotpQrValue = (value: string) => { const normalized = normalizeTotpInput(value); if (!normalized) return false; props.onUpdateDraft({ loginTotp: normalized }); setTotpQrStatus(t('txt_totp_qr_scanned')); setTotpQrOpen(false); return true; }; const createTotpQrDetector = (): BarcodeDetector | null => { if (typeof window === 'undefined' || !window.BarcodeDetector) return null; return new window.BarcodeDetector({ formats: ['qr_code'] }); }; const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => { const width = 'videoWidth' in source ? source.videoWidth : source.width; const height = 'videoHeight' in source ? source.videoHeight : source.height; if (!width || !height) return ''; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const context = canvas.getContext('2d'); if (!context) return ''; // jsQR ignores alpha and reads RGB directly, so transparent pixels would be // treated as black. Composite over white first so transparent-background QR // exports do not become black-on-black and fail to decode. context.fillStyle = '#ffffff'; context.fillRect(0, 0, width, height); context.drawImage(source, 0, 0, width, height); const imageData = context.getImageData(0, 0, width, height); return String(jsQR(imageData.data, width, height)?.data || '').trim(); }; const decodeTotpQrImage = async (source: ImageBitmap): Promise => { const detector = createTotpQrDetector(); if (detector) { try { const results = await detector.detect(source); const value = String(results[0]?.rawValue || '').trim(); if (value && applyTotpQrValue(value)) return true; } catch { // Fall back to jsQR when the native detector is present but not usable. } } const value = decodeTotpQrCanvas(source); return value ? applyTotpQrValue(value) : false; }; const handleTotpQrFile = async (file: File | null) => { if (!file) return; if (file.type && !file.type.startsWith('image/')) { setTotpQrStatus(t('txt_totp_qr_invalid_image_type')); return; } if (file.size > TOTP_QR_IMAGE_MAX_BYTES) { setTotpQrStatus(t('txt_totp_qr_image_too_large')); return; } setTotpQrBusy(true); setTotpQrStatus(t('txt_totp_qr_scanning')); let bitmap: ImageBitmap | null = null; try { bitmap = await createImageBitmap(file); const found = await decodeTotpQrImage(bitmap); if (!found) setTotpQrStatus(t('txt_totp_qr_not_found')); } catch { setTotpQrStatus(t('txt_totp_qr_scan_failed')); } finally { bitmap?.close(); setTotpQrBusy(false); } }; useEffect(() => { if (!totpQrOpen) { stopTotpQrScanner(); return; } let stopped = false; let lastCanvasScan = 0; const detector = createTotpQrDetector(); if (!navigator.mediaDevices?.getUserMedia) { setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); return () => { stopped = true; stopTotpQrScanner(); }; } const scan = async () => { if (stopped) return; const video = totpQrVideoRef.current; if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { totpQrFrameRef.current = window.requestAnimationFrame(scan); return; } try { let value = ''; if (detector) { try { const results = await detector.detect(video); value = String(results[0]?.rawValue || '').trim(); } catch { // Fall back to jsQR when the native detector is present but not usable. } } // The jsQR fallback runs a synchronous full-frame decode, so throttle // it to a few times per second instead of every animation frame to // avoid pegging the CPU while a code is being aligned. if (!value) { const now = performance.now(); if (now - lastCanvasScan >= 250) { lastCanvasScan = now; value = decodeTotpQrCanvas(video); } } if (value && applyTotpQrValue(value)) return; } catch { // Keep the camera active; transient frame decode failures are common. } totpQrFrameRef.current = window.requestAnimationFrame(scan); }; setTotpQrBusy(true); setTotpQrStatus(t('txt_totp_qr_starting_camera')); navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }) .then((stream) => { if (stopped) { for (const track of stream.getTracks()) track.stop(); return; } totpQrStreamRef.current = stream; const video = totpQrVideoRef.current; if (!video) return; video.srcObject = stream; setTotpQrStatus(t('txt_totp_qr_point_camera')); void video.play().then(() => { setTotpQrBusy(false); totpQrFrameRef.current = window.requestAnimationFrame(scan); }).catch(() => { setTotpQrBusy(false); setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); }); }) .catch(() => { setTotpQrBusy(false); setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); }); return () => { stopped = true; stopTotpQrScanner(); }; }, [totpQrOpen]); const formatDownloadLabel = (attachmentId: string) => { const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`; if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); return props.attachmentDownloadPercent == null ? t('txt_downloading') : t('txt_downloading_percent', { percent: props.attachmentDownloadPercent }); }; const uploadLabel = props.attachmentUploadPercent == null ? t('txt_uploading_attachment_named', { name: props.uploadingAttachmentName || t('txt_attachment') }) : t('txt_uploading_attachment_named_percent', { name: props.uploadingAttachmentName || t('txt_attachment'), percent: props.attachmentUploadPercent, }); const addLoginUri = () => { props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] }); }; const removeLoginUri = (index: number) => { props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) }); }; const moveLoginUri = (fromIndex: number, toIndex: number) => { if (fromIndex < 0 || toIndex < 0 || fromIndex >= props.draft.loginUris.length || toIndex >= props.draft.loginUris.length || fromIndex === toIndex) return; props.onReorderDraftLoginUri(fromIndex, toIndex); }; return ( <>

{props.isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(props.draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(props.draft.type) })}

{props.draft.type === 1 && (

{t('txt_login_credentials')}

{t('txt_websites')}

{props.draft.loginUris.map((uriEntry, index) => ( 0} canMoveDown={index < props.draft.loginUris.length - 1} canRemove={props.draft.loginUris.length > 1} onUpdateUri={props.onUpdateDraftLoginUri} onUpdateMatch={props.onUpdateDraftLoginUriMatch} onMove={moveLoginUri} onRemove={removeLoginUri} /> ))} {props.draft.loginFido2Credentials.length > 0 && ( <>

{t('txt_passkeys')}

{props.draft.loginFido2Credentials.map((credential, index) => { const createdAt = String(credential?.creationDate || '').trim(); const label = createdAt ? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) }) : t('txt_passkey'); return (
{t('txt_passkey')} {label}
); })}
)}
)} {props.draft.type === 3 && (

{t('txt_card_details')}

)} {props.draft.type === 4 && (

{t('txt_identity_details')}

)} {props.draft.type === 5 && (

{t('txt_ssh_key')}