From 1fd4b3c64e11b03689b0b72163806f618ba703f3 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 8 Jan 2026 20:50:58 +0100 Subject: [PATCH] refactor: split dialogs into components --- app/client/components/ui/input-otp.tsx | 103 ++-- app/client/modules/auth/routes/login.tsx | 6 +- .../components/backup-codes-dialog.tsx | 135 +++++ .../components/two-factor-disable-dialog.tsx | 98 ++++ .../components/two-factor-section.tsx | 71 +++ .../components/two-factor-setup-dialog.tsx | 266 ++++++++++ .../modules/settings/routes/settings.tsx | 487 +----------------- app/utils/clipboard.ts | 4 + 8 files changed, 640 insertions(+), 530 deletions(-) create mode 100644 app/client/modules/settings/components/backup-codes-dialog.tsx create mode 100644 app/client/modules/settings/components/two-factor-disable-dialog.tsx create mode 100644 app/client/modules/settings/components/two-factor-section.tsx create mode 100644 app/client/modules/settings/components/two-factor-setup-dialog.tsx diff --git a/app/client/components/ui/input-otp.tsx b/app/client/components/ui/input-otp.tsx index 376b8381..11575d92 100644 --- a/app/client/components/ui/input-otp.tsx +++ b/app/client/components/ui/input-otp.tsx @@ -1,75 +1,66 @@ -import * as React from "react" -import { OTPInput, OTPInputContext } from "input-otp" -import { MinusIcon } from "lucide-react" +import * as React from "react"; +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; -import { cn } from "~/client/lib/utils" +import { cn } from "~/client/lib/utils"; function InputOTP({ - className, - containerClassName, - ...props + className, + containerClassName, + ...props }: React.ComponentProps & { - containerClassName?: string + containerClassName?: string; }) { - return ( - - ) + return ( + + ); } function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return
; } function InputOTPSlot({ - index, - className, - ...props + index, + className, + ...props }: React.ComponentProps<"div"> & { - index: number + index: number; }) { - const inputOTPContext = React.useContext(OTPInputContext) - const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {} + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; - return ( -
- {char} - {hasFakeCaret && ( -
-
-
- )} -
- ) + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); } function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( -
- -
- ) + return ( +
+ +
+ ); } -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index 6d543516..1e6a92e6 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -10,10 +10,10 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from " import { Input } from "~/client/components/ui/input"; import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; import { Label } from "~/client/components/ui/label"; -import { authMiddleware } from "~/middleware/auth"; -import type { Route } from "./+types/login"; -import { ResetPasswordDialog } from "../components/reset-password-dialog"; import { authClient } from "~/client/lib/auth-client"; +import { authMiddleware } from "~/middleware/auth"; +import { ResetPasswordDialog } from "../components/reset-password-dialog"; +import type { Route } from "./+types/login"; export const clientMiddleware = [authMiddleware]; diff --git a/app/client/modules/settings/components/backup-codes-dialog.tsx b/app/client/modules/settings/components/backup-codes-dialog.tsx new file mode 100644 index 00000000..eb6b2627 --- /dev/null +++ b/app/client/modules/settings/components/backup-codes-dialog.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Copy, RefreshCw } from "lucide-react"; +import { Button } from "~/client/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/client/components/ui/dialog"; +import { Input } from "~/client/components/ui/input"; +import { Label } from "~/client/components/ui/label"; +import { authClient } from "~/client/lib/auth-client"; +import { copyToClipboard } from "~/utils/clipboard"; + +type BackupCodesDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export const BackupCodesDialog = ({ open, onOpenChange }: BackupCodesDialogProps) => { + const [password, setPassword] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + + const handleGenerate = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error("Password is required"); + return; + } + + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + fetchOptions: { + onRequest: () => { + setIsGenerating(true); + }, + onResponse: () => { + setIsGenerating(false); + }, + }, + }); + + if (error) { + console.error(error); + toast.error("Failed to generate backup codes", { description: error.message }); + return; + } + + setBackupCodes(data.backupCodes); + setPassword(""); + toast.success("New backup codes generated successfully"); + }; + + const handleClose = () => { + onOpenChange(false); + setTimeout(() => { + setBackupCodes([]); + setPassword(""); + }, 200); + }; + + const copyAllBackupCodes = () => { + const text = backupCodes.join("\n"); + void copyToClipboard(text); + }; + + return ( + + + + Backup Codes + + Use these codes to access your account if you lose access to your authenticator app. Each code can only be + used once. + + +
+ {backupCodes.length > 0 ? ( + <> +
+ {backupCodes.map((code) => ( +
+ {code} + +
+ ))} +
+ + + ) : ( +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+ +
+ )} +
+ + + +
+
+ ); +}; diff --git a/app/client/modules/settings/components/two-factor-disable-dialog.tsx b/app/client/modules/settings/components/two-factor-disable-dialog.tsx new file mode 100644 index 00000000..81c7e986 --- /dev/null +++ b/app/client/modules/settings/components/two-factor-disable-dialog.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/client/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/client/components/ui/dialog"; +import { Input } from "~/client/components/ui/input"; +import { Label } from "~/client/components/ui/label"; +import { authClient } from "~/client/lib/auth-client"; + +type TwoFactorDisableDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +}; + +export const TwoFactorDisableDialog = ({ open, onOpenChange, onSuccess }: TwoFactorDisableDialogProps) => { + const [password, setPassword] = useState(""); + const [isDisabling, setIsDisabling] = useState(false); + + const handleDisable = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error("Password is required"); + return; + } + + const { error } = await authClient.twoFactor.disable({ + password, + fetchOptions: { + onRequest: () => { + setIsDisabling(true); + }, + onResponse: () => { + setIsDisabling(false); + }, + }, + }); + + if (error) { + console.error(error); + toast.error("Failed to disable 2FA", { description: error.message }); + return; + } + + toast.success("Two-factor authentication disabled successfully"); + handleClose(); + onSuccess(); + }; + + const handleClose = () => { + onOpenChange(false); + setPassword(""); + }; + + return ( + + +
+ + Disable Two-Factor Authentication + + Are you sure you want to disable 2FA? Your account will be less secure. Enter your password to confirm. + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + + + +
+
+
+ ); +}; diff --git a/app/client/modules/settings/components/two-factor-section.tsx b/app/client/modules/settings/components/two-factor-section.tsx new file mode 100644 index 00000000..7f8b5c8f --- /dev/null +++ b/app/client/modules/settings/components/two-factor-section.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { Shield } from "lucide-react"; +import { Button } from "~/client/components/ui/button"; +import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; +import { TwoFactorSetupDialog } from "./two-factor-setup-dialog"; +import { TwoFactorDisableDialog } from "./two-factor-disable-dialog"; +import { BackupCodesDialog } from "./backup-codes-dialog"; + +type TwoFactorSectionProps = { + twoFactorEnabled?: boolean | null; +}; + +export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) => { + const [setupDialogOpen, setSetupDialogOpen] = useState(false); + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [backupCodesDialogOpen, setBackupCodesDialogOpen] = useState(false); + + const handleSuccess = async () => { + window.location.reload(); + }; + + return ( + <> +
+ + + Two-Factor Authentication + + Add an extra layer of security to your account +
+ +
+
+

+ Status:  + {twoFactorEnabled ? ( + Enabled + ) : ( + Disabled + )} +

+

+ Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app + in addition to your password. +

+
+
+ {!twoFactorEnabled ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ + + + + + + + ); +}; diff --git a/app/client/modules/settings/components/two-factor-setup-dialog.tsx b/app/client/modules/settings/components/two-factor-setup-dialog.tsx new file mode 100644 index 00000000..8cf15fa8 --- /dev/null +++ b/app/client/modules/settings/components/two-factor-setup-dialog.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Copy } from "lucide-react"; +import { QRCodeCanvas } from "qrcode.react"; +import { Button } from "~/client/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/client/components/ui/dialog"; +import { Input } from "~/client/components/ui/input"; +import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; +import { Label } from "~/client/components/ui/label"; +import { authClient } from "~/client/lib/auth-client"; +import { copyToClipboard } from "~/utils/clipboard"; + +type TwoFactorSetupDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +}; + +export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFactorSetupDialogProps) => { + const [setupStep, setSetupStep] = useState<"password" | "qr" | "verify">("password"); + const [password, setPassword] = useState(""); + const [totpUri, setTotpUri] = useState(null); + const [verificationCode, setVerificationCode] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [isEnabling2FA, setIsEnabling2FA] = useState(false); + const [isVerifying2FA, setIsVerifying2FA] = useState(false); + + const handleEnable2FA = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!password) { + toast.error("Password is required"); + return; + } + + const { data, error } = await authClient.twoFactor.enable({ + password, + issuer: "Zerobyte", + fetchOptions: { + onRequest: () => { + setIsEnabling2FA(true); + }, + onResponse: () => { + setIsEnabling2FA(false); + }, + }, + }); + + if (error) { + console.error(error); + toast.error("Failed to enable 2FA", { description: error.message }); + return; + } + + setTotpUri(data.totpURI); + setBackupCodes(data.backupCodes); + setSetupStep("qr"); + }; + + const handleVerify2FA = async () => { + if (verificationCode.length !== 6) { + toast.error("Please enter a 6-digit code"); + return; + } + + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: verificationCode, + fetchOptions: { + onRequest: () => { + setIsVerifying2FA(true); + }, + onResponse: () => { + setIsVerifying2FA(false); + }, + }, + }); + + if (error) { + console.error(error); + toast.error("Verification failed", { description: error.message }); + setVerificationCode(""); + return; + } + + if (data) { + toast.success("Two-factor authentication enabled successfully"); + handleClose(); + onSuccess(); + } + }; + + const handleClose = () => { + onOpenChange(false); + setTimeout(() => { + setPassword(""); + setTotpUri(null); + setVerificationCode(""); + setBackupCodes([]); + setSetupStep("password"); + }, 200); + }; + + const copyAllBackupCodes = () => { + const text = backupCodes.join("\n"); + void copyToClipboard(text); + }; + + return ( + + + {setupStep === "password" && ( +
+ + Enable Two-Factor Authentication + + Enter your password to generate a QR code for your authenticator app + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + autoFocus + /> +
+
+ + + + +
+ )} + + {setupStep === "qr" && totpUri && ( + <> + + Scan QR Code + + Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) + + +
+
+ +
+
+ +
+ + +
+
+ {backupCodes.length > 0 && ( +
+ +
+ {backupCodes.map((code) => ( +
+ {code} + +
+ ))} +
+ +
+ )} +
+ + + + + )} + + {setupStep === "verify" && ( + <> + + Verify setup + + Enter the 6-digit code from your authenticator app to complete setup + + +
+
+
+ + + + + + + + + + + + + +
+
+
+ + + + + + )} +
+
+ ); +}; diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 4b2eb0d1..e0a1638c 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,9 +1,9 @@ import { useMutation } from "@tanstack/react-query"; -import { Download, KeyRound, User, X, Shield, Copy, RefreshCw } from "lucide-react"; +import { Download, KeyRound, User, X } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; -import { QRCodeCanvas } from "qrcode.react"; +import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { @@ -16,12 +16,11 @@ import { DialogTrigger, } from "~/client/components/ui/dialog"; import { Input } from "~/client/components/ui/input"; -import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp"; import { Label } from "~/client/components/ui/label"; -import { appContext } from "~/context"; -import type { Route } from "./+types/settings"; -import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { authClient } from "~/client/lib/auth-client"; +import { appContext } from "~/context"; +import { TwoFactorSection } from "../components/two-factor-section"; +import type { Route } from "./+types/settings"; export const handle = { breadcrumb: () => [{ label: "Settings" }], @@ -50,22 +49,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const [downloadPassword, setDownloadPassword] = useState(""); const [isChangingPassword, setIsChangingPassword] = useState(false); - // 2FA states - const [setup2FADialogOpen, setSetup2FADialogOpen] = useState(false); - const [disable2FADialogOpen, setDisable2FADialogOpen] = useState(false); - const [backupCodesDialogOpen, setBackupCodesDialogOpen] = useState(false); - const [setup2FAPassword, setSetup2FAPassword] = useState(""); - const [disable2FAPassword, setDisable2FAPassword] = useState(""); - const [totpUri, setTotpUri] = useState(null); - const [verificationCode, setVerificationCode] = useState(""); - const [backupCodes, setBackupCodes] = useState([]); - const [setupStep, setSetupStep] = useState<"password" | "qr" | "verify">("password"); - const [isEnabling2FA, setIsEnabling2FA] = useState(false); - const [isVerifying2FA, setIsVerifying2FA] = useState(false); - const [isDisabling2FA, setIsDisabling2FA] = useState(false); - const [isGeneratingBackupCodes, setIsGeneratingBackupCodes] = useState(false); - const [backupCodesPassword, setBackupCodesPassword] = useState(""); - const navigate = useNavigate(); const handleLogout = async () => { @@ -100,7 +83,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) { setDownloadPassword(""); }, onError: (error) => { - toast.error("Failed to download Restic password", { description: error.message }); + toast.error("Failed to download Restic password", { + description: error.message, + }); }, }); @@ -129,7 +114,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }, 1500); }, onError: (error) => { - toast.error("Failed to change password", { description: error.error.message }); + toast.error("Failed to change password", { + description: error.error.message, + }); }, onRequest: () => { setIsChangingPassword(true); @@ -156,151 +143,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) { }); }; - // 2FA handlers - const handleEnable2FA = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!setup2FAPassword) { - toast.error("Password is required"); - return; - } - - setIsEnabling2FA(true); - - const { data, error } = await authClient.twoFactor.enable({ - password: setup2FAPassword, - issuer: "Zerobyte", - }); - - setIsEnabling2FA(false); - - if (error) { - console.error(error); - toast.error("Failed to enable 2FA", { description: error.message }); - return; - } - - if (data?.totpURI && data?.backupCodes) { - setTotpUri(data.totpURI); - setBackupCodes(data.backupCodes); - setSetupStep("qr"); - } - }; - - const handleVerify2FA = async () => { - if (verificationCode.length !== 6) { - toast.error("Please enter a 6-digit code"); - return; - } - - setIsVerifying2FA(true); - - const { data, error } = await authClient.twoFactor.verifyTotp({ - code: verificationCode, - }); - - setIsVerifying2FA(false); - - if (error) { - console.error(error); - toast.error("Verification failed", { description: error.message }); - setVerificationCode(""); - return; - } - - if (data) { - toast.success("Two-factor authentication enabled successfully"); - // Refresh the session to get updated user data - await authClient.getSession(); - // Reset and close dialog - handleClose2FASetup(); - // Reload the page to update the UI - window.location.reload(); - } - }; - - const handleDisable2FA = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!disable2FAPassword) { - toast.error("Password is required"); - return; - } - - setIsDisabling2FA(true); - - const { data, error } = await authClient.twoFactor.disable({ - password: disable2FAPassword, - }); - - setIsDisabling2FA(false); - - if (error) { - console.error(error); - toast.error("Failed to disable 2FA", { description: error.message }); - return; - } - - if (data) { - toast.success("Two-factor authentication disabled successfully"); - setDisable2FADialogOpen(false); - setDisable2FAPassword(""); - // Refresh the session to get updated user data - await authClient.getSession(); - // Reload the page to update the UI - window.location.reload(); - } - }; - - const handleGenerateBackupCodes = async (e: React.FormEvent) => { - e.preventDefault(); - - if (!backupCodesPassword) { - toast.error("Password is required"); - return; - } - - setIsGeneratingBackupCodes(true); - - const { data, error } = await authClient.twoFactor.generateBackupCodes({ - password: backupCodesPassword, - }); - - setIsGeneratingBackupCodes(false); - - if (error) { - console.error(error); - toast.error("Failed to generate backup codes", { description: error.message }); - return; - } - - if (data?.backupCodes) { - setBackupCodes(data.backupCodes); - setBackupCodesPassword(""); - toast.success("New backup codes generated successfully"); - } - }; - - const handleClose2FASetup = () => { - setSetup2FADialogOpen(false); - setSetup2FAPassword(""); - setTotpUri(null); - setVerificationCode(""); - setBackupCodes([]); - setSetupStep("password"); - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - toast.success("Copied to clipboard"); - }; - - const copyAllBackupCodes = () => { - const text = backupCodes.join("\n"); - navigator.clipboard.writeText(text); - toast.success("All backup codes copied to clipboard"); - }; - return (
@@ -315,6 +157,10 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
+
+ + +
@@ -433,308 +279,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) { -
- - - Two-Factor Authentication - - Add an extra layer of security to your account -
- -
-
-

- Status:  - {loaderData.user?.twoFactorEnabled ? ( - Enabled - ) : ( - Disabled - )} -

-

- Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app - in addition to your password. -

-
-
- {!loaderData.user?.twoFactorEnabled ? ( - - ) : ( - <> - - - - )} -
-
- - - - {setupStep === "password" && ( -
- - Enable Two-Factor Authentication - - Enter your password to generate a QR code for your authenticator app - - -
-
- - setSetup2FAPassword(e.target.value)} - placeholder="Enter your password" - required - autoFocus - /> -
-
- - - - -
- )} - - {setupStep === "qr" && totpUri && ( - <> - - Scan QR Code - - Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) - - -
-
- -
-
- -
- - -
-
- {backupCodes.length > 0 && ( -
- -
- {backupCodes.map((code) => ( -
- {code} - -
- ))} -
- -
- )} -
- - - - - )} - - {setupStep === "verify" && ( - <> - - Verify Setup - - Enter the 6-digit code from your authenticator app to complete setup - - -
-
- -
- - - - - - - - - - - - - -
-
-
- - - - - - )} -
-
- - - -
- - Disable Two-Factor Authentication - - Are you sure you want to disable 2FA? Your account will be less secure. Enter your password to - confirm. - - -
-
- - setDisable2FAPassword(e.target.value)} - placeholder="Enter your password" - required - autoFocus - /> -
-
- - - - -
-
-
- - - - - Backup Codes - - Use these codes to access your account if you lose access to your authenticator app. Each code can only - be used once. - - -
- {backupCodes.length > 0 ? ( - <> -
- {backupCodes.map((code) => ( -
- {code} - -
- ))} -
- - - ) : ( -
-
- - setBackupCodesPassword(e.target.value)} - placeholder="Enter your password" - required - autoFocus - /> -
- -
- )} -
- - - -
-
-
+ ); } diff --git a/app/utils/clipboard.ts b/app/utils/clipboard.ts index 95868893..f91c0153 100644 --- a/app/utils/clipboard.ts +++ b/app/utils/clipboard.ts @@ -1,7 +1,10 @@ +import { toast } from "sonner"; + export async function copyToClipboard(textToCopy: string) { // Navigator clipboard api needs a secure context (https) if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(textToCopy); + toast.success("Copied to clipboard"); } else { // Use the 'out of viewport hidden text area' trick const textArea = document.createElement("textarea"); @@ -16,6 +19,7 @@ export async function copyToClipboard(textToCopy: string) { try { document.execCommand("copy"); + toast.success("Copied to clipboard"); } catch (error) { console.error(error); } finally {