diff --git a/server/emails/templates/TwoFactorAuthNotification.tsx b/server/emails/templates/TwoFactorAuthNotification.tsx new file mode 100644 index 00000000..adb2e26f --- /dev/null +++ b/server/emails/templates/TwoFactorAuthNotification.tsx @@ -0,0 +1,83 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind +} from "@react-email/components"; +import * as React from "react"; + +interface Props { + email: string; + enabled: boolean; +} + +export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { + const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`; + + return ( + + + {previewText} + + + + + Two-Factor Authentication{" "} + {enabled ? "Enabled" : "Disabled"} + + + Hi {email || "there"}, + + + This email confirms that Two-Factor Authentication + has been successfully{" "} + {enabled ? "enabled" : "disabled"} on your account. + +
+ {enabled ? ( + + With Two-Factor Authentication enabled, your + account is now more secure. Please ensure + you keep your authentication method safe. + + ) : ( + + With Two-Factor Authentication disabled, + your account may be less secure. We + recommend enabling it to protect your + account. + + )} +
+ + If you did not make this change, please contact our + support team immediately. + + + Best regards, +
+ Fossorial +
+
+ +
+ + ); +}; + +export default TwoFactorAuthNotification; diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 05ed6338..8a635744 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -11,11 +11,16 @@ import { response } from "@server/utils"; import { verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/2fa"; import logger from "@server/logger"; +import { sendEmail } from "@server/emails"; +import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; +import config from "@server/config"; -export const disable2faBody = z.object({ - password: z.string(), - code: z.string().optional(), -}).strict(); +export const disable2faBody = z + .object({ + password: z.string(), + code: z.string().optional() + }) + .strict(); export type Disable2faBody = z.infer; @@ -26,7 +31,7 @@ export type Disable2faResponse = { export async function disable2fa( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { const parsedBody = disable2faBody.safeParse(req.body); @@ -34,8 +39,8 @@ export async function disable2fa( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -52,8 +57,8 @@ export async function disable2fa( return next( createHttpError( HttpCode.BAD_REQUEST, - "Two-factor authentication is already disabled", - ), + "Two-factor authentication is already disabled" + ) ); } else { if (!code) { @@ -62,7 +67,7 @@ export async function disable2fa( success: true, error: false, message: "Two-factor authentication required", - status: HttpCode.ACCEPTED, + status: HttpCode.ACCEPTED }); } } @@ -70,15 +75,15 @@ export async function disable2fa( const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, - user.userId, + user.userId ); if (!validOTP) { return next( createHttpError( HttpCode.BAD_REQUEST, - "The two-factor code you entered is incorrect", - ), + "The two-factor code you entered is incorrect" + ) ); } @@ -91,22 +96,32 @@ export async function disable2fa( .delete(twoFactorBackupCodes) .where(eq(twoFactorBackupCodes.userId, user.userId)); - // TODO: send email to user confirming two-factor authentication is disabled + sendEmail( + TwoFactorAuthNotification({ + email: user.email, + enabled: false + }), + { + to: user.email, + from: config.email?.no_reply, + subject: "Two-factor authentication disabled" + } + ); return response(res, { data: null, success: true, error: false, message: "Two-factor authentication disabled", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to disable two-factor authentication", - ), + "Failed to disable two-factor authentication" + ) ); } } diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 1c99ebba..4f8dfb42 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -25,6 +25,7 @@ export type RequestTotpSecretBody = z.infer; export type RequestTotpSecretResponse = { secret: string; + uri: string; }; export async function requestTotpSecret( @@ -75,7 +76,8 @@ export async function requestTotpSecret( return response(res, { data: { - secret: uri + secret, + uri }, success: true, error: false, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 185f3d1a..ccc24df1 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -11,6 +11,9 @@ import { alphabet, generateRandomString } from "oslo/crypto"; import { hashPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/2fa"; import logger from "@server/logger"; +import { sendEmail } from "@server/emails"; +import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; +import config from "@server/config"; export const verifyTotpBody = z .object({ @@ -90,8 +93,6 @@ export async function verifyTotp( } } - // TODO: send email to user confirming two-factor authentication is enabled - if (!valid) { return next( createHttpError( @@ -101,6 +102,18 @@ export async function verifyTotp( ); } + sendEmail( + TwoFactorAuthNotification({ + email: user.email, + enabled: true + }), + { + to: user.email, + from: config.email?.no_reply, + subject: "Two-factor authentication enabled" + } + ); + return response(res, { data: { valid, diff --git a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx index 7b719321..143bf91f 100644 --- a/src/app/[orgId]/settings/access/users/components/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/components/UsersTable.tsx @@ -13,7 +13,6 @@ import { UsersDataTable } from "./UsersDataTable"; import { useState } from "react"; import InviteUserForm from "./InviteUserForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useUserContext } from "@app/hooks/useUserContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { useToast } from "@app/hooks/useToast"; import Link from "next/link"; @@ -21,6 +20,7 @@ import { useRouter } from "next/navigation"; import { formatAxiosError } from "@app/lib/utils"; import { createApiClient } from "@app/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useUserContext } from "@app/hooks/useUserContext"; export type UserRow = { id: string; @@ -45,7 +45,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { const api = createApiClient(useEnvContext()); - const user = useUserContext(); + const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); const { toast } = useToast(); diff --git a/src/app/auth/reset-password/ResetPasswordForm.tsx b/src/app/auth/reset-password/ResetPasswordForm.tsx index 919a417b..b780d754 100644 --- a/src/app/auth/reset-password/ResetPasswordForm.tsx +++ b/src/app/auth/reset-password/ResetPasswordForm.tsx @@ -368,7 +368,6 @@ export default function ResetPasswordForm({ index={2} /> - + {/* */} + + ); +} diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index 5a2148e7..8dfc1c6a 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -2,7 +2,7 @@ import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, - DoubleArrowRightIcon, + DoubleArrowRightIcon } from "@radix-ui/react-icons"; import { Table } from "@tanstack/react-table"; @@ -12,7 +12,7 @@ import { SelectContent, SelectItem, SelectTrigger, - SelectValue, + SelectValue } from "@app/components/ui/select"; interface DataTablePaginationProps { @@ -20,38 +20,34 @@ interface DataTablePaginationProps { } export function DataTablePagination({ - table, + table }: DataTablePaginationProps) { return ( -
+
+
+

Rows per page

+ +
+
-
-

Rows per page

- -
Page {table.getState().pagination.pageIndex + 1} of{" "} {table.getPageCount()} diff --git a/src/components/Disable2FaForm.tsx b/src/components/Disable2FaForm.tsx new file mode 100644 index 00000000..e3e9f168 --- /dev/null +++ b/src/components/Disable2FaForm.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { createApiClient } from "@app/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { AxiosResponse } from "axios"; +import { Disable2faBody, Disable2faResponse } from "@server/routers/auth"; +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/utils"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { CheckCircle2 } from "lucide-react"; + +const disableSchema = z.object({ + password: z.string().min(1, { message: "Password is required" }), + code: z.string().min(1, { message: "Code is required" }) +}); + +type Disable2FaProps = { + open: boolean; + setOpen: (val: boolean) => void; +}; + +export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) { + const [loading, setLoading] = useState(false); + + const [step, setStep] = useState<"password" | "success">("password"); + + const { toast } = useToast(); + + const { user, updateUser } = useUserContext(); + + const api = createApiClient(useEnvContext()); + + const disableForm = useForm>({ + resolver: zodResolver(disableSchema), + defaultValues: { + password: "", + code: "" + } + }); + + const request2fa = async (values: z.infer) => { + setLoading(true); + + const res = await api + .post>(`/auth/2fa/disable`, { + password: values.password, + code: values.code + } as Disable2faBody) + .catch((e) => { + toast({ + title: "Unable to disable 2FA", + description: formatAxiosError( + e, + "An error occurred while disabling 2FA" + ), + variant: "destructive" + }); + }); + + if (res) { + // toast({ + // title: "Two-factor disabled", + // description: + // "Two-factor authentication has been disabled for your account" + // }); + updateUser({ twoFactorEnabled: false }); + setStep("success"); + } + + setLoading(false); + }; + + return ( + { + setOpen(val); + setLoading(false); + }} + > + + + + Disable Two-factor Authentication + + + Disable two-factor authentication for your account + + + + {step === "password" && ( +
+ +
+ ( + + Password + + + + + + )} + /> + + ( + + + Authenticator Code + + + + + + + + + + + + + + + + + + )} + /> +
+
+ + )} + + {step === "success" && ( +
+ +

+ Two-Factor Authentication Disabled +

+

+ Two-factor authentication has been disabled for + your account. You can enable it again at any + time. +

+
+ )} +
+ + {step === "password" && ( + + )} + + + + +
+
+ ); +} diff --git a/src/components/Enable2FaForm.tsx b/src/components/Enable2FaForm.tsx index 9266edca..0bdaf1fd 100644 --- a/src/components/Enable2FaForm.tsx +++ b/src/components/Enable2FaForm.tsx @@ -39,7 +39,7 @@ import { useToast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/utils"; import CopyTextBox from "@app/components/CopyTextBox"; import { QRCodeSVG } from "qrcode.react"; -import { userUserContext } from "@app/hooks/useUserContext"; +import { useUserContext } from "@app/hooks/useUserContext"; const enableSchema = z.object({ password: z.string().min(1, { message: "Password is required" }) @@ -57,6 +57,7 @@ type Enable2FaProps = { export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { const [step, setStep] = useState(1); const [secretKey, setSecretKey] = useState(""); + const [secretUri, setSecretUri] = useState(""); const [verificationCode, setVerificationCode] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(false); @@ -65,7 +66,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { const { toast } = useToast(); - const { user, updateUser } = userUserContext(); + const { user, updateUser } = useUserContext(); const api = createApiClient(useEnvContext()); @@ -106,6 +107,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { if (res && res.data.data.secret) { setSecretKey(res.data.data.secret); + setSecretUri(res.data.data.uri); setStep(2); } @@ -132,7 +134,7 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { if (res && res.data.data.valid) { setBackupCodes(res.data.data.backupCodes || []); - updateUser({ twoFactorEnabled: true }) + updateUser({ twoFactorEnabled: true }); setStep(3); } @@ -203,11 +205,11 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) { {step === 2 && (

- scan this qr code with your authenticator app or + Scan this QR code with your authenticator app or enter the secret key manually:

-
- +
+
( - Verification Code + Authenticator Code ( @@ -62,6 +63,7 @@ export function Header({ orgId, orgs }: HeaderProps) { ); const [openEnable2fa, setOpenEnable2fa] = useState(false); + const [openDisable2fa, setOpenDisable2fa] = useState(false); const router = useRouter(); @@ -93,6 +95,7 @@ export function Header({ orgId, orgs }: HeaderProps) { return ( <> +
@@ -133,7 +136,9 @@ export function Header({ orgId, orgs }: HeaderProps) { )} {user.twoFactorEnabled && ( - + setOpenDisable2fa(true)} + > Disable Two-factor )} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 3da37fec..0d9402f3 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -103,8 +103,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const data = res.data.data; - console.log(data); - if (data?.codeRequested) { setMfaRequested(true); setLoading(false); @@ -136,6 +134,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
- - {error && ( - - {error} - - )} - )} {mfaRequested && ( -
- - ( - - Authenticator Code - -
- - - - - - - - - - - - - -
-
- -
- )} - /> - {error && ( - - {error} - - )} - -
- - -
- - + <> +
+

+ Two-Factor Authentication +

+

+ Enter the code from your authenticator app. +

+
+
+ + ( + + +
+ + + + + + + + + + + + +
+
+ +
+ )} + /> + + + )} + + {error && ( + + {error} + + )} + +
+ {mfaRequested && ( + + )} + + {!mfaRequested && ( + + )} + + {mfaRequested && ( + + )} +
); } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 2f4da515..d4dca564 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -10,18 +10,10 @@ const Input = React.forwardRef( const [showPassword, setShowPassword] = React.useState(false); const togglePasswordVisibility = () => setShowPassword(!showPassword); - console.log("type", type); - - return ( + return type === "password" ? (
( ref={ref} {...props} /> - {type === "password" && ( -
- {showPassword ? ( - - ) : ( - - )} -
- )} +
+ {showPassword ? ( + + ) : ( + + )} +
+ ) : ( + ); } ); diff --git a/src/hooks/useUserContext.ts b/src/hooks/useUserContext.ts index cf90217d..e0b64b4b 100644 --- a/src/hooks/useUserContext.ts +++ b/src/hooks/useUserContext.ts @@ -1,7 +1,7 @@ import UserContext from "@app/contexts/userContext"; import { useContext } from "react"; -export function userUserContext() { +export function useUserContext() { const context = useContext(UserContext); if (context === undefined) { throw new Error("useUserContext must be used within a UserProvider");