diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index fb089eb6..6d543516 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -8,6 +8,8 @@ import { AuthLayout } from "~/client/components/auth-layout"; import { Button } from "~/client/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form"; 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"; @@ -36,6 +38,10 @@ export default function LoginPage() { const navigate = useNavigate(); const [showResetDialog, setShowResetDialog] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false); + const [requires2FA, setRequires2FA] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [isVerifying2FA, setIsVerifying2FA] = useState(false); + const [trustDevice, setTrustDevice] = useState(false); const form = useForm({ resolver: arktypeResolver(loginSchema), @@ -65,6 +71,11 @@ export default function LoginPage() { return; } + if ("twoFactorRedirect" in data && data.twoFactorRedirect) { + setRequires2FA(true); + return; + } + const d = await authClient.getSession(); if (data.user && !d.data?.user.hasDownloadedResticPassword) { void navigate("/download-recovery-key"); @@ -73,6 +84,113 @@ export default function LoginPage() { } }; + const handleVerify2FA = async () => { + if (totpCode.length !== 6) { + toast.error("Please enter a 6-digit code"); + return; + } + + setIsVerifying2FA(true); + + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: totpCode, + trustDevice, + }); + + setIsVerifying2FA(false); + + if (error) { + console.error(error); + toast.error("Verification failed", { description: error.message }); + setTotpCode(""); + return; + } + + if (data) { + toast.success("Login successful"); + const session = await authClient.getSession(); + if (session.data?.user && !session.data.user.hasDownloadedResticPassword) { + void navigate("/download-recovery-key"); + } else { + void navigate("/volumes"); + } + } + }; + + const handleBackToLogin = () => { + setRequires2FA(false); + setTotpCode(""); + setTrustDevice(false); + form.reset(); + }; + + if (requires2FA) { + return ( + +
+
+ +
+ + + + + + + + + + + + + +
+
+ +
+ setTrustDevice(e.target.checked)} + className="h-4 w-4" + /> + +
+ +
+ + +
+
+
+ ); + } + return (
diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 70a2fe8a..4b2eb0d1 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -1,8 +1,9 @@ import { useMutation } from "@tanstack/react-query"; -import { Download, KeyRound, User, X } from "lucide-react"; +import { Download, KeyRound, User, X, Shield, Copy, RefreshCw } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { QRCodeCanvas } from "qrcode.react"; import { Button } from "~/client/components/ui/button"; import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card"; import { @@ -15,6 +16,7 @@ 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"; @@ -47,6 +49,23 @@ export default function Settings({ loaderData }: Route.ComponentProps) { const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); 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 () => { @@ -137,6 +156,151 @@ 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 (
@@ -268,6 +432,309 @@ 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/context.ts b/app/context.ts index 686511ad..17a8ce0e 100644 --- a/app/context.ts +++ b/app/context.ts @@ -5,6 +5,7 @@ type User = { email: string; username: string; hasDownloadedResticPassword: boolean; + twoFactorEnabled?: boolean | null; }; type AppContext = { diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 9220c02c..647a2f4e 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -114,12 +114,12 @@ export const verification = sqliteTable( id: text("id").primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), - expiresAt: integer("expires_at", { mode: "number" }).notNull(), - createdAt: integer("created_at", { mode: "number" }) + expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) .notNull() .default(sql`(unixepoch() * 1000)`), - updatedAt: integer("updated_at", { mode: "number" }) - .$onUpdate(() => Date.now()) + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .$onUpdate(() => new Date()) .notNull() .default(sql`(unixepoch() * 1000)`), },