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

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,84 +1100,65 @@ function TokenCard({
? `${code.slice(0, 4)} ${code.slice(4)}` ? `${code.slice(0, 4)} ${code.slice(4)}`
: code : code
if (compact) {
return ( return (
<Card className="hover:shadow-md transition-shadow"> <Card className="hover:shadow-md transition-shadow">
<CardContent className="py-3 px-4"> <CardContent className="py-3 px-4">
<div className="flex items-center 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-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> <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> <span className="text-sm font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span>
</div> </div>
<div> <div className="min-w-0 flex-1">
<p className="font-medium text-sm">{token.name}</p> <p className="font-medium text-sm truncate" title={token.name}>
{token.issuer && <p className="text-xs text-muted-foreground">{token.issuer}</p>} {token.name}
</p>
{token.issuer && (
<p className="text-xs text-muted-foreground truncate" title={token.issuer}>
{token.issuer}
</p>
)}
</div> </div>
</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 ( {/* Right: Code and actions - fixed width */}
<Card className="hover:shadow-md transition-shadow"> <div className="flex items-center gap-1 shrink-0">
<CardHeader className="pb-2"> <button
<div className="flex items-start justify-between"> onClick={() => setVisible(!visible)}
<div className="flex items-center gap-3"> className="p-1.5 hover:bg-muted rounded-md transition-colors"
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center"> title={visible ? t.hideCode : t.showCode}
<span className="text-lg font-bold text-primary">{token.name.charAt(0).toUpperCase()}</span> >
</div> {visible ? (
<div> <Eye className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-base">{token.name}</CardTitle> ) : (
{token.issuer && <CardDescription>{token.issuer}</CardDescription>} <EyeOff className="h-4 w-4 text-muted-foreground" />
</div> )}
</div> </button>
<span
className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${
copied ? "text-green-500" : ""
}`}
>
{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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4 text-muted-foreground" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <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}> <DropdownMenuItem onClick={onEdit}>
<Edit2 className="h-4 w-4 mr-2" /> <Edit2 className="h-4 w-4 mr-2" />
{t.edit} {t.edit}
@@ -1177,50 +1170,7 @@ function TokenCard({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </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>
<span
className={`font-mono text-2xl font-bold tracking-wider cursor-pointer transition-colors ${
copied ? "text-green-500" : ""
}`}
onClick={handleCopy}
>
{copied ? (language === "zh" ? "已复制!" : "Copied!") : formattedCode}
</span>
</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 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",
}, },
} }