From 08a37a72e73fc095f6ed036d6f96c99d9e76e88d Mon Sep 17 00:00:00 2001 From: v0 Date: Thu, 15 Jan 2026 17:21:46 +0000 Subject: [PATCH] feat: implement QR code scanning with jsQR Add jsQR for QR detection and update translations for scanning feedback Co-authored-by: Simon <85533298+handsomezhuzhu@users.noreply.github.com> --- app/page.tsx | 83 ++++++++++++++++++++++++++++++++++++++++++++------ lib/i18n.tsx | 12 ++++++++ package.json | 1 + pnpm-lock.yaml | 8 +++++ 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index cd5f743..83beff0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import type React from "react" import { useState, useEffect, useRef, useCallback } from "react" +import jsQR from "jsqr" import { Plus, Camera, @@ -337,7 +338,7 @@ export default function TwoFactorAuth() { setIsCameraOpen(false) } - const scanQRCode = async () => { + const scanQRCode = () => { if (!videoRef.current || !canvasRef.current) return const video = videoRef.current @@ -346,8 +347,8 @@ export default function TwoFactorAuth() { if (!ctx) return - const scan = async () => { - if (!isCameraOpen && !streamRef.current) return + const scan = () => { + if (!streamRef.current) return if (video.readyState === video.HAVE_ENOUGH_DATA) { canvas.width = video.videoWidth @@ -355,9 +356,31 @@ export default function TwoFactorAuth() { ctx.drawImage(video, 0, 0, canvas.width, canvas.height) const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "dontInvert", + }) - // Simple QR detection - in production, use a library like jsQR - // For now, we'll rely on image upload for QR codes + if (code && code.data) { + // Found QR code + const parsed = parseOTPAuthURI(code.data) + if (parsed) { + stopCamera() + setNewToken({ + name: parsed.name || "", + issuer: parsed.issuer || "", + secret: parsed.secret || "", + algorithm: parsed.algorithm || "SHA1", + digits: parsed.digits || 6, + period: parsed.period || 30, + }) + setIsAddDialogOpen(true) + toast({ + title: t.scanSuccess, + description: t.qrCodeDetected, + }) + return + } + } } if (streamRef.current) { @@ -365,7 +388,12 @@ export default function TwoFactorAuth() { } } - scan() + // Wait for video to be ready before scanning + if (video.readyState >= video.HAVE_ENOUGH_DATA) { + scan() + } else { + video.addEventListener("loadeddata", scan, { once: true }) + } } // Handle image upload for QR scanning @@ -375,7 +403,7 @@ export default function TwoFactorAuth() { const img = new Image() img.crossOrigin = "anonymous" - img.onload = async () => { + img.onload = () => { const canvas = document.createElement("canvas") canvas.width = img.width canvas.height = img.height @@ -383,10 +411,47 @@ export default function TwoFactorAuth() { if (!ctx) return ctx.drawImage(img, 0, 0) + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "attemptBoth", + }) + if (code && code.data) { + const parsed = parseOTPAuthURI(code.data) + if (parsed) { + setNewToken({ + name: parsed.name || "", + issuer: parsed.issuer || "", + secret: parsed.secret || "", + algorithm: parsed.algorithm || "SHA1", + digits: parsed.digits || 6, + period: parsed.period || 30, + }) + setIsAddDialogOpen(true) + toast({ + title: t.scanSuccess, + description: t.qrCodeDetected, + }) + } else { + toast({ + title: t.parseFailed, + description: t.invalidQrCode, + variant: "destructive", + }) + } + } else { + toast({ + title: t.scanFailed, + description: t.noQrCodeFound, + variant: "destructive", + }) + } + } + img.onerror = () => { toast({ - title: t.imageUploaded, - description: t.imageUploadedHint, + title: t.error, + description: t.imageLoadFailed, + variant: "destructive", }) } img.src = URL.createObjectURL(file) diff --git a/lib/i18n.tsx b/lib/i18n.tsx index ac394ba..8775a3b 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -108,6 +108,12 @@ const translations = { importedTokens: "个令牌", importFailed: "导入失败", invalidFormat: "无效的文件格式", + scanSuccess: "扫描成功", + qrCodeDetected: "已识别二维码,信息已自动填充", + scanFailed: "扫描失败", + noQrCodeFound: "未能在图片中识别到二维码", + invalidQrCode: "二维码不是有效的 TOTP 格式", + imageLoadFailed: "图片加载失败", }, en: { // Header @@ -211,6 +217,12 @@ const translations = { importedTokens: "tokens", importFailed: "Import failed", invalidFormat: "Invalid file format", + scanSuccess: "Scan successful", + qrCodeDetected: "QR code detected, info auto-filled", + scanFailed: "Scan failed", + noQrCodeFound: "Could not find QR code in image", + invalidQrCode: "QR code is not a valid TOTP format", + imageLoadFailed: "Failed to load image", }, } diff --git a/package.json b/package.json index 60cce49..83ce636 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "date-fns": "4.1.0", "embla-carousel-react": "8.5.1", "input-otp": "1.4.1", + "jsqr": "1.4.0", "lucide-react": "^0.454.0", "next": "16.0.10", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 213f3e0..4974cc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: input-otp: specifier: 1.4.1 version: 1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + jsqr: + specifier: 1.4.0 + version: 1.4.0 lucide-react: specifier: ^0.454.0 version: 0.454.0(react@19.2.0) @@ -1390,6 +1393,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -2879,6 +2885,8 @@ snapshots: js-tokens@4.0.0: {} + jsqr@1.4.0: {} + lightningcss-darwin-arm64@1.30.1: optional: true