From 71152b664be2713167dfdac8ae814578469db8ca Mon Sep 17 00:00:00 2001 From: Mohammed Imran Date: Fri, 17 Oct 2025 23:08:54 +0530 Subject: [PATCH 1/4] feature: enhance 2FA management UI and logic in profile settings - Replaced AlertDialog with Dialog for managing 2FA settings, improving user experience. - Introduced multi-step dialog flow for verifying identity, managing actions, and displaying backup codes. --- .../settings/profile/disable-2fa.tsx | 355 ++++++++++++++---- .../settings/profile/profile-form.tsx | 48 +-- 2 files changed, 309 insertions(+), 94 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx index 4055d4079..aea342b18 100644 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx @@ -1,17 +1,28 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useState } from "react"; +import { KeyRound, RefreshCw, ShieldOff } from "lucide-react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; import { AlertDialog, + AlertDialogAction, + AlertDialogCancel, AlertDialogContent, AlertDialogDescription, + AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; import { Form, FormControl, @@ -32,11 +43,17 @@ const PasswordSchema = z.object({ }); type PasswordForm = z.infer; +type Step = "password" | "actions" | "backup-codes"; export const Disable2FA = () => { const utils = api.useUtils(); - const [isOpen, setIsOpen] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [step, setStep] = useState("password"); + const [password, setPassword] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [showDisableConfirm, setShowDisableConfirm] = useState(false); + const [isDisabling, setIsDisabling] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); const form = useForm({ resolver: zodResolver(PasswordSchema), @@ -45,91 +62,287 @@ export const Disable2FA = () => { }, }); - const handleSubmit = async (formData: PasswordForm) => { - setIsLoading(true); + useEffect(() => { + if (!isDialogOpen) { + setStep("password"); + setPassword(""); + setBackupCodes([]); + form.reset(); + } + }, [isDialogOpen, form]); + + const handlePasswordSubmit = async (formData: PasswordForm) => { + setIsRegenerating(true); try { - const result = await authClient.twoFactor.disable({ + // Verify password by attempting to generate backup codes + // This validates the password and checks if 2FA is enabled + const result = await authClient.twoFactor.generateBackupCodes({ password: formData.password, }); if (result.error) { - form.setError("password", { - message: result.error.message, - }); + form.setError("password", { message: result.error.message }); + toast.error(result.error.message); + return; + } + + // If we get here, password is correct + setPassword(formData.password); + setStep("actions"); + } catch (error) { + form.setError("password", { + message: error instanceof Error ? error.message : "Incorrect password", + }); + toast.error("Incorrect password"); + } finally { + setIsRegenerating(false); + } + }; + + const handleRegenerateBackupCodes = async () => { + setIsRegenerating(true); + try { + const result = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (result.error) { + toast.error(result.error.message); + return; + } + + if (result.data?.backupCodes) { + setBackupCodes(result.data.backupCodes); + setStep("backup-codes"); + toast.success("Backup codes regenerated successfully"); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to regenerate backup codes", + ); + } finally { + setIsRegenerating(false); + } + }; + + const handleDisable2FA = async () => { + setIsDisabling(true); + try { + const result = await authClient.twoFactor.disable({ + password, + }); + + if (result.error) { toast.error(result.error.message); return; } toast.success("2FA disabled successfully"); utils.user.get.invalidate(); - setIsOpen(false); - } catch { - form.setError("password", { - message: "Connection error. Please try again.", - }); - toast.error("Connection error. Please try again."); + setIsDialogOpen(false); + setShowDisableConfirm(false); + } catch (error) { + toast.error("Failed to disable 2FA. Please try again."); } finally { - setIsLoading(false); + setIsDisabling(false); + } + }; + + const handleCloseDialog = () => { + if (step === "backup-codes") { + setStep("actions"); + } else { + setIsDialogOpen(false); } }; return ( - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently disable - Two-Factor Authentication for your account. - - + <> + + + + + + + + {step === "password" && "Verify Your Identity"} + {step === "actions" && "2FA Configuration"} + {step === "backup-codes" && "New Backup Codes"} + + + {step === "password" && + "Enter your password to manage your 2FA settings"} + {step === "actions" && + "Choose an action to manage your two-factor authentication"} + {step === "backup-codes" && + "Save these backup codes in a secure place"} + + -
- - ( - - Password - - - - - Enter your password to disable 2FA - - - - )} - /> -
- - + ( + + Password + + + + + Enter your password to continue + + + + )} + /> +
+ + +
+ + + )} + + {step === "actions" && ( +
+
+
+
+
+

+ + Regenerate Backup Codes +

+

+ Generate new backup codes to replace your existing ones. + This will invalidate all previous backup codes. +

+
+
+ +
+ +
+
+
+

+ + Disable 2FA +

+

+ Completely disable two-factor authentication for your + account. This will make your account less secure. +

+
+
+ +
+
+ +
+ +
- - - - + )} + + {step === "backup-codes" && ( +
+
+
+ {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. Each code can only be used once. +

+
+ +
+ + +
+
+ )} + +
+ + + + + Are you absolutely sure? + + This will permanently disable Two-Factor Authentication for your + account. Your account will be less secure without 2FA enabled. + + + + Cancel + + {isDisabling ? "Disabling..." : "Disable 2FA"} + + + + + ); }; diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index c481d5b8b..79f18225e 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -62,7 +62,6 @@ const randomImages = [ ]; export const ProfileForm = () => { - const _utils = api.useUtils(); const { data, refetch, isLoading } = api.user.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -120,28 +119,27 @@ export const ProfileForm = () => { }, [form, data]); const onSubmit = async (values: Profile) => { - await mutateAsync({ - email: values.email.toLowerCase(), - password: values.password || undefined, - image: values.image, - currentPassword: values.currentPassword || undefined, - allowImpersonation: values.allowImpersonation, - name: values.name || undefined, - }) - .then(async () => { - await refetch(); - toast.success("Profile Updated"); - form.reset({ - email: values.email, - password: "", - image: values.image, - currentPassword: "", - name: values.name || "", - }); - }) - .catch(() => { - toast.error("Error updating the profile"); + try { + await mutateAsync({ + email: values.email.toLowerCase(), + password: values.password || undefined, + image: values.image, + currentPassword: values.currentPassword || undefined, + allowImpersonation: values.allowImpersonation, + name: values.name || undefined, }); + await refetch(); + toast.success("Profile Updated"); + form.reset({ + email: values.email, + password: "", + image: values.image, + currentPassword: "", + name: values.name || "", + }); + } catch (error) { + toast.error("Error updating the profile"); + } }; return ( @@ -158,7 +156,9 @@ export const ProfileForm = () => { {t("settings.profile.description")} - {!data?.user.twoFactorEnabled ? : } +
+ {!data?.user.twoFactorEnabled ? : } +
@@ -304,6 +304,7 @@ export const ProfileForm = () => { } > {field.value?.startsWith("data:") ? ( + // biome-ignore lint/performance/noImgElement: this is an justified use of img element Custom avatar { /> + {/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */} Date: Fri, 17 Oct 2025 23:10:09 +0530 Subject: [PATCH 2/4] chore: rename disable-2fa to configure-2fa --- .../settings/profile/{disable-2fa.tsx => configure-2fa.tsx} | 2 +- .../components/dashboard/settings/profile/profile-form.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename apps/dokploy/components/dashboard/settings/profile/{disable-2fa.tsx => configure-2fa.tsx} (99%) diff --git a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx similarity index 99% rename from apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx rename to apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx index aea342b18..4bd858a8a 100644 --- a/apps/dokploy/components/dashboard/settings/profile/disable-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx @@ -45,7 +45,7 @@ const PasswordSchema = z.object({ type PasswordForm = z.infer; type Step = "password" | "actions" | "backup-codes"; -export const Disable2FA = () => { +export const Configure2FA = () => { const utils = api.useUtils(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [step, setStep] = useState("password"); diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 79f18225e..42f0f05d7 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Switch } from "@/components/ui/switch"; import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils"; import { api } from "@/utils/api"; -import { Disable2FA } from "./disable-2fa"; +import { Configure2FA } from "./configure-2fa"; import { Enable2FA } from "./enable-2fa"; const profileSchema = z.object({ @@ -157,7 +157,7 @@ export const ProfileForm = () => {
- {!data?.user.twoFactorEnabled ? : } + {!data?.user.twoFactorEnabled ? : }
From 622bb3ff4eedd623e1a3544263b263245b4a2795 Mon Sep 17 00:00:00 2001 From: Mohammed Imran Date: Fri, 17 Oct 2025 23:17:59 +0530 Subject: [PATCH 3/4] chore: simplify 2FA component rendering in profile form --- .../components/dashboard/settings/profile/profile-form.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx index 42f0f05d7..583f3fefe 100644 --- a/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/profile-form.tsx @@ -156,9 +156,8 @@ export const ProfileForm = () => { {t("settings.profile.description")} -
- {!data?.user.twoFactorEnabled ? : } -
+ + {!data?.user.twoFactorEnabled ? : } From a5eeb74831f2f828e2f806761d66c0f947f2f82b Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sat, 25 Oct 2025 13:01:25 -0600 Subject: [PATCH 4/4] feat(2fa): add functionality to download and copy backup codes to clipboard --- .../settings/profile/configure-2fa.tsx | 83 ++++++++++++++++++- .../dashboard/settings/profile/enable-2fa.tsx | 8 +- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx index 4bd858a8a..17220cd11 100644 --- a/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/configure-2fa.tsx @@ -1,5 +1,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { KeyRound, RefreshCw, ShieldOff } from "lucide-react"; +import copy from "copy-to-clipboard"; +import { + CopyIcon, + DownloadIcon, + KeyRound, + RefreshCw, + ShieldOff, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -35,6 +42,12 @@ import { import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; +import { + BACKUP_CODES_PLACEHOLDER, + backupCodeTemplate, + DATE_PLACEHOLDER, + USERNAME_PLACEHOLDER, +} from "./enable-2fa"; const PasswordSchema = z.object({ password: z.string().min(8, { @@ -47,6 +60,7 @@ type Step = "password" | "actions" | "backup-codes"; export const Configure2FA = () => { const utils = api.useUtils(); + const { data: currentUser } = api.user.get.useQuery(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [step, setStep] = useState("password"); const [password, setPassword] = useState(""); @@ -158,6 +172,54 @@ export const Configure2FA = () => { } }; + 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 ( <> @@ -308,6 +370,25 @@ export const Configure2FA = () => {

+
+ + +
+