mirror of
https://github.com/handsomezhuzhu/2fa-tool.git
synced 2026-02-20 19:50:15 +00:00
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:
81
app/page.tsx
81
app/page.tsx
@@ -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() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for video to be ready before scanning
|
||||||
|
if (video.readyState >= video.HAVE_ENOUGH_DATA) {
|
||||||
scan()
|
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({
|
toast({
|
||||||
title: t.imageUploaded,
|
title: t.scanSuccess,
|
||||||
description: t.imageUploadedHint,
|
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.error,
|
||||||
|
description: t.imageLoadFailed,
|
||||||
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
img.src = URL.createObjectURL(file)
|
img.src = URL.createObjectURL(file)
|
||||||
|
|||||||
12
lib/i18n.tsx
12
lib/i18n.tsx
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user