feat: Implement a 2FA authenticator tool with token management, QR scanning, and UI components.

This commit is contained in:
2026-01-21 15:37:29 +08:00
parent f42ad02cc3
commit bad93bb877
7 changed files with 106 additions and 53 deletions

View File

@@ -18,6 +18,14 @@
- **样式**: Tailwind CSS - **样式**: Tailwind CSS
- **工具库**: jsQR, date-fns - **工具库**: jsQR, date-fns
## 环境变量
可以在部署时设置以下环境变量来配置页脚信息:
- `NEXT_PUBLIC_SHOW_FOOTER`: 是否显示页脚 (默认: true, 设置为 "false" 隐藏)
- `NEXT_PUBLIC_FILING_ICP`: ICP 备案号 (例如: 滇ICP备xxxxxxxx号)
- `NEXT_PUBLIC_FILING_SECURITY`: 公安联网备案号 (例如: 滇公网安备xxxxxxxxxxxxxx号)
## 声明 ## 声明
本项目由阿里云ESA提供加速、计算和保护 本项目由阿里云ESA提供加速、计算和保护

View File

@@ -19,7 +19,7 @@
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);

View File

@@ -27,6 +27,7 @@ import {
Monitor, Monitor,
Languages, Languages,
Check, Check,
Github,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -162,6 +163,7 @@ export default function TwoFactorAuth() {
const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [editingToken, setEditingToken] = useState<TOTPToken | null>(null) const [editingToken, setEditingToken] = useState<TOTPToken | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const [mounted, setMounted] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
@@ -195,6 +197,7 @@ export default function TwoFactorAuth() {
if (savedSettings) { if (savedSettings) {
setSettings(JSON.parse(savedSettings)) setSettings(JSON.parse(savedSettings))
} }
setMounted(true)
}, []) }, [])
// Save tokens to localStorage // Save tokens to localStorage
@@ -655,15 +658,33 @@ export default function TwoFactorAuth() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<a
href="https://github.com/handsomezhuzhu/2fa-tool"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<Github className="h-5 w-5" />
<span className="sr-only">GitHub</span>
</a>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={cycleTheme} onClick={cycleTheme}
title={theme === "light" ? t.themeLight : theme === "dark" ? t.themeDark : t.themeSystem} title={mounted ? (theme === "light" ? t.themeLight : theme === "dark" ? t.themeDark : t.themeSystem) : t.themeSystem}
> >
{theme === "light" && <Sun className="h-5 w-5" />} {!mounted ? (
{theme === "dark" && <Moon className="h-5 w-5" />} <Monitor className="h-5 w-5" />
{theme === "system" && <Monitor className="h-5 w-5" />} ) : (
<>
{theme === "light" && <Sun className="h-5 w-5" />}
{theme === "dark" && <Moon className="h-5 w-5" />}
{theme === "system" && <Monitor className="h-5 w-5" />}
</>
)}
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
@@ -1028,39 +1049,50 @@ export default function TwoFactorAuth() {
</Dialog> </Dialog>
{/* Footer */} {/* Footer */}
<footer className="border-t py-6 mt-auto"> {/* Footer */}
<div className="mx-auto flex max-w-4xl flex-col items-center gap-2 text-center md:flex-row md:justify-between md:gap-4 px-4"> {(process.env.NEXT_PUBLIC_SHOW_FOOTER !== "false") && (
<p className="text-xs tracking-wider text-muted-foreground">© 2025 Simon. All rights reserved.</p> <footer className="border-t py-6 mt-auto">
<div className="flex items-center gap-4 text-xs tracking-wider text-muted-foreground/60"> <div className="mx-auto flex max-w-4xl flex-col items-center gap-2 text-center md:flex-row md:justify-between md:gap-4 px-4">
<a <p className="text-xs tracking-wider text-muted-foreground">© 2025 Simon. All rights reserved.</p>
href="https://beian.miit.gov.cn/" {(process.env.NEXT_PUBLIC_FILING_ICP || process.env.NEXT_PUBLIC_FILING_SECURITY) && (
target="_blank" <div className="flex items-center gap-4 text-xs tracking-wider text-muted-foreground/60">
rel="noopener noreferrer" {process.env.NEXT_PUBLIC_FILING_ICP && (
className="transition-colors hover:text-muted-foreground" <a
> href="https://beian.miit.gov.cn/"
ICP备2025074424号 target="_blank"
</a> rel="noopener noreferrer"
<span className="text-muted-foreground/30">|</span> className="transition-colors hover:text-muted-foreground"
<a >
href="https://beian.mps.gov.cn" {process.env.NEXT_PUBLIC_FILING_ICP}
target="_blank" </a>
rel="noopener noreferrer" )}
className="flex items-center gap-1.5 transition-colors hover:text-muted-foreground" {process.env.NEXT_PUBLIC_FILING_ICP && process.env.NEXT_PUBLIC_FILING_SECURITY && (
> <span className="text-muted-foreground/30">|</span>
<img )}
alt="公安备案" {process.env.NEXT_PUBLIC_FILING_SECURITY && (
loading="lazy" <a
width="14" href="https://beian.mps.gov.cn"
height="14" target="_blank"
decoding="async" rel="noopener noreferrer"
className="opacity-60" className="flex items-center gap-1.5 transition-colors hover:text-muted-foreground"
src="/images/beian.png" >
/> <img
53250402000233 alt="公安备案"
</a> loading="lazy"
width="14"
height="14"
decoding="async"
className="opacity-60"
src="/images/beian.png"
/>
{process.env.NEXT_PUBLIC_FILING_SECURITY}
</a>
)}
</div>
)}
</div> </div>
</div> </footer>
</footer> )}
<Toaster /> <Toaster />
</div> </div>
@@ -1136,9 +1168,8 @@ function TokenCard({ token, code, timeLeft, showCode, onCopy, onEdit, onDelete,
</button> </button>
<span <span
className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${ className={`font-mono text-base font-bold min-w-[90px] text-center transition-colors ${copied ? "text-green-500" : ""
copied ? "text-green-500" : "" }`}
}`}
> >
{visible ? formattedCode : "••• •••"} {visible ? formattedCode : "••• •••"}
</span> </span>

View File

@@ -77,7 +77,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600', 'absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className, className,
)} )}
toast-close="" toast-close=""

View File

@@ -69,6 +69,6 @@
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",
"typescript": "^5" "typescript": "^5.9.3"
} }
} }

12
pnpm-lock.yaml generated
View File

@@ -184,8 +184,8 @@ importers:
specifier: 1.3.3 specifier: 1.3.3
version: 1.3.3 version: 1.3.3
typescript: typescript:
specifier: ^5 specifier: ^5.9.3
version: 5.0.2 version: 5.9.3
packages: packages:
@@ -1694,9 +1694,9 @@ packages:
tw-animate-css@1.3.3: tw-animate-css@1.3.3:
resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==} resolution: {integrity: sha512-tXE2TRWrskc4TU3RDd7T8n8Np/wCfoeH9gz22c7PzYqNPQ9FBGFbWWzwL0JyHcFp+jHozmF76tbHfPAx22ua2Q==}
typescript@5.0.2: typescript@5.9.3:
resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=12.20'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@6.11.1: undici-types@6.11.1:
@@ -3173,7 +3173,7 @@ snapshots:
tw-animate-css@1.3.3: {} tw-animate-css@1.3.3: {}
typescript@5.0.2: {} typescript@5.9.3: {}
undici-types@6.11.1: {} undici-types@6.11.1: {}

View File

@@ -1,6 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"target": "ES6", "target": "ES6",
"skipLibCheck": true, "skipLibCheck": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -19,9 +23,19 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }