Require your TFA pin to disable TFA
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 1m16s

This commit is contained in:
Braydon 2024-09-19 23:34:11 -04:00
parent 505e62aa61
commit a71502d0b2
4 changed files with 228 additions and 22 deletions

@ -21,9 +21,8 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import TfaSetupForm from "@/components/dashboard/user/settings/tfa/tfa-setup-form"; 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";
import { toast } from "sonner"; import { toast } from "sonner";
import UserTfaPrompt from "@/components/user/user-tfa-prompt";
/** /**
* The setting that allows a * The setting that allows a
@ -39,13 +38,11 @@ const TFASetting = (): ReactElement => {
const user: User | undefined = useUserContext( const user: User | undefined = useUserContext(
(state: UserState) => state.user (state: UserState) => state.user
); );
const router: AppRouterInstance = useRouter();
const [tfaResponse, setTfaResponse] = useState< const [tfaResponse, setTfaResponse] = useState<
UserSetupTfaResponse | undefined UserSetupTfaResponse | undefined
>(undefined); >(undefined);
const [enabledTfa, setEnabledTfa] = useState<boolean>(false); const [enabledTfa, setEnabledTfa] = useState<boolean>(false);
const [disabling, setDisabling] = useState<boolean>(false);
const onDialogStateChange = async (open: boolean) => { const onDialogStateChange = async (open: boolean) => {
if (open) { if (open) {
@ -77,20 +74,30 @@ const TFASetting = (): ReactElement => {
/** /**
* Disable two-factor auth. * Disable two-factor auth.
*/ */
const disableTfa = async () => { const disableTfa = async ({
setDisabling(true); pin,
await apiRequest<void>({ setError,
}: {
pin: string;
setError: (error: string | undefined) => void;
}) => {
const { error } = await apiRequest<void>({
endpoint: "/user/disable-tfa", endpoint: "/user/disable-tfa",
method: "POST", method: "POST",
session, session,
body: { pin },
}); });
setError(error?.message);
if (!error) {
toast("Two-Factor Auth", { toast("Two-Factor Auth", {
icon: "🔓", icon: "🔓",
description: "Two-factor auth has been disabled for your account.", description:
"Two-factor auth has been disabled for your account.",
}); });
setTimeout(() => { setTimeout(() => {
window.location.reload(); window.location.reload();
}, 1500); }, 1500);
}
}; };
return ( return (
@ -107,14 +114,15 @@ const TFASetting = (): ReactElement => {
{/* Setting */} {/* Setting */}
{hasFlag(user as User, UserFlag.TFA_ENABLED) ? ( {hasFlag(user as User, UserFlag.TFA_ENABLED) ? (
<Button <UserTfaPrompt
size="sm" message="Please verify it's you before disabling two-factor auth."
variant="destructive" submitButtonText="Disable TFA"
onClick={disableTfa} onSubmit={disableTfa}
disabled={disabling}
> >
<Button size="sm" variant="destructive">
Disable Disable
</Button> </Button>
</UserTfaPrompt>
) : ( ) : (
<Dialog onOpenChange={onDialogStateChange}> <Dialog onOpenChange={onDialogStateChange}>
<DialogTrigger> <DialogTrigger>
@ -122,6 +130,7 @@ const TFASetting = (): ReactElement => {
className="bg-background/30" className="bg-background/30"
size="sm" size="sm"
variant="outline" variant="outline"
disabled={enabledTfa}
> >
Setup Setup
</Button> </Button>

@ -1,3 +1,5 @@
"use client";
import { ReactElement, useState } from "react"; import { ReactElement, useState } from "react";
import { UserSetupTfaResponse } from "@/app/types/user/response/user-setup-tfa-response"; import { UserSetupTfaResponse } from "@/app/types/user/response/user-setup-tfa-response";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@ -25,6 +27,7 @@ const FormSchema = z.object({
* the setup of TFA for a user. * the setup of TFA for a user.
* *
* @param tfaResponse the tfa setup response * @param tfaResponse the tfa setup response
* @param setEnabledTfa the function to invoke to indicate tfa is enabled
* @return the form jsx * @return the form jsx
*/ */
const TfaSetupForm = ({ const TfaSetupForm = ({

@ -44,7 +44,10 @@ const DialogContent = React.forwardRef<
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <DialogPrimitive.Close
id="closeDialog"
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
>
<Cross2Icon className="h-4 w-4" /> <Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

@ -0,0 +1,191 @@
"use client";
import { ReactElement, ReactNode, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Button } from "@/components/ui/button";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
/**
* The props for the TFA prompt.
*/
type UserTfaPromptProps = {
/**
* The message to display under the title.
*/
message: string;
/**
* The text to display for the submit button.
*/
submitButtonText: string;
/**
* Invoked when the form is submitted.
*
* @param pin the pin to submit
* @param setError the error setter
* @param closePrompt the prompt closer
*/
onSubmit: ({
pin,
setError,
closePrompt,
}: {
pin: string;
setError: (error: string | undefined) => void;
closePrompt: () => void;
}) => Promise<any>;
/**
* The elements to trigger the prompt.
*/
children: ReactNode;
};
const FormSchema = z.object({
pin: z.string(),
});
/**
* A universal prompt for a user's TFA pin.
*
* @param message the message to display
* @param submitButtonText the submit text
* @param onSubmit the function to invoke when the form is submitted
* @param children the elements to trigger the prompt.
* @return the prompt jsx
*/
const UserTfaPrompt = ({
message,
submitButtonText,
onSubmit,
children,
}: UserTfaPromptProps): ReactElement => {
const [pin, setPin] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>();
// Build the form
const {
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(FormSchema),
});
/**
* Handle submitting the form.
*
* @param pin the submitted pin
*/
const handleFormSubmit = async ({ pin }: any) => {
if (pin.length === 6) {
setLoading(true);
await onSubmit({
pin,
setError: (error: string | undefined) => {
setError(error);
if (error) {
setLoading(false);
}
},
closePrompt: () =>
document.getElementById("closeDialog")?.click(),
});
}
};
/**
* Cleanup the prompt when closed.
*/
const cleanup = () => {
setPin("");
setLoading(false);
setError(undefined);
};
// Render the prompt
return (
<Dialog
onOpenChange={(open: boolean) => {
if (!open) {
cleanup();
}
}}
>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent>
{/* Header */}
<DialogHeader>
<DialogTitle>Two-Factor Border</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
{/* Content */}
<form
className="flex flex-col gap-4 items-center"
onSubmit={async (event) => {
event.preventDefault();
await handleFormSubmit({ pin });
}}
>
{/* Input */}
<InputOTP
maxLength={6}
value={pin}
onChange={async (pin: string) => {
await handleFormSubmit({ pin });
setPin(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={loading}
>
{loading && (
<ArrowPathIcon className="w-4 h-4 animate-spin" />
)}
{submitButtonText}
</Button>
</form>
</DialogContent>
</Dialog>
);
};
export default UserTfaPrompt;