2025-11-07 20:36:46 +01:00
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import { useState } from "react"
|
|
|
|
|
import { Button } from "./ui/button"
|
|
|
|
|
import { Input } from "./ui/input"
|
|
|
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
|
|
|
|
import { AlertCircle, CheckCircle, Copy, Shield, Check } from "lucide-react"
|
|
|
|
|
import { getApiUrl } from "../lib/api-config"
|
2026-08-04 17:01:19 +02:00
|
|
|
import { useT } from "../lib/i18n/provider"
|
2025-11-07 20:36:46 +01:00
|
|
|
|
|
|
|
|
interface TwoFactorSetupProps {
|
|
|
|
|
open: boolean
|
|
|
|
|
onClose: () => void
|
|
|
|
|
onSuccess: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps) {
|
2026-08-04 17:01:19 +02:00
|
|
|
const t = useT()
|
|
|
|
|
const tf = (key: string) => t(`securityPage.twoFactorSetup.${key}`)
|
2025-11-07 20:36:46 +01:00
|
|
|
const [step, setStep] = useState(1)
|
|
|
|
|
const [qrCode, setQrCode] = useState("")
|
|
|
|
|
const [secret, setSecret] = useState("")
|
|
|
|
|
const [backupCodes, setBackupCodes] = useState<string[]>([])
|
|
|
|
|
const [verificationCode, setVerificationCode] = useState("")
|
|
|
|
|
const [error, setError] = useState("")
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
const [copiedSecret, setCopiedSecret] = useState(false)
|
|
|
|
|
const [copiedCodes, setCopiedCodes] = useState(false)
|
|
|
|
|
|
|
|
|
|
const handleSetupStart = async () => {
|
|
|
|
|
setError("")
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem("proxmenux-auth-token")
|
|
|
|
|
const response = await fetch(getApiUrl("/api/auth/totp/setup"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2026-08-04 17:01:19 +02:00
|
|
|
throw new Error(data.message || tf("setupFailed"))
|
2025-11-07 20:36:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setQrCode(data.qr_code)
|
|
|
|
|
setSecret(data.secret)
|
|
|
|
|
setBackupCodes(data.backup_codes)
|
|
|
|
|
setStep(2)
|
|
|
|
|
} catch (err) {
|
2026-08-04 17:01:19 +02:00
|
|
|
setError(err instanceof Error ? err.message : tf("setupFailed"))
|
2025-11-07 20:36:46 +01:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleVerify = async () => {
|
|
|
|
|
if (!verificationCode || verificationCode.length !== 6) {
|
2026-08-04 17:01:19 +02:00
|
|
|
setError(tf("enterSixDigitCode"))
|
2025-11-07 20:36:46 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setError("")
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const token = localStorage.getItem("proxmenux-auth-token")
|
|
|
|
|
const response = await fetch(getApiUrl("/api/auth/totp/enable"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ token: verificationCode }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
2026-08-04 17:01:19 +02:00
|
|
|
throw new Error(data.message || tf("invalidCode"))
|
2025-11-07 20:36:46 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setStep(3)
|
|
|
|
|
} catch (err) {
|
2026-08-04 17:01:19 +02:00
|
|
|
setError(err instanceof Error ? err.message : tf("verificationFailed"))
|
2025-11-07 20:36:46 +01:00
|
|
|
} finally {
|
|
|
|
|
setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 18:03:46 +01:00
|
|
|
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
2026-04-17 17:36:57 +02:00
|
|
|
let ok = false
|
|
|
|
|
|
2026-06-22 11:38:06 +02:00
|
|
|
// Path 1: modern Clipboard API. Only works on HTTPS / localhost.
|
2026-02-07 18:03:46 +01:00
|
|
|
try {
|
2026-06-22 11:38:06 +02:00
|
|
|
if (navigator.clipboard?.writeText) {
|
2026-02-07 18:03:46 +01:00
|
|
|
await navigator.clipboard.writeText(text)
|
2026-04-17 17:36:57 +02:00
|
|
|
ok = true
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
2026-06-22 11:38:06 +02:00
|
|
|
// fall through
|
2026-04-17 17:36:57 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 11:38:06 +02:00
|
|
|
// Path 2: legacy execCommand. Picky — some browsers (iOS Safari
|
|
|
|
|
// especially) refuse to copy from an element placed off-screen
|
|
|
|
|
// (`left: -9999px`), which is the previous version's mistake.
|
|
|
|
|
// Keep the textarea inside the viewport but visually invisible.
|
2026-04-17 17:36:57 +02:00
|
|
|
if (!ok) {
|
2026-06-22 11:38:06 +02:00
|
|
|
const textarea = document.createElement("textarea")
|
|
|
|
|
textarea.value = text
|
|
|
|
|
textarea.style.position = "fixed"
|
|
|
|
|
textarea.style.top = "0"
|
|
|
|
|
textarea.style.left = "0"
|
|
|
|
|
textarea.style.width = "2em"
|
|
|
|
|
textarea.style.height = "2em"
|
|
|
|
|
textarea.style.padding = "0"
|
|
|
|
|
textarea.style.border = "none"
|
|
|
|
|
textarea.style.outline = "none"
|
|
|
|
|
textarea.style.boxShadow = "none"
|
|
|
|
|
textarea.style.background = "transparent"
|
|
|
|
|
textarea.style.opacity = "0"
|
|
|
|
|
textarea.setAttribute("readonly", "")
|
|
|
|
|
textarea.setAttribute("aria-hidden", "true")
|
|
|
|
|
document.body.appendChild(textarea)
|
2026-04-17 17:36:57 +02:00
|
|
|
try {
|
2026-02-07 18:03:46 +01:00
|
|
|
textarea.focus()
|
|
|
|
|
textarea.select()
|
2026-06-22 11:38:06 +02:00
|
|
|
textarea.setSelectionRange(0, text.length)
|
2026-04-17 17:36:57 +02:00
|
|
|
ok = document.execCommand("copy")
|
|
|
|
|
} catch {
|
|
|
|
|
ok = false
|
2026-06-22 11:38:06 +02:00
|
|
|
} finally {
|
|
|
|
|
document.body.removeChild(textarea)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Path 3: last-resort window.prompt — ugly but unblockable. The
|
|
|
|
|
// user can select+copy from the prompt manually. This guarantees
|
|
|
|
|
// they can finish the 2FA setup even on plain-HTTP Monitor where
|
|
|
|
|
// both the Clipboard API and execCommand may be locked down.
|
|
|
|
|
if (!ok) {
|
|
|
|
|
try {
|
2026-08-04 17:01:19 +02:00
|
|
|
window.prompt(tf("copyPrompt"), text)
|
2026-06-22 11:38:06 +02:00
|
|
|
ok = true
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
2026-02-07 18:03:46 +01:00
|
|
|
}
|
2026-04-17 17:36:57 +02:00
|
|
|
}
|
2026-02-07 18:03:46 +01:00
|
|
|
|
2026-04-17 17:36:57 +02:00
|
|
|
if (!ok) {
|
2026-02-07 18:03:46 +01:00
|
|
|
console.error("Failed to copy to clipboard")
|
2026-04-17 17:36:57 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === "secret") {
|
|
|
|
|
setCopiedSecret(true)
|
|
|
|
|
setTimeout(() => setCopiedSecret(false), 2000)
|
|
|
|
|
} else {
|
|
|
|
|
setCopiedCodes(true)
|
|
|
|
|
setTimeout(() => setCopiedCodes(false), 2000)
|
2025-11-07 20:36:46 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
setStep(1)
|
|
|
|
|
setQrCode("")
|
|
|
|
|
setSecret("")
|
|
|
|
|
setBackupCodes([])
|
|
|
|
|
setVerificationCode("")
|
|
|
|
|
setError("")
|
|
|
|
|
onClose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleFinish = () => {
|
|
|
|
|
handleClose()
|
|
|
|
|
onSuccess()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
|
|
|
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
<Shield className="h-5 w-5 text-blue-500" />
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("title")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</DialogTitle>
|
2026-08-04 17:01:19 +02:00
|
|
|
<DialogDescription>{tf("description")}</DialogDescription>
|
2025-11-07 20:36:46 +01:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3 flex items-start gap-2">
|
|
|
|
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
|
|
|
<p className="text-sm text-red-500">{error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{step === 1 && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
|
|
|
|
<p className="text-sm text-blue-500">
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("intro")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<h4 className="font-medium">{tf("youWillNeed")}</h4>
|
2025-11-07 20:36:46 +01:00
|
|
|
<ul className="text-sm text-muted-foreground space-y-1 list-disc list-inside">
|
2026-08-04 17:01:19 +02:00
|
|
|
<li>{tf("needApp")}</li>
|
|
|
|
|
<li>{tf("needQrOrKey")}</li>
|
|
|
|
|
<li>{tf("needBackupCodes")}</li>
|
2025-11-07 20:36:46 +01:00
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button onClick={handleSetupStart} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
2026-08-04 17:01:19 +02:00
|
|
|
{loading ? tf("starting") : tf("startSetup")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{step === 2 && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<h4 className="font-medium">{tf("scanTitle")}</h4>
|
|
|
|
|
<p className="text-sm text-muted-foreground">{tf("scanDescription")}</p>
|
2025-11-07 20:36:46 +01:00
|
|
|
{qrCode && (
|
|
|
|
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
2026-08-04 17:01:19 +02:00
|
|
|
<img src={qrCode || "/placeholder.svg"} alt={tf("qrCodeAlt")} width={200} height={200} className="rounded" />
|
2025-11-07 20:36:46 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<h4 className="font-medium">{tf("manualKey")}</h4>
|
2025-11-07 20:36:46 +01:00
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Input value={secret} readOnly className="font-mono text-sm" />
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => copyToClipboard(secret, "secret")}
|
2026-08-04 17:01:19 +02:00
|
|
|
title={tf("copyKey")}
|
2025-11-07 20:36:46 +01:00
|
|
|
>
|
|
|
|
|
{copiedSecret ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<h4 className="font-medium">{tf("verifyTitle")}</h4>
|
|
|
|
|
<p className="text-sm text-muted-foreground">{tf("verifyDescription")}</p>
|
2025-11-07 20:36:46 +01:00
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
placeholder="000000"
|
|
|
|
|
value={verificationCode}
|
|
|
|
|
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
2025-11-07 20:55:00 +01:00
|
|
|
className="text-center text-lg tracking-widest font-mono text-base"
|
2025-11-07 20:36:46 +01:00
|
|
|
maxLength={6}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button onClick={handleVerify} className="flex-1 bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
2026-08-04 17:01:19 +02:00
|
|
|
{loading ? tf("verifying") : tf("verifyAndEnable")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleClose} variant="outline" className="flex-1 bg-transparent" disabled={loading}>
|
2026-08-04 17:01:19 +02:00
|
|
|
{t("actions.cancel")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{step === 3 && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="bg-green-500/10 border border-green-500/20 rounded-lg p-4 flex items-start gap-2">
|
|
|
|
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
|
|
|
|
<div>
|
2026-08-04 17:01:19 +02:00
|
|
|
<p className="font-medium text-green-500">{tf("enabledTitle")}</p>
|
2025-11-07 20:36:46 +01:00
|
|
|
<p className="text-sm text-green-500 mt-1">
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("enabledDescription")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<h4 className="font-medium text-orange-500">{tf("saveCodesTitle")}</h4>
|
2025-11-07 20:36:46 +01:00
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("saveCodesDescription")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div className="bg-muted/50 rounded-lg p-4 space-y-2">
|
|
|
|
|
<div className="flex justify-between items-center mb-2">
|
2026-08-04 17:01:19 +02:00
|
|
|
<span className="text-sm font-medium">{tf("backupCodes")}</span>
|
2025-11-07 20:36:46 +01:00
|
|
|
<Button variant="outline" size="sm" onClick={() => copyToClipboard(backupCodes.join("\n"), "codes")}>
|
|
|
|
|
{copiedCodes ? (
|
|
|
|
|
<Check className="h-4 w-4 text-green-500 mr-2" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="h-4 w-4 mr-2" />
|
|
|
|
|
)}
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("copyAll")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
{backupCodes.map((code, index) => (
|
|
|
|
|
<div key={index} className="bg-background rounded px-3 py-2 font-mono text-sm text-center">
|
|
|
|
|
{code}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button onClick={handleFinish} className="w-full bg-blue-500 hover:bg-blue-600">
|
2026-08-04 17:01:19 +02:00
|
|
|
{tf("finish")}
|
2025-11-07 20:36:46 +01:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|