diff --git a/bun.lockb b/bun.lockb index c3bb01f..434348d 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c3618b6..4da62df 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,13 @@ "clsx": "^2.1.1", "cmdk": "1.0.0", "framer-motion": "^11.5.4", + "input-otp": "^1.2.4", "lossless-json": "^4.0.2", "lucide-react": "^0.441.0", "next": "14.2.8", "next-client-cookies": "^1.1.1", "next-themes": "^0.3.0", + "qrcode.react": "^4.0.1", "react": "^18", "react-dom": "^18", "react-hook-form": "^7.53.0", diff --git a/src/app/(pages)/dashboard/user/profile/page.tsx b/src/app/(pages)/dashboard/user/profile/page.tsx index 07936dc..47758ff 100644 --- a/src/app/(pages)/dashboard/user/profile/page.tsx +++ b/src/app/(pages)/dashboard/user/profile/page.tsx @@ -13,10 +13,10 @@ import { User } from "@/app/types/user/user"; import { useUserContext } from "@/app/provider/user-provider"; import { UserState } from "@/app/store/user-store"; import { Separator } from "@/components/ui/separator"; -import AvatarSetting from "@/components/dashboard/user/avatar-setting"; -import UsernameSetting from "@/components/dashboard/user/username-setting"; -import EmailSetting from "@/components/dashboard/user/email-setting"; -import TierSetting from "@/components/dashboard/user/tier-setting"; +import AvatarSetting from "@/components/dashboard/user/profile/avatar-setting"; +import UsernameSetting from "@/components/dashboard/user/profile/username-setting"; +import EmailSetting from "@/components/dashboard/user/profile/email-setting"; +import TierSetting from "@/components/dashboard/user/profile/tier-setting"; /** * The user profile page. diff --git a/src/app/(pages)/dashboard/user/settings/page.tsx b/src/app/(pages)/dashboard/user/settings/page.tsx index 682d8e6..a30d346 100644 --- a/src/app/(pages)/dashboard/user/settings/page.tsx +++ b/src/app/(pages)/dashboard/user/settings/page.tsx @@ -13,6 +13,7 @@ import { User } from "@/app/types/user/user"; import { useUserContext } from "@/app/provider/user-provider"; import { UserState } from "@/app/store/user-store"; import { Separator } from "@/components/ui/separator"; +import TFASetting from "@/components/dashboard/user/settings/tfa/tfa-setting"; /** * The user settings page. @@ -26,7 +27,7 @@ const UserSettingsPage = (): ReactElement => ( {/* Content */}
- Hello World +
); diff --git a/src/app/types/user/response/user-setup-tfa-response.ts b/src/app/types/user/response/user-setup-tfa-response.ts new file mode 100644 index 0000000..f81026a --- /dev/null +++ b/src/app/types/user/response/user-setup-tfa-response.ts @@ -0,0 +1,16 @@ +/** + * The response for when a {@link User} + * initializes the setup of two-factor + * authentication. + */ +export type UserSetupTfaResponse = { + /** + * The TFA secret. + */ + secret: string; + + /** + * The URL to the QR code. + */ + qrCodeUrl: string; +}; diff --git a/src/app/types/user/user-flag.ts b/src/app/types/user/user-flag.ts index 7efe8f7..b48aa38 100644 --- a/src/app/types/user/user-flag.ts +++ b/src/app/types/user/user-flag.ts @@ -2,7 +2,23 @@ * Flags for a {@link User}. */ export enum UserFlag { + /** + * The user is disabled. + */ DISABLED = 0, + + /** + * The user completed the onboarding process. + */ COMPLETED_ONBOARDING = 1, - ADMINISTRATOR = 2, + + /** + * The user has two-factor auth enabled. + */ + TFA_ENABLED = 2, + + /** + * The user is an administrator. + */ + ADMINISTRATOR = 3, } diff --git a/src/components/dashboard/user/avatar-setting.tsx b/src/components/dashboard/user/profile/avatar-setting.tsx similarity index 100% rename from src/components/dashboard/user/avatar-setting.tsx rename to src/components/dashboard/user/profile/avatar-setting.tsx diff --git a/src/components/dashboard/user/email-setting.tsx b/src/components/dashboard/user/profile/email-setting.tsx similarity index 100% rename from src/components/dashboard/user/email-setting.tsx rename to src/components/dashboard/user/profile/email-setting.tsx diff --git a/src/components/dashboard/user/tier-setting.tsx b/src/components/dashboard/user/profile/tier-setting.tsx similarity index 97% rename from src/components/dashboard/user/tier-setting.tsx rename to src/components/dashboard/user/profile/tier-setting.tsx index 3359293..ee041c1 100644 --- a/src/components/dashboard/user/tier-setting.tsx +++ b/src/components/dashboard/user/profile/tier-setting.tsx @@ -33,7 +33,7 @@ const TierSetting = (): ReactElement => { {capitalizeWords(user?.tier)} - + + ) : ( + { + if (open) { + setupTfa(); + } else if (enabledTfa) { + router.push("/auth"); + } + }} + > + + + + + + + {enabledTfa + ? "Two-Factor Auth Enabled" + : "Setup Two-Factor Auth"} + + {enabledTfa ? ( + + Your account now has two-factor + authentication enabled! Make sure to + save the backup codes below,{" "} + + this is the last time you'll see + them! + + + ) : ( + + Follow the steps below to setup two-factor + authentication on your account. This adds an + extra layer of security and will require not + only your password, but also your mobile + device during login. + + )} + + + {/* Content */} + {!tfaResponse ? ( +
+ Loading... +
+ ) : ( +
+ {!enabledTfa && ( + + )} + +
+ )} + + {/* Close */} + {enabledTfa && ( +
+ + + +
+ )} + + {/* Notice */} + {!enabledTfa && ( + + NOTE:Enabling two-factor auth will log + you out of all devices. + + )} +
+
+ )} + + ); +}; + +const QRCode = ({ + tfaResponse, +}: { + tfaResponse: UserSetupTfaResponse; +}): ReactElement => ( +
+ +
+

Or manually copy this code...

+ +
+
+); + +export default TFASetting; diff --git a/src/components/dashboard/user/settings/tfa/tfa-setup-form.tsx b/src/components/dashboard/user/settings/tfa/tfa-setup-form.tsx new file mode 100644 index 0000000..af6ca39 --- /dev/null +++ b/src/components/dashboard/user/settings/tfa/tfa-setup-form.tsx @@ -0,0 +1,146 @@ +import { ReactElement, useState } from "react"; +import { UserSetupTfaResponse } from "@/app/types/user/response/user-setup-tfa-response"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { Button } from "@/components/ui/button"; +import { z } from "zod"; +import { apiRequest } from "@/lib/api"; +import { Session } from "@/app/types/user/session"; +import { useUserContext } from "@/app/provider/user-provider"; +import { UserState } from "@/app/store/user-store"; +import { ArrowPathIcon } from "@heroicons/react/24/outline"; + +const FormSchema = z.object({ + pin: z.string(), +}); + +/** + * The form used to complete + * the setup of TFA for a user. + * + * @param tfaResponse the tfa setup response + * @return the form jsx + */ +const TfaSetupForm = ({ + tfaResponse, + setEnabledTfa, +}: { + tfaResponse: UserSetupTfaResponse; + setEnabledTfa: (hidden: boolean) => void; +}): ReactElement => { + const session: Session | undefined = useUserContext( + (state: UserState) => state.session + ); + + const [value, setValue] = useState(); + const [verifying, setVerifying] = useState(false); + const [backupCodes, setBackupCodes] = useState(); + const [error, setError] = useState(undefined); + + // Build the form + const { + register, + handleSubmit, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(FormSchema), + }); + + /** + * Handle submitting the form. + */ + const onSubmit = async ({ pin }: any) => { + if (pin.length !== 6) { + return; + } + setVerifying(true); + const { data, error } = await apiRequest({ + endpoint: "/user/enable-tfa", + method: "POST", + session, + body: { + secret: tfaResponse?.secret, + pin, + }, + }); + setBackupCodes(data); + if (data) { + setEnabledTfa(true); + } + setError(error?.message ?? undefined); + setVerifying(false); + }; + + return ( +
+ {backupCodes ? ( +
+ {backupCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+ ) : ( + <> + {/* Notice */} +

+ Enter the 6-digit pin provided by your authenticator app + below to enable two-factor authentication! +

+ + {/* Input */} + { + await onSubmit({ pin }); + setValue(pin); + }} + > + + {[0, 1, 2].map((index: number) => ( + + ))} + + + + {[3, 4, 5].map((index: number) => ( + + ))} + + + + {/* Display the error if it exists */} + {error && ( +

{error}

+ )} + + {/* Verify Pin */} + + + )} +
+ ); +}; + +export default TfaSetupForm; diff --git a/src/components/ui/input-otp.tsx b/src/components/ui/input-otp.tsx new file mode 100644 index 0000000..25be93f --- /dev/null +++ b/src/components/ui/input-otp.tsx @@ -0,0 +1,71 @@ +"use client"; + +import * as React from "react"; +import { DashIcon } from "@radix-ui/react-icons"; +import { OTPInput, OTPInputContext } from "input-otp"; + +import { cn } from "@/lib/utils"; + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)); +InputOTP.displayName = "InputOTP"; + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)); +InputOTPGroup.displayName = "InputOTPGroup"; + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +}); +InputOTPSlot.displayName = "InputOTPSlot"; + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)); +InputOTPSeparator.displayName = "InputOTPSeparator"; + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/tailwind.config.ts b/tailwind.config.ts index ffff7ea..8ae2243 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -61,6 +61,15 @@ const config: Config = { md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, + keyframes: { + "caret-blink": { + "0%,70%,100%": { opacity: "1" }, + "20%,50%": { opacity: "0" }, + }, + }, + animation: { + "caret-blink": "caret-blink 1.25s ease-out infinite", + }, }, }, plugins: [require("tailwindcss-animate")],