"use client" import type React from "react" import { useState, useEffect, useRef, useCallback } from "react" import { Plus, Camera, Upload, Settings, Copy, Trash2, Edit2, QrCode, Key, Clock, Search, ChevronDown, ChevronUp, Eye, EyeOff, Download, MoreVertical, Sun, Moon, Monitor, Languages, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Switch } from "@/components/ui/switch" import { Badge } from "@/components/ui/badge" import { Progress } from "@/components/ui/progress" import { Toaster } from "@/components/ui/toaster" import { useToast } from "@/hooks/use-toast" import { useTheme } from "@/components/theme-provider" import { useLanguage } from "@/lib/i18n" interface TOTPToken { id: string name: string issuer: string secret: string algorithm: "SHA1" | "SHA256" | "SHA512" digits: 6 | 8 period: number createdAt: number } interface AppSettings { showCodes: boolean autoRefresh: boolean compactMode: boolean sortBy: "name" | "issuer" | "recent" } // TOTP Generation functions function base32Decode(encoded: string): Uint8Array { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" const cleanedInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, "") let bits = "" for (const char of cleanedInput) { const val = alphabet.indexOf(char) if (val === -1) continue bits += val.toString(2).padStart(5, "0") } const bytes = new Uint8Array(Math.floor(bits.length / 8)) for (let i = 0; i < bytes.length; i++) { bytes[i] = Number.parseInt(bits.slice(i * 8, (i + 1) * 8), 2) } return bytes } async function hmacSha(algorithm: string, key: Uint8Array, message: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: algorithm }, false, ["sign"]) const signature = await crypto.subtle.sign("HMAC", cryptoKey, message) return new Uint8Array(signature) } async function generateTOTP( secret: string, algorithm: "SHA1" | "SHA256" | "SHA512" = "SHA1", digits: 6 | 8 = 6, period = 30, ): Promise { const key = base32Decode(secret) const time = Math.floor(Date.now() / 1000 / period) const timeBuffer = new ArrayBuffer(8) const timeView = new DataView(timeBuffer) timeView.setBigUint64(0, BigInt(time), false) const hashAlgorithm = algorithm === "SHA1" ? "SHA-1" : algorithm === "SHA256" ? "SHA-256" : "SHA-512" const hmac = await hmacSha(hashAlgorithm, key, new Uint8Array(timeBuffer)) const offset = hmac[hmac.length - 1] & 0x0f const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | (hmac[offset + 3] & 0xff) const otp = binary % Math.pow(10, digits) return otp.toString().padStart(digits, "0") } function parseOTPAuthURI(uri: string): Partial | null { try { const url = new URL(uri) if (url.protocol !== "otpauth:") return null if (url.host !== "totp") return null const path = decodeURIComponent(url.pathname.slice(1)) const params = url.searchParams let issuer = params.get("issuer") || "" let name = path if (path.includes(":")) { const [iss, n] = path.split(":") issuer = issuer || iss name = n } return { name, issuer, secret: params.get("secret") || "", algorithm: (params.get("algorithm")?.toUpperCase() as "SHA1" | "SHA256" | "SHA512") || "SHA1", digits: Number.parseInt(params.get("digits") || "6") as 6 | 8, period: Number.parseInt(params.get("period") || "30"), } } catch { return null } } export default function TwoFactorAuth() { const [tokens, setTokens] = useState([]) const [codes, setCodes] = useState>({}) const [timeLeft, setTimeLeft] = useState(30) const [searchQuery, setSearchQuery] = useState("") const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isCameraOpen, setIsCameraOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [editingToken, setEditingToken] = useState(null) const [showAdvanced, setShowAdvanced] = useState(false) const videoRef = useRef(null) const canvasRef = useRef(null) const fileInputRef = useRef(null) const streamRef = useRef(null) const { toast } = useToast() const { theme, setTheme } = useTheme() const { language, setLanguage, t } = useLanguage() const [newToken, setNewToken] = useState({ name: "", issuer: "", secret: "", algorithm: "SHA1" as "SHA1" | "SHA256" | "SHA512", digits: 6 as 6 | 8, period: 30, }) const [settings, setSettings] = useState({ showCodes: true, autoRefresh: true, compactMode: false, sortBy: "name", }) // Load tokens and settings from localStorage useEffect(() => { const savedTokens = localStorage.getItem("2fa-tokens") const savedSettings = localStorage.getItem("2fa-settings") if (savedTokens) { setTokens(JSON.parse(savedTokens)) } if (savedSettings) { setSettings(JSON.parse(savedSettings)) } }, []) // Save tokens to localStorage useEffect(() => { localStorage.setItem("2fa-tokens", JSON.stringify(tokens)) }, [tokens]) // Save settings to localStorage useEffect(() => { localStorage.setItem("2fa-settings", JSON.stringify(settings)) }, [settings]) // Generate codes for all tokens const generateAllCodes = useCallback(async () => { const newCodes: Record = {} for (const token of tokens) { try { newCodes[token.id] = await generateTOTP(token.secret, token.algorithm, token.digits, token.period) } catch { newCodes[token.id] = "Error" } } setCodes(newCodes) }, [tokens]) // Timer for TOTP refresh useEffect(() => { generateAllCodes() const interval = setInterval(() => { const now = Math.floor(Date.now() / 1000) const remaining = 30 - (now % 30) setTimeLeft(remaining) if (remaining === 30) { generateAllCodes() } }, 1000) return () => clearInterval(interval) }, [generateAllCodes]) const addToken = () => { if (!newToken.name || !newToken.secret) { toast({ title: t.error, description: t.fillRequired, variant: "destructive", }) return } const cleanSecret = newToken.secret.replace(/\s/g, "").toUpperCase() const token: TOTPToken = { id: crypto.randomUUID(), name: newToken.name, issuer: newToken.issuer, secret: cleanSecret, algorithm: newToken.algorithm, digits: newToken.digits, period: newToken.period, createdAt: Date.now(), } setTokens([...tokens, token]) setNewToken({ name: "", issuer: "", secret: "", algorithm: "SHA1", digits: 6, period: 30, }) setShowAdvanced(false) setIsAddDialogOpen(false) toast({ title: t.addSuccess, description: `${t.added} ${token.name}`, }) } const deleteToken = (id: string) => { const token = tokens.find((t) => t.id === id) setTokens(tokens.filter((t) => t.id !== id)) toast({ title: t.deleted, description: `${t.deleted} ${token?.name}`, }) } const updateToken = () => { if (!editingToken) return setTokens(tokens.map((tk) => (tk.id === editingToken.id ? editingToken : tk))) setEditingToken(null) toast({ title: t.updated, description: t.tokenUpdated, }) } const copyCode = (code: string, name: string) => { navigator.clipboard.writeText(code) toast({ title: t.copied, description: `${name} ${t.codeCopied}`, }) } // QR Code scanning from camera const startCamera = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" }, }) streamRef.current = stream if (videoRef.current) { videoRef.current.srcObject = stream videoRef.current.play() } setIsCameraOpen(true) scanQRCode() } catch (error) { toast({ title: t.cameraFailed, description: t.cameraPermission, variant: "destructive", }) } } const stopCamera = () => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()) streamRef.current = null } setIsCameraOpen(false) } const scanQRCode = async () => { if (!videoRef.current || !canvasRef.current) return const video = videoRef.current const canvas = canvasRef.current const ctx = canvas.getContext("2d") if (!ctx) return const scan = async () => { if (!isCameraOpen && !streamRef.current) return if (video.readyState === video.HAVE_ENOUGH_DATA) { canvas.width = video.videoWidth canvas.height = video.videoHeight ctx.drawImage(video, 0, 0, canvas.width, canvas.height) const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) // Simple QR detection - in production, use a library like jsQR // For now, we'll rely on image upload for QR codes } if (streamRef.current) { requestAnimationFrame(scan) } } scan() } // Handle image upload for QR scanning const handleImageUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return const img = new Image() img.crossOrigin = "anonymous" img.onload = async () => { const canvas = document.createElement("canvas") canvas.width = img.width canvas.height = img.height const ctx = canvas.getContext("2d") if (!ctx) return ctx.drawImage(img, 0, 0) toast({ title: t.imageUploaded, description: t.imageUploadedHint, }) } img.src = URL.createObjectURL(file) // Reset file input if (fileInputRef.current) { fileInputRef.current.value = "" } } // Handle URI paste const handleURIPaste = (uri: string) => { const parsed = parseOTPAuthURI(uri) if (parsed) { setNewToken({ name: parsed.name || "", issuer: parsed.issuer || "", secret: parsed.secret || "", algorithm: parsed.algorithm || "SHA1", digits: parsed.digits || 6, period: parsed.period || 30, }) toast({ title: t.parseSuccess, description: t.extractedInfo, }) } else { toast({ title: t.parseFailed, description: t.invalidUri, variant: "destructive", }) } } // Export tokens const exportTokens = () => { const data = JSON.stringify(tokens, null, 2) const blob = new Blob([data], { type: "application/json" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = "2fa-tokens-backup.json" a.click() URL.revokeObjectURL(url) toast({ title: t.exportSuccess, description: t.exportedJson, }) } // Import tokens const importTokens = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (e) => { try { const imported = JSON.parse(e.target?.result as string) if (Array.isArray(imported)) { setTokens([...tokens, ...imported]) toast({ title: t.importSuccess, description: `${t.added} ${imported.length} ${t.importedTokens}`, }) } } catch { toast({ title: t.importFailed, description: t.invalidFormat, variant: "destructive", }) } } reader.readAsText(file) } // Filter and sort tokens const filteredTokens = tokens .filter( (t) => t.name.toLowerCase().includes(searchQuery.toLowerCase()) || t.issuer.toLowerCase().includes(searchQuery.toLowerCase()), ) .sort((a, b) => { switch (settings.sortBy) { case "name": return a.name.localeCompare(b.name) case "issuer": return a.issuer.localeCompare(b.issuer) case "recent": return b.createdAt - a.createdAt default: return 0 } }) return (
{/* Header */}

