impl tfa setup via user settings
This commit is contained in:
parent
45abb31cfe
commit
63166d4e45
@ -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",
|
||||
|
@ -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.
|
||||
|
@ -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 */}
|
||||
<div className="flex flex-col gap-5">
|
||||
<Separator className="opacity-65" />
|
||||
Hello World
|
||||
<TFASetting />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
16
src/app/types/user/response/user-setup-tfa-response.ts
Normal file
16
src/app/types/user/response/user-setup-tfa-response.ts
Normal file
@ -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}.
|
||||
*/
|
||||
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,
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ const TierSetting = (): ReactElement => {
|
||||
{capitalizeWords(user?.tier)}
|
||||
</span>
|
||||
|
||||
<Link href="/#pricing">
|
||||
<Link href="/public#pricing">
|
||||
<Button
|
||||
className="bg-background/30"
|
||||
size="sm"
|
182
src/components/dashboard/user/settings/tfa/tfa-setting.tsx
Normal file
182
src/components/dashboard/user/settings/tfa/tfa-setting.tsx
Normal file
@ -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'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;
|
146
src/components/dashboard/user/settings/tfa/tfa-setup-form.tsx
Normal file
146
src/components/dashboard/user/settings/tfa/tfa-setup-form.tsx
Normal file
@ -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;
|
71
src/components/ui/input-otp.tsx
Normal file
71
src/components/ui/input-otp.tsx
Normal file
@ -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)",
|
||||
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")],
|
||||
|
Loading…
x
Reference in New Issue
Block a user