Skip to content

Commit b119a52

Browse files
ericallamsamejr
andauthored
Implement MFA (#2244)
* Adds a new route for logging in with mfa * New path for security page * Adds “Security” link to account side menu * Update the Switch component to allow label positions left and right * Optionally hide the Close button in the Dialog title bar * Installs `qrcode` react package for generating QR codes. * CopyButton component now takes children * New Security route for setting up MFA * Adds new OTP package for the chadcn InputOTP component * Adds new InputOTP chadcn component * Adds InputOTP chadcn component to the MFA login screen * InputOTP component supports variant styles * Improvements to form handling * Show a confirmation modal before you can disable MFA * Revert redirect back to the dashboard for now * Implement MFA enabling and disabling * Refactor and cleanup mfa management code * More cleanup * Handle errors in the management action * Implement mfa login flow * recovery code input should be password * Implement rate limiting on the mfa validation endpoint * Better error ux * Implement mfa emails and apply James' updates * Use latest @better-auth/utils * Improvements via CodeRabbit review --------- Co-authored-by: James Ritchie <[email protected]>
1 parent e02c90a commit b119a52

File tree

30 files changed

+2222
-75
lines changed

30 files changed

+2222
-75
lines changed

apps/webapp/app/components/navigation/AccountSideMenu.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1-
import { ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
1+
import { LockClosedIcon, ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
22
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
33
import { type User } from "@trigger.dev/database";
44
import { cn } from "~/utils/cn";
5-
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
5+
import {
6+
accountPath,
7+
accountSecurityPath,
8+
personalAccessTokensPath,
9+
rootPath,
10+
} from "~/utils/pathBuilder";
611
import { LinkButton } from "../primitives/Buttons";
712
import { SideMenuHeader } from "./SideMenuHeader";
813
import { SideMenuItem } from "./SideMenuItem";
@@ -42,6 +47,13 @@ export function AccountSideMenu({ user }: { user: User }) {
4247
to={personalAccessTokensPath()}
4348
data-action="tokens"
4449
/>
50+
<SideMenuItem
51+
name="Security"
52+
icon={LockClosedIcon}
53+
activeIconColor="text-rose-500"
54+
to={accountSecurityPath()}
55+
data-action="security"
56+
/>
4557
</div>
4658
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
4759
<HelpAndFeedback />

apps/webapp/app/components/primitives/CopyButton.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type CopyButtonProps = {
2727
buttonClassName?: string;
2828
showTooltip?: boolean;
2929
buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal";
30+
children?: React.ReactNode;
3031
};
3132

3233
export function CopyButton({
@@ -37,6 +38,7 @@ export function CopyButton({
3738
buttonClassName,
3839
showTooltip = true,
3940
buttonVariant = "tertiary",
41+
children,
4042
}: CopyButtonProps) {
4143
const { copy, copied } = useCopy(value);
4244

@@ -66,22 +68,25 @@ export function CopyButton({
6668
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
6769
onClick={copy}
6870
className={cn("shrink-0", buttonClassName)}
71+
LeadingIcon={
72+
copied ? (
73+
<ClipboardCheckIcon
74+
className={cn(
75+
iconSize,
76+
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
77+
)}
78+
/>
79+
) : (
80+
<ClipboardIcon
81+
className={cn(
82+
iconSize,
83+
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
84+
)}
85+
/>
86+
)
87+
}
6988
>
70-
{copied ? (
71-
<ClipboardCheckIcon
72-
className={cn(
73-
iconSize,
74-
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
75-
)}
76-
/>
77-
) : (
78-
<ClipboardIcon
79-
className={cn(
80-
iconSize,
81-
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
82-
)}
83-
/>
84-
)}
89+
{children}
8590
</Button>
8691
);
8792

apps/webapp/app/components/primitives/Dialog.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
3636

3737
const DialogContent = React.forwardRef<
3838
React.ElementRef<typeof DialogPrimitive.Content>,
39-
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
40-
>(({ className, children, ...props }, ref) => (
39+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
40+
showCloseButton?: boolean;
41+
}
42+
>(({ className, children, showCloseButton = true, ...props }, ref) => (
4143
<DialogPortal>
4244
<DialogOverlay />
4345
<DialogPrimitive.Content
@@ -50,16 +52,18 @@ const DialogContent = React.forwardRef<
5052
>
5153
<hr className="absolute left-0 top-11 w-full" />
5254
{children}
53-
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
54-
<ShortcutKey
55-
shortcut={{
56-
key: "esc",
57-
}}
58-
variant="medium"
59-
/>
60-
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
61-
<span className="sr-only">Close</span>
62-
</DialogPrimitive.Close>
55+
{showCloseButton && (
56+
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
57+
<ShortcutKey
58+
shortcut={{
59+
key: "esc",
60+
}}
61+
variant="medium"
62+
/>
63+
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
64+
<span className="sr-only">Close</span>
65+
</DialogPrimitive.Close>
66+
)}
6367
</DialogPrimitive.Content>
6468
</DialogPortal>
6569
));
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { OTPInput, OTPInputContext } from "input-otp";
5+
import { MinusIcon } from "lucide-react";
6+
7+
import { cn } from "~/utils/cn";
8+
9+
const variants = {
10+
default: {
11+
container: "flex items-center gap-2 has-disabled:opacity-50",
12+
group: "flex items-center",
13+
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]",
14+
},
15+
large: {
16+
container: "flex items-center gap-3 has-disabled:opacity-50",
17+
group: "flex items-center gap-1",
18+
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",
19+
},
20+
minimal: {
21+
container: "flex items-center gap-2 has-disabled:opacity-50",
22+
group: "flex items-center",
23+
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",
24+
},
25+
};
26+
27+
function InputOTP({
28+
className,
29+
containerClassName,
30+
variant = "default",
31+
fullWidth = false,
32+
...props
33+
}: React.ComponentProps<typeof OTPInput> & {
34+
containerClassName?: string;
35+
variant?: keyof typeof variants;
36+
fullWidth?: boolean;
37+
}) {
38+
const variantStyles = variants[variant];
39+
40+
return (
41+
<OTPInput
42+
data-slot="input-otp"
43+
containerClassName={cn(variantStyles.container, fullWidth && "w-full", containerClassName)}
44+
className={cn("disabled:cursor-not-allowed", className)}
45+
{...props}
46+
/>
47+
);
48+
}
49+
50+
function InputOTPGroup({
51+
className,
52+
variant = "default",
53+
fullWidth = false,
54+
...props
55+
}: React.ComponentProps<"div"> & {
56+
variant?: keyof typeof variants;
57+
fullWidth?: boolean;
58+
}) {
59+
const variantStyles = variants[variant];
60+
61+
return (
62+
<div
63+
data-slot="input-otp-group"
64+
className={cn(variantStyles.group, fullWidth && "flex-1 gap-1", className)}
65+
{...props}
66+
/>
67+
);
68+
}
69+
70+
function InputOTPSlot({
71+
index,
72+
className,
73+
variant = "default",
74+
fullWidth = false,
75+
...props
76+
}: React.ComponentProps<"div"> & {
77+
index: number;
78+
variant?: keyof typeof variants;
79+
fullWidth?: boolean;
80+
}) {
81+
const inputOTPContext = React.useContext(OTPInputContext);
82+
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
83+
const variantStyles = variants[variant];
84+
85+
return (
86+
<div
87+
data-slot="input-otp-slot"
88+
data-active={isActive}
89+
className={cn(variantStyles.slot, fullWidth && "flex-1", className)}
90+
{...props}
91+
>
92+
{char}
93+
{hasFakeCaret && (
94+
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
95+
<div className="animate-caret-blink h-4 w-px bg-text-bright duration-1000" />
96+
</div>
97+
)}
98+
</div>
99+
);
100+
}
101+
102+
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
103+
return (
104+
<div data-slot="input-otp-separator" role="separator" {...props}>
105+
<MinusIcon />
106+
</div>
107+
);
108+
}
109+
110+
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

apps/webapp/app/components/primitives/Switch.tsx

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
4646
label?: React.ReactNode;
4747
variant: keyof typeof variations;
4848
shortcut?: ShortcutDefinition;
49+
labelPosition?: "left" | "right";
4950
};
5051

5152
export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
52-
({ className, variant, label, ...props }, ref) => {
53+
({ className, variant, label, labelPosition = "left", ...props }, ref) => {
5354
const innerRef = React.useRef<HTMLButtonElement>(null);
5455
React.useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
5556

@@ -67,35 +68,39 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
6768
});
6869
}
6970

71+
const labelElement = label ? (
72+
<label
73+
className={cn("cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed", text)}
74+
>
75+
{typeof label === "string" ? <span>{label}</span> : label}
76+
</label>
77+
) : null;
78+
79+
const switchElement = (
80+
<div
81+
className={cn(
82+
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
83+
root
84+
)}
85+
>
86+
<SwitchPrimitives.Thumb
87+
className={cn(
88+
thumb,
89+
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
90+
)}
91+
/>
92+
</div>
93+
);
94+
7095
return (
7196
<SwitchPrimitives.Root
7297
className={cn("group", container, className)}
7398
{...props}
7499
ref={innerRef}
75100
>
76-
{label ? (
77-
<label
78-
className={cn(
79-
"cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed",
80-
text
81-
)}
82-
>
83-
{typeof label === "string" ? <span>{label}</span> : label}
84-
</label>
85-
) : null}
86-
<div
87-
className={cn(
88-
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
89-
root
90-
)}
91-
>
92-
<SwitchPrimitives.Thumb
93-
className={cn(
94-
thumb,
95-
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
96-
)}
97-
/>
98-
</div>
101+
{labelPosition === "left" ? labelElement : null}
102+
{switchElement}
103+
{labelPosition === "right" ? labelElement : null}
99104
</SwitchPrimitives.Root>
100105
);
101106
}

apps/webapp/app/models/message.server.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { json, Session } from "@remix-run/node";
22
import { createCookieSessionStorage } from "@remix-run/node";
3-
import { redirect } from "remix-typedjson";
3+
import { redirect, typedjson } from "remix-typedjson";
44
import { env } from "~/env.server";
55

66
export type ToastMessage = {
@@ -121,6 +121,44 @@ export async function jsonWithErrorMessage(
121121
});
122122
}
123123

124+
export async function typedJsonWithSuccessMessage<T>(
125+
data: T,
126+
request: Request,
127+
message: string,
128+
options?: ToastMessageOptions
129+
) {
130+
const session = await getSession(request.headers.get("cookie"));
131+
132+
setSuccessMessage(session, message, options);
133+
134+
return typedjson(data, {
135+
headers: {
136+
"Set-Cookie": await commitSession(session, {
137+
expires: new Date(Date.now() + ONE_YEAR),
138+
}),
139+
},
140+
});
141+
}
142+
143+
export async function typedJsonWithErrorMessage<T>(
144+
data: T,
145+
request: Request,
146+
message: string,
147+
options?: ToastMessageOptions
148+
) {
149+
const session = await getSession(request.headers.get("cookie"));
150+
151+
setErrorMessage(session, message, options);
152+
153+
return typedjson(data, {
154+
headers: {
155+
"Set-Cookie": await commitSession(session, {
156+
expires: new Date(Date.now() + ONE_YEAR),
157+
}),
158+
},
159+
});
160+
}
161+
124162
export async function redirectWithSuccessMessage(
125163
path: string,
126164
request: Request,

0 commit comments

Comments
 (0)