Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
984e628
Adds a new route for logging in with mfa
samejr Jul 4, 2025
b9be001
New path for security page
samejr Jul 4, 2025
65e30ce
Adds “Security” link to account side menu
samejr Jul 4, 2025
e7b2794
Update the Switch component to allow label positions left and right
samejr Jul 4, 2025
093c684
Optionally hide the Close button in the Dialog title bar
samejr Jul 4, 2025
4a80331
Installs `qrcode` react package for generating QR codes.
samejr Jul 4, 2025
4bac355
CopyButton component now takes children
samejr Jul 4, 2025
d7f85bb
New Security route for setting up MFA
samejr Jul 4, 2025
1cc622b
Adds new OTP package for the chadcn InputOTP component
samejr Jul 4, 2025
fb0fa76
Adds new InputOTP chadcn component
samejr Jul 4, 2025
6aa5d1b
Adds InputOTP chadcn component to the MFA login screen
samejr Jul 4, 2025
c19a899
InputOTP component supports variant styles
samejr Jul 4, 2025
3c067b3
Improvements to form handling
samejr Jul 4, 2025
661c3a6
Show a confirmation modal before you can disable MFA
samejr Jul 4, 2025
b9d43bc
Revert redirect back to the dashboard for now
samejr Jul 4, 2025
bce4833
Implement MFA enabling and disabling
ericallam Jul 6, 2025
58ec5e9
Refactor and cleanup mfa management code
ericallam Jul 6, 2025
cb59c75
More cleanup
ericallam Jul 6, 2025
0311c59
Handle errors in the management action
ericallam Jul 6, 2025
e7cb0d6
Implement mfa login flow
ericallam Jul 6, 2025
54d0b39
recovery code input should be password
ericallam Jul 6, 2025
71aa702
Implement rate limiting on the mfa validation endpoint
ericallam Jul 6, 2025
f020c80
Better error ux
ericallam Jul 6, 2025
bf3db78
Implement mfa emails and apply James' updates
ericallam Jul 8, 2025
c506c10
Use latest @better-auth/utils
ericallam Jul 8, 2025
04bb9a8
Improvements via CodeRabbit review
ericallam Jul 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions apps/webapp/app/components/navigation/AccountSideMenu.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -42,6 +47,13 @@ export function AccountSideMenu({ user }: { user: User }) {
to={personalAccessTokensPath()}
data-action="tokens"
/>
<SideMenuItem
name="Security"
icon={LockClosedIcon}
activeIconColor="text-rose-500"
to={accountSecurityPath()}
data-action="security"
/>
</div>
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<HelpAndFeedback />
Expand Down
35 changes: 20 additions & 15 deletions apps/webapp/app/components/primitives/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type CopyButtonProps = {
buttonClassName?: string;
showTooltip?: boolean;
buttonVariant?: "primary" | "secondary" | "tertiary" | "minimal";
children?: React.ReactNode;
};

export function CopyButton({
Expand All @@ -37,6 +38,7 @@ export function CopyButton({
buttonClassName,
showTooltip = true,
buttonVariant = "tertiary",
children,
}: CopyButtonProps) {
const { copy, copied } = useCopy(value);

Expand Down Expand Up @@ -66,22 +68,25 @@ export function CopyButton({
variant={`${buttonVariant}/${size === "extra-small" ? "small" : size}`}
onClick={copy}
className={cn("shrink-0", buttonClassName)}
LeadingIcon={
copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)
}
>
{copied ? (
<ClipboardCheckIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-green-500"
)}
/>
) : (
<ClipboardIcon
className={cn(
iconSize,
buttonVariant === "primary" ? "text-background-dimmed" : "text-text-dimmed"
)}
/>
)}
{children}
</Button>
);

Expand Down
28 changes: 16 additions & 12 deletions apps/webapp/app/components/primitives/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
Expand All @@ -50,16 +52,18 @@ const DialogContent = React.forwardRef<
>
<hr className="absolute left-0 top-11 w-full" />
{children}
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
<ShortcutKey
shortcut={{
key: "esc",
}}
variant="medium"
/>
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<DialogPrimitive.Close className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground group absolute right-2 top-[0.5625rem] flex items-center gap-1 rounded-sm p-1 py-1 pl-0 pr-1 opacity-70 transition focus-custom hover:bg-charcoal-750 hover:opacity-100 focus-visible:focus-custom disabled:pointer-events-none">
<ShortcutKey
shortcut={{
key: "esc",
}}
variant="medium"
/>
<XMarkIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
Expand Down
110 changes: 110 additions & 0 deletions apps/webapp/app/components/primitives/InputOTP.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";

import { cn } from "~/utils/cn";

const variants = {
default: {
container: "flex items-center gap-2 has-disabled:opacity-50",
group: "flex items-center",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex size-9 items-center justify-center border-y border-r text-sm outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
},
large: {
container: "flex items-center gap-3 has-disabled:opacity-50",
group: "flex items-center gap-1",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive bg-charcoal-750 border-charcoal-700 hover:border-charcoal-600 hover:bg-charcoal-650 relative flex h-12 w-12 items-center justify-center border text-base outline-none transition-all rounded-md data-[active=true]:z-10 data-[active=true]:ring-[3px] data-[active=true]:border-indigo-500",
},
minimal: {
container: "flex items-center gap-2 has-disabled:opacity-50",
group: "flex items-center",
slot: "data-[active=true]:border-ring data-[active=true]:ring-ring/50 border-transparent bg-transparent relative flex h-9 w-9 items-center justify-center border-b-2 border-b-charcoal-600 text-sm outline-none transition-all data-[active=true]:border-b-indigo-500 data-[active=true]:z-10",
},
};

function InputOTP({
className,
containerClassName,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const variantStyles = variants[variant];

return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(variantStyles.container, fullWidth && "w-full", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}

function InputOTPGroup({
className,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<"div"> & {
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const variantStyles = variants[variant];

return (
<div
data-slot="input-otp-group"
className={cn(variantStyles.group, fullWidth && "flex-1 gap-1", className)}
{...props}
/>
);
}

function InputOTPSlot({
index,
className,
variant = "default",
fullWidth = false,
...props
}: React.ComponentProps<"div"> & {
index: number;
variant?: keyof typeof variants;
fullWidth?: boolean;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
const variantStyles = variants[variant];

return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(variantStyles.slot, fullWidth && "flex-1", className)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-text-bright duration-1000" />
</div>
)}
</div>
);
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
53 changes: 29 additions & 24 deletions apps/webapp/app/components/primitives/Switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
label?: React.ReactNode;
variant: keyof typeof variations;
shortcut?: ShortcutDefinition;
labelPosition?: "left" | "right";
};

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

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

const labelElement = label ? (
<label
className={cn("cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed", text)}
>
{typeof label === "string" ? <span>{label}</span> : label}
</label>
) : null;

const switchElement = (
<div
className={cn(
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
root
)}
>
<SwitchPrimitives.Thumb
className={cn(
thumb,
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
)}
/>
</div>
);

return (
<SwitchPrimitives.Root
className={cn("group", container, className)}
{...props}
ref={innerRef}
>
{label ? (
<label
className={cn(
"cursor-pointer whitespace-nowrap group-disabled:cursor-not-allowed",
text
)}
>
{typeof label === "string" ? <span>{label}</span> : label}
</label>
) : null}
<div
className={cn(
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-charcoal-700 group-data-[state=unchecked]:group-hover:bg-charcoal-500/50",
root
)}
>
<SwitchPrimitives.Thumb
className={cn(
thumb,
"pointer-events-none block rounded-full bg-charcoal-200 transition group-data-[state=checked]:bg-text-bright"
)}
/>
</div>
{labelPosition === "left" ? labelElement : null}
{switchElement}
{labelPosition === "right" ? labelElement : null}
</SwitchPrimitives.Root>
);
}
Expand Down
40 changes: 39 additions & 1 deletion apps/webapp/app/models/message.server.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -121,6 +121,44 @@ export async function jsonWithErrorMessage(
});
}

export async function typedJsonWithSuccessMessage<T>(
data: T,
request: Request,
message: string,
options?: ToastMessageOptions
) {
const session = await getSession(request.headers.get("cookie"));

setSuccessMessage(session, message, options);

return typedjson(data, {
headers: {
"Set-Cookie": await commitSession(session, {
expires: new Date(Date.now() + ONE_YEAR),
}),
},
});
}

export async function typedJsonWithErrorMessage<T>(
data: T,
request: Request,
message: string,
options?: ToastMessageOptions
) {
const session = await getSession(request.headers.get("cookie"));

setErrorMessage(session, message, options);

return typedjson(data, {
headers: {
"Set-Cookie": await commitSession(session, {
expires: new Date(Date.now() + ONE_YEAR),
}),
},
});
}

export async function redirectWithSuccessMessage(
path: string,
request: Request,
Expand Down
Loading
Loading