impl tfa setup via user settings

This commit is contained in:
Braydon 2024-09-19 07:24:10 -04:00
parent 45abb31cfe
commit 63166d4e45
14 changed files with 450 additions and 7 deletions

BIN
bun.lockb

Binary file not shown.

@ -30,11 +30,13 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "1.0.0", "cmdk": "1.0.0",
"framer-motion": "^11.5.4", "framer-motion": "^11.5.4",
"input-otp": "^1.2.4",
"lossless-json": "^4.0.2", "lossless-json": "^4.0.2",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"next": "14.2.8", "next": "14.2.8",
"next-client-cookies": "^1.1.1", "next-client-cookies": "^1.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"qrcode.react": "^4.0.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",

@ -13,10 +13,10 @@ import { User } from "@/app/types/user/user";
import { useUserContext } from "@/app/provider/user-provider"; import { useUserContext } from "@/app/provider/user-provider";
import { UserState } from "@/app/store/user-store"; import { UserState } from "@/app/store/user-store";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import AvatarSetting from "@/components/dashboard/user/avatar-setting"; import AvatarSetting from "@/components/dashboard/user/profile/avatar-setting";
import UsernameSetting from "@/components/dashboard/user/username-setting"; import UsernameSetting from "@/components/dashboard/user/profile/username-setting";
import EmailSetting from "@/components/dashboard/user/email-setting"; import EmailSetting from "@/components/dashboard/user/profile/email-setting";
import TierSetting from "@/components/dashboard/user/tier-setting"; import TierSetting from "@/components/dashboard/user/profile/tier-setting";
/** /**
* The user profile page. * The user profile page.

@ -13,6 +13,7 @@ import { User } from "@/app/types/user/user";
import { useUserContext } from "@/app/provider/user-provider"; import { useUserContext } from "@/app/provider/user-provider";
import { UserState } from "@/app/store/user-store"; import { UserState } from "@/app/store/user-store";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import TFASetting from "@/components/dashboard/user/settings/tfa/tfa-setting";
/** /**
* The user settings page. * The user settings page.
@ -26,7 +27,7 @@ const UserSettingsPage = (): ReactElement => (
{/* Content */} {/* Content */}
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<Separator className="opacity-65" /> <Separator className="opacity-65" />
Hello World <TFASetting />
</div> </div>
</main> </main>
); );

@ -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;
};

@ -2,7 +2,23 @@
* Flags for a {@link User}. * Flags for a {@link User}.
*/ */
export enum UserFlag { export enum UserFlag {
/**
* The user is disabled.
*/
DISABLED = 0, DISABLED = 0,
/**
* The user completed the onboarding process.
*/
COMPLETED_ONBOARDING = 1, COMPLETED_ONBOARDING = 1,
ADMINISTRATOR = 2,
/**
* The user has two-factor auth enabled.
*/
TFA_ENABLED = 2,
/**
* The user is an administrator.
*/
ADMINISTRATOR = 3,
} }

