From 984e6286ff3268bd25ddd1a857155c1fcf357a06 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 13:24:25 +0100 Subject: [PATCH 01/26] Adds a new route for logging in with mfa --- apps/webapp/app/routes/login.magic/route.tsx | 2 +- apps/webapp/app/routes/login.mfa/route.tsx | 217 +++++++++++++++++++ apps/webapp/app/routes/magic.tsx | 3 +- 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/routes/login.mfa/route.tsx diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 69b6d84c5c8..04085ebd612 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -113,7 +113,7 @@ export default function LoginMagicLinkPage() { We've sent you a magic link!
- + We sent you an email which contains a magic link that will log you in to your account. diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx new file mode 100644 index 00000000000..00b4a8e9ed0 --- /dev/null +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -0,0 +1,217 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; +import { Form, useNavigation } from "@remix-run/react"; +import { 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 { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { authenticator } from "~/services/auth.server"; +import { commitSession, getUserSession } from "~/services/sessionStorage.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) { + await authenticator.isAuthenticated(request, { + successRedirect: "/", + }); + + const session = await getUserSession(request); + const error = session.get("auth:error"); + + let mfaError: string | undefined; + if (error) { + if ("message" in error) { + mfaError = error.message; + } else { + mfaError = JSON.stringify(error, null, 2); + } + } + + return typedjson( + { + mfaError, + }, + { + headers: { "Set-Cookie": await commitSession(session) }, + } + ); +} + +export async function action({ request }: ActionFunctionArgs) { + const clonedRequest = request.clone(); + + const payload = Object.fromEntries(await clonedRequest.formData()); + + const { action } = z + .object({ + action: z.enum(["verify-recovery", "verify-mfa"]), + }) + .parse(payload); + + if (action === "verify-recovery") { + // TODO: Implement recovery code verification logic + const recoveryCode = payload.recoveryCode; + + // For now, just redirect to dashboard + return redirect("/"); + } else if (action === "verify-mfa") { + // TODO: Implement MFA code verification logic + const mfaCode = payload.mfaCode; + + // For now, just redirect to dashboard + return redirect("/"); + } else { + const session = await getUserSession(request); + session.unset("triggerdotdev:magiclink"); + + return redirect("/login/magic", { + headers: { + "Set-Cookie": await commitSession(session), + }, + }); + } +} + +export default function LoginMfaPage() { + const { mfaError } = useTypedLoaderData(); + const navigate = useNavigation(); + const [showRecoveryCode, setShowRecoveryCode] = useState(false); + + const isLoading = + (navigate.state === "loading" || navigate.state === "submitting") && + navigate.formAction !== undefined && + (navigate.formData?.get("action") === "verify-mfa" || + navigate.formData?.get("action") === "verify-recovery"); + + return ( + +
+
+ + Multi-factor authentication + + {showRecoveryCode ? ( + <> + + Enter one of your recovery codes to log in. + +
+ + + + + + {mfaError && {mfaError}} +
+ + + ) : ( + <> + + Open your authenticator app to get your code. + +
+ + + + + + {mfaError && {mfaError}} +
+ + + )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index 725640c6f4b..6d301b0fe7e 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -6,7 +6,8 @@ export async function loader({ request }: LoaderFunctionArgs) { const redirectTo = await getRedirectTo(request); await authenticator.authenticate("email-link", request, { - successRedirect: redirectTo ?? "/", + //Todo: only redirect to /mfa if mfa enabled + successRedirect: redirectTo ?? "/login/mfa", failureRedirect: "/login/magic", }); } From b9be001eab9558e22a351bfa2f440a873ce43203 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 13:33:20 +0100 Subject: [PATCH 02/26] New path for security page --- apps/webapp/app/utils/pathBuilder.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 4a48b3a4b82..c4a4247ac1d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -60,6 +60,10 @@ export function personalAccessTokensPath() { return `/account/tokens`; } +export function accountSecurityPath() { + return `/account/security`; +} + export function invitesPath() { return `/invites`; } From 65e30ce88bb9cbb0294f37fc429192ec8ea421db Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 13:33:42 +0100 Subject: [PATCH 03/26] =?UTF-8?q?Adds=20=E2=80=9CSecurity=E2=80=9D=20link?= =?UTF-8?q?=20to=20account=20side=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/navigation/AccountSideMenu.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/navigation/AccountSideMenu.tsx b/apps/webapp/app/components/navigation/AccountSideMenu.tsx index 0c04044d913..374b2f31618 100644 --- a/apps/webapp/app/components/navigation/AccountSideMenu.tsx +++ b/apps/webapp/app/components/navigation/AccountSideMenu.tsx @@ -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" /> +
From e7b2794b8a03444641b618e522502f78ea965377 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 13:46:41 +0100 Subject: [PATCH 04/26] Update the Switch component to allow label positions left and right --- .../app/components/primitives/Switch.tsx | 53 ++++++++++--------- .../app/routes/storybook.switch/route.tsx | 1 + 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 07bafd13b83..68c967b92d9 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -46,10 +46,11 @@ type SwitchProps = React.ComponentPropsWithoutRef label?: React.ReactNode; variant: keyof typeof variations; shortcut?: ShortcutDefinition; + labelPosition?: "left" | "right"; }; export const Switch = React.forwardRef, SwitchProps>( - ({ className, variant, label, ...props }, ref) => { + ({ className, variant, label, labelPosition = "left", ...props }, ref) => { const innerRef = React.useRef(null); React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement); @@ -67,35 +68,39 @@ export const Switch = React.forwardRef + {typeof label === "string" ? {label} : label} + + ) : null; + + const switchElement = ( +
+ +
+ ); + return ( - {label ? ( - - ) : null} -
- -
+ {labelPosition === "left" ? labelElement : null} + {switchElement} + {labelPosition === "right" ? labelElement : null}
); } diff --git a/apps/webapp/app/routes/storybook.switch/route.tsx b/apps/webapp/app/routes/storybook.switch/route.tsx index d4db97f6f3a..63b49922c8a 100644 --- a/apps/webapp/app/routes/storybook.switch/route.tsx +++ b/apps/webapp/app/routes/storybook.switch/route.tsx @@ -6,6 +6,7 @@ export default function Story() { + From 093c6841b3f3001f726a85dc7a0920487a453d3a Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 14:57:17 +0100 Subject: [PATCH 05/26] Optionally hide the Close button in the Dialog title bar --- .../app/components/primitives/Dialog.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index 5ac179646bf..7c28193717b 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -36,8 +36,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean; + } +>(({ className, children, showCloseButton = true, ...props }, ref) => (
{children} - - - - Close - + {showCloseButton && ( + + + + Close + + )}
)); From 4a803313a9d04ef6768dcafc021f87d3b61299f4 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 14:57:51 +0100 Subject: [PATCH 06/26] Installs `qrcode` react package for generating QR codes. --- apps/webapp/package.json | 1 + pnpm-lock.yaml | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 6d0e2b24075..828a1dc85fe 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -160,6 +160,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8aa284058e..11b3cbdb6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -575,6 +575,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 @@ -31114,6 +31117,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'} From 4bac3557c304948c77bbfba43067e358c1cde872 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 15:49:03 +0100 Subject: [PATCH 07/26] CopyButton component now takes children --- .../app/components/primitives/CopyButton.tsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 1c1d611987b..2a93cb2c79f 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -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 ? ( + + ) : ( + + ) + } > - {copied ? ( - - ) : ( - - )} + {children} ); From d7f85bbc21e79aa531335a51d8a204120207a8e9 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 15:49:30 +0100 Subject: [PATCH 08/26] New Security route for setting up MFA --- .../app/routes/account.security/route.tsx | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 apps/webapp/app/routes/account.security/route.tsx diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx new file mode 100644 index 00000000000..bbc215ab55f --- /dev/null +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -0,0 +1,349 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, type MetaFunction, useActionData } from "@remix-run/react"; +import { type ActionFunction, json } from "@remix-run/server-runtime"; +import { useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { z } from "zod"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, +} from "~/components/primitives/Dialog"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Switch } from "~/components/primitives/Switch"; +import { prisma } from "~/db.server"; +import { useUser } from "~/hooks/useUser"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { updateUser } from "~/models/user.server"; +import { requireUserId } from "~/services/session.server"; +import { accountPath } from "~/utils/pathBuilder"; +import { CopyButton } from "~/components/primitives/CopyButton"; +import { DownloadIcon } from "lucide-react"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Security | Trigger.dev`, + }, + ]; +}; + +function createSchema( + constraints: { + isEmailUnique?: (email: string) => Promise; + } = {} +) { + return z.object({ + name: z + .string({ required_error: "You must enter a name" }) + .min(2, "Your name must be at least 2 characters long") + .max(50), + email: z + .string() + .email() + .superRefine((email, ctx) => { + if (constraints.isEmailUnique === undefined) { + //client-side validation skips this + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: conform.VALIDATION_UNDEFINED, + }); + } else { + // Tell zod this is an async validation by returning the promise + return constraints.isEmailUnique(email).then((isUnique) => { + if (isUnique) { + return; + } + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Email is already being used by a different account", + }); + }); + } + }), + marketingEmails: z.preprocess((value) => value === "on", z.boolean()), + }); +} + +export const action: ActionFunction = async ({ request }) => { + const userId = await requireUserId(request); + + const formData = await request.formData(); + + // TODO: Handle MFA actions here (enable/disable/validate TOTP) + const action = formData.get("action"); + + if (action === "enable-mfa") { + // TODO: Validate TOTP code and enable MFA for user + return json({ success: true }); + } + + if (action === "disable-mfa") { + // TODO: Disable MFA for user + return json({ success: true }); + } + + const formSchema = createSchema({ + isEmailUnique: async (email) => { + const existingUser = await prisma.user.findFirst({ + where: { + email, + }, + }); + + if (!existingUser) { + return true; + } + + if (existingUser.id === userId) { + return true; + } + + return false; + }, + }); + + const submission = await parse(formData, { schema: formSchema, async: true }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + return json({ success: true }); +}; + +export default function Page() { + const lastSubmission = useActionData(); + + // MFA state management - TODO: Get actual MFA state from backend + const [isMfaEnabled, setIsMfaEnabled] = useState(false); + const [showQrDialog, setShowQrDialog] = useState(false); + const [showRecoveryDialog, setShowRecoveryDialog] = useState(false); + const [totpCode, setTotpCode] = useState(""); + + // TODO: Replace with actual data from backend + const qrCodeValue = + "otpauth://totp/Trigger.dev:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Trigger.dev"; + const secretKey = "JBSWY3DPEHPK3PXP"; + const recoveryCodes = [ + "abc123def456", + "ghi789jkl012", + "mno345pqr678", + "stu901vwx234", + "yz567abc890d", + "efg123hij456", + "klm789nop012", + "qrs345tuv678", + ]; + + const [form, {}] = useForm({ + id: "security", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema: createSchema() }); + }, + }); + + const handleSwitchChange = (checked: boolean) => { + if (checked && !isMfaEnabled) { + // Show QR code dialog to enable MFA + setShowQrDialog(true); + } else if (!checked && isMfaEnabled) { + // TODO: Handle disabling MFA - might need backend call + setIsMfaEnabled(false); + } + }; + + const handleQrConfirm = () => { + // TODO: Submit TOTP code to backend for validation + console.log("Validating TOTP code:", totpCode); + + // For now, simulate successful validation + setShowQrDialog(false); + setShowRecoveryDialog(true); + setTotpCode(""); + }; + + const handleQrCancel = () => { + setShowQrDialog(false); + setTotpCode(""); + // Don't change the switch state when canceling + }; + + const handleRecoveryComplete = () => { + setShowRecoveryDialog(false); + setIsMfaEnabled(true); + }; + + const handleEditMfa = () => { + // Show QR dialog again with fresh QR code + setShowQrDialog(true); + }; + + const downloadRecoveryCodes = () => { + 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); + }; + + return ( + + + + + + + +
+ Security +
+
+ + + + Enable an extra layer of security by requiring a one-time code from your + authenticator app (TOTP) each time you log in. + + +
+ + {isMfaEnabled && ( + + )} +
+
+ + {/* QR Code Dialog */} + + + + Enable authenticator app + +
+ + 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. + + +
+
+ +
+ +
+ + { + const value = e.target.value.replace(/\D/g, "").slice(0, 6); + setTotpCode(value); + }} + placeholder="000000" + maxLength={6} + className="text-center font-mono tracking-wider" + /> +
+ + + + + +
+
+ + {/* Recovery Codes Dialog */} + + + + Recovery codes + +
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ + + Copy + +
+
+
+ + + + +
+
+
+
+
+ ); +} From 1cc622b188b58d4fef255901c1c75ff4d9463eaa Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 16:21:23 +0100 Subject: [PATCH 09/26] Adds new OTP package for the chadcn InputOTP component --- apps/webapp/package.json | 1 + pnpm-lock.yaml | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 828a1dc85fe..d2463424943 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -139,6 +139,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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11b3cbdb6a5..279808533ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -512,6 +512,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 @@ -26066,6 +26069,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 From fb0fa763858cbd6499aa26c2eb15593dd4b78511 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 16:21:44 +0100 Subject: [PATCH 10/26] Adds new InputOTP chadcn component --- .../app/components/primitives/InputOTP.tsx | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 apps/webapp/app/components/primitives/InputOTP.tsx diff --git a/apps/webapp/app/components/primitives/InputOTP.tsx b/apps/webapp/app/components/primitives/InputOTP.tsx new file mode 100644 index 00000000000..10ce31c5c28 --- /dev/null +++ b/apps/webapp/app/components/primitives/InputOTP.tsx @@ -0,0 +1,70 @@ +"use client"; + +import * as React from "react"; +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; + +import { cn } from "~/utils/cn"; + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; From 6aa5d1b4612afa0b90cfcfe9593ad81b0a5f665f Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 17:21:44 +0100 Subject: [PATCH 11/26] Adds InputOTP chadcn component to the MFA login screen --- apps/webapp/app/routes/login.mfa/route.tsx | 38 +++++++++++++--------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index 00b4a8e9ed0..d0664a74de0 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -11,6 +11,7 @@ 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"; @@ -101,6 +102,7 @@ export default function LoginMfaPage() { const { mfaError } = useTypedLoaderData(); const navigate = useNavigation(); const [showRecoveryCode, setShowRecoveryCode] = useState(false); + const [mfaCode, setMfaCode] = useState(""); const isLoading = (navigate.state === "loading" || navigate.state === "submitting") && @@ -111,7 +113,7 @@ export default function LoginMfaPage() { return (
-
+
Multi-factor authentication @@ -164,29 +166,33 @@ export default function LoginMfaPage() { ) : ( <> - Open your authenticator app to get your code. + Open your authenticator app to get your code. Then enter it below.
- - - + setMfaCode(value)} + variant="large" + fullWidth + > + + + + + + + + + + - - + + + + + @@ -303,43 +322,53 @@ export default function Page() { Recovery codes -
- - Copy and store these recovery codes carefully in case you lose your device. - - -
-
- {recoveryCodes.map((code, index) => ( -
- {code} -
- ))} -
-
- - - Copy - +
+ +
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ + + Copy + +
-
- - - + + + + From 661c3a67b04fc02dd17572a96b46f08b225b3422 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 18:05:40 +0100 Subject: [PATCH 14/26] Show a confirmation modal before you can disable MFA --- .../app/routes/account.security/route.tsx | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx index 093a0fae78f..dbe6fb29ddb 100644 --- a/apps/webapp/app/routes/account.security/route.tsx +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -137,6 +137,7 @@ export default function Page() { const [isMfaEnabled, setIsMfaEnabled] = useState(false); const [showQrDialog, setShowQrDialog] = useState(false); const [showRecoveryDialog, setShowRecoveryDialog] = useState(false); + const [showDisableDialog, setShowDisableDialog] = useState(false); const [totpCode, setTotpCode] = useState(""); // TODO: Replace with actual data from backend @@ -168,8 +169,8 @@ export default function Page() { // Show QR code dialog to enable MFA setShowQrDialog(true); } else if (!checked && isMfaEnabled) { - // TODO: Handle disabling MFA - might need backend call - setIsMfaEnabled(false); + // Show confirmation dialog before disabling MFA + setShowDisableDialog(true); } }; @@ -214,6 +215,18 @@ export default function Page() { URL.revokeObjectURL(url); }; + const handleDisableMfa = (e: React.FormEvent) => { + e.preventDefault(); + // TODO: Handle disabling MFA - backend call + setShowDisableDialog(false); + setIsMfaEnabled(false); + }; + + const handleDisableCancel = () => { + setShowDisableDialog(false); + // Don't change the switch state when canceling + }; + return ( @@ -371,6 +384,32 @@ export default function Page() { + + {/* Disable MFA Confirmation Dialog */} + + + + Disable multi-factor authentication + +
+ +
+ + Are you sure you want to disable multi-factor authentication? + +
+ + + + + +
+
+
From b9d43bc6b49518a3a7e6d93a43659d0ccb588c75 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 4 Jul 2025 18:10:21 +0100 Subject: [PATCH 15/26] Revert redirect back to the dashboard for now --- apps/webapp/app/routes/magic.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/webapp/app/routes/magic.tsx b/apps/webapp/app/routes/magic.tsx index 6d301b0fe7e..725640c6f4b 100644 --- a/apps/webapp/app/routes/magic.tsx +++ b/apps/webapp/app/routes/magic.tsx @@ -6,8 +6,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const redirectTo = await getRedirectTo(request); await authenticator.authenticate("email-link", request, { - //Todo: only redirect to /mfa if mfa enabled - successRedirect: redirectTo ?? "/login/mfa", + successRedirect: redirectTo ?? "/", failureRedirect: "/login/magic", }); } From bce4833dd7bf5cf6cd0ca6f0e4cececf14754410 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 6 Jul 2025 05:45:40 +0100 Subject: [PATCH 16/26] Implement MFA enabling and disabling --- apps/webapp/app/models/message.server.ts | 40 +- .../app/routes/account.security/route.tsx | 390 +----------- .../resources.account.mfa.setup/route.tsx | 554 ++++++++++++++++++ .../mfa/multiFactorAuthentication.server.ts | 309 ++++++++++ apps/webapp/app/services/session.server.ts | 1 + apps/webapp/package.json | 1 + .../migration.sql | 26 + .../database/prisma/schema.prisma | 24 + pnpm-lock.yaml | 16 + 9 files changed, 981 insertions(+), 380 deletions(-) create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/route.tsx create mode 100644 apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts create mode 100644 internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql diff --git a/apps/webapp/app/models/message.server.ts b/apps/webapp/app/models/message.server.ts index b488a43044d..cb6ca2963a9 100644 --- a/apps/webapp/app/models/message.server.ts +++ b/apps/webapp/app/models/message.server.ts @@ -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( + 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( + 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, diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx index dbe6fb29ddb..18cf8b54ab7 100644 --- a/apps/webapp/app/routes/account.security/route.tsx +++ b/apps/webapp/app/routes/account.security/route.tsx @@ -1,41 +1,15 @@ -import { conform, useForm } from "@conform-to/react"; -import { parse } from "@conform-to/zod"; -import { Form, type MetaFunction, useActionData } from "@remix-run/react"; -import { type ActionFunction, json } from "@remix-run/server-runtime"; -import { useState } from "react"; -import { QRCodeSVG } from "qrcode.react"; -import { z } from "zod"; +import { type MetaFunction } from "@remix-run/react"; import { MainHorizontallyCenteredContainer, PageBody, PageContainer, } from "~/components/layout/AppLayout"; -import { Button } from "~/components/primitives/Buttons"; -import { CopyableText } from "~/components/primitives/CopyableText"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, -} from "~/components/primitives/Dialog"; -import { DialogClose } from "@radix-ui/react-dialog"; import { Header2 } from "~/components/primitives/Headers"; -import { Input } from "~/components/primitives/Input"; -import { InputGroup } from "~/components/primitives/InputGroup"; -import { Label } from "~/components/primitives/Label"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { Switch } from "~/components/primitives/Switch"; -import { prisma } from "~/db.server"; -import { useUser } from "~/hooks/useUser"; -import { redirectWithSuccessMessage } from "~/models/message.server"; -import { updateUser } from "~/models/user.server"; -import { requireUserId } from "~/services/session.server"; -import { accountPath } from "~/utils/pathBuilder"; -import { CopyButton } from "~/components/primitives/CopyButton"; -import { DownloadIcon } from "lucide-react"; -import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/InputOTP"; +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 [ @@ -45,187 +19,16 @@ export const meta: MetaFunction = () => { ]; }; -function createSchema( - constraints: { - isEmailUnique?: (email: string) => Promise; - } = {} -) { - return z.object({ - name: z - .string({ required_error: "You must enter a name" }) - .min(2, "Your name must be at least 2 characters long") - .max(50), - email: z - .string() - .email() - .superRefine((email, ctx) => { - if (constraints.isEmailUnique === undefined) { - //client-side validation skips this - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: conform.VALIDATION_UNDEFINED, - }); - } else { - // Tell zod this is an async validation by returning the promise - return constraints.isEmailUnique(email).then((isUnique) => { - if (isUnique) { - return; - } +export async function loader({ request }: LoaderFunctionArgs) { + const user = await requireUser(request); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Email is already being used by a different account", - }); - }); - } - }), - marketingEmails: z.preprocess((value) => value === "on", z.boolean()), + return typedjson({ + user, }); } -export const action: ActionFunction = async ({ request }) => { - const userId = await requireUserId(request); - - const formData = await request.formData(); - - // TODO: Handle MFA actions here (enable/disable/validate TOTP) - const action = formData.get("action"); - - if (action === "enable-mfa") { - // TODO: Validate TOTP code and enable MFA for user - return json({ success: true }); - } - - if (action === "disable-mfa") { - // TODO: Disable MFA for user - return json({ success: true }); - } - - const formSchema = createSchema({ - isEmailUnique: async (email) => { - const existingUser = await prisma.user.findFirst({ - where: { - email, - }, - }); - - if (!existingUser) { - return true; - } - - if (existingUser.id === userId) { - return true; - } - - return false; - }, - }); - - const submission = await parse(formData, { schema: formSchema, async: true }); - - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } - - return json({ success: true }); -}; - export default function Page() { - const lastSubmission = useActionData(); - - // MFA state management - TODO: Get actual MFA state from backend - const [isMfaEnabled, setIsMfaEnabled] = useState(false); - const [showQrDialog, setShowQrDialog] = useState(false); - const [showRecoveryDialog, setShowRecoveryDialog] = useState(false); - const [showDisableDialog, setShowDisableDialog] = useState(false); - const [totpCode, setTotpCode] = useState(""); - - // TODO: Replace with actual data from backend - const qrCodeValue = - "otpauth://totp/Trigger.dev:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Trigger.dev"; - const secretKey = "JBSWY3DPEHPK3PXP"; - const recoveryCodes = [ - "abc123def456", - "ghi789jkl012", - "mno345pqr678", - "stu901vwx234", - "yz567abc890d", - "efg123hij456", - "klm789nop012", - "qrs345tuv678", - ]; - - const [form, {}] = useForm({ - id: "security", - // TODO: type this - lastSubmission: lastSubmission as any, - onValidate({ formData }) { - return parse(formData, { schema: createSchema() }); - }, - }); - - const handleSwitchChange = (checked: boolean) => { - if (checked && !isMfaEnabled) { - // Show QR code dialog to enable MFA - setShowQrDialog(true); - } else if (!checked && isMfaEnabled) { - // Show confirmation dialog before disabling MFA - setShowDisableDialog(true); - } - }; - - const handleQrConfirm = (e: React.FormEvent) => { - e.preventDefault(); - // TODO: Submit TOTP code to backend for validation - console.log("Validating TOTP code:", totpCode); - - // For now, simulate successful validation - setShowQrDialog(false); - setShowRecoveryDialog(true); - setTotpCode(""); - }; - - const handleQrCancel = () => { - setShowQrDialog(false); - setTotpCode(""); - // Don't change the switch state when canceling - }; - - const handleRecoveryComplete = (e: React.FormEvent) => { - e.preventDefault(); - setShowRecoveryDialog(false); - setIsMfaEnabled(true); - }; - - const handleEditMfa = () => { - // Show QR dialog again with fresh QR code - setShowQrDialog(true); - }; - - const downloadRecoveryCodes = () => { - 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); - }; - - const handleDisableMfa = (e: React.FormEvent) => { - e.preventDefault(); - // TODO: Handle disabling MFA - backend call - setShowDisableDialog(false); - setIsMfaEnabled(false); - }; - - const handleDisableCancel = () => { - setShowDisableDialog(false); - // Don't change the switch state when canceling - }; + const { user } = useTypedLoaderData(); return ( @@ -238,178 +41,7 @@ export default function Page() {
Security
-
- - - - Enable an extra layer of security by requiring a one-time code from your - authenticator app (TOTP) each time you log in. - - -
- - {isMfaEnabled && ( - - )} -
-
- - {/* QR Code Dialog */} - - - - Enable authenticator app - -
- -
- - 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. - - -
-
- -
- -
- -
- setTotpCode(value)} - variant="large" - name="totpCode" - onKeyDown={(e) => { - if (e.key === "Enter" && totpCode.length === 6) { - handleQrConfirm(e); - } - }} - > - - - - - - - - - -
-
- - - - - -
-
-
- - {/* Recovery Codes Dialog */} - - - - Recovery codes - -
- -
- - Copy and store these recovery codes carefully in case you lose your device. - - -
-
- {recoveryCodes.map((code, index) => ( -
- {code} -
- ))} -
-
- - - Copy - -
-
-
- - - - -
-
-
- - {/* Disable MFA Confirmation Dialog */} - - - - Disable multi-factor authentication - -
- -
- - Are you sure you want to disable multi-factor authentication? - -
- - - - - -
-
-
+
diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx new file mode 100644 index 00000000000..a4f0d720ac6 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -0,0 +1,554 @@ +import { Form } from "@remix-run/react"; +import { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { DownloadIcon } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import React, { useState, useEffect } from "react"; +import { redirect, typedjson, useTypedFetcher } from "remix-typedjson"; +import { z } from "zod"; +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 { 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 { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, + typedJsonWithErrorMessage, + typedJsonWithSuccessMessage, +} from "~/models/message.server"; +import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; +import { requireUserId } from "~/services/session.server"; + +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) { + 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"); + } + } +} + +export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { + const fetcher = useTypedFetcher(); + const [showDisableDialog, setShowDisableDialog] = useState(false); + + const formAction = fetcher.formData?.get("action"); + + const data = fetcher.data; + + // TODO: Remove this + console.log("fetcher.state", fetcher.state); + console.log("fetcher.formData", Object.fromEntries(fetcher.formData?.entries() ?? [])); + console.log("fetcher.data", fetcher.data); + + const isMfaEnabled = + (fetcher.state === "submitting" && formAction === "enable-mfa") || + (data && data.action === "enable-mfa") || + (isEnabled && !(data?.action === "disable-mfa" && data.success)) || + (data && data.action === "validate-totp" && !data.success); + + const [totpCode, setTotpCode] = useState(""); + + // Additional state for disable MFA functionality + const [recoveryCode, setRecoveryCode] = useState(""); + const [showRecoveryCode, setShowRecoveryCode] = useState(false); + const [mfaDisableError, setMfaDisableError] = useState(undefined); + + const qrCodeValue = data && "otpAuthUrl" in data ? data.otpAuthUrl : undefined; + const secretKey = data && "secret" in data ? data.secret : undefined; + const recoveryCodes = + data?.action === "validate-totp" && data.success ? data.recoveryCodes ?? [] : []; + + const showQrDialog = + (data?.action === "enable-mfa" || (data?.action === "validate-totp" && !data.success)) && + typeof qrCodeValue === "string" && + typeof secretKey === "string"; + + const showRecoveryDialog = + data?.action === "validate-totp" && Array.isArray(recoveryCodes) && recoveryCodes.length > 0; + + const showInvalidTotpErrorMessage = data?.action === "validate-totp" && !data.success; + + const isDisabling = fetcher.state === "submitting" && formAction === "disable-mfa"; + + // Set error from fetcher data + useEffect(() => { + if (data?.action === "disable-mfa" && !data.success && data.error) { + setMfaDisableError(data.error); + } + }, [data]); + + // Clear TOTP code when form is submitted + useEffect(() => { + if ( + fetcher.state === "submitting" && + (formAction === "validate-totp" || formAction === "disable-mfa") + ) { + setTotpCode(""); + } + }, [fetcher.state, formAction]); + + // Close disable dialog on successful disable + const shouldCloseDisableDialog = data?.action === "disable-mfa" && data.success; + + useEffect(() => { + if (shouldCloseDisableDialog) { + setShowDisableDialog(false); + setTotpCode(""); + setRecoveryCode(""); + setShowRecoveryCode(false); + } + }, [shouldCloseDisableDialog]); + + const handleSwitchChange = (checked: boolean) => { + if (checked && !isMfaEnabled) { + fetcher.submit( + { action: "enable-mfa" }, + { + method: "POST", + action: `/resources/account/mfa/setup`, + } + ); + } else if (!checked && isMfaEnabled) { + setShowDisableDialog(true); + } + }; + + const handleQrConfirm = (e: React.FormEvent) => { + e.preventDefault(); + + fetcher.submit( + { action: "validate-totp", totpCode }, + { method: "POST", action: `/resources/account/mfa/setup` } + ); + + setTotpCode(""); + }; + + const handleQrCancel = () => { + setTotpCode(""); + // Don't change the switch state when canceling + + fetcher.submit( + { action: "cancel-totp" }, + { method: "POST", action: `/resources/account/mfa/setup` } + ); + }; + + const handleRecoveryComplete = (e: React.FormEvent) => { + e.preventDefault(); + + fetcher.submit( + { action: "saved-recovery-codes" }, + { method: "POST", action: `/resources/account/mfa/setup` } + ); + }; + + const downloadRecoveryCodes = () => { + 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); + }; + + const handleDisableCancel = () => { + setShowDisableDialog(false); + setTotpCode(""); + setRecoveryCode(""); + setShowRecoveryCode(false); + setMfaDisableError(undefined); + }; + + const handleDisableMfa = (e: React.FormEvent) => { + e.preventDefault(); + + fetcher.submit( + { action: "disable-mfa", totpCode, recoveryCode }, + { method: "POST", action: `/resources/account/mfa/setup` } + ); + }; + + const handleSwitchToRecoveryCode = () => { + setShowRecoveryCode(true); + setMfaDisableError(undefined); + }; + + const handleSwitchToTotpCode = () => { + setShowRecoveryCode(false); + setMfaDisableError(undefined); + }; + + return ( + <> +
+ + + + Enable an extra layer of security by requiring a one-time code from your authenticator + app (TOTP) each time you log in. + + +
+ +
+
+ + {/* QR Code Dialog */} + + + + Enable authenticator app + +
+
+ + 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. + + +
+
+ +
+ +
+ +
+ setTotpCode(value)} + variant="large" + name="totpCode" + onKeyDown={(e) => { + if (e.key === "Enter" && totpCode.length === 6) { + handleQrConfirm(e); + } + }} + > + + + + + + + + + +
+
+ + {showInvalidTotpErrorMessage && ( + Invalid code provided. Please try again. + )} + + + + + +
+
+
+ + {/* Recovery Codes Dialog */} + + + + Recovery codes + +
+ +
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ + + Copy + +
+
+
+ + + + +
+
+
+ + {/* Disable MFA Confirmation Dialog */} + + + + Disable multi-factor authentication + +
+ + + {showRecoveryCode ? ( + <> + + Enter one of your recovery codes. + +
+ + setRecoveryCode(e.target.value)} + /> + +
+ + + + ) : ( + <> + + Enter the code from your authenticator app. + +
+ setTotpCode(value)} + variant="large" + fullWidth + > + + + + + + + + + + +
+ + + )} + + {mfaDisableError && {mfaDisableError}} + + + + + +
+
+
+ + ); +} diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts new file mode 100644 index 00000000000..2995dd9486b --- /dev/null +++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts @@ -0,0 +1,309 @@ +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"; + +const generateRandomString = createRandomStringGenerator("A-Z", "0-9"); + +const SecretSchema = z.object({ + secret: z.string(), +}); + +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, + }, + }, + }); + + 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, + }, + }); + } + + 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; + + console.log("secret", secret); + console.log("totpCode", totpCode); + + const isValid = await createOTP(secret, { + digits: 6, + period: 30, + }).verify(totpCode); + + console.log("isValid", isValid); + + 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 }, + }); + + return !!backupCode; + } +} + +function generateQRCodeUrl({ + issuer, + account, + secret, + digits = 6, + period = 30, +}: { + issuer: string; + account: string; + secret: string; + digits?: number; + period?: number; +}) { + const encodedIssuer = encodeURIComponent(issuer); + const encodedAccountName = encodeURIComponent(account); + const baseURI = `otpauth://totp/${encodedIssuer}:${encodedAccountName}`; + const params = new URLSearchParams({ + secret, + issuer, + }); + + if (digits !== undefined) { + params.set("digits", digits.toString()); + } + if (period !== undefined) { + params.set("period", period.toString()); + } + return `${baseURI}?${params.toString()}`; +} diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts index 55cbd06b5a3..70450afb694 100644 --- a/apps/webapp/app/services/session.server.ts +++ b/apps/webapp/app/services/session.server.ts @@ -53,6 +53,7 @@ export async function requireUser(request: Request) { updatedAt: user.updatedAt, dashboardPreferences: user.dashboardPreferences, confirmedBasicDetails: user.confirmedBasicDetails, + mfaEnabledAt: user.mfaEnabledAt, isImpersonating: !!impersonationId, }; } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index d2463424943..8b98467a624 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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.5", "@codemirror/autocomplete": "^6.3.1", "@codemirror/commands": "^6.1.2", "@codemirror/lang-javascript": "^6.1.1", diff --git a/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql b/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql new file mode 100644 index 00000000000..7aef24a4cba --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250705082744_add_mfa_schema/migration.sql @@ -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; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3143b089f22..86bcf07f480 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 279808533ae..e07e366cfe2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@aws-sdk/client-sts': specifier: ^3.840.0 version: 3.840.0 + '@better-auth/utils': + specifier: ^0.2.5 + version: 0.2.5 '@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) @@ -5353,6 +5356,13 @@ packages: engines: {node: '>=18'} dev: true + /@better-auth/utils@0.2.5: + resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==} + dependencies: + typescript: 5.8.3 + uncrypto: 0.1.3 + dev: false + /@bufbuild/protobuf@1.10.0: resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} dev: false @@ -35157,6 +35167,12 @@ packages: engines: {node: '>=14.17'} hasBin: true + /typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: false + /ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} From 58ec5e94601673754dd0f52ac00a6cae6268769f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 6 Jul 2025 06:28:14 +0100 Subject: [PATCH 17/26] Refactor and cleanup mfa management code --- .../MfaDisableDialog.tsx | 150 ++++++ .../MfaRecoveryDialog.tsx | 102 ++++ .../MfaSetupDialog.tsx | 118 +++++ .../resources.account.mfa.setup/MfaToggle.tsx | 35 ++ .../resources.account.mfa.setup/route.tsx | 460 ++---------------- .../useMfaSetup.ts | 302 ++++++++++++ packages/build/src/package.json | 3 + 7 files changed, 750 insertions(+), 420 deletions(-) create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx create mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts create mode 100644 packages/build/src/package.json diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx new file mode 100644 index 00000000000..931f83b095d --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaDisableDialog.tsx @@ -0,0 +1,150 @@ +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 ( + + + + Disable multi-factor authentication + +
+ {useRecoveryCode ? ( + <> + + Enter one of your recovery codes. + +
+ + setRecoveryCode(e.target.value)} + /> + +
+ + + + ) : ( + <> + + Enter the code from your authenticator app. + +
+ setTotpCode(value)} + variant="large" + fullWidth + > + + + + + + + + + +
+ + + )} + + {error && {error}} + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx new file mode 100644 index 00000000000..4b76304bed6 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx @@ -0,0 +1,102 @@ +import { Form } from "@remix-run/react"; +import { DownloadIcon } from "lucide-react"; +import { Button } from "~/components/primitives/Buttons"; +import { CopyButton } from "~/components/primitives/CopyButton"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; +import { Paragraph } from "~/components/primitives/Paragraph"; + +interface MfaRecoveryDialogProps { + isOpen: boolean; + recoveryCodes?: string[]; + onSave: () => void; +} + +export function MfaRecoveryDialog({ + isOpen, + recoveryCodes, + onSave, +}: MfaRecoveryDialogProps) { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSave(); + }; + + 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); + }; + + if (!recoveryCodes) return null; + + return ( + + + + Recovery codes + +
+
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ + + Copy + +
+
+
+ + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx new file mode 100644 index 00000000000..29f33bbc35b --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx @@ -0,0 +1,118 @@ +import { Form } from "@remix-run/react"; +import { QRCodeSVG } from "qrcode.react"; +import { useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { CopyableText } from "~/components/primitives/CopyableText"; +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; + }; + error?: string; + isSubmitting: boolean; + onValidate: (code: string) => void; + onCancel: () => void; +} + +export function MfaSetupDialog({ + isOpen, + setupData, + error, + isSubmitting, + onValidate, + onCancel, +}: MfaSetupDialogProps) { + const [totpCode, setTotpCode] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onValidate(totpCode); + setTotpCode(""); + }; + + const handleCancel = () => { + setTotpCode(""); + onCancel(); + }; + + if (!setupData) return null; + + return ( + + + + Enable authenticator app + +
+
+ + 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. + + +
+
+ +
+ +
+ +
+ setTotpCode(value)} + variant="large" + name="totpCode" + onKeyDown={(e) => { + if (e.key === "Enter" && totpCode.length === 6) { + handleSubmit(e); + } + }} + > + + + + + + + + + +
+
+ + {error && {error}} + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx new file mode 100644 index 00000000000..8fecc0a6493 --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaToggle.tsx @@ -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 ( +
+ + + + Enable an extra layer of security by requiring a one-time code from your authenticator + app (TOTP) each time you log in. + + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx index a4f0d720ac6..a7d838aab96 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -1,37 +1,14 @@ -import { Form } from "@remix-run/react"; import { ActionFunctionArgs } from "@remix-run/server-runtime"; -import { DownloadIcon } from "lucide-react"; -import { QRCodeSVG } from "qrcode.react"; -import React, { useState, useEffect } from "react"; -import { redirect, typedjson, useTypedFetcher } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { z } from "zod"; -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 { 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 { Label } from "~/components/primitives/Label"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { Spinner } from "~/components/primitives/Spinner"; -import { Switch } from "~/components/primitives/Switch"; -import { - redirectWithErrorMessage, - redirectWithSuccessMessage, - typedJsonWithErrorMessage, - typedJsonWithSuccessMessage, -} from "~/models/message.server"; +import { redirectWithSuccessMessage, typedJsonWithSuccessMessage } from "~/models/message.server"; import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; import { requireUserId } from "~/services/session.server"; +import { useMfaSetup } from "./useMfaSetup"; +import { MfaToggle } from "./MfaToggle"; +import { MfaSetupDialog } from "./MfaSetupDialog"; +import { MfaRecoveryDialog } from "./MfaRecoveryDialog"; +import { MfaDisableDialog } from "./MfaDisableDialog"; const formSchema = z.discriminatedUnion("action", [ z.object({ @@ -153,402 +130,45 @@ export async function action({ request }: ActionFunctionArgs) { } export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { - const fetcher = useTypedFetcher(); - const [showDisableDialog, setShowDisableDialog] = useState(false); + const { state, actions, isQrDialogOpen, isRecoveryDialogOpen, isDisableDialogOpen } = useMfaSetup(isEnabled); - const formAction = fetcher.formData?.get("action"); - - const data = fetcher.data; - - // TODO: Remove this - console.log("fetcher.state", fetcher.state); - console.log("fetcher.formData", Object.fromEntries(fetcher.formData?.entries() ?? [])); - console.log("fetcher.data", fetcher.data); - - const isMfaEnabled = - (fetcher.state === "submitting" && formAction === "enable-mfa") || - (data && data.action === "enable-mfa") || - (isEnabled && !(data?.action === "disable-mfa" && data.success)) || - (data && data.action === "validate-totp" && !data.success); - - const [totpCode, setTotpCode] = useState(""); - - // Additional state for disable MFA functionality - const [recoveryCode, setRecoveryCode] = useState(""); - const [showRecoveryCode, setShowRecoveryCode] = useState(false); - const [mfaDisableError, setMfaDisableError] = useState(undefined); - - const qrCodeValue = data && "otpAuthUrl" in data ? data.otpAuthUrl : undefined; - const secretKey = data && "secret" in data ? data.secret : undefined; - const recoveryCodes = - data?.action === "validate-totp" && data.success ? data.recoveryCodes ?? [] : []; - - const showQrDialog = - (data?.action === "enable-mfa" || (data?.action === "validate-totp" && !data.success)) && - typeof qrCodeValue === "string" && - typeof secretKey === "string"; - - const showRecoveryDialog = - data?.action === "validate-totp" && Array.isArray(recoveryCodes) && recoveryCodes.length > 0; - - const showInvalidTotpErrorMessage = data?.action === "validate-totp" && !data.success; - - const isDisabling = fetcher.state === "submitting" && formAction === "disable-mfa"; - - // Set error from fetcher data - useEffect(() => { - if (data?.action === "disable-mfa" && !data.success && data.error) { - setMfaDisableError(data.error); - } - }, [data]); - - // Clear TOTP code when form is submitted - useEffect(() => { - if ( - fetcher.state === "submitting" && - (formAction === "validate-totp" || formAction === "disable-mfa") - ) { - setTotpCode(""); + const handleToggle = (enabled: boolean) => { + if (enabled && !state.isEnabled) { + actions.enableMfa(); + } else if (!enabled && state.isEnabled) { + actions.openDisableDialog(); } - }, [fetcher.state, formAction]); - - // Close disable dialog on successful disable - const shouldCloseDisableDialog = data?.action === "disable-mfa" && data.success; - - useEffect(() => { - if (shouldCloseDisableDialog) { - setShowDisableDialog(false); - setTotpCode(""); - setRecoveryCode(""); - setShowRecoveryCode(false); - } - }, [shouldCloseDisableDialog]); - - const handleSwitchChange = (checked: boolean) => { - if (checked && !isMfaEnabled) { - fetcher.submit( - { action: "enable-mfa" }, - { - method: "POST", - action: `/resources/account/mfa/setup`, - } - ); - } else if (!checked && isMfaEnabled) { - setShowDisableDialog(true); - } - }; - - const handleQrConfirm = (e: React.FormEvent) => { - e.preventDefault(); - - fetcher.submit( - { action: "validate-totp", totpCode }, - { method: "POST", action: `/resources/account/mfa/setup` } - ); - - setTotpCode(""); - }; - - const handleQrCancel = () => { - setTotpCode(""); - // Don't change the switch state when canceling - - fetcher.submit( - { action: "cancel-totp" }, - { method: "POST", action: `/resources/account/mfa/setup` } - ); - }; - - const handleRecoveryComplete = (e: React.FormEvent) => { - e.preventDefault(); - - fetcher.submit( - { action: "saved-recovery-codes" }, - { method: "POST", action: `/resources/account/mfa/setup` } - ); - }; - - const downloadRecoveryCodes = () => { - 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); - }; - - const handleDisableCancel = () => { - setShowDisableDialog(false); - setTotpCode(""); - setRecoveryCode(""); - setShowRecoveryCode(false); - setMfaDisableError(undefined); - }; - - const handleDisableMfa = (e: React.FormEvent) => { - e.preventDefault(); - - fetcher.submit( - { action: "disable-mfa", totpCode, recoveryCode }, - { method: "POST", action: `/resources/account/mfa/setup` } - ); - }; - - const handleSwitchToRecoveryCode = () => { - setShowRecoveryCode(true); - setMfaDisableError(undefined); - }; - - const handleSwitchToTotpCode = () => { - setShowRecoveryCode(false); - setMfaDisableError(undefined); }; return ( <> -
- - - - Enable an extra layer of security by requiring a one-time code from your authenticator - app (TOTP) each time you log in. - - -
- -
-
- - {/* QR Code Dialog */} - - - - Enable authenticator app - -
-
- - 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. - - -
-
- -
- -
- -
- setTotpCode(value)} - variant="large" - name="totpCode" - onKeyDown={(e) => { - if (e.key === "Enter" && totpCode.length === 6) { - handleQrConfirm(e); - } - }} - > - - - - - - - - - -
-
- - {showInvalidTotpErrorMessage && ( - Invalid code provided. Please try again. - )} - - - - - -
-
-
- - {/* Recovery Codes Dialog */} - - - - Recovery codes - -
- -
- - Copy and store these recovery codes carefully in case you lose your device. - - -
-
- {recoveryCodes.map((code, index) => ( -
- {code} -
- ))} -
-
- - - Copy - -
-
-
- - - - -
-
-
- - {/* Disable MFA Confirmation Dialog */} - - - - Disable multi-factor authentication - -
- - - {showRecoveryCode ? ( - <> - - Enter one of your recovery codes. - -
- - setRecoveryCode(e.target.value)} - /> - -
- - - - ) : ( - <> - - Enter the code from your authenticator app. - -
- setTotpCode(value)} - variant="large" - fullWidth - > - - - - - - - - - - -
- - - )} - - {mfaDisableError && {mfaDisableError}} - - - - - -
-
-
+ + + + + + + ); } diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts new file mode 100644 index 00000000000..7762bbeb6ff --- /dev/null +++ b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts @@ -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(); + + 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 = { 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, + isRecoveryDialogOpen: state.phase === 'showing-recovery' && !!state.recoveryCodes, + isDisableDialogOpen: state.phase === 'disabling', + }; +} \ No newline at end of file diff --git a/packages/build/src/package.json b/packages/build/src/package.json new file mode 100644 index 00000000000..5bbefffbabe --- /dev/null +++ b/packages/build/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} From cb59c7538192dde1c356ca6f84256f1b201adc59 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 6 Jul 2025 06:32:45 +0100 Subject: [PATCH 18/26] More cleanup --- .../MfaRecoveryDialog.tsx | 102 ------------------ .../MfaSetupDialog.tsx | 86 +++++++++++++++ .../resources.account.mfa.setup/route.tsx | 9 +- .../useMfaSetup.ts | 4 +- 4 files changed, 90 insertions(+), 111 deletions(-) delete mode 100644 apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx deleted file mode 100644 index 4b76304bed6..00000000000 --- a/apps/webapp/app/routes/resources.account.mfa.setup/MfaRecoveryDialog.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { Form } from "@remix-run/react"; -import { DownloadIcon } from "lucide-react"; -import { Button } from "~/components/primitives/Buttons"; -import { CopyButton } from "~/components/primitives/CopyButton"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "~/components/primitives/Dialog"; -import { Paragraph } from "~/components/primitives/Paragraph"; - -interface MfaRecoveryDialogProps { - isOpen: boolean; - recoveryCodes?: string[]; - onSave: () => void; -} - -export function MfaRecoveryDialog({ - isOpen, - recoveryCodes, - onSave, -}: MfaRecoveryDialogProps) { - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSave(); - }; - - 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); - }; - - if (!recoveryCodes) return null; - - return ( - - - - Recovery codes - -
-
- - Copy and store these recovery codes carefully in case you lose your device. - - -
-
- {recoveryCodes.map((code, index) => ( -
- {code} -
- ))} -
-
- - - Copy - -
-
-
- - - - -
-
-
- ); -} \ No newline at end of file diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx index 29f33bbc35b..3c65e412994 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx @@ -1,8 +1,10 @@ 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, @@ -20,19 +22,23 @@ interface MfaSetupDialogProps { 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(""); @@ -47,6 +53,86 @@ export function MfaSetupDialog({ 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 ( + + + + Recovery codes + +
+
+ + Copy and store these recovery codes carefully in case you lose your device. + + +
+
+ {recoveryCodes.map((code, index) => ( +
+ {code} +
+ ))} +
+
+ + + Copy + +
+
+
+ + + + +
+
+
+ ); + } + + // Show QR setup if no recovery codes yet if (!setupData) return null; return ( diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx index a7d838aab96..bd9b482b87e 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -7,7 +7,6 @@ import { requireUserId } from "~/services/session.server"; import { useMfaSetup } from "./useMfaSetup"; import { MfaToggle } from "./MfaToggle"; import { MfaSetupDialog } from "./MfaSetupDialog"; -import { MfaRecoveryDialog } from "./MfaRecoveryDialog"; import { MfaDisableDialog } from "./MfaDisableDialog"; const formSchema = z.discriminatedUnion("action", [ @@ -150,16 +149,12 @@ export function MfaSetup({ isEnabled }: { isEnabled: boolean }) { - - Date: Sun, 6 Jul 2025 14:27:44 +0100 Subject: [PATCH 19/26] Handle errors in the management action --- .../resources.account.mfa.setup/route.tsx | 130 ++++++++++-------- 1 file changed, 70 insertions(+), 60 deletions(-) diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx index bd9b482b87e..33800cb842f 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx @@ -1,9 +1,10 @@ import { ActionFunctionArgs } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; -import { redirectWithSuccessMessage, typedJsonWithSuccessMessage } from "~/models/message.server"; +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"; @@ -49,82 +50,91 @@ function validateForm(formData: FormData) { } export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); + try { + const userId = await requireUserId(request); - const formData = await request.formData(); + 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); + const submission = validateForm(formData); + if (!submission.valid) { return typedjson({ - action: "enable-mfa" as const, - secret: result.secret, - otpAuthUrl: result.otpAuthUrl, + action: "invalid-form" as const, + errors: submission.errors, }); } - 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 { + const mfaSetupService = new MultiFactorAuthenticationService(); + + switch (submission.data.action) { + case "enable-mfa": { + const result = await mfaSetupService.enableTotp(userId); + return typedjson({ - action: "disable-mfa" as const, - success: false as const, - error: "Invalid code provided. Please try again.", + action: "enable-mfa" as const, + secret: result.secret, + otpAuthUrl: result.otpAuthUrl, }); } - } - case "validate-totp": { - const result = await mfaSetupService.validateTotpSetup(userId, submission.data.totpCode); + case "disable-mfa": { + const result = await mfaSetupService.disableTotp(userId, { + totpCode: submission.data.totpCode, + recoveryCode: submission.data.recoveryCode, + }); - if (result.success) { + 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: "validate-totp" as const, + action: "cancel-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 "saved-recovery-codes": { + return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA"); + } } - 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; } } From e7cb0d678d1f74f87515e1072d3d7707585267f1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 6 Jul 2025 19:54:34 +0100 Subject: [PATCH 20/26] Implement mfa login flow --- .../app/routes/auth.github.callback.tsx | 56 ++++++-- apps/webapp/app/routes/login.mfa/route.tsx | 128 ++++++++++++++---- apps/webapp/app/routes/magic.tsx | 37 ++++- apps/webapp/app/services/emailAuth.server.tsx | 12 ++ apps/webapp/app/services/gitHubAuth.server.ts | 12 ++ .../mfa/multiFactorAuthentication.server.ts | 124 +++++++++++++---- 6 files changed, 294 insertions(+), 75 deletions(-) diff --git a/apps/webapp/app/routes/auth.github.callback.tsx b/apps/webapp/app/routes/auth.github.callback.tsx index 15068414cfe..4c1c452d44b 100644 --- a/apps/webapp/app/routes/auth.github.callback.tsx +++ b/apps/webapp/app/routes/auth.github.callback.tsx @@ -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"); + } }; diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index d0664a74de0..fe9abdfeaa3 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -15,7 +15,10 @@ import { InputOTP, InputOTPGroup, InputOTPSlot } from "~/components/primitives/I import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { authenticator } from "~/services/auth.server"; -import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { commitSession, getUserSession, sessionStorage } from "~/services/sessionStorage.server"; +import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches @@ -37,11 +40,20 @@ export const meta: MetaFunction = ({ matches }) => { }; 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"); + } + const error = session.get("auth:error"); let mfaError: string | undefined; @@ -64,42 +76,100 @@ export async function loader({ request }: LoaderFunctionArgs) { } export async function action({ request }: ActionFunctionArgs) { - const clonedRequest = request.clone(); + try { + const session = await getUserSession(request); + const pendingUserId = session.get("pending-mfa-user-id"); + + if (!pendingUserId) { + return redirect("/login"); + } - const payload = Object.fromEntries(await clonedRequest.formData()); + const payload = Object.fromEntries(await request.formData()); - const { action } = z - .object({ - action: z.enum(["verify-recovery", "verify-mfa"]), - }) - .parse(payload); + const { action } = z + .object({ + action: z.enum(["verify-recovery", "verify-mfa"]), + }) + .parse(payload); - if (action === "verify-recovery") { - // TODO: Implement recovery code verification logic - const recoveryCode = payload.recoveryCode; + const mfaService = new MultiFactorAuthenticationService(); - // For now, just redirect to dashboard - return redirect("/"); - } else if (action === "verify-mfa") { - // TODO: Implement MFA code verification logic - const mfaCode = payload.mfaCode; + if (action === "verify-recovery") { + const recoveryCode = payload.recoveryCode as string; + + if (!recoveryCode) { + session.set("auth:error", { message: "Recovery code is required" }); + return redirect("/login/mfa", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } - // For now, just redirect to dashboard - return redirect("/"); - } else { - const session = await getUserSession(request); - session.unset("triggerdotdev:magiclink"); + const result = await mfaService.verifyRecoveryCodeForLogin(pendingUserId, recoveryCode); + + if (!result.success) { + session.set("auth:error", { message: result.error }); + return redirect("/login/mfa", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } - return redirect("/login/magic", { - headers: { - "Set-Cookie": await commitSession(session), - }, - }); + // 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) { + session.set("auth:error", { message: "Valid 6-digit code is required" }); + return redirect("/login/mfa", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + const result = await mfaService.verifyTotpForLogin(pendingUserId, mfaCode); + + if (!result.success) { + session.set("auth:error", { message: result.error }); + return redirect("/login/mfa", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + // 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); + } + throw error; } } +async function completeLogin(request: Request, session: any, 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"); + session.unset("auth:error"); + + return redirect(redirectTo, { + headers: { + "Set-Cookie": await sessionStorage.commitSession(authSession), + }, + }); +} + export default function LoginMfaPage() { - const { mfaError } = useTypedLoaderData(); + const data = useTypedLoaderData(); + const mfaError = 'mfaError' in data ? data.mfaError : undefined; const navigate = useNavigation(); const [showRecoveryCode, setShowRecoveryCode] = useState(false); const [mfaCode, setMfaCode] = useState(""); @@ -151,7 +221,7 @@ export default function LoginMfaPage() { Verify )} - {mfaError && {mfaError}} + {typeof mfaError === 'string' && {mfaError}}
- {mfaError && {mfaError}} + {typeof mfaError === 'string' && {mfaError}}
- +
+ +
+ ) : ( - <> +
- Enter the code from your authenticator app. + Enter the code from your authenticator app to disable MFA.
setTotpCode(value)} variant="large" - fullWidth > @@ -117,15 +117,17 @@ export function MfaDisableDialog({
- - +
+ +
+
)} {error && {error}} @@ -147,4 +149,4 @@ export function MfaDisableDialog({ ); -} \ No newline at end of file +} diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx index 3c65e412994..3f5bb9ff367 100644 --- a/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx +++ b/apps/webapp/app/routes/resources.account.mfa.setup/MfaSetupDialog.tsx @@ -60,7 +60,7 @@ export function MfaSetupDialog({ const downloadRecoveryCodes = () => { if (!recoveryCodes) return; - + const content = recoveryCodes.join("\n"); const blob = new Blob([content], { type: "text/plain" }); const url = URL.createObjectURL(blob); @@ -87,12 +87,12 @@ export function MfaSetupDialog({ Copy and store these recovery codes carefully in case you lose your device. -
-
+
+
{recoveryCodes.map((code, index) => ( -
+ {code} -
+ ))}
@@ -145,15 +145,15 @@ export function MfaSetupDialog({
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. + code that the app generates. Alternatively, you can copy the secret below and paste it + into your app.
- +
@@ -181,7 +181,7 @@ export function MfaSetupDialog({
- {error && {error}} +
{error && {error}}
- {typeof mfaError === 'string' && {mfaError}} + {typeof mfaError === "string" && {mfaError}} - {typeof mfaError === 'string' && {mfaError}} + {typeof mfaError === "string" && {mfaError}}