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"
/>
+
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}
);
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
+
+ )}
));
diff --git a/apps/webapp/app/components/primitives/InputOTP.tsx b/apps/webapp/app/components/primitives/InputOTP.tsx
new file mode 100644
index 00000000000..54ca070ac05
--- /dev/null
+++ b/apps/webapp/app/components/primitives/InputOTP.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import * as React from "react";
+import { OTPInput, OTPInputContext } from "input-otp";
+import { MinusIcon } from "lucide-react";
+
+import { cn } from "~/utils/cn";
+
+const variants = {
+ default: {
+ container: "flex items-center gap-2 has-disabled:opacity-50",
+ group: "flex items-center",
+ slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-sm outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
+ },
+ large: {
+ container: "flex items-center gap-3 has-disabled:opacity-50",
+ group: "flex items-center gap-1",
+ slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-charcoal-750 border-charcoal-700 hover:border-charcoal-600 hover:bg-charcoal-650 relative flex h-12 w-12 items-center justify-center border text-base outline-none transition-all rounded-md data-[active=true]:z-10 data-[active=true]:ring-[3px] data-[active=true]:border-indigo-500",
+ },
+ minimal: {
+ container: "flex items-center gap-2 has-disabled:opacity-50",
+ group: "flex items-center",
+ slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 border-transparent bg-transparent relative flex h-9 w-9 items-center justify-center border-b-2 border-b-charcoal-600 text-sm outline-none transition-all data-[active=true]:border-b-indigo-500 data-[active=true]:z-10",
+ },
+};
+
+function InputOTP({
+ className,
+ containerClassName,
+ variant = "default",
+ fullWidth = false,
+ ...props
+}: React.ComponentProps & {
+ containerClassName?: string;
+ variant?: keyof typeof variants;
+ fullWidth?: boolean;
+}) {
+ const variantStyles = variants[variant];
+
+ return (
+
+ );
+}
+
+function InputOTPGroup({
+ className,
+ variant = "default",
+ fullWidth = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ variant?: keyof typeof variants;
+ fullWidth?: boolean;
+}) {
+ const variantStyles = variants[variant];
+
+ return (
+
+ );
+}
+
+function InputOTPSlot({
+ index,
+ className,
+ variant = "default",
+ fullWidth = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ index: number;
+ variant?: keyof typeof variants;
+ fullWidth?: boolean;
+}) {
+ const inputOTPContext = React.useContext(OTPInputContext);
+ const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
+ const variantStyles = variants[variant];
+
+ return (
+
+ {char}
+ {hasFakeCaret && (
+
+ )}
+
+ );
+}
+
+function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
+ return (
+
+
+
+ );
+}
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
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/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
new file mode 100644
index 00000000000..18cf8b54ab7
--- /dev/null
+++ b/apps/webapp/app/routes/account.security/route.tsx
@@ -0,0 +1,49 @@
+import { type MetaFunction } from "@remix-run/react";
+import {
+ MainHorizontallyCenteredContainer,
+ PageBody,
+ PageContainer,
+} from "~/components/layout/AppLayout";
+import { Header2 } from "~/components/primitives/Headers";
+import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
+import { MfaSetup } from "../resources.account.mfa.setup/route";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { requireUser } from "~/services/session.server";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+
+export const meta: MetaFunction = () => {
+ return [
+ {
+ title: `Security | Trigger.dev`,
+ },
+ ];
+};
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const user = await requireUser(request);
+
+ return typedjson({
+ user,
+ });
+}
+
+export default function Page() {
+ const { user } = useTypedLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+ Security
+
+
+
+
+
+ );
+}
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.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!