11import { ArrowLeftIcon , EnvelopeIcon } from "@heroicons/react/20/solid" ;
22import { InboxArrowDownIcon } from "@heroicons/react/24/solid" ;
3- import type { ActionFunctionArgs , LoaderFunctionArgs , MetaFunction } from "@remix-run/node" ;
4- import { redirect } from "@remix-run/node" ;
3+ import {
4+ redirect ,
5+ type ActionFunctionArgs ,
6+ type LoaderFunctionArgs ,
7+ type MetaFunction ,
8+ } from "@remix-run/node" ;
59import { Form , useNavigation } from "@remix-run/react" ;
610import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
711import { z } from "zod" ;
@@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner";
1822import { TextLink } from "~/components/primitives/TextLink" ;
1923import { authenticator } from "~/services/auth.server" ;
2024import { commitSession , getUserSession } from "~/services/sessionStorage.server" ;
25+ import {
26+ checkMagicLinkEmailRateLimit ,
27+ checkMagicLinkEmailDailyRateLimit ,
28+ MagicLinkRateLimitError ,
29+ checkMagicLinkIpRateLimit ,
30+ } from "~/services/magicLinkRateLimiter.server" ;
31+ import { logger , tryCatch } from "@trigger.dev/core/v3" ;
32+ import { env } from "~/env.server" ;
2133
2234export const meta : MetaFunction = ( { matches } ) => {
2335 const parentMeta = matches
@@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) {
7183
7284 const payload = Object . fromEntries ( await clonedRequest . formData ( ) ) ;
7385
74- const { action } = z
75- . object ( {
76- action : z . enum ( [ "send" , "reset" ] ) ,
77- } )
86+ const data = z
87+ . discriminatedUnion ( "action" , [
88+ z . object ( {
89+ action : z . literal ( "send" ) ,
90+ email : z . string ( ) . trim ( ) . toLowerCase ( ) ,
91+ } ) ,
92+ z . object ( {
93+ action : z . literal ( "reset" ) ,
94+ } ) ,
95+ ] )
7896 . parse ( payload ) ;
7997
80- if ( action === "send" ) {
81- return authenticator . authenticate ( "email-link" , request , {
82- successRedirect : "/login/magic" ,
83- failureRedirect : "/login/magic" ,
84- } ) ;
85- } else {
86- const session = await getUserSession ( request ) ;
87- session . unset ( "triggerdotdev:magiclink" ) ;
88-
89- return redirect ( "/login/magic" , {
90- headers : {
91- "Set-Cookie" : await commitSession ( session ) ,
92- } ,
93- } ) ;
98+ switch ( data . action ) {
99+ case "send" : {
100+ if ( ! env . LOGIN_RATE_LIMITS_ENABLED ) {
101+ return authenticator . authenticate ( "email-link" , request , {
102+ successRedirect : "/login/magic" ,
103+ failureRedirect : "/login/magic" ,
104+ } ) ;
105+ }
106+
107+ const { email } = data ;
108+ const xff = request . headers . get ( "x-forwarded-for" ) ;
109+ const clientIp = extractClientIp ( xff ) ;
110+
111+ const [ error ] = await tryCatch (
112+ Promise . all ( [
113+ clientIp ? checkMagicLinkIpRateLimit ( clientIp ) : Promise . resolve ( ) ,
114+ checkMagicLinkEmailRateLimit ( email ) ,
115+ checkMagicLinkEmailDailyRateLimit ( email ) ,
116+ ] )
117+ ) ;
118+
119+ if ( error ) {
120+ if ( error instanceof MagicLinkRateLimitError ) {
121+ logger . warn ( "Login magic link rate limit exceeded" , {
122+ clientIp,
123+ email,
124+ error,
125+ } ) ;
126+ } else {
127+ logger . error ( "Failed sending login magic link" , {
128+ clientIp,
129+ email,
130+ error,
131+ } ) ;
132+ }
133+
134+ const errorMessage =
135+ error instanceof MagicLinkRateLimitError
136+ ? "Too many magic link requests. Please try again shortly."
137+ : "Failed sending magic link. Please try again shortly." ;
138+
139+ const session = await getUserSession ( request ) ;
140+ session . set ( "auth:error" , {
141+ message : errorMessage ,
142+ } ) ;
143+
144+ return redirect ( "/login/magic" , {
145+ headers : {
146+ "Set-Cookie" : await commitSession ( session ) ,
147+ } ,
148+ } ) ;
149+ }
150+
151+ return authenticator . authenticate ( "email-link" , request , {
152+ successRedirect : "/login/magic" ,
153+ failureRedirect : "/login/magic" ,
154+ } ) ;
155+ }
156+ case "reset" :
157+ default : {
158+ data . action satisfies "reset" ;
159+
160+ const session = await getUserSession ( request ) ;
161+ session . unset ( "triggerdotdev:magiclink" ) ;
162+
163+ return redirect ( "/login/magic" , {
164+ headers : {
165+ "Set-Cookie" : await commitSession ( session ) ,
166+ } ,
167+ } ) ;
168+ }
94169 }
95170}
96171
172+ const extractClientIp = ( xff : string | null ) => {
173+ if ( ! xff ) return null ;
174+
175+ const parts = xff . split ( "," ) . map ( ( p ) => p . trim ( ) ) ;
176+ return parts [ parts . length - 1 ] ; // take last item, ALB appends the real client IP by default
177+ } ;
178+
97179export default function LoginMagicLinkPage ( ) {
98180 const { magicLinkSent, magicLinkError } = useTypedLoaderData < typeof loader > ( ) ;
99181 const navigate = useNavigation ( ) ;
0 commit comments