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:
Eric Allam
2025-07-08 13:44:47 +01:00
committed by GitHub
parent e02c90a4ea
commit b119a52e08
30 changed files with 2222 additions and 75 deletions
@@ -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>
);
}
+39 -1
View File
@@ -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>
);
}
+42 -14
View File
@@ -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");
}
};
+1 -1
View File
@@ -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.
+320
View File
@@ -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>
);
}
+32 -5
View File
@@ -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,
};
}
+4
View File
@@ -60,6 +60,10 @@ export function personalAccessTokensPath() {
return `/account/tokens`;
}
export function accountSecurityPath() {
return `/account/security`;
}
export function invitesPath() {
return `/invites`;
}
+3
View File
@@ -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",
@@ -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>
);
}
+17 -1
View File
@@ -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} />,
};
}
}
}
}
+33
View File
@@ -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'}