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:
332
app/page.tsx
332
app/page.tsx
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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: "未能在图片中识别到二维码",
|
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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user