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:
v0
2026-01-15 17:42:49 +00:00
parent 8303e4f261
commit 539ae38b25
5 changed files with 147 additions and 239 deletions

2
.gitignore vendored
View File

@@ -24,4 +24,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts

View File

@@ -27,4 +27,4 @@ Continue building your app on:
1. Create and modify your project using [v0.app](https://v0.app) 1. Create and modify your project using [v0.app](https://v0.app)
2. Deploy your chats from the v0 interface 2. Deploy your chats from the v0 interface
3. Changes are automatically pushed to this repository 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

View File

@@ -31,7 +31,7 @@ import {
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -45,7 +45,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Toaster } from "@/components/ui/toaster" import { Toaster } from "@/components/ui/toaster"
import { useToast } from "@/hooks/use-toast" import { useToast } from "@/hooks/use-toast"
@@ -66,7 +65,6 @@ interface TOTPToken {
interface AppSettings { interface AppSettings {
showCodes: boolean showCodes: boolean
autoRefresh: boolean autoRefresh: boolean
compactMode: boolean
sortBy: "name" | "issuer" | "recent" sortBy: "name" | "issuer" | "recent"
} }
@@ -184,7 +182,6 @@ export default function TwoFactorAuth() {
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
showCodes: true, showCodes: true,
autoRefresh: true, autoRefresh: true,
compactMode: false,
sortBy: "name", sortBy: "name",
}) })
@@ -252,6 +249,16 @@ export default function TwoFactorAuth() {
const cleanSecret = newToken.secret.replace(/\s/g, "").toUpperCase() 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 = { const token: TOTPToken = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
name: newToken.name, 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 deleteToken = (id: string) => {
const token = tokens.find((t) => t.id === id) const token = tokens.find((t) => t.id === id)
setTokens(tokens.filter((t) => t.id !== id)) setTokens(tokens.filter((t) => t.id !== id))
@@ -400,19 +466,7 @@ export default function TwoFactorAuth() {
const parsed = parseOTPAuthURI(code.data) const parsed = parseOTPAuthURI(code.data)
if (parsed) { if (parsed) {
stopCamera() stopCamera()
setNewToken({ addTokenDirectly(parsed)
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 return
} }
} }
@@ -454,46 +508,27 @@ export default function TwoFactorAuth() {
if (code && code.data) { if (code && code.data) {
const parsed = parseOTPAuthURI(code.data) const parsed = parseOTPAuthURI(code.data)
if (parsed) { if (parsed) {
setNewToken({ addTokenDirectly(parsed)
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 { } else {
toast({ toast({
title: t.parseFailed, title: t.scanFailed,
description: t.invalidQrCode, description: t.invalidQRCode,
variant: "destructive", variant: "destructive",
}) })
} }
} else { } else {
toast({ toast({
title: t.scanFailed, title: t.scanFailed,
description: t.noQrCodeFound, description: t.noQRCodeFound,
variant: "destructive", variant: "destructive",
}) })
} }
} }
img.onerror = () => {
toast({
title: t.error,
description: t.imageLoadFailed,
variant: "destructive",
})
}
img.src = URL.createObjectURL(file) img.src = URL.createObjectURL(file)
// Reset file input // Reset file input
if (fileInputRef.current) { if (event.target) {
fileInputRef.current.value = "" event.target.value = ""
} }
} }
@@ -669,16 +704,6 @@ export default function TwoFactorAuth() {
onCheckedChange={(checked) => setSettings({ ...settings, showCodes: checked })} onCheckedChange={(checked) => setSettings({ ...settings, showCodes: checked })}
/> />
</div> </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"> <div className="space-y-2">
<Label>{t.sortBy}</Label> <Label>{t.sortBy}</Label>
<Select <Select
@@ -948,9 +973,8 @@ export default function TwoFactorAuth() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div // Update grid layout - always use single column
className={`grid gap-4 ${settings.compactMode ? "grid-cols-1" : "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"}`} <div className="grid gap-3 grid-cols-1">
>
{filteredTokens.map((token) => ( {filteredTokens.map((token) => (
<TokenCard <TokenCard
key={token.id} key={token.id}
@@ -958,12 +982,12 @@ export default function TwoFactorAuth() {
code={codes[token.id] || "------"} code={codes[token.id] || "------"}
timeLeft={timeLeft} timeLeft={timeLeft}
showCode={settings.showCodes} showCode={settings.showCodes}
compact={settings.compactMode} // Remove compactMode prop
onCopy={() => copyCode(codes[token.id], token.name)} onCopy={() => copyCode(codes[token.id], token.name)}
onEdit={() => setEditingToken(token)} onEdit={() => setEditingToken(token)}
onDelete={() => deleteToken(token.id)} onDelete={() => deleteToken(token.id)}
t={t} t={t}
language={language} // Pass language prop to TokenCard language={language}
/> />
))} ))}
</div> </div>
@@ -1048,26 +1072,14 @@ interface TokenCardProps {
code: string code: string
timeLeft: number timeLeft: number
showCode: boolean showCode: boolean
compact: boolean
onCopy: () => void onCopy: () => void
onEdit: () => void onEdit: () => void
onDelete: () => void onDelete: () => void
t: Record<string, string> t: Record<string, string>
language: string // Added language prop language: string
} }
function TokenCard({ function TokenCard({ token, code, timeLeft, showCode, onCopy, onEdit, onDelete, t, language }: TokenCardProps) {
token,
code,
timeLeft,
showCode,
compact,
onCopy,
onEdit,
onDelete,
t,
language,
}: TokenCardProps) {
const [visible, setVisible] = useState(showCode) const [visible, setVisible] = useState(showCode)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -1088,139 +1100,77 @@ function TokenCard({
? `${code.slice(0, 4)} ${code.slice(4)}` ? `${code.slice(0, 4)} ${code.slice(4)}`
: code : 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 ( return (
<Card className="hover:shadow-md transition-shadow"> <Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-2"> <CardContent className="py-3 px-4">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3"> {/* Left: Avatar and name - with truncation */}
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center"> <div className="flex items-center gap-3 min-w-0 flex-1">
<span className="text-lg font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span> <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>
<div> <div className="min-w-0 flex-1">
<CardTitle className="text-base">{token.name}</CardTitle> <p className="font-medium text-sm truncate" title={token.name}>
{token.issuer && <CardDescription>{token.issuer}</CardDescription>} {token.name}
</p>
{token.issuer && (
<p className="text-xs text-muted-foreground truncate" title={token.issuer}>
{token.issuer}
</p>
)}
</div> </div>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild> {/* Right: Code and actions - fixed width */}
<Button variant="ghost" size="icon" className="h-8 w-8"> <div className="flex items-center gap-1 shrink-0">
<MoreVertical className="h-4 w-4" /> <button
</Button> onClick={() => setVisible(!visible)}
</DropdownMenuTrigger> className="p-1.5 hover:bg-muted rounded-md transition-colors"
<DropdownMenuContent align="end"> title={visible ? t.hideCode : t.showCode}
<DropdownMenuItem onClick={handleCopy}> >
{copied ? <Check className="h-4 w-4 mr-2 text-green-500" /> : <Copy className="h-4 w-4 mr-2" />} {visible ? (
{t.copyCode} <Eye className="h-4 w-4 text-muted-foreground" />
</DropdownMenuItem> ) : (
<DropdownMenuItem onClick={onEdit}> <EyeOff className="h-4 w-4 text-muted-foreground" />
<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" />}
</button> </button>
<span <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" : "" copied ? "text-green-500" : ""
}`} }`}
onClick={handleCopy}
> >
{copied ? (language === "zh" ? "已复制!" : "Copied!") : formattedCode} {visible ? formattedCode : "••• •••"}
</span> </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> </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>
<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> </CardContent>
</Card> </Card>
) )

View File

@@ -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 }

View File

@@ -114,6 +114,8 @@ const translations = {
noQrCodeFound: "未能在图片中识别到二维码", noQrCodeFound: "未能在图片中识别到二维码",
invalidQrCode: "二维码不是有效的 TOTP 格式", invalidQrCode: "二维码不是有效的 TOTP 格式",
imageLoadFailed: "图片加载失败", imageLoadFailed: "图片加载失败",
duplicateToken: "令牌已存在",
duplicateTokenDesc: "该密钥的令牌已添加过",
}, },
en: { en: {
// Header // Header
@@ -223,6 +225,8 @@ const translations = {
noQrCodeFound: "Could not find QR code in image", noQrCodeFound: "Could not find QR code in image",
invalidQrCode: "QR code is not a valid TOTP format", invalidQrCode: "QR code is not a valid TOTP format",
imageLoadFailed: "Failed to load image", imageLoadFailed: "Failed to load image",
duplicateToken: "Token already exists",
duplicateTokenDesc: "A token with this secret key has already been added",
}, },
} }