mirror of
https://github.com/handsomezhuzhu/2fa-tool.git
synced 2026-02-20 11:43:19 +00:00
feat: add duplicate detection in token functions
Add duplicate check logic to `addToken` and `addTokenDirectly` functions. #VERCEL_SKIP Co-authored-by: Simon <85533298+handsomezhuzhu@users.noreply.github.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,4 +24,4 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
||||
@@ -27,4 +27,4 @@ Continue building your app on:
|
||||
1. Create and modify your project using [v0.app](https://v0.app)
|
||||
2. Deploy your chats from the v0 interface
|
||||
3. Changes are automatically pushed to this repository
|
||||
4. Vercel deploys the latest version from this repository
|
||||
4. Vercel deploys the latest version from this repository
|
||||
|
||||
332
app/page.tsx
332
app/page.tsx
@@ -31,7 +31,7 @@ import {
|
||||
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 { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -45,7 +45,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
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"
|
||||
@@ -66,7 +65,6 @@ interface TOTPToken {
|
||||
interface AppSettings {
|
||||
showCodes: boolean
|
||||
autoRefresh: boolean
|
||||
compactMode: boolean
|
||||
sortBy: "name" | "issuer" | "recent"
|
||||
}
|
||||
|
||||
@@ -184,7 +182,6 @@ export default function TwoFactorAuth() {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
showCodes: true,
|
||||
autoRefresh: true,
|
||||
compactMode: false,
|
||||
sortBy: "name",
|
||||
})
|
||||
|
||||
@@ -252,6 +249,16 @@ export default function TwoFactorAuth() {
|
||||
|
||||
const cleanSecret = newToken.secret.replace(/\s/g, "").toUpperCase()
|
||||
|
||||
const isDuplicate = tokens.some((token) => token.secret === cleanSecret)
|
||||
if (isDuplicate) {
|
||||
toast({
|
||||
title: t.duplicateToken,
|
||||
description: t.duplicateTokenDesc,
|
||||
variant: "destructive",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const token: TOTPToken = {
|
||||
id: crypto.randomUUID(),
|
||||
name: newToken.name,
|
||||
@@ -281,6 +288,65 @@ export default function TwoFactorAuth() {
|
||||
})
|
||||
}
|
||||
|
||||
const addTokenDirectly = (parsed: {
|
||||
name?: string
|
||||
issuer?: string
|
||||
secret?: string
|
||||
algorithm?: string
|
||||
digits?: number
|
||||
period?: number
|
||||
}) => {
|
||||
if (!parsed.name || !parsed.secret) {
|
||||
toast({
|
||||
title: t.error,
|
||||
description: t.fillRequired,
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const cleanSecret = parsed.secret.replace(/\s/g, "").toUpperCase()
|
||||
|
||||
const isDuplicate = tokens.some((token) => token.secret === cleanSecret)
|
||||
if (isDuplicate) {
|
||||
toast({
|
||||
title: t.duplicateToken,
|
||||
description: t.duplicateTokenDesc,
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const token: TOTPToken = {
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.name,
|
||||
issuer: parsed.issuer || "",
|
||||
secret: cleanSecret,
|
||||
algorithm: (parsed.algorithm as "SHA1" | "SHA256" | "SHA512") || "SHA1",
|
||||
digits: parsed.digits || 6,
|
||||
period: parsed.period || 30,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
|
||||
setTokens((prev) => [...prev, token])
|
||||
setNewToken({
|
||||
name: "",
|
||||
issuer: "",
|
||||
secret: "",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
})
|
||||
setShowAdvanced(false)
|
||||
setIsAddDialogOpen(false)
|
||||
|
||||
toast({
|
||||
title: t.addSuccess,
|
||||
description: `${t.added} ${token.name}`,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const deleteToken = (id: string) => {
|
||||
const token = tokens.find((t) => t.id === id)
|
||||
setTokens(tokens.filter((t) => t.id !== id))
|
||||
@@ -400,19 +466,7 @@ export default function TwoFactorAuth() {
|
||||
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,
|
||||
})
|
||||
addTokenDirectly(parsed)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -454,46 +508,27 @@ export default function TwoFactorAuth() {
|
||||
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,
|
||||
})
|
||||
addTokenDirectly(parsed)
|
||||
} else {
|
||||
toast({
|
||||
title: t.parseFailed,
|
||||
description: t.invalidQrCode,
|
||||
title: t.scanFailed,
|
||||
description: t.invalidQRCode,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: t.scanFailed,
|
||||
description: t.noQrCodeFound,
|
||||
description: t.noQRCodeFound,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
toast({
|
||||
title: t.error,
|
||||
description: t.imageLoadFailed,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
|
||||
// Reset file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
if (event.target) {
|
||||
event.target.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,16 +704,6 @@ export default function TwoFactorAuth() {
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, showCodes: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t.compactMode}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t.compactModeDesc}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.compactMode}
|
||||
onCheckedChange={(checked) => setSettings({ ...settings, compactMode: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t.sortBy}</Label>
|
||||
<Select
|
||||
@@ -948,9 +973,8 @@ export default function TwoFactorAuth() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div
|
||||
className={`grid gap-4 ${settings.compactMode ? "grid-cols-1" : "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"}`}
|
||||
>
|
||||
// Update grid layout - always use single column
|
||||
<div className="grid gap-3 grid-cols-1">
|
||||
{filteredTokens.map((token) => (
|
||||
<TokenCard
|
||||
key={token.id}
|
||||
@@ -958,12 +982,12 @@ export default function TwoFactorAuth() {
|
||||
code={codes[token.id] || "------"}
|
||||
timeLeft={timeLeft}
|
||||
showCode={settings.showCodes}
|
||||
compact={settings.compactMode}
|
||||
// Remove compactMode prop
|
||||
onCopy={() => copyCode(codes[token.id], token.name)}
|
||||
onEdit={() => setEditingToken(token)}
|
||||
onDelete={() => deleteToken(token.id)}
|
||||
t={t}
|
||||
language={language} // Pass language prop to TokenCard
|
||||
language={language}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1048,26 +1072,14 @@ interface TokenCardProps {
|
||||
code: string
|
||||
timeLeft: number
|
||||
showCode: boolean
|
||||
compact: boolean
|
||||
onCopy: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
t: Record<string, string>
|
||||
language: string // Added language prop
|
||||
language: string
|
||||
}
|
||||
|
||||
function TokenCard({
|
||||
token,
|
||||
code,
|
||||
timeLeft,
|
||||
showCode,
|
||||
compact,
|
||||
onCopy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
t,
|
||||
language,
|
||||
}: TokenCardProps) {
|
||||
function TokenCard({ token, code, timeLeft, showCode, onCopy, onEdit, onDelete, t, language }: TokenCardProps) {
|
||||
const [visible, setVisible] = useState(showCode)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -1088,139 +1100,77 @@ function TokenCard({
|
||||
? `${code.slice(0, 4)} ${code.slice(4)}`
|
||||
: code
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{token.name}</p>
|
||||
{token.issuer && <p className="text-xs text-muted-foreground">{token.issuer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setVisible(!visible)} className="p-1 hover:bg-muted rounded">
|
||||
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
<span
|
||||
className={`font-mono text-lg font-bold cursor-pointer transition-colors ${
|
||||
copied ? "text-green-500" : ""
|
||||
}`}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (language === "zh" ? "已复制!" : "Copied!") : formattedCode}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4 mr-2 text-green-500" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{t.copy}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* Left: Avatar and name - with truncation */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{token.name}</CardTitle>
|
||||
{token.issuer && <CardDescription>{token.issuer}</CardDescription>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate" title={token.name}>
|
||||
{token.name}
|
||||
</p>
|
||||
{token.issuer && (
|
||||
<p className="text-xs text-muted-foreground truncate" title={token.issuer}>
|
||||
{token.issuer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
{copied ? <Check className="h-4 w-4 mr-2 text-green-500" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{t.copyCode}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setVisible(!visible)} className="p-1 hover:bg-muted rounded transition-colors">
|
||||
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
|
||||
{/* Right: Code and actions - fixed width */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setVisible(!visible)}
|
||||
className="p-1.5 hover:bg-muted rounded-md transition-colors"
|
||||
title={visible ? t.hideCode : t.showCode}
|
||||
>
|
||||
{visible ? (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={`font-mono text-2xl font-bold tracking-wider cursor-pointer transition-colors ${
|
||||
className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${
|
||||
copied ? "text-green-500" : ""
|
||||
}`}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (language === "zh" ? "已复制!" : "Copied!") : formattedCode}
|
||||
{visible ? formattedCode : "••• •••"}
|
||||
</span>
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleCopy} title={t.copy}>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* More actions dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
{t.edit}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{t.delete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={handleCopy} className={copied ? "text-green-500" : ""}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Progress value={(timeLeft / token.period) * 100} className="h-1 flex-1" />
|
||||
<span className="text-xs text-muted-foreground w-6">{timeLeft}s</span>
|
||||
</div>
|
||||
{(token.algorithm !== "SHA1" || token.digits !== 6 || token.period !== 30) && (
|
||||
<div className="mt-2 flex gap-1 flex-wrap">
|
||||
{token.algorithm !== "SHA1" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{token.algorithm}
|
||||
</Badge>
|
||||
)}
|
||||
{token.digits !== 6 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{token.digits}
|
||||
{t.digits8?.includes("digits") ? " digits" : "位"}
|
||||
</Badge>
|
||||
)}
|
||||
{token.period !== 30 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{token.period}s
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -114,6 +114,8 @@ const translations = {
|
||||
noQrCodeFound: "未能在图片中识别到二维码",
|
||||
invalidQrCode: "二维码不是有效的 TOTP 格式",
|
||||
imageLoadFailed: "图片加载失败",
|
||||
duplicateToken: "令牌已存在",
|
||||
duplicateTokenDesc: "该密钥的令牌已添加过",
|
||||
},
|
||||
en: {
|
||||
// Header
|
||||
@@ -223,6 +225,8 @@ const translations = {
|
||||
noQrCodeFound: "Could not find QR code in image",
|
||||
invalidQrCode: "QR code is not a valid TOTP format",
|
||||
imageLoadFailed: "Failed to load image",
|
||||
duplicateToken: "Token already exists",
|
||||
duplicateTokenDesc: "A token with this secret key has already been added",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user