@ -33,7 +33,7 @@ const TierSetting = (): ReactElement => {
{capitalizeWords(user?.tier)} {capitalizeWords(user?.tier)}
</span> </span>
<Link href="/#pricing"> <Link href="/public#pricing">
<Button <Button
className="bg-background/30" className="bg-background/30"
size="sm" size="sm"

@ -0,0 +1,182 @@
import { ReactElement, useState } from "react";
import { User } from "@/app/types/user/user";
import { useUserContext } from "@/app/provider/user-provider";
import { UserState } from "@/app/store/user-store";
import { apiRequest } from "@/lib/api";
import { QRCodeCanvas } from "qrcode.react";
import { Session } from "@/app/types/user/session";
import { UserSetupTfaResponse } from "@/app/types/user/response/user-setup-tfa-response";
import { Input } from "@/components/ui/input";
import { hasFlag } from "@/lib/user";
import { UserFlag } from "@/app/types/user/user-flag";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import TfaSetupForm from "@/components/dashboard/user/settings/tfa/tfa-setup-form";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
/**
* The setting that allows a
* {@link User} to enable
* two-factor authentication.
*
* @return the setting jsx
*/
const TFASetting = (): ReactElement => {
const session: Session | undefined = useUserContext(
(state: UserState) => state.session
);
const user: User | undefined = useUserContext(
(state: UserState) => state.user
);
const [tfaResponse, setTfaResponse] = useState<
UserSetupTfaResponse | undefined
>(undefined);
const [enabledTfa, setEnabledTfa] = useState<boolean>(false);
const router: AppRouterInstance = useRouter();
/**
* Start setting up two-factor auth.
*/
const setupTfa = async () => {
const { data, error } = await apiRequest<UserSetupTfaResponse>({
endpoint: "/user/setup-tfa",
method: "POST",
session,
});
setTfaResponse(data);
};
return (
<div className="px-5 flex items-center">
{/* Name & Description */}
<div className="w-96 flex flex-col gap-0.5 select-none pointer-events-none">
<h1 className="text-lg font-bold">Two-Factor Auth</h1>
<p className="max-w-64 text-sm opacity-75">
Enhance your account security with an extra layer of
protection. Enable Two-Factor Authentication for safer
access!
</p>
</div>
{/* Setting */}
{hasFlag(user as User, UserFlag.TFA_ENABLED) ? (
<Button size="sm" variant="destructive">
Disable
</Button>
) : (
<Dialog
onOpenChange={async (open: boolean) => {
if (open) {
setupTfa();
} else if (enabledTfa) {
router.push("/auth");
}
}}
>
<DialogTrigger>
<Button
className="bg-background/30"
size="sm"
variant="outline"
>
Setup
</Button>
</DialogTrigger>
<DialogContent className="max-w-[38rem]">
<DialogHeader className="select-none pointer-events-none">
<DialogTitle className="text-xl font-bold">
{enabledTfa
? "Two-Factor Auth Enabled"
: "Setup Two-Factor Auth"}
</DialogTitle>
{enabledTfa ? (
<DialogDescription>
Your account now has two-factor
authentication <b>enabled</b>! Make sure to
save the backup codes below,{" "}
<b>
this is the last time you&apos;ll see
them!
</b>
</DialogDescription>
) : (
<DialogDescription>
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.
</DialogDescription>
)}
</DialogHeader>
{/* Content */}
{!tfaResponse ? (
<div className="opacity-75 select-none pointer-events-none">
Loading...
</div>
) : (
<div className="flex gap-7 justify-center items-center">
{!enabledTfa && (
<QRCode tfaResponse={tfaResponse} />
)}
<TfaSetupForm
tfaResponse={tfaResponse}
setEnabledTfa={setEnabledTfa}
/>
</div>
)}
{/* Close */}
{enabledTfa && (
<div className="mx-auto">
<DialogClose asChild>
<Button
className="w-44 bg-zinc-900"
variant="outline"
>
I saved my codes!
</Button>
</DialogClose>
</div>
)}
{/* Notice */}
{!enabledTfa && (
<DialogFooter className="sm:justify-center gap-1.5 text-sm opacity-75">
<b>NOTE:</b>Enabling two-factor auth will log
you out of all devices.
</DialogFooter>
)}
</DialogContent>
</Dialog>
)}
</div>
);
};
const QRCode = ({
tfaResponse,
}: {
tfaResponse: UserSetupTfaResponse;
}): ReactElement => (
<div className="px-2 py-6 flex flex-col gap-4 items-center">
<QRCodeCanvas size={156} value={tfaResponse.qrCodeUrl} />
<div className="flex flex-col gap-1 items-center">
<p className="opacity-75">Or manually copy this code...</p>
<Input className="mx-14" value={tfaResponse.secret} readOnly />
</div>
</div>
);
export default TFASetting;

@ -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<string | undefined>();
const [verifying, setVerifying] = useState<boolean>(false);
const [backupCodes, setBackupCodes] = useState<string[] | undefined>();
const [error, setError] = useState<string | undefined>(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<string[]>({
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 (
<form
className="flex flex-col gap-4 items-center"
onSubmit={handleSubmit(onSubmit)}
>
{backupCodes ? (
<div className="mt-3 grid grid-cols-2 gap-4">
{backupCodes.map((code, index) => (
<div key={index} className="p-2 border rounded-md">
{code}
</div>
))}
</div>
) : (
<>
{/* Notice */}
<p className="max-w-64 text-sm text-center opacity-75">
Enter the 6-digit pin provided by your authenticator app
below to enable two-factor authentication!
</p>
{/* Input */}
<InputOTP
maxLength={6}
value={value}
onChange={async (pin: string) => {
await onSubmit({ pin });
setValue(pin);
}}
>
<InputOTPGroup>
{[0, 1, 2].map((index: number) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
{[3, 4, 5].map((index: number) => (
<InputOTPSlot key={index} index={index} />
))}
</InputOTPGroup>
</InputOTP>
{/* Display the error if it exists */}
{error && (
<p className="-mt-2 pb-0.5 text-red-500">{error}</p>
)}
{/* Verify Pin */}
<Button
className="w-32 flex gap-2.5 items-center text-white"
type="submit"
disabled={verifying}
>
{verifying && (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
)}
<span>{verifying ? "Verifying..." : "Verify Pin"}</span>
</Button>
</>
)}
</form>
);
};
export default TfaSetupForm;

@ -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<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
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 (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<DashIcon />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

@ -61,6 +61,15 @@ const config: Config = {
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", 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")], plugins: [require("tailwindcss-animate")],