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>
This commit is contained in:
v0
2026-01-15 17:21:46 +00:00
parent 16d51ea92c
commit 08a37a72e7
4 changed files with 95 additions and 9 deletions

View File

@@ -3,6 +3,7 @@
import type React from "react" import type React from "react"
import { useState, useEffect, useRef, useCallback } from "react" import { useState, useEffect, useRef, useCallback } from "react"
import jsQR from "jsqr"
import { import {
Plus, Plus,
Camera, Camera,
@@ -337,7 +338,7 @@ export default function TwoFactorAuth() {
setIsCameraOpen(false) setIsCameraOpen(false)
} }
const scanQRCode = async () => { const scanQRCode = () => {
if (!videoRef.current || !canvasRef.current) return if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current const video = videoRef.current
@@ -346,8 +347,8 @@ export default function TwoFactorAuth() {
if (!ctx) return if (!ctx) return
const scan = async () => { const scan = () => {
if (!isCameraOpen && !streamRef.current) return if (!streamRef.current) return
if (video.readyState === video.HAVE_ENOUGH_DATA) { if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth canvas.width = video.videoWidth
@@ -355,9 +356,31 @@ export default function TwoFactorAuth() {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height) ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(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 if (code && code.data) {
// For now, we'll rely on image upload for QR codes // 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) { 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 // Handle image upload for QR scanning
@@ -375,7 +403,7 @@ export default function TwoFactorAuth() {
const img = new Image() const img = new Image()
img.crossOrigin = "anonymous" img.crossOrigin = "anonymous"
img.onload = async () => { img.onload = () => {
const canvas = document.createElement("canvas") const canvas = document.createElement("canvas")
canvas.width = img.width canvas.width = img.width
canvas.height = img.height canvas.height = img.height
@@ -383,10 +411,47 @@ export default function TwoFactorAuth() {
if (!ctx) return if (!ctx) return
ctx.drawImage(img, 0, 0) 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({ toast({
title: t.imageUploaded, title: t.error,
description: t.imageUploadedHint, description: t.imageLoadFailed,
variant: "destructive",
}) })
} }
img.src = URL.createObjectURL(file) img.src = URL.createObjectURL(file)

View File

@@ -108,6 +108,12 @@ const translations = {
importedTokens: "个令牌", importedTokens: "个令牌",
importFailed: "导入失败", importFailed: "导入失败",
invalidFormat: "无效的文件格式", invalidFormat: "无效的文件格式",
scanSuccess: "扫描成功",
qrCodeDetected: "已识别二维码,信息已自动填充",
scanFailed: "扫描失败",
noQrCodeFound: "未能在图片中识别到二维码",
invalidQrCode: "二维码不是有效的 TOTP 格式",
imageLoadFailed: "图片加载失败",
}, },
en: { en: {
// Header // Header
@@ -211,6 +217,12 @@ const translations = {
importedTokens: "tokens", importedTokens: "tokens",
importFailed: "Import failed", importFailed: "Import failed",
invalidFormat: "Invalid file format", 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",
}, },
} }

View File

@@ -45,6 +45,7 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"embla-carousel-react": "8.5.1", "embla-carousel-react": "8.5.1",
"input-otp": "1.4.1", "input-otp": "1.4.1",
"jsqr": "1.4.0",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "16.0.10", "next": "16.0.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",

8
pnpm-lock.yaml generated
View File

@@ -116,6 +116,9 @@ importers:
input-otp: input-otp:
specifier: 1.4.1 specifier: 1.4.1
version: 1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) 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: lucide-react:
specifier: ^0.454.0 specifier: ^0.454.0
version: 0.454.0(react@19.2.0) version: 0.454.0(react@19.2.0)
@@ -1390,6 +1393,9 @@ packages:
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsqr@1.4.0:
resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2879,6 +2885,8 @@ snapshots:
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
jsqr@1.4.0: {}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
optional: true optional: true