import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import copy from "copy-to-clipboard"; import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react"; import QRCode from "qrcode"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { InputOTP } from "@/components/ui/input-otp"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; const PasswordSchema = z.object({ password: z.string().min(8, { message: "Password is required", }), issuer: z.string().optional(), }); const PinSchema = z.object({ pin: z.string().min(6, { message: "Pin is required", }), }); type TwoFactorSetupData = { qrCodeUrl: string; secret: string; totpURI: string; }; type PasswordForm = z.infer; type PinForm = z.infer; export const USERNAME_PLACEHOLDER = "%username%"; export const DATE_PLACEHOLDER = "%date%"; export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%"; export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES Points to note -------------- # Each code can be used only once. # Do not share these codes with anyone. Generated codes --------------- Username: ${USERNAME_PLACEHOLDER} Generated on: ${DATE_PLACEHOLDER} ${BACKUP_CODES_PLACEHOLDER} `; export const Enable2FA = () => { const utils = api.useUtils(); const [data, setData] = useState(null); const [backupCodes, setBackupCodes] = useState([]); const [isDialogOpen, setIsDialogOpen] = useState(false); const [step, setStep] = useState<"password" | "verify">("password"); const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [otpValue, setOtpValue] = useState(""); const { data: currentUser } = api.user.get.useQuery(); const handleVerifySubmit = async (e: React.FormEvent) => { e.preventDefault(); try { const result = await authClient.twoFactor.verifyTotp({ code: otpValue, }); if (result.error) { if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") { toast.error("Invalid verification code"); return; } throw result.error; } if (!result.data) { throw new Error("No response received from server"); } toast.success("2FA configured successfully"); utils.user.get.invalidate(); setIsDialogOpen(false); } catch (error) { if (error instanceof Error) { const errorMessage = error.message === "Failed to fetch" ? "Connection error. Please check your internet connection." : error.message; toast.error(errorMessage); } else { toast.error("Error verifying 2FA code", { description: error instanceof Error ? error.message : "Unknown error", }); } } }; const passwordForm = useForm({ resolver: zodResolver(PasswordSchema), defaultValues: { password: "", }, }); const pinForm = useForm({ resolver: zodResolver(PinSchema), defaultValues: { pin: "", }, }); useEffect(() => { if (!isDialogOpen) { setStep("password"); setData(null); setBackupCodes([]); setOtpValue(""); passwordForm.reset({ password: "", issuer: "", }); } }, [isDialogOpen, passwordForm]); useEffect(() => { if (step === "verify") { setOtpValue(""); } }, [step]); const handlePasswordSubmit = async (formData: PasswordForm) => { setIsPasswordLoading(true); try { const { data: enableData, error } = await authClient.twoFactor.enable({ password: formData.password, issuer: formData.issuer, }); if (!enableData) { throw new Error(error?.message || "Error enabling 2FA"); } if (enableData.backupCodes) { setBackupCodes(enableData.backupCodes); } if (enableData.totpURI) { const qrCodeUrl = await QRCode.toDataURL(enableData.totpURI); setData({ qrCodeUrl, secret: enableData.totpURI.split("secret=")[1]?.split("&")[0] || "", totpURI: enableData.totpURI, }); setStep("verify"); toast.success("Scan the QR code with your authenticator app"); } else { throw new Error("No TOTP URI received from server"); } } catch (error) { toast.error( error instanceof Error ? error.message : "Error setting up 2FA", ); passwordForm.setError("password", { message: error instanceof Error ? error.message : "Error setting up 2FA", }); } finally { setIsPasswordLoading(false); } }; const handleDownloadBackupCodes = () => { if (!backupCodes || backupCodes.length === 0) { toast.error("No backup codes to download."); return; } const backupCodesFormatted = backupCodes .map((code, index) => ` ${index + 1}. ${code}`) .join("\n"); const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`; const backupCodesText = backupCodeTemplate .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") .replace(DATE_PLACEHOLDER, date.toLocaleString()) .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); const blob = new Blob([backupCodesText], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }; const handleCopyBackupCodes = () => { const date = new Date(); const backupCodesFormatted = backupCodes .map((code, index) => ` ${index + 1}. ${code}`) .join("\n"); const backupCodesText = backupCodeTemplate .replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown") .replace(DATE_PLACEHOLDER, date.toLocaleString()) .replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted); copy(backupCodesText); toast.success("Backup codes copied to clipboard"); }; return ( 2FA Setup {step === "password" ? "Enter your password to begin 2FA setup" : "Scan the QR code and verify with your authenticator app"} {step === "password" ? (
( Password Enter your password to enable 2FA )} /> ( Issuer Use a custom issuer to identify the service you're authenticating with. )} /> ) : (
{data?.qrCodeUrl ? ( <>
Scan this QR code with your authenticator app {/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */} 2FA QR Code
Can't scan the QR code? {data.secret}
{backupCodes && backupCodes.length > 0 && (

Backup Codes

Copy

Download

{backupCodes.map((code, index) => ( {code} ))}

Save these backup codes in a secure place. You can use them to access your account if you lose access to your authenticator device.

)} ) : (
)}
Verification Code Enter the 6-digit code from your authenticator app
)}
); };