From 45f03875268d4215472ecd6911ef57af0baaf066 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 4 May 2026 01:44:27 +0800 Subject: [PATCH] feat: add TOTP QR code scanning functionality and related UI components --- webapp/src/components/vault/VaultEditor.tsx | 203 +++++++++++++++++++- webapp/src/lib/i18n/locales/en.ts | 10 + webapp/src/lib/i18n/locales/es.ts | 12 +- webapp/src/lib/i18n/locales/ru.ts | 10 + webapp/src/lib/i18n/locales/zh-CN.ts | 10 + webapp/src/lib/i18n/locales/zh-TW.ts | 10 + webapp/src/styles/forms.css | 23 +++ webapp/src/styles/responsive.css | 4 + webapp/src/styles/vault.css | 71 +++++++ webapp/src/vite-env.d.ts | 16 ++ 10 files changed, 366 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index 0c3b660..02e0acf 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -1,6 +1,8 @@ import type { JSX, RefObject } from 'preact'; -import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; +import { createPortal } from 'preact/compat'; +import { CheckCheck, Download, GripVertical, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { useEffect, useRef, useState } from 'preact/hooks'; +import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { closestCenter, DndContext, @@ -137,8 +139,16 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) { export default function VaultEditor(props: VaultEditorProps) { const createTypeOptions = getCreateTypeOptions(); const uriIdSeedRef = useRef(0); + const totpQrVideoRef = useRef(null); + const totpQrFileRef = useRef(null); + const totpQrStreamRef = useRef(null); + const totpQrFrameRef = useRef(null); const [uriItemIds, setUriItemIds] = useState([]); const [activeUriId, setActiveUriId] = useState(null); + const [totpQrOpen, setTotpQrOpen] = useState(false); + const [totpQrStatus, setTotpQrStatus] = useState(''); + const [totpQrBusy, setTotpQrBusy] = useState(false); + useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false)); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -155,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) { const createUriId = () => `login-uri-${uriIdSeedRef.current++}`; + 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 trimmed = value.trim(); + if (!trimmed) return false; + props.onUpdateDraft({ loginTotp: trimmed }); + 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 decodeTotpQrImage = async (source: ImageBitmapSource): Promise => { + const detector = createTotpQrDetector(); + if (!detector) { + setTotpQrStatus(t('txt_totp_qr_unsupported')); + return false; + } + const results = await detector.detect(source); + const value = String(results[0]?.rawValue || '').trim(); + if (!value) return false; + return applyTotpQrValue(value); + }; + + const handleTotpQrFile = async (file: File | null) => { + if (!file) 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(() => { setUriItemIds((prev) => { if (prev.length === props.draft.loginUris.length) return prev; @@ -170,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) { setActiveUriId(null); }, [props.draft.id, props.isCreating]); + useEffect(() => { + if (!totpQrOpen) { + stopTotpQrScanner(); + return; + } + let stopped = false; + const detector = createTotpQrDetector(); + if (!detector) { + setTotpQrStatus(t('txt_totp_qr_unsupported')); + return () => { + stopped = true; + stopTotpQrScanner(); + }; + } + 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 { + const results = await detector.detect(video); + const value = String(results[0]?.rawValue || '').trim(); + 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'); @@ -274,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {

{t('txt_websites')}

@@ -571,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) { )}
{props.localError &&
{props.localError}
} + {totpQrOpen && typeof document !== 'undefined' ? createPortal(( +
event.target === event.currentTarget && setTotpQrOpen(false)}> +
+
+

{t('txt_scan_totp_qr')}

+ +
+
+
+
+ ), document.body) : null} ); } diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index b685baf..43ad00f 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -697,6 +697,16 @@ const en: Record = { "txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.", "txt_total_items_count": "{count} items", "txt_totp_secret": "TOTP Secret", + "txt_scan_totp_qr": "Scan TOTP QR code", + "txt_totp_qr_starting_camera": "Starting camera...", + "txt_totp_qr_point_camera": "Point the camera at a TOTP QR code.", + "txt_totp_qr_scanning": "Scanning QR code...", + "txt_totp_qr_scanned": "TOTP value added.", + "txt_totp_qr_not_found": "No QR code found in that image.", + "txt_totp_qr_scan_failed": "Failed to scan QR code.", + "txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.", + "txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.", + "txt_totp_qr_choose_image": "Choose image", "txt_totp_verify_failed": "TOTP verify failed", "txt_attachments": "Attachments", "txt_upload_attachments": "Upload attachments", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index f981d7c..c2b8f5a 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -697,6 +697,16 @@ const es: Record = { "txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.", "txt_total_items_count": "{count} elementos", "txt_totp_secret": "Secreto TOTP", + "txt_scan_totp_qr": "Escanear QR TOTP", + "txt_totp_qr_starting_camera": "Iniciando cámara...", + "txt_totp_qr_point_camera": "Apunte la cámara a un código QR TOTP.", + "txt_totp_qr_scanning": "Escaneando código QR...", + "txt_totp_qr_scanned": "Valor TOTP agregado.", + "txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.", + "txt_totp_qr_scan_failed": "No se pudo escanear el código QR.", + "txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.", + "txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.", + "txt_totp_qr_choose_image": "Elegir imagen", "txt_totp_verify_failed": "Error al verificar TOTP", "txt_attachments": "Archivos adjuntos", "txt_upload_attachments": "Subir archivos adjuntos", @@ -845,4 +855,4 @@ const es: Record = { "txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez." }; -export default es; \ No newline at end of file +export default es; diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 1cf124b..79b5bc8 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -697,6 +697,16 @@ const ru: Record = { "txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.", "txt_total_items_count": "{count} товаров", "txt_totp_secret": "Секрет TOTP", + "txt_scan_totp_qr": "Сканировать QR TOTP", + "txt_totp_qr_starting_camera": "Запуск камеры...", + "txt_totp_qr_point_camera": "Наведите камеру на QR-код TOTP.", + "txt_totp_qr_scanning": "Сканирование QR-кода...", + "txt_totp_qr_scanned": "Значение TOTP добавлено.", + "txt_totp_qr_not_found": "QR-код на этом изображении не найден.", + "txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.", + "txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.", + "txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.", + "txt_totp_qr_choose_image": "Выбрать изображение", "txt_totp_verify_failed": "Проверка TOTP не удалась", "txt_attachments": "Вложения", "txt_upload_attachments": "Загрузить вложения", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index b66f63e..6f62000 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -697,6 +697,16 @@ const zhCN: Record = { "txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。", "txt_total_items_count": "共 {count} 项", "txt_totp_secret": "TOTP 密钥", + "txt_scan_totp_qr": "扫描 TOTP 二维码", + "txt_totp_qr_starting_camera": "正在启动摄像头...", + "txt_totp_qr_point_camera": "把摄像头对准 TOTP 二维码。", + "txt_totp_qr_scanning": "正在扫描二维码...", + "txt_totp_qr_scanned": "TOTP 内容已填入。", + "txt_totp_qr_not_found": "这张图片里没有识别到二维码。", + "txt_totp_qr_scan_failed": "二维码扫描失败。", + "txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。", + "txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。", + "txt_totp_qr_choose_image": "选择图片", "txt_totp_verify_failed": "TOTP 验证失败", "txt_attachments": "附件", "txt_upload_attachments": "上传附件", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index dc1edbc..7b7b673 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -697,6 +697,16 @@ const zhTW: Record = { "txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。", "txt_total_items_count": "共 {count} 項", "txt_totp_secret": "TOTP 密鑰", + "txt_scan_totp_qr": "掃描 TOTP 二維碼", + "txt_totp_qr_starting_camera": "正在啟動攝影機...", + "txt_totp_qr_point_camera": "把攝影機對準 TOTP 二維碼。", + "txt_totp_qr_scanning": "正在掃描二維碼...", + "txt_totp_qr_scanned": "TOTP 內容已填入。", + "txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。", + "txt_totp_qr_scan_failed": "二維碼掃描失敗。", + "txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。", + "txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。", + "txt_totp_qr_choose_image": "選擇圖片", "txt_totp_verify_failed": "TOTP 驗證失敗", "txt_attachments": "附件", "txt_upload_attachments": "上傳附件", diff --git a/webapp/src/styles/forms.css b/webapp/src/styles/forms.css index 6783409..ea2e7a8 100644 --- a/webapp/src/styles/forms.css +++ b/webapp/src/styles/forms.css @@ -74,6 +74,29 @@ input[type='file'].input::file-selector-button:hover { @apply pr-11; } +.input-action-wrap { + @apply relative; +} + +.input-action-wrap .input { + @apply pr-12; +} + +.input-icon-btn { + @apply absolute right-2 top-1/2 grid h-8 w-8 cursor-pointer place-items-center rounded-full border-0 bg-transparent text-slate-700 transition; + transform: translateY(-50%); +} + +.input-icon-btn:hover:not(:disabled) { + color: var(--primary); + background: rgba(37, 99, 235, 0.08); + transform: translateY(-50%) scale(1.04); +} + +.input-icon-btn:disabled { + @apply cursor-not-allowed text-slate-400; +} + .password-toggle { @apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition; transform: translateY(-50%); diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index ed0d881..e1b1126 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -501,6 +501,10 @@ height: 160px; } + .totp-scan-actions { + grid-template-columns: 1fr; + } + .invite-toolbar { align-items: stretch; } diff --git a/webapp/src/styles/vault.css b/webapp/src/styles/vault.css index 512c645..6aaafdd 100644 --- a/webapp/src/styles/vault.css +++ b/webapp/src/styles/vault.css @@ -663,6 +663,77 @@ color: #0f172a; } +.dialog-mask.totp-scan-mask { + @apply block p-0; + background: #0f172a; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.dialog-card.totp-scan-dialog { + @apply flex h-dvh w-screen max-w-none flex-col overflow-hidden rounded-none border-0 p-0 text-left; + max-height: 100dvh; + background: #0f172a; + color: #f8fafc; + box-shadow: none; +} + +.totp-scan-head { + @apply flex shrink-0 items-center justify-between gap-3 px-4 py-3; + padding-top: calc(12px + env(safe-area-inset-top)); +} + +.totp-scan-head .dialog-title { + @apply m-0 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xl; + color: #f8fafc; +} + +.totp-scan-close { + @apply grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-full border-0 bg-white/10 text-white transition; +} + +.totp-scan-close:hover { + background: rgba(255, 255, 255, 0.18); + transform: scale(1.04); +} + +.totp-scan-frame { + @apply relative min-h-0 flex-1 overflow-hidden rounded-none; + aspect-ratio: auto; + background: #0f172a; +} + +.totp-scan-video { + @apply h-full w-full object-cover; +} + +.totp-scan-corners { + @apply pointer-events-none absolute rounded-[18px]; + inset: max(34px, 10vmin); + border: 2px solid rgba(255, 255, 255, 0.88); + box-shadow: 0 0 0 999px rgba(15, 23, 42, 0.28); +} + +.totp-scan-footer { + @apply shrink-0 px-4 py-3; + padding-bottom: calc(12px + env(safe-area-inset-bottom)); + background: linear-gradient(180deg, rgba(15, 23, 42, 0.74), #0f172a); +} + +.totp-scan-status { + @apply mb-3 min-h-6 text-center text-sm; + color: rgba(248, 250, 252, 0.86); +} + +.totp-scan-actions { + @apply grid gap-2; + grid-template-columns: 1fr 1fr; +} + +.totp-scan-actions .dialog-btn { + @apply mt-0 text-base; +} + .totp-codes-page { @apply flex min-h-full flex-col; } diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts index 049a086..820c235 100644 --- a/webapp/src/vite-env.d.ts +++ b/webapp/src/vite-env.d.ts @@ -8,3 +8,19 @@ declare module 'qrcode-generator' { } export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode; } + +interface BarcodeDetectorResult { + rawValue: string; +} + +interface BarcodeDetector { + detect(image: ImageBitmapSource): Promise; +} + +interface BarcodeDetectorConstructor { + new (options?: { formats?: string[] }): BarcodeDetector; +} + +interface Window { + BarcodeDetector?: BarcodeDetectorConstructor; +}