feat: add TOTP QR code scanning functionality and related UI components

This commit is contained in:
shuaiplus
2026-05-04 01:44:27 +08:00
parent 851c9c4080
commit 45f0387526
10 changed files with 366 additions and 3 deletions
+201 -2
View File
@@ -1,6 +1,8 @@
import type { JSX, RefObject } from 'preact'; 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 { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog';
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -137,8 +139,16 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
export default function VaultEditor(props: VaultEditorProps) { export default function VaultEditor(props: VaultEditorProps) {
const createTypeOptions = getCreateTypeOptions(); const createTypeOptions = getCreateTypeOptions();
const uriIdSeedRef = useRef(0); const uriIdSeedRef = useRef(0);
const totpQrVideoRef = useRef<HTMLVideoElement | null>(null);
const totpQrFileRef = useRef<HTMLInputElement | null>(null);
const totpQrStreamRef = useRef<MediaStream | null>(null);
const totpQrFrameRef = useRef<number | null>(null);
const [uriItemIds, setUriItemIds] = useState<string[]>([]); const [uriItemIds, setUriItemIds] = useState<string[]>([]);
const [activeUriId, setActiveUriId] = useState<string | null>(null); const [activeUriId, setActiveUriId] = useState<string | null>(null);
const [totpQrOpen, setTotpQrOpen] = useState(false);
const [totpQrStatus, setTotpQrStatus] = useState('');
const [totpQrBusy, setTotpQrBusy] = useState(false);
useDialogLifecycle(totpQrOpen, () => setTotpQrOpen(false));
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@@ -155,6 +165,63 @@ export default function VaultEditor(props: VaultEditorProps) {
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`; 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<boolean> => {
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(() => { useEffect(() => {
setUriItemIds((prev) => { setUriItemIds((prev) => {
if (prev.length === props.draft.loginUris.length) return prev; if (prev.length === props.draft.loginUris.length) return prev;
@@ -170,6 +237,77 @@ export default function VaultEditor(props: VaultEditorProps) {
setActiveUriId(null); setActiveUriId(null);
}, [props.draft.id, props.isCreating]); }, [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 formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`; const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download'); if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
@@ -274,7 +412,22 @@ export default function VaultEditor(props: VaultEditorProps) {
</div> </div>
<label className="field"> <label className="field">
<span>{t('txt_totp_secret')}</span> <span>{t('txt_totp_secret')}</span>
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} /> <div className="input-action-wrap">
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
<button
type="button"
className="input-icon-btn"
title={t('txt_scan_totp_qr')}
aria-label={t('txt_scan_totp_qr')}
disabled={props.busy}
onClick={() => {
setTotpQrStatus('');
setTotpQrOpen(true);
}}
>
<QrCode size={18} className="btn-icon" />
</button>
</div>
</label> </label>
<div className="section-head"> <div className="section-head">
<h4>{t('txt_websites')}</h4> <h4>{t('txt_websites')}</h4>
@@ -571,6 +724,52 @@ export default function VaultEditor(props: VaultEditorProps) {
)} )}
</div> </div>
{props.localError && <div className="local-error">{props.localError}</div>} {props.localError && <div className="local-error">{props.localError}</div>}
{totpQrOpen && typeof document !== 'undefined' ? createPortal((
<div className="dialog-mask totp-scan-mask open" onClick={(event) => event.target === event.currentTarget && setTotpQrOpen(false)}>
<section className="dialog-card totp-scan-dialog open" role="dialog" aria-modal="true" aria-label={t('txt_scan_totp_qr')}>
<div className="totp-scan-head">
<h3 className="dialog-title">{t('txt_scan_totp_qr')}</h3>
<button
type="button"
className="totp-scan-close"
onClick={() => setTotpQrOpen(false)}
title={t('txt_close')}
aria-label={t('txt_close')}
>
<X size={20} className="btn-icon" />
</button>
</div>
<div className="totp-scan-frame">
<video ref={totpQrVideoRef} className="totp-scan-video" muted playsInline />
<div className="totp-scan-corners" aria-hidden="true" />
</div>
<div className="totp-scan-footer">
<div className="dialog-message totp-scan-status">{totpQrStatus || t('txt_totp_qr_point_camera')}</div>
<div className="actions totp-scan-actions">
<button type="button" className="btn btn-secondary dialog-btn" disabled={totpQrBusy} onClick={() => totpQrFileRef.current?.click()}>
<Upload size={14} className="btn-icon" />
{t('txt_totp_qr_choose_image')}
</button>
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setTotpQrOpen(false)}>
<X size={14} className="btn-icon" />
{t('txt_close')}
</button>
</div>
</div>
<input
ref={totpQrFileRef}
type="file"
accept="image/*"
className="attachment-file-input"
onChange={(event) => {
const input = event.currentTarget as HTMLInputElement;
void handleTotpQrFile(input.files?.[0] || null);
input.value = '';
}}
/>
</section>
</div>
), document.body) : null}
</> </>
); );
} }
+10
View File
@@ -697,6 +697,16 @@ const en: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.", "txt_totp_is_enabled_for_this_account": "TOTP is enabled for this account.",
"txt_total_items_count": "{count} items", "txt_total_items_count": "{count} items",
"txt_totp_secret": "TOTP Secret", "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_totp_verify_failed": "TOTP verify failed",
"txt_attachments": "Attachments", "txt_attachments": "Attachments",
"txt_upload_attachments": "Upload attachments", "txt_upload_attachments": "Upload attachments",
+11 -1
View File
@@ -697,6 +697,16 @@ const es: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.", "txt_totp_is_enabled_for_this_account": "TOTP está activado para esta cuenta.",
"txt_total_items_count": "{count} elementos", "txt_total_items_count": "{count} elementos",
"txt_totp_secret": "Secreto TOTP", "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_totp_verify_failed": "Error al verificar TOTP",
"txt_attachments": "Archivos adjuntos", "txt_attachments": "Archivos adjuntos",
"txt_upload_attachments": "Subir archivos adjuntos", "txt_upload_attachments": "Subir archivos adjuntos",
@@ -845,4 +855,4 @@ const es: Record<string, string> = {
"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." "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; export default es;
+10
View File
@@ -697,6 +697,16 @@ const ru: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.", "txt_totp_is_enabled_for_this_account": "TOTP включен для этой учетной записи.",
"txt_total_items_count": "{count} товаров", "txt_total_items_count": "{count} товаров",
"txt_totp_secret": "Секрет TOTP", "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_totp_verify_failed": "Проверка TOTP не удалась",
"txt_attachments": "Вложения", "txt_attachments": "Вложения",
"txt_upload_attachments": "Загрузить вложения", "txt_upload_attachments": "Загрузить вложения",
+10
View File
@@ -697,6 +697,16 @@ const zhCN: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。", "txt_totp_is_enabled_for_this_account": "此账户已启用 TOTP。",
"txt_total_items_count": "共 {count} 项", "txt_total_items_count": "共 {count} 项",
"txt_totp_secret": "TOTP 密钥", "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_totp_verify_failed": "TOTP 验证失败",
"txt_attachments": "附件", "txt_attachments": "附件",
"txt_upload_attachments": "上传附件", "txt_upload_attachments": "上传附件",
+10
View File
@@ -697,6 +697,16 @@ const zhTW: Record<string, string> = {
"txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。", "txt_totp_is_enabled_for_this_account": "此賬戶已啟用 TOTP。",
"txt_total_items_count": "共 {count} 項", "txt_total_items_count": "共 {count} 項",
"txt_totp_secret": "TOTP 密鑰", "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_totp_verify_failed": "TOTP 驗證失敗",
"txt_attachments": "附件", "txt_attachments": "附件",
"txt_upload_attachments": "上傳附件", "txt_upload_attachments": "上傳附件",
+23
View File
@@ -74,6 +74,29 @@ input[type='file'].input::file-selector-button:hover {
@apply pr-11; @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 { .password-toggle {
@apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition; @apply absolute right-2 top-1/2 grid cursor-pointer place-items-center border-0 bg-transparent text-blue-700 transition;
transform: translateY(-50%); transform: translateY(-50%);
+4
View File
@@ -501,6 +501,10 @@
height: 160px; height: 160px;
} }
.totp-scan-actions {
grid-template-columns: 1fr;
}
.invite-toolbar { .invite-toolbar {
align-items: stretch; align-items: stretch;
} }
+71
View File
@@ -663,6 +663,77 @@
color: #0f172a; 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 { .totp-codes-page {
@apply flex min-h-full flex-col; @apply flex min-h-full flex-col;
} }
+16
View File
@@ -8,3 +8,19 @@ declare module 'qrcode-generator' {
} }
export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode; export default function qrcode(typeNumber: number, errorCorrectionLevel: 'L' | 'M' | 'Q' | 'H'): QrCode;
} }
interface BarcodeDetectorResult {
rawValue: string;
}
interface BarcodeDetector {
detect(image: ImageBitmapSource): Promise<BarcodeDetectorResult[]>;
}
interface BarcodeDetectorConstructor {
new (options?: { formats?: string[] }): BarcodeDetector;
}
interface Window {
BarcodeDetector?: BarcodeDetectorConstructor;
}