{t.appName}

{t.appDescription}

setTheme("light")}> {t.themeLight} setTheme("dark")}> {t.themeDark} setTheme("system")}> {t.themeSystem} setLanguage("zh")}>中文 {language === "zh" && "✓"} setLanguage("en")}> English {language === "en" && "✓"} {t.settings} {t.settingsDesc}

{t.showCodesDesc}

setSettings({ ...settings, showCodes: checked })} />

{t.compactModeDesc}

setSettings({ ...settings, compactMode: checked })} />
{/* Main Content */}
{/* Timer Progress */}
{t.nextRefresh}
{timeLeft}s
{/* Search and Actions */}
setSearchQuery(e.target.value)} className="pl-10" />
{t.addNewToken} {t.addNewTokenDesc} {t.manualInput} {t.scanQr} {t.uploadImage}
handleURIPaste(e.target.value)} />

{t.otpauthUriHint}

setNewToken({ ...newToken, name: e.target.value })} />
setNewToken({ ...newToken, issuer: e.target.value })} />
setNewToken({ ...newToken, secret: e.target.value })} className="font-mono" />
{/* Advanced Settings */}
{showAdvanced && (
setNewToken({ ...newToken, period: Number.parseInt(e.target.value) || 30 }) } />
)}
{!isCameraOpen ? (
) : (
fileInputRef.current?.click()} >

{t.uploadHint}

{t.uploadFormats}

{/* Tokens List */} {filteredTokens.length === 0 ? (

{t.noTokens}

{t.noTokensHint}

) : (
{filteredTokens.map((token) => ( copyCode(codes[token.id], token.name)} onEdit={() => setEditingToken(token)} onDelete={() => deleteToken(token.id)} t={t} /> ))}
)}
{/* Edit Dialog */} !open && setEditingToken(null)}> {t.editToken} {editingToken && (
setEditingToken({ ...editingToken, name: e.target.value })} />
setEditingToken({ ...editingToken, issuer: e.target.value })} />
)}
{/* Footer */}
) } interface TokenCardProps { token: TOTPToken code: string timeLeft: number showCode: boolean compact: boolean onCopy: () => void onEdit: () => void onDelete: () => void t: Record } function TokenCard({ token, code, timeLeft, showCode, compact, onCopy, onEdit, onDelete, t }: TokenCardProps) { const [visible, setVisible] = useState(showCode) useEffect(() => { setVisible(showCode) }, [showCode]) const formattedCode = code.length === 6 ? `${code.slice(0, 3)} ${code.slice(3)}` : code.length === 8 ? `${code.slice(0, 4)} ${code.slice(4)}` : code if (compact) { return (
{token.name.charAt(0).toUpperCase()}

{token.name}

{token.issuer &&

{token.issuer}

}
{visible ? formattedCode : "••• •••"} {t.copy} {t.edit} {t.delete}
) } return (
{token.name.charAt(0).toUpperCase()}
{token.name} {token.issuer && {token.issuer}}
{t.copyCode} {t.edit} {t.delete}
{visible ? formattedCode : "••• •••"}
{timeLeft}s
{(token.algorithm !== "SHA1" || token.digits !== 6 || token.period !== 30) && (
{token.algorithm !== "SHA1" && ( {token.algorithm} )} {token.digits !== 6 && ( {token.digits} {t.digits8?.includes("digits") ? " digits" : "位"} )} {token.period !== 30 && ( {token.period}s )}
)}
) }