Implement MFA (#2244)
* Adds a new route for logging in with mfa * New path for security page * Adds “Security” link to account side menu * Update the Switch component to allow label positions left and right * Optionally hide the Close button in the Dialog title bar * Installs `qrcode` react package for generating QR codes. * CopyButton component now takes children * New Security route for setting up MFA * Adds new OTP package for the chadcn InputOTP component * Adds new InputOTP chadcn component * Adds InputOTP chadcn component to the MFA login screen * InputOTP component supports variant styles * Improvements to form handling * Show a confirmation modal before you can disable MFA * Revert redirect back to the dashboard for now * Implement MFA enabling and disabling * Refactor and cleanup mfa management code * More cleanup * Handle errors in the management action * Implement mfa login flow * recovery code input should be password * Implement rate limiting on the mfa validation endpoint * Better error ux * Implement mfa emails and apply James' updates * Use latest @better-auth/utils * Improvements via CodeRabbit review --------- Co-authored-by: James Ritchie <james@trigger.dev>
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { type User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
accountPath,
|
||||
accountSecurityPath,
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
@@ -42,6 +47,13 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Security"
|
||||
icon={LockClosedIcon}
|
||||
activeIconColor="text-rose-500"
|
||||
to={accountSecurityPath()}
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
|
||||
@@ -27,6 +27,7 @@ type CopyButtonProps = {
|
||||
buttonClassName?: string;
|
||||
showTooltip?: boolean;
|
||||
buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal";
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function CopyButton({
|
||||
@@ -37,6 +38,7 @@ export function CopyButton({
|
||||
buttonClassName,
|
||||
showTooltip = true,
|
||||
buttonVariant = "tertiary",
|
||||
children,
|
||||
}: CopyButtonProps) {
|
||||
const { copy, copied } = useCopy(value);
|
||||
|
||||
@@ -66,22 +68,25 @@ export function CopyButton({
|
||||
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
|
||||
onClick={copy}
|
||||
className={cn("shrink-0", buttonClassName)}
|
||||
LeadingIcon={
|
||||
copied ? (
|
||||
<ClipboardCheckIcon
|
||||
className={cn(
|
||||
iconSize,
|
||||
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ClipboardIcon
|
||||
className={cn(
|
||||
iconSize,
|
||||
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheckIcon
|
||||
className={cn(
|
||||
iconSize,
|
||||
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ClipboardIcon
|
||||
className={cn(
|
||||
iconSize,
|
||||
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -50,16 +52,18 @@ const DialogContent = React.forwardRef<
|
||||
>
|
||||
<hr className="absolute left-0 top-11 w-full" />
|
||||
{children}
|
||||
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
|
||||
<ShortcutKey
|
||||
shortcut={{
|
||||
key: "esc",
|
||||
}}
|
||||
variant="medium"
|
||||
/>
|
||||
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
|
||||
<ShortcutKey
|
||||
shortcut={{
|
||||
key: "esc",
|
||||
}}
|
||||
variant="medium"
|
||||
/>
|
||||
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { MinusIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
default: {
|
||||
container: "flex items-center gap-2 has-disabled:opacity-50",
|
||||
group: "flex items-center",
|
||||
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-sm outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||
},
|
||||
large: {
|
||||
container: "flex items-center gap-3 has-disabled:opacity-50",
|
||||
group: "flex items-center gap-1",
|
||||
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-charcoal-750 border-charcoal-700 hover:border-charcoal-600 hover:bg-charcoal-650 relative flex h-12 w-12 items-center justify-center border text-base outline-none transition-all rounded-md data-[active=true]:z-10 data-[active=true]:ring-[3px] data-[active=true]:border-indigo-500",
|
||||
},
|
||||
minimal: {
|
||||
container: "flex items-center gap-2 has-disabled:opacity-50",
|
||||
group: "flex items-center",
|
||||
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 border-transparent bg-transparent relative flex h-9 w-9 items-center justify-center border-b-2 border-b-charcoal-600 text-sm outline-none transition-all data-[active=true]:border-b-indigo-500 data-[active=true]:z-10",
|
||||
},
|
||||
};
|
||||
|
||||
function InputOTP({
|
||||
className,
|
||||
containerClassName,
|
||||
variant = "default",
|
||||
fullWidth = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof OTPInput> & {
|
||||
containerClassName?: string;
|
||||
variant?: keyof typeof variants;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const variantStyles = variants[variant];
|
||||
|
||||
return (
|
||||
<OTPInput
|
||||
data-slot="input-otp"
|
||||
containerClassName={cn(variantStyles.container, fullWidth && "w-full", containerClassName)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPGroup({
|
||||
className,
|
||||
variant = "default",
|
||||
fullWidth = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
variant?: keyof typeof variants;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const variantStyles = variants[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-group"
|
||||
className={cn(variantStyles.group, fullWidth && "flex-1 gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSlot({
|
||||
index,
|
||||
className,
|
||||
variant = "default",
|
||||
fullWidth = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
index: number;
|
||||
variant?: keyof typeof variants;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||
const variantStyles = variants[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={isActive}
|
||||
className={cn(variantStyles.slot, fullWidth && "flex-1", className)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-text-bright duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
@@ -46,10 +46,11 @@ type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
label?: React.ReactNode;
|
||||
variant: keyof typeof variations;
|
||||
shortcut?: ShortcutDefinition;
|
||||
labelPosition?: "left" | "right";
|
||||
};
|
||||
|
||||
export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
||||
({ className, variant, label, ...props }, ref) => {
|
||||
({ className, variant, label, labelPosition = "left", ...props }, ref) => {
|
||||
const innerRef = React.useRef<HTMLButtonElement>(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
|
||||
|
||||
@@ -67,35 +68,39 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
|
||||
});
|
||||
}
|
||||
|
||||
const labelElement = label ? (
|
||||
<label
|
||||
className={cn("cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed", text)}
|
||||
>
|
||||
{typeof label === "string" ? <span>{label}</span> : label}
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
const switchElement = (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
|
||||
root
|
||||
)}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
thumb,
|
||||
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn("group", container, className)}
|
||||
{...props}
|
||||
ref={innerRef}
|
||||
>
|
||||
{label ? (
|
||||
<label
|
||||
className={cn(
|
||||
"cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed",
|
||||
text
|
||||
)}
|
||||
>
|
||||
{typeof label === "string" ? <span>{label}</span> : label}
|
||||
</label>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
|
||||
root
|
||||
)}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
thumb,
|
||||
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{labelPosition === "left" ? labelElement : null}
|
||||
{switchElement}
|
||||
{labelPosition === "right" ? labelElement : null}
|
||||
</SwitchPrimitives.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { redirect, typedjson } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export type ToastMessage = {
|
||||
@@ -121,6 +121,44 @@ export async function jsonWithErrorMessage(
|
||||
});
|
||||
}
|
||||
|
||||
export async function typedJsonWithSuccessMessage<T>(
|
||||
data: T,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setSuccessMessage(session, message, options);
|
||||
|
||||
return typedjson(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function typedJsonWithErrorMessage<T>(
|
||||
data: T,
|
||||
request: Request,
|
||||
message: string,
|
||||
options?: ToastMessageOptions
|
||||
) {
|
||||
const session = await getSession(request.headers.get("cookie"));
|
||||
|
||||
setErrorMessage(session, message, options);
|
||||
|
||||
return typedjson(data, {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session, {
|
||||
expires: new Date(Date.now() + ONE_YEAR),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function redirectWithSuccessMessage(
|
||||
path: string,
|
||||
request: Request,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import {
|
||||
MainHorizontallyCenteredContainer,
|
||||
PageBody,
|
||||
PageContainer,
|
||||
} from "~/components/layout/AppLayout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { MfaSetup } from "../resources.account.mfa.setup/route";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: `Security | Trigger.dev`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
return typedjson({
|
||||
user,
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { user } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Security" />
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
<MainHorizontallyCenteredContainer className="grid place-items-center overflow-visible">
|
||||
<div className="mb-3 w-full border-b border-grid-dimmed pb-3">
|
||||
<Header2>Security</Header2>
|
||||
</div>
|
||||
<MfaSetup isEnabled={!!user.mfaEnabledAt} />
|
||||
</MainHorizontallyCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,53 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { redirectCookie } from "./auth.github";
|
||||
import { getUserSession, commitSession } from "~/services/sessionStorage.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server";
|
||||
|
||||
export let loader: LoaderFunction = async ({ request }) => {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const redirectValue = await redirectCookie.parse(cookie);
|
||||
const redirectTo = redirectValue ?? "/";
|
||||
try {
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const redirectValue = await redirectCookie.parse(cookie);
|
||||
const redirectTo = redirectValue ?? "/";
|
||||
|
||||
logger.debug("auth.github.callback loader", {
|
||||
redirectTo,
|
||||
});
|
||||
logger.debug("auth.github.callback loader", {
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
const authuser = await authenticator.authenticate("github", request, {
|
||||
successRedirect: redirectTo,
|
||||
failureRedirect: "/login",
|
||||
});
|
||||
const authuser = await authenticator.authenticate("github", request, {
|
||||
successRedirect: undefined, // Don't auto-redirect, we'll handle it
|
||||
failureRedirect: undefined, // Don't auto-redirect on failure either
|
||||
});
|
||||
|
||||
logger.debug("auth.github.callback authuser", {
|
||||
authuser,
|
||||
});
|
||||
logger.debug("auth.github.callback authuser", {
|
||||
authuser,
|
||||
});
|
||||
|
||||
return authuser;
|
||||
// If we get here, user doesn't have MFA - complete login normally
|
||||
return redirect(redirectTo);
|
||||
} catch (error) {
|
||||
// Check if this is an MFA_REQUIRED error
|
||||
if (error instanceof MfaRequiredError) {
|
||||
// User has MFA enabled - store pending user ID and redirect to MFA page
|
||||
const session = await getUserSession(request);
|
||||
session.set("pending-mfa-user-id", error.userId);
|
||||
|
||||
const cookie = request.headers.get("Cookie");
|
||||
const redirectValue = await redirectCookie.parse(cookie);
|
||||
const redirectTo = redirectValue ?? "/";
|
||||
session.set("pending-mfa-redirect-to", redirectTo);
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Regular authentication failure, redirect to login page
|
||||
logger.debug("auth.github.callback error", { error });
|
||||
return redirect("/login");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function LoginMagicLinkPage() {
|
||||
We've sent you a magic link!
|
||||
</Header1>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InboxArrowDownIcon className="mb-4 h-12 w-12 text-primary" />
|
||||
<InboxArrowDownIcon className="mb-4 h-12 w-12 text-indigo-500" />
|
||||
<Paragraph className="mb-6 text-center">
|
||||
We sent you an email which contains a magic link that will log you in to your
|
||||
account.
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
MetaFunction,
|
||||
Session,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import React, { useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { LoginPageLayout } from "~/components/LoginPageLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { commitSession, getUserSession, sessionStorage } from "~/services/sessionStorage.server";
|
||||
import { getSession as getMessageSession } from "~/models/message.server";
|
||||
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
|
||||
import { redirectWithErrorMessage, redirectBackWithErrorMessage } from "~/models/message.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server";
|
||||
|
||||
export const meta: MetaFunction = ({ matches }) => {
|
||||
const parentMeta = matches
|
||||
.flatMap((match) => match.meta ?? [])
|
||||
.filter((meta) => {
|
||||
if ("title" in meta) return false;
|
||||
if ("name" in meta && meta.name === "viewport") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return [
|
||||
...parentMeta,
|
||||
{ title: `Multi-factor authentication` },
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width,initial-scale=1",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// Check if user is already fully authenticated
|
||||
await authenticator.isAuthenticated(request, {
|
||||
successRedirect: "/",
|
||||
});
|
||||
|
||||
const session = await getUserSession(request);
|
||||
|
||||
// Check if there's a pending MFA user ID
|
||||
const pendingUserId = session.get("pending-mfa-user-id");
|
||||
if (!pendingUserId) {
|
||||
// No pending MFA, redirect to login
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
// Get flash message for MFA errors
|
||||
const messageSession = await getMessageSession(request.headers.get("cookie"));
|
||||
const toastMessage = messageSession.get("toastMessage");
|
||||
|
||||
let mfaError: string | undefined;
|
||||
if (toastMessage?.type === "error") {
|
||||
mfaError = toastMessage.message;
|
||||
}
|
||||
|
||||
return typedjson(
|
||||
{
|
||||
mfaError,
|
||||
},
|
||||
{
|
||||
headers: { "Set-Cookie": await commitSession(session) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const session = await getUserSession(request);
|
||||
const pendingUserId = session.get("pending-mfa-user-id");
|
||||
|
||||
if (!pendingUserId) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const payload = Object.fromEntries(await request.formData());
|
||||
|
||||
const { action } = z
|
||||
.object({
|
||||
action: z.enum(["verify-recovery", "verify-mfa"]),
|
||||
})
|
||||
.parse(payload);
|
||||
|
||||
const mfaService = new MultiFactorAuthenticationService();
|
||||
|
||||
if (action === "verify-recovery") {
|
||||
const recoveryCode = payload.recoveryCode as string;
|
||||
|
||||
if (!recoveryCode) {
|
||||
return redirectBackWithErrorMessage(request, "Recovery code is required");
|
||||
}
|
||||
|
||||
// Rate limit MFA verification attempts
|
||||
await checkMfaRateLimit(pendingUserId);
|
||||
|
||||
const result = await mfaService.verifyRecoveryCodeForLogin(pendingUserId, recoveryCode);
|
||||
|
||||
if (!result.success) {
|
||||
return redirectBackWithErrorMessage(request, result.error || "Invalid authentication code");
|
||||
}
|
||||
// Recovery code verified - complete the login
|
||||
return await completeLogin(request, session, pendingUserId);
|
||||
} else if (action === "verify-mfa") {
|
||||
const mfaCode = payload.mfaCode as string;
|
||||
|
||||
if (!mfaCode || mfaCode.length !== 6) {
|
||||
return redirectBackWithErrorMessage(request, "Valid 6-digit code is required");
|
||||
}
|
||||
|
||||
// Rate limit MFA verification attempts
|
||||
await checkMfaRateLimit(pendingUserId);
|
||||
|
||||
const result = await mfaService.verifyTotpForLogin(pendingUserId, mfaCode);
|
||||
|
||||
if (!result.success) {
|
||||
return redirectBackWithErrorMessage(request, result.error || "Invalid authentication code");
|
||||
}
|
||||
|
||||
// TOTP code verified - complete the login
|
||||
return await completeLogin(request, session, pendingUserId);
|
||||
}
|
||||
|
||||
return redirect("/login");
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return redirectWithErrorMessage("/login", request, error.message);
|
||||
}
|
||||
|
||||
if (error instanceof MfaRateLimitError) {
|
||||
return redirectBackWithErrorMessage(request, error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function completeLogin(request: Request, session: Session, userId: string) {
|
||||
// Create a new authenticated session
|
||||
const authSession = await sessionStorage.getSession(request.headers.get("Cookie"));
|
||||
authSession.set(authenticator.sessionKey, { userId });
|
||||
|
||||
// Get the redirect URL and clean up pending MFA data
|
||||
const redirectTo = session.get("pending-mfa-redirect-to") ?? "/";
|
||||
session.unset("pending-mfa-user-id");
|
||||
session.unset("pending-mfa-redirect-to");
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await sessionStorage.commitSession(authSession),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function LoginMfaPage() {
|
||||
const data = useTypedLoaderData<typeof loader>();
|
||||
const rawMfaError = "mfaError" in data ? data.mfaError : undefined;
|
||||
const navigate = useNavigation();
|
||||
const [showRecoveryCode, setShowRecoveryCode] = useState(false);
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [hideError, setHideError] = useState(false);
|
||||
|
||||
// Clear the MFA code when form submission completes (success or failure)
|
||||
const prevNavigationState = React.useRef(navigate.state);
|
||||
React.useEffect(() => {
|
||||
if (prevNavigationState.current === "submitting" && navigate.state === "idle") {
|
||||
setMfaCode("");
|
||||
}
|
||||
prevNavigationState.current = navigate.state;
|
||||
}, [navigate.state]);
|
||||
|
||||
// Reset hideError when a new error appears
|
||||
React.useEffect(() => {
|
||||
if (rawMfaError) {
|
||||
setHideError(false);
|
||||
}
|
||||
}, [rawMfaError]);
|
||||
|
||||
// Clear error and MFA code when switching between modes
|
||||
const handleShowRecoveryCode = (show: boolean) => {
|
||||
setShowRecoveryCode(show);
|
||||
setHideError(true);
|
||||
if (!show) {
|
||||
setMfaCode("");
|
||||
}
|
||||
};
|
||||
|
||||
// Only show error if not explicitly hidden and we have an error
|
||||
const mfaError = hideError ? undefined : rawMfaError;
|
||||
|
||||
const isLoading =
|
||||
(navigate.state === "loading" || navigate.state === "submitting") &&
|
||||
navigate.formAction !== undefined;
|
||||
|
||||
return (
|
||||
<LoginPageLayout>
|
||||
<Form method="post">
|
||||
<div className="flex max-w-xs flex-col items-center justify-center">
|
||||
<Header1 className="pb-4 text-center font-semibold leading-7 sm:text-2xl md:text-3xl md:leading-8 lg:text-4xl lg:leading-9">
|
||||
Multi-factor authentication
|
||||
</Header1>
|
||||
{showRecoveryCode ? (
|
||||
<>
|
||||
<Paragraph className="mb-6 text-center">
|
||||
Enter one of your recovery codes to log in.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputGroup>
|
||||
<Input
|
||||
type="password"
|
||||
name="recoveryCode"
|
||||
spellCheck={false}
|
||||
placeholder="Enter recovery code"
|
||||
variant="large"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="verify-recovery"
|
||||
type="submit"
|
||||
variant="primary/large"
|
||||
disabled={isLoading}
|
||||
fullWidth
|
||||
data-action="verify recovery code"
|
||||
>
|
||||
{isLoading ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isLoading ? (
|
||||
<span className="text-text-bright">Verifying…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Verify</span>
|
||||
)}
|
||||
</Button>
|
||||
{typeof mfaError === "string" && <FormError>{mfaError}</FormError>}
|
||||
</Fieldset>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleShowRecoveryCode(false)}
|
||||
variant="minimal/small"
|
||||
data-action="use authenticator app"
|
||||
className="mt-4"
|
||||
>
|
||||
Use an authenticator app
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph variant="base" className="mb-6 text-center">
|
||||
Open your authenticator app to get your code. Then enter it below.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={mfaCode}
|
||||
onChange={(value) => setMfaCode(value)}
|
||||
variant="large"
|
||||
fullWidth
|
||||
>
|
||||
<InputOTPGroup variant="large" fullWidth>
|
||||
<InputOTPSlot index={0} autoFocus variant="large" fullWidth />
|
||||
<InputOTPSlot index={1} variant="large" fullWidth />
|
||||
<InputOTPSlot index={2} variant="large" fullWidth />
|
||||
<InputOTPSlot index={3} variant="large" fullWidth />
|
||||
<InputOTPSlot index={4} variant="large" fullWidth />
|
||||
<InputOTPSlot index={5} variant="large" fullWidth />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<input type="hidden" name="mfaCode" value={mfaCode} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="verify-mfa"
|
||||
type="submit"
|
||||
variant="primary/large"
|
||||
disabled={isLoading || mfaCode.length !== 6}
|
||||
fullWidth
|
||||
data-action="verify mfa code"
|
||||
>
|
||||
{isLoading ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isLoading ? (
|
||||
<span className="text-text-bright">Verifying…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Verify</span>
|
||||
)}
|
||||
</Button>
|
||||
{typeof mfaError === "string" && <FormError>{mfaError}</FormError>}
|
||||
</Fieldset>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleShowRecoveryCode(true)}
|
||||
variant="minimal/small"
|
||||
data-action="use recovery code"
|
||||
className="mt-4"
|
||||
>
|
||||
Use a recovery code
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</LoginPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,39 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { redirect } from "@remix-run/server-runtime";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { MfaRequiredError } from "~/services/mfa/multiFactorAuthentication.server";
|
||||
import { getRedirectTo } from "~/services/redirectTo.server";
|
||||
import { getUserSession, commitSession } from "~/services/sessionStorage.server";
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const redirectTo = await getRedirectTo(request);
|
||||
try {
|
||||
// Attempt to authenticate the user with email-link
|
||||
const authUser = await authenticator.authenticate("email-link", request, {
|
||||
successRedirect: undefined, // Don't auto-redirect, we'll handle it
|
||||
failureRedirect: undefined, // Don't auto-redirect on failure either
|
||||
});
|
||||
|
||||
await authenticator.authenticate("email-link", request, {
|
||||
successRedirect: redirectTo ?? "/",
|
||||
failureRedirect: "/login/magic",
|
||||
});
|
||||
// If we get here, user doesn't have MFA - complete login normally
|
||||
const redirectTo = await getRedirectTo(request);
|
||||
return redirect(redirectTo ?? "/");
|
||||
} catch (error) {
|
||||
// Check if this is an MFA_REQUIRED error
|
||||
if (error instanceof MfaRequiredError) {
|
||||
// User has MFA enabled - store pending user ID and redirect to MFA page
|
||||
const session = await getUserSession(request);
|
||||
session.set("pending-mfa-user-id", error.userId);
|
||||
|
||||
const redirectTo = await getRedirectTo(request);
|
||||
session.set("pending-mfa-redirect-to", redirectTo ?? "/");
|
||||
|
||||
return redirect("/login/mfa", {
|
||||
headers: {
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Regular authentication failure, redirect to magic link page
|
||||
return redirect("/login/magic");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
interface MfaDisableDialogProps {
|
||||
isOpen: boolean;
|
||||
isSubmitting: boolean;
|
||||
error?: string;
|
||||
onDisable: (totpCode?: string, recoveryCode?: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MfaDisableDialog({
|
||||
isOpen,
|
||||
isSubmitting,
|
||||
error,
|
||||
onDisable,
|
||||
onCancel,
|
||||
}: MfaDisableDialogProps) {
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [recoveryCode, setRecoveryCode] = useState("");
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onDisable(useRecoveryCode ? undefined : totpCode, useRecoveryCode ? recoveryCode : undefined);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTotpCode("");
|
||||
setRecoveryCode("");
|
||||
setUseRecoveryCode(false);
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleSwitchToRecoveryCode = () => {
|
||||
setUseRecoveryCode(true);
|
||||
setTotpCode("");
|
||||
};
|
||||
|
||||
const handleSwitchToTotpCode = () => {
|
||||
setUseRecoveryCode(false);
|
||||
setRecoveryCode("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable multi-factor authentication</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleSubmit}>
|
||||
{useRecoveryCode ? (
|
||||
<div className="pt-3">
|
||||
<Paragraph className="mb-6 text-center">
|
||||
Enter one of your recovery codes to disable MFA.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputGroup>
|
||||
<Input
|
||||
type="password"
|
||||
name="recoveryCode"
|
||||
spellCheck={false}
|
||||
placeholder="Enter recovery code"
|
||||
variant="large"
|
||||
required
|
||||
autoFocus
|
||||
value={recoveryCode}
|
||||
onChange={(e) => setRecoveryCode(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSwitchToTotpCode}
|
||||
variant="minimal/small"
|
||||
className="my-4"
|
||||
>
|
||||
Use an authenticator app
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-3">
|
||||
<Paragraph variant="base" className="mb-6 text-center">
|
||||
Enter the code from your authenticator app to disable MFA.
|
||||
</Paragraph>
|
||||
<Fieldset className="flex w-full flex-col items-center gap-y-2">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(value) => setTotpCode(value)}
|
||||
variant="large"
|
||||
>
|
||||
<InputOTPGroup variant="large" fullWidth>
|
||||
<InputOTPSlot index={0} autoFocus variant="large" fullWidth />
|
||||
<InputOTPSlot index={1} variant="large" fullWidth />
|
||||
<InputOTPSlot index={2} variant="large" fullWidth />
|
||||
<InputOTPSlot index={3} variant="large" fullWidth />
|
||||
<InputOTPSlot index={4} variant="large" fullWidth />
|
||||
<InputOTPSlot index={5} variant="large" fullWidth />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</Fieldset>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSwitchToRecoveryCode}
|
||||
variant="minimal/small"
|
||||
className="my-4"
|
||||
>
|
||||
Use a recovery code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <FormError>{error}</FormError>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isSubmitting ? (
|
||||
<span className="text-text-bright">Disabling…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Disable MFA</span>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { CopyButton } from "~/components/primitives/CopyButton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
interface MfaSetupDialogProps {
|
||||
isOpen: boolean;
|
||||
setupData?: {
|
||||
secret: string;
|
||||
otpAuthUrl: string;
|
||||
};
|
||||
recoveryCodes?: string[];
|
||||
error?: string;
|
||||
isSubmitting: boolean;
|
||||
onValidate: (code: string) => void;
|
||||
onCancel: () => void;
|
||||
onSaveRecoveryCodes: () => void;
|
||||
}
|
||||
|
||||
export function MfaSetupDialog({
|
||||
isOpen,
|
||||
setupData,
|
||||
recoveryCodes,
|
||||
error,
|
||||
isSubmitting,
|
||||
onValidate,
|
||||
onCancel,
|
||||
onSaveRecoveryCodes,
|
||||
}: MfaSetupDialogProps) {
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onValidate(totpCode);
|
||||
setTotpCode("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setTotpCode("");
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleRecoverySubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSaveRecoveryCodes();
|
||||
};
|
||||
|
||||
const downloadRecoveryCodes = () => {
|
||||
if (!recoveryCodes) return;
|
||||
|
||||
const content = recoveryCodes.join("\n");
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "trigger-dev-recovery-codes.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Show recovery codes if they exist
|
||||
if (recoveryCodes && recoveryCodes.length > 0) {
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Recovery codes</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleRecoverySubmit}>
|
||||
<div className="flex flex-col gap-2 pb-0 pt-3">
|
||||
<Paragraph spacing>
|
||||
Copy and store these recovery codes carefully in case you lose your device.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex flex-col rounded border border-grid-dimmed bg-background-bright">
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-4 px-3 py-6">
|
||||
{recoveryCodes.map((code, index) => (
|
||||
<span key={index} className="text-center font-mono text-xs text-text-bright">
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-grid-bright px-1.5 py-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/medium"
|
||||
onClick={downloadRecoveryCodes}
|
||||
LeadingIcon={DownloadIcon}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
<CopyButton
|
||||
value={recoveryCodes.join("\n")}
|
||||
buttonVariant="minimal"
|
||||
showTooltip={false}
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="justify-end border-t-0">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
shortcut={{ key: "Enter" }}
|
||||
hideShortcutKey
|
||||
autoFocus
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Show QR setup if no recovery codes yet
|
||||
if (!setupData) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable authenticator app</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form method="post" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<Paragraph>
|
||||
Scan the QR code below with your preferred authenticator app then enter the 6 digit
|
||||
code that the app generates. Alternatively, you can copy the secret below and paste it
|
||||
into your app.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 rounded border border-grid-dimmed bg-background-bright py-4">
|
||||
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
|
||||
<QRCodeSVG value={setupData.otpAuthUrl} size={300} marginSize={3} />
|
||||
</div>
|
||||
<CopyableText value={setupData.secret} className="font-mono text-sm tracking-wide" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(value) => setTotpCode(value)}
|
||||
variant="large"
|
||||
name="totpCode"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && totpCode.length === 6) {
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup variant="large">
|
||||
<InputOTPSlot index={0} variant="large" autoFocus />
|
||||
<InputOTPSlot index={1} variant="large" />
|
||||
<InputOTPSlot index={2} variant="large" />
|
||||
<InputOTPSlot index={3} variant="large" />
|
||||
<InputOTPSlot index={4} variant="large" />
|
||||
<InputOTPSlot index={5} variant="large" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex justify-center">{error && <FormError>{error}</FormError>}</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary/medium" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={totpCode.length !== 6 || isSubmitting}
|
||||
shortcut={{ key: "Enter" }}
|
||||
hideShortcutKey
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
|
||||
interface MfaToggleProps {
|
||||
isEnabled: boolean;
|
||||
onToggle: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function MfaToggle({ isEnabled, onToggle }: MfaToggleProps) {
|
||||
return (
|
||||
<Form method="post" className="w-full">
|
||||
<InputGroup className="mb-4">
|
||||
<Label>Multi-factor authentication</Label>
|
||||
<Paragraph variant="small">
|
||||
Enable an extra layer of security by requiring a one-time code from your authenticator
|
||||
app (TOTP) each time you log in.
|
||||
</Paragraph>
|
||||
</InputGroup>
|
||||
<div className="flex items-center justify-between">
|
||||
<Switch
|
||||
id="mfa"
|
||||
variant="medium"
|
||||
label={isEnabled ? "Enabled" : "Enable"}
|
||||
labelPosition="right"
|
||||
className="-ml-2 w-fit pr-3"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { redirectWithSuccessMessage, redirectWithErrorMessage, typedJsonWithSuccessMessage } from "~/models/message.server";
|
||||
import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { useMfaSetup } from "./useMfaSetup";
|
||||
import { MfaToggle } from "./MfaToggle";
|
||||
import { MfaSetupDialog } from "./MfaSetupDialog";
|
||||
import { MfaDisableDialog } from "./MfaDisableDialog";
|
||||
|
||||
const formSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("enable-mfa"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("disable-mfa"),
|
||||
totpCode: z.string().optional(),
|
||||
recoveryCode: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("saved-recovery-codes"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("cancel-totp"),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("validate-totp"),
|
||||
totpCode: z.string().length(6, "TOTP code must be 6 digits"),
|
||||
}),
|
||||
]);
|
||||
|
||||
function validateForm(formData: FormData) {
|
||||
const formEntries = Object.fromEntries(formData.entries());
|
||||
|
||||
const result = formSchema.safeParse(formEntries);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
errors: result.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true as const,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
try {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = validateForm(formData);
|
||||
|
||||
if (!submission.valid) {
|
||||
return typedjson({
|
||||
action: "invalid-form" as const,
|
||||
errors: submission.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const mfaSetupService = new MultiFactorAuthenticationService();
|
||||
|
||||
switch (submission.data.action) {
|
||||
case "enable-mfa": {
|
||||
const result = await mfaSetupService.enableTotp(userId);
|
||||
|
||||
return typedjson({
|
||||
action: "enable-mfa" as const,
|
||||
secret: result.secret,
|
||||
otpAuthUrl: result.otpAuthUrl,
|
||||
});
|
||||
}
|
||||
case "disable-mfa": {
|
||||
const result = await mfaSetupService.disableTotp(userId, {
|
||||
totpCode: submission.data.totpCode,
|
||||
recoveryCode: submission.data.recoveryCode,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return typedJsonWithSuccessMessage(
|
||||
{
|
||||
action: "disable-mfa" as const,
|
||||
success: true as const,
|
||||
},
|
||||
request,
|
||||
"Successfully disabled MFA"
|
||||
);
|
||||
} else {
|
||||
return typedjson({
|
||||
action: "disable-mfa" as const,
|
||||
success: false as const,
|
||||
error: "Invalid code provided. Please try again.",
|
||||
});
|
||||
}
|
||||
}
|
||||
case "validate-totp": {
|
||||
const result = await mfaSetupService.validateTotpSetup(userId, submission.data.totpCode);
|
||||
|
||||
if (result.success) {
|
||||
return typedjson({
|
||||
action: "validate-totp" as const,
|
||||
success: true as const,
|
||||
recoveryCodes: result.recoveryCodes,
|
||||
});
|
||||
} else {
|
||||
return typedjson({
|
||||
action: "validate-totp" as const,
|
||||
success: false as const,
|
||||
error: "Invalid code provided. Please try again.",
|
||||
otpAuthUrl: result.otpAuthUrl,
|
||||
secret: result.secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
case "cancel-totp": {
|
||||
return typedjson({
|
||||
action: "cancel-totp" as const,
|
||||
success: true as const,
|
||||
});
|
||||
}
|
||||
case "saved-recovery-codes": {
|
||||
return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return redirectWithErrorMessage("/account/security", request, error.message);
|
||||
}
|
||||
|
||||
// Re-throw unexpected errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {
|
||||
const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = useMfaSetup(isEnabled);
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (enabled && !state.isEnabled) {
|
||||
actions.enableMfa();
|
||||
} else if (!enabled && state.isEnabled) {
|
||||
actions.openDisableDialog();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MfaToggle
|
||||
isEnabled={state.isEnabled}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
<MfaSetupDialog
|
||||
isOpen={isQrDialogOpen}
|
||||
setupData={state.setupData}
|
||||
recoveryCodes={state.recoveryCodes}
|
||||
error={state.error}
|
||||
isSubmitting={state.isSubmitting}
|
||||
onValidate={actions.validateTotp}
|
||||
onCancel={actions.cancelSetup}
|
||||
onSaveRecoveryCodes={actions.saveRecoveryCodes}
|
||||
/>
|
||||
|
||||
<MfaDisableDialog
|
||||
isOpen={isDisableDialogOpen}
|
||||
isSubmitting={state.isSubmitting}
|
||||
error={state.error}
|
||||
onDisable={actions.disableMfa}
|
||||
onCancel={actions.cancelDisable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useReducer, useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { action } from "./route";
|
||||
|
||||
export type MfaPhase = 'idle' | 'enabling' | 'validating' | 'showing-recovery' | 'disabling';
|
||||
|
||||
export interface MfaState {
|
||||
phase: MfaPhase;
|
||||
isEnabled: boolean;
|
||||
setupData?: {
|
||||
secret: string;
|
||||
otpAuthUrl: string;
|
||||
};
|
||||
recoveryCodes?: string[];
|
||||
error?: string;
|
||||
isSubmitting: boolean;
|
||||
disableMethod: 'totp' | 'recovery';
|
||||
}
|
||||
|
||||
export type MfaAction =
|
||||
| { type: 'ENABLE_MFA' }
|
||||
| { type: 'SETUP_DATA_RECEIVED'; setupData: { secret: string; otpAuthUrl: string } }
|
||||
| { type: 'CANCEL_SETUP' }
|
||||
| { type: 'VALIDATE_TOTP'; code: string }
|
||||
| { type: 'VALIDATION_SUCCESS'; recoveryCodes: string[] }
|
||||
| { type: 'VALIDATION_FAILED'; error: string; setupData: { secret: string; otpAuthUrl: string } }
|
||||
| { type: 'RECOVERY_CODES_SAVED' }
|
||||
| { type: 'OPEN_DISABLE_DIALOG' }
|
||||
| { type: 'DISABLE_MFA' }
|
||||
| { type: 'DISABLE_SUCCESS' }
|
||||
| { type: 'DISABLE_FAILED'; error: string }
|
||||
| { type: 'CANCEL_DISABLE' }
|
||||
| { type: 'SET_DISABLE_METHOD'; method: 'totp' | 'recovery' }
|
||||
| { type: 'SET_ERROR'; error: string }
|
||||
| { type: 'CLEAR_ERROR' }
|
||||
| { type: 'SET_SUBMITTING'; isSubmitting: boolean };
|
||||
|
||||
function mfaReducer(state: MfaState, action: MfaAction): MfaState {
|
||||
switch (action.type) {
|
||||
case 'ENABLE_MFA':
|
||||
return {
|
||||
...state,
|
||||
phase: 'enabling',
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'SETUP_DATA_RECEIVED':
|
||||
return {
|
||||
...state,
|
||||
phase: 'enabling',
|
||||
setupData: action.setupData,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'CANCEL_SETUP':
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
setupData: undefined,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'VALIDATE_TOTP':
|
||||
return {
|
||||
...state,
|
||||
phase: 'validating',
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'VALIDATION_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
phase: 'showing-recovery',
|
||||
recoveryCodes: action.recoveryCodes,
|
||||
isSubmitting: false,
|
||||
isEnabled: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'VALIDATION_FAILED':
|
||||
return {
|
||||
...state,
|
||||
phase: 'enabling',
|
||||
setupData: action.setupData,
|
||||
error: action.error,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'RECOVERY_CODES_SAVED':
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
setupData: undefined,
|
||||
recoveryCodes: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'OPEN_DISABLE_DIALOG':
|
||||
return {
|
||||
...state,
|
||||
phase: 'disabling',
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'DISABLE_MFA':
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: true,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'DISABLE_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
isEnabled: false,
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'DISABLE_FAILED':
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'CANCEL_DISABLE':
|
||||
return {
|
||||
...state,
|
||||
phase: 'idle',
|
||||
error: undefined,
|
||||
isSubmitting: false,
|
||||
};
|
||||
|
||||
case 'SET_DISABLE_METHOD':
|
||||
return {
|
||||
...state,
|
||||
disableMethod: action.method,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'SET_ERROR':
|
||||
return {
|
||||
...state,
|
||||
error: action.error,
|
||||
};
|
||||
|
||||
case 'CLEAR_ERROR':
|
||||
return {
|
||||
...state,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
case 'SET_SUBMITTING':
|
||||
return {
|
||||
...state,
|
||||
isSubmitting: action.isSubmitting,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function useMfaSetup(initialIsEnabled: boolean) {
|
||||
const fetcher = useTypedFetcher<typeof action>();
|
||||
|
||||
const [state, dispatch] = useReducer(mfaReducer, {
|
||||
phase: 'idle',
|
||||
isEnabled: initialIsEnabled,
|
||||
isSubmitting: false,
|
||||
disableMethod: 'totp',
|
||||
});
|
||||
|
||||
// Handle fetcher responses
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const { data } = fetcher;
|
||||
|
||||
switch (data.action) {
|
||||
case 'enable-mfa':
|
||||
dispatch({
|
||||
type: 'SETUP_DATA_RECEIVED',
|
||||
setupData: { secret: data.secret, otpAuthUrl: data.otpAuthUrl }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'validate-totp':
|
||||
if (data.success) {
|
||||
dispatch({
|
||||
type: 'VALIDATION_SUCCESS',
|
||||
recoveryCodes: data.recoveryCodes || []
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'VALIDATION_FAILED',
|
||||
error: data.error || 'Invalid code',
|
||||
setupData: { secret: data.secret!, otpAuthUrl: data.otpAuthUrl! }
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'disable-mfa':
|
||||
if (data.success) {
|
||||
dispatch({ type: 'DISABLE_SUCCESS' });
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'DISABLE_FAILED',
|
||||
error: data.error || 'Failed to disable MFA'
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cancel-totp':
|
||||
dispatch({ type: 'CANCEL_SETUP' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
// Handle submitting state
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_SUBMITTING', isSubmitting: fetcher.state === 'submitting' });
|
||||
}, [fetcher.state]);
|
||||
|
||||
const actions = {
|
||||
enableMfa: () => {
|
||||
dispatch({ type: 'ENABLE_MFA' });
|
||||
fetcher.submit(
|
||||
{ action: 'enable-mfa' },
|
||||
{ method: 'POST', action: '/resources/account/mfa/setup' }
|
||||
);
|
||||
},
|
||||
|
||||
cancelSetup: () => {
|
||||
dispatch({ type: 'CANCEL_SETUP' });
|
||||
fetcher.submit(
|
||||
{ action: 'cancel-totp' },
|
||||
{ method: 'POST', action: '/resources/account/mfa/setup' }
|
||||
);
|
||||
},
|
||||
|
||||
validateTotp: (code: string) => {
|
||||
dispatch({ type: 'VALIDATE_TOTP', code });
|
||||
fetcher.submit(
|
||||
{ action: 'validate-totp', totpCode: code },
|
||||
{ method: 'POST', action: '/resources/account/mfa/setup' }
|
||||
);
|
||||
},
|
||||
|
||||
saveRecoveryCodes: () => {
|
||||
dispatch({ type: 'RECOVERY_CODES_SAVED' });
|
||||
fetcher.submit(
|
||||
{ action: 'saved-recovery-codes' },
|
||||
{ method: 'POST', action: '/resources/account/mfa/setup' }
|
||||
);
|
||||
},
|
||||
|
||||
openDisableDialog: () => {
|
||||
dispatch({ type: 'OPEN_DISABLE_DIALOG' });
|
||||
},
|
||||
|
||||
disableMfa: (totpCode?: string, recoveryCode?: string) => {
|
||||
dispatch({ type: 'DISABLE_MFA' });
|
||||
const formData: Record<string, string> = { action: 'disable-mfa' };
|
||||
if (totpCode) formData.totpCode = totpCode;
|
||||
if (recoveryCode) formData.recoveryCode = recoveryCode;
|
||||
|
||||
fetcher.submit(
|
||||
formData,
|
||||
{ method: 'POST', action: '/resources/account/mfa/setup' }
|
||||
);
|
||||
},
|
||||
|
||||
cancelDisable: () => {
|
||||
dispatch({ type: 'CANCEL_DISABLE' });
|
||||
},
|
||||
|
||||
setDisableMethod: (method: 'totp' | 'recovery') => {
|
||||
dispatch({ type: 'SET_DISABLE_METHOD', method });
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
dispatch({ type: 'CLEAR_ERROR' });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
state,
|
||||
actions,
|
||||
// Computed properties for easier access
|
||||
isQrDialogOpen: (state.phase === 'enabling' && !!state.setupData) || (state.phase === 'showing-recovery' && !!state.recoveryCodes),
|
||||
isRecoveryDialogOpen: false, // Recovery is now handled within the setup dialog
|
||||
isDisableDialogOpen: state.phase === 'disabling',
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export default function Story() {
|
||||
<Switch variant="large" />
|
||||
<Switch variant="large" disabled />
|
||||
<Switch variant="large" label="Toggle me" />
|
||||
<Switch variant="large" label="Label position right" labelPosition="right" />
|
||||
<Switch variant="large" label="Toggle me" disabled />
|
||||
<Switch variant="small" />
|
||||
<Switch variant="small" disabled />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
|
||||
import { sendMagicLinkEmail } from "~/services/email.server";
|
||||
import { postAuthentication } from "./postAuth.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server";
|
||||
|
||||
let secret = env.MAGIC_LINK_SECRET;
|
||||
if (!secret) throw new Error("Missing MAGIC_LINK_SECRET env variable.");
|
||||
@@ -36,8 +37,19 @@ const emailStrategy = new EmailLinkStrategy(
|
||||
|
||||
await postAuthentication({ user, isNewUser, loginMethod: "MAGIC_LINK" });
|
||||
|
||||
// Check if user has MFA enabled
|
||||
if (user.mfaEnabledAt) {
|
||||
// Throw a special error that will be caught by the magic route
|
||||
throw new MfaRequiredError(user.id);
|
||||
}
|
||||
|
||||
return { userId: user.id };
|
||||
} catch (error) {
|
||||
// Skip logging the error if it's a MfaRequiredError
|
||||
if (error instanceof MfaRequiredError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.debug("Magic link user failed to authenticate", { error: JSON.stringify(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { findOrCreateUser } from "~/models/user.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { postAuthentication } from "./postAuth.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { MfaRequiredError } from "./mfa/multiFactorAuthentication.server";
|
||||
|
||||
export function addGitHubStrategy(
|
||||
authenticator: Authenticator<AuthUser>,
|
||||
@@ -40,10 +41,21 @@ export function addGitHubStrategy(
|
||||
|
||||
await postAuthentication({ user, isNewUser, loginMethod: "GITHUB" });
|
||||
|
||||
// Check if user has MFA enabled
|
||||
if (user.mfaEnabledAt) {
|
||||
// Throw a special error that will be caught by the callback route
|
||||
throw new MfaRequiredError(user.id);
|
||||
}
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
};
|
||||
} catch (error) {
|
||||
// Skip logging the error if it's a MfaRequiredError
|
||||
if (error instanceof MfaRequiredError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { env } from "~/env.server";
|
||||
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
export const mfaRateLimiter = singleton("mfaRateLimiter", initializeMfaRateLimiter);
|
||||
|
||||
function initializeMfaRateLimiter() {
|
||||
const redisClient = createRedisRateLimitClient({
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
return new RateLimiter({
|
||||
redisClient,
|
||||
keyPrefix: "mfa:validation",
|
||||
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 attempts per minute
|
||||
logSuccess: false, // Don't log successful attempts for privacy
|
||||
logFailure: true, // Log rate limit violations for security monitoring
|
||||
});
|
||||
}
|
||||
|
||||
export class MfaRateLimitError extends Error {
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(retryAfter: number) {
|
||||
super(`MFA validation rate limit exceeded.`);
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user can attempt MFA validation
|
||||
* @param userId - The user ID to rate limit
|
||||
* @throws {MfaRateLimitError} If rate limit is exceeded
|
||||
*/
|
||||
export async function checkMfaRateLimit(userId: string): Promise<void> {
|
||||
const result = await mfaRateLimiter.limit(userId);
|
||||
|
||||
if (!result.success) {
|
||||
const retryAfter = new Date(result.reset).getTime() - Date.now();
|
||||
throw new MfaRateLimitError(retryAfter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { SecretReference, User, type PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { createRandomStringGenerator } from "@better-auth/utils/random";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { createHash } from "@better-auth/utils/hash";
|
||||
import { createOTP } from "@better-auth/utils/otp";
|
||||
import { base32 } from "@better-auth/utils/base32";
|
||||
import { z } from "zod";
|
||||
import { scheduleEmail } from "../email.server";
|
||||
|
||||
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
|
||||
|
||||
const SecretSchema = z.object({
|
||||
secret: z.string(),
|
||||
});
|
||||
|
||||
export class MfaRequiredError extends Error {
|
||||
public readonly userId: string;
|
||||
|
||||
constructor(userId: string) {
|
||||
super(`MFA is required for user ${userId}`);
|
||||
this.userId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
export class MultiFactorAuthenticationService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.mfaEnabledAt) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.mfaSecretReference) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// validate the TOTP code
|
||||
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const isValid = await this.#verifyTotpCodeOrRecoveryCode(
|
||||
user,
|
||||
user.mfaSecretReference,
|
||||
params.totpCode,
|
||||
params.recoveryCode
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Delete the MFA secret
|
||||
await secretStore.deleteSecret(user.mfaSecretReference.key);
|
||||
|
||||
// Delete the MFA backup codes
|
||||
await this.#prismaClient.mfaBackupCode.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaEnabledAt: null,
|
||||
mfaSecretReference: {
|
||||
delete: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleEmail({
|
||||
email: "mfa-disabled",
|
||||
to: user.email,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async enableTotp(userId: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ServiceValidationError("User not found");
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore("DATABASE");
|
||||
|
||||
// Generate a new secret
|
||||
const secret = generateRandomString(24);
|
||||
const secretKey = `mfa:${userId}:${generateRandomString(8)}`;
|
||||
|
||||
// Store the secret in the SecretStore
|
||||
await secretStore.setSecret(secretKey, {
|
||||
secret,
|
||||
});
|
||||
|
||||
// Update the user's secret reference to the secret store
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaSecretReference: {
|
||||
create: {
|
||||
provider: "DATABASE",
|
||||
key: secretKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Return the secret and the recovery codes
|
||||
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
||||
|
||||
const displaySecret = base32.encode(secret, {
|
||||
padding: false,
|
||||
});
|
||||
|
||||
return {
|
||||
secret: displaySecret,
|
||||
otpAuthUrl,
|
||||
};
|
||||
}
|
||||
|
||||
public async validateTotpSetup(userId: string, totpCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ServiceValidationError("User not found");
|
||||
}
|
||||
|
||||
if (!user.mfaSecretReference) {
|
||||
throw new ServiceValidationError("User has not enabled MFA");
|
||||
}
|
||||
|
||||
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
throw new ServiceValidationError("User has not enabled MFA");
|
||||
}
|
||||
|
||||
const secret = secretResult.secret;
|
||||
|
||||
const otp = createOTP(secret, {
|
||||
digits: 6,
|
||||
period: 30,
|
||||
});
|
||||
|
||||
const isValid = await otp.verify(totpCode);
|
||||
|
||||
if (!isValid) {
|
||||
// Return the secret and the recovery codes
|
||||
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
||||
|
||||
const displaySecret = base32.encode(secret, {
|
||||
padding: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
otpAuthUrl,
|
||||
secret: displaySecret,
|
||||
};
|
||||
}
|
||||
|
||||
// Now that we've validated the TOTP code, we can enable MFA for the user
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaEnabledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate a new set of recovery codes
|
||||
const recoveryCodes = Array.from({ length: 9 }, () => generateRandomString(16, "a-z", "0-9"));
|
||||
|
||||
// Delete any existing recovery codes
|
||||
await this.#prismaClient.mfaBackupCode.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Hash and store the recovery codes
|
||||
for (const code of recoveryCodes) {
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(code);
|
||||
await this.#prismaClient.mfaBackupCode.create({
|
||||
data: {
|
||||
userId,
|
||||
code: hashedCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await scheduleEmail({
|
||||
email: "mfa-enabled",
|
||||
to: user.email,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
recoveryCodes,
|
||||
};
|
||||
}
|
||||
|
||||
async #verifyTotpCodeOrRecoveryCode(
|
||||
user: User,
|
||||
secretReference: SecretReference,
|
||||
totpCode?: string,
|
||||
recoveryCode?: string
|
||||
) {
|
||||
if (!totpCode && !recoveryCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof totpCode === "string" && totpCode.length === 6) {
|
||||
return this.#verifyTotpCode(user, secretReference, totpCode);
|
||||
}
|
||||
|
||||
if (typeof recoveryCode === "string") {
|
||||
return this.#verifyRecoveryCode(user, recoveryCode);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async #verifyTotpCode(user: User, secretReference: SecretReference, totpCode: string) {
|
||||
const secretStore = getSecretStore(secretReference.provider);
|
||||
const secretResult = await secretStore.getSecret(SecretSchema, secretReference.key);
|
||||
|
||||
if (!secretResult) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secret = secretResult.secret;
|
||||
|
||||
const isValid = await createOTP(secret, {
|
||||
digits: 6,
|
||||
period: 30,
|
||||
}).verify(totpCode);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async #verifyRecoveryCode(user: User, recoveryCode: string) {
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
||||
|
||||
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
||||
where: { userId: user.id, code: hashedCode, usedAt: null },
|
||||
});
|
||||
|
||||
return !!backupCode;
|
||||
}
|
||||
|
||||
// Public methods for login flow with security measures
|
||||
public async verifyTotpForLogin(userId: string, totpCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
mfaSecretReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.mfaEnabledAt || !user.mfaSecretReference) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for replay attack - if this code was already used
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(totpCode);
|
||||
if (user.mfaLastUsedCode === hashedCode) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the TOTP code
|
||||
const isValid = await this.#verifyTotpCode(user, user.mfaSecretReference, totpCode);
|
||||
|
||||
if (!isValid) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Mark this code as used to prevent replay
|
||||
await this.#prismaClient.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
mfaLastUsedCode: hashedCode,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
public async verifyRecoveryCodeForLogin(userId: string, recoveryCode: string) {
|
||||
const user = await this.#prismaClient.user.findFirst({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user || !user.mfaEnabledAt) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
||||
|
||||
// Find an unused recovery code
|
||||
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
code: hashedCode,
|
||||
usedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!backupCode) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Invalid authentication code",
|
||||
};
|
||||
}
|
||||
|
||||
// Mark this recovery code as used
|
||||
await this.#prismaClient.mfaBackupCode.update({
|
||||
where: { id: backupCode.id },
|
||||
data: {
|
||||
usedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export async function requireUser(request: Request) {
|
||||
updatedAt: user.updatedAt,
|
||||
dashboardPreferences: user.dashboardPreferences,
|
||||
confirmedBasicDetails: user.confirmedBasicDetails,
|
||||
mfaEnabledAt: user.mfaEnabledAt,
|
||||
isImpersonating: !!impersonationId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ export function personalAccessTokensPath() {
|
||||
return `/account/tokens`;
|
||||
}
|
||||
|
||||
export function accountSecurityPath() {
|
||||
return `/account/security`;
|
||||
}
|
||||
|
||||
export function invitesPath() {
|
||||
return `/invites`;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@aws-sdk/client-ecr": "^3.839.0",
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
"@aws-sdk/client-sts": "^3.840.0",
|
||||
"@better-auth/utils": "^0.2.6",
|
||||
"@codemirror/autocomplete": "^6.3.1",
|
||||
"@codemirror/commands": "^6.1.2",
|
||||
"@codemirror/lang-javascript": "^6.1.1",
|
||||
@@ -139,6 +140,7 @@
|
||||
"graphile-worker": "0.16.6",
|
||||
"highlight.run": "^7.3.4",
|
||||
"humanize-duration": "^3.27.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"intl-parse-accept-language": "^1.0.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"isbot": "^3.6.5",
|
||||
@@ -160,6 +162,7 @@
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"random-words": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-aria": "^3.31.1",
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "mfaEnabledAt" TIMESTAMP(3),
|
||||
ADD COLUMN "mfaLastUsedCode" TEXT,
|
||||
ADD COLUMN "mfaSecretReferenceId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MfaBackupCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MfaBackupCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MfaBackupCode_userId_code_key" ON "MfaBackupCode"("userId", "code");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_mfaSecretReferenceId_fkey" FOREIGN KEY ("mfaSecretReferenceId") REFERENCES "SecretReference"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MfaBackupCode" ADD CONSTRAINT "MfaBackupCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -46,10 +46,33 @@ model User {
|
||||
orgMemberships OrgMember[]
|
||||
sentInvites OrgMemberInvite[]
|
||||
|
||||
mfaEnabledAt DateTime?
|
||||
mfaSecretReference SecretReference? @relation(fields: [mfaSecretReferenceId], references: [id])
|
||||
mfaSecretReferenceId String?
|
||||
/// Hash of the last used code to prevent replay attacks
|
||||
mfaLastUsedCode String?
|
||||
|
||||
invitationCode InvitationCode? @relation(fields: [invitationCodeId], references: [id])
|
||||
invitationCodeId String?
|
||||
personalAccessTokens PersonalAccessToken[]
|
||||
deployments WorkerDeployment[]
|
||||
backupCodes MfaBackupCode[]
|
||||
}
|
||||
|
||||
model MfaBackupCode {
|
||||
id String @id @default(cuid())
|
||||
/// Hash of the actual code
|
||||
code String
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
|
||||
usedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, code])
|
||||
}
|
||||
|
||||
// @deprecated This model is no longer used as the Cloud is out of private beta
|
||||
@@ -346,6 +369,7 @@ model SecretReference {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
OrganizationIntegration OrganizationIntegration[]
|
||||
User User[]
|
||||
}
|
||||
|
||||
enum SecretStoreProvider {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Body, Container, Head, Html, Preview, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { container, h1, main, paragraphLight } from "./components/styles";
|
||||
import { z } from "zod";
|
||||
|
||||
export const MfaDisabledEmailSchema = z.object({
|
||||
email: z.literal("mfa-disabled"),
|
||||
userEmail: z.string(),
|
||||
});
|
||||
|
||||
type MfaDisabledEmailProps = z.infer<typeof MfaDisabledEmailSchema>;
|
||||
|
||||
const previewDefaults: MfaDisabledEmailProps = {
|
||||
email: "mfa-disabled",
|
||||
userEmail: "user@example.com",
|
||||
};
|
||||
|
||||
export default function Email(props: MfaDisabledEmailProps) {
|
||||
const { userEmail } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Multi-factor authentication disabled</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Multi-factor authentication disabled</Text>
|
||||
<Text style={paragraphLight}>Hi there,</Text>
|
||||
<Text style={paragraphLight}>
|
||||
You have successfully disabled multi-factor authentication (MFA) for your Trigger.dev
|
||||
account ({userEmail}). Your account no longer has the additional security layer provided
|
||||
by MFA.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
You can re-enable MFA at any time from your account security page. If you didn't disable
|
||||
MFA, please contact our support team immediately.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Body, Container, Head, Html, Preview, Text } from "@react-email/components";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { Image } from "./components/Image";
|
||||
import { container, h1, main, paragraphLight } from "./components/styles";
|
||||
import { z } from "zod";
|
||||
|
||||
export const MfaEnabledEmailSchema = z.object({
|
||||
email: z.literal("mfa-enabled"),
|
||||
userEmail: z.string(),
|
||||
});
|
||||
|
||||
type MfaEnabledEmailProps = z.infer<typeof MfaEnabledEmailSchema>;
|
||||
|
||||
const previewDefaults: MfaEnabledEmailProps = {
|
||||
email: "mfa-enabled",
|
||||
userEmail: "user@example.com",
|
||||
};
|
||||
|
||||
export default function Email(props: MfaEnabledEmailProps) {
|
||||
const { userEmail } = {
|
||||
...previewDefaults,
|
||||
...props,
|
||||
};
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Multi-factor authentication enabled ✅</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Text style={h1}>Multi-factor authentication enabled</Text>
|
||||
<Text style={paragraphLight}>Hi there,</Text>
|
||||
<Text style={paragraphLight}>
|
||||
Multi-factor authentication was successfully enabled for your Trigger.dev account (
|
||||
{userEmail}). If you did not make this change, contact our support team immediately.
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
<strong>Staying secure:</strong>
|
||||
</Text>
|
||||
<Text style={paragraphLight}>
|
||||
• Keep your authenticator app safe and secured
|
||||
<br />
|
||||
• Never share your MFA codes with anyone
|
||||
<br />• Store your recovery codes in a secure location
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
...paragraphLight,
|
||||
display: "block",
|
||||
marginBottom: "50px",
|
||||
}}
|
||||
>
|
||||
Your account now has an additional layer of protection and you'll need to enter a code
|
||||
from your authenticator app when logging in.
|
||||
</Text>
|
||||
<Image path="/emails/logo-mono.png" width="120" height="22" alt="Trigger.dev" />
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import AlertDeploymentSuccessEmail, {
|
||||
} from "../emails/deployment-success";
|
||||
import InviteEmail, { InviteEmailSchema } from "../emails/invite";
|
||||
import MagicLinkEmail from "../emails/magic-link";
|
||||
import WelcomeEmail from "../emails/welcome";
|
||||
|
||||
import { constructMailTransport, MailTransport, MailTransportOptions } from "./transports";
|
||||
import MfaEnabledEmail, { MfaEnabledEmailSchema } from "../emails/mfa-enabled";
|
||||
import MfaDisabledEmail, { MfaDisabledEmailSchema } from "../emails/mfa-disabled";
|
||||
|
||||
export { type MailTransportOptions };
|
||||
|
||||
@@ -28,6 +30,8 @@ export const DeliverEmailSchema = z
|
||||
AlertAttemptEmailSchema,
|
||||
AlertDeploymentFailureEmailSchema,
|
||||
AlertDeploymentSuccessEmailSchema,
|
||||
MfaEnabledEmailSchema,
|
||||
MfaDisabledEmailSchema,
|
||||
])
|
||||
.and(z.object({ to: z.string() }));
|
||||
|
||||
@@ -118,6 +122,18 @@ export class EmailClient {
|
||||
component: <AlertDeploymentSuccessEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "mfa-enabled": {
|
||||
return {
|
||||
subject: `Multi-factor authentication enabled on your Trigger.dev account`,
|
||||
component: <MfaEnabledEmail {...data} />,
|
||||
};
|
||||
}
|
||||
case "mfa-disabled": {
|
||||
return {
|
||||
subject: `Multi-factor authentication disabled on your Trigger.dev account`,
|
||||
component: <MfaDisabledEmail {...data} />,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+33
@@ -203,6 +203,9 @@ importers:
|
||||
'@aws-sdk/client-sts':
|
||||
specifier: ^3.840.0
|
||||
version: 3.840.0
|
||||
'@better-auth/utils':
|
||||
specifier: ^0.2.6
|
||||
version: 0.2.6
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.3.1
|
||||
version: 6.4.0(@codemirror/language@6.3.2)(@codemirror/state@6.2.0)(@codemirror/view@6.7.2)(@lezer/common@1.2.3)
|
||||
@@ -512,6 +515,9 @@ importers:
|
||||
humanize-duration:
|
||||
specifier: ^3.27.3
|
||||
version: 3.27.3
|
||||
input-otp:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(react-dom@18.2.0)(react@18.2.0)
|
||||
intl-parse-accept-language:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
@@ -575,6 +581,9 @@ importers:
|
||||
prom-client:
|
||||
specifier: ^15.1.0
|
||||
version: 15.1.0
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@18.2.0)
|
||||
random-words:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
@@ -5347,6 +5356,12 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
dev: true
|
||||
|
||||
/@better-auth/utils@0.2.6:
|
||||
resolution: {integrity: sha512-3y/vaL5Ox33dBwgJ6ub3OPkVqr6B5xL2kgxNHG8eHZuryLyG/4JSPGqjbdRSgjuy9kALUZYDFl+ORIAxlWMSuA==}
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
dev: false
|
||||
|
||||
/@bufbuild/protobuf@1.10.0:
|
||||
resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==}
|
||||
dev: false
|
||||
@@ -26063,6 +26078,16 @@ packages:
|
||||
css-in-js-utils: 3.1.0
|
||||
dev: false
|
||||
|
||||
/input-otp@1.4.2(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/install-artifact-from-github@1.3.5:
|
||||
resolution: {integrity: sha512-gZHC7f/cJgXz7MXlHFBxPVMsvIbev1OQN1uKQYKVJDydGNm9oYf9JstbU4Atnh/eSvk41WtEovoRm+8IF686xg==}
|
||||
hasBin: true
|
||||
@@ -31114,6 +31139,14 @@ packages:
|
||||
postcss-selector-parser: 6.1.2
|
||||
dev: false
|
||||
|
||||
/qrcode.react@4.2.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/qs@6.11.0:
|
||||
resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
Reference in New Issue
Block a user