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,13 @@ 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" ;
2132
2233export const meta : MetaFunction = ( { matches } ) => {
2334 const parentMeta = matches
@@ -71,26 +82,81 @@ export async function action({ request }: ActionFunctionArgs) {
7182
7283 const payload = Object . fromEntries ( await clonedRequest . formData ( ) ) ;
7384
74- const { action } = z
75- . object ( {
76- action : z . enum ( [ "send" , "reset" ] ) ,
77- } )
85+ const data = z
86+ . discriminatedUnion ( "action" , [
87+ z . object ( {
88+ action : z . literal ( "send" ) ,
89+ email : z . string ( ) . trim ( ) . toLowerCase ( ) ,
90+ } ) ,
91+ z . object ( {
92+ action : z . literal ( "reset" ) ,
93+ } ) ,
94+ ] )
7895 . parse ( payload ) ;
7996
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- } ) ;
97+ switch ( data . action ) {
98+ case "send" : {
99+ const { email } = data ;
100+ const clientIp = request . headers . get ( "x-forwarded-for" ) ;
101+
102+ const [ error ] = await tryCatch (
103+ Promise . all ( [
104+ clientIp ? checkMagicLinkIpRateLimit ( clientIp ) : Promise . resolve ( ) ,
105+ checkMagicLinkEmailRateLimit ( email ) ,
106+ checkMagicLinkEmailDailyRateLimit ( email ) ,
107+ ] )
108+ ) ;
109+
110+ if ( error ) {
111+ if ( error instanceof MagicLinkRateLimitError ) {
112+ logger . warn ( "Login magic link rate limit exceeded" , {
113+ clientIp,
114+ email,
115+ error,
116+ } ) ;
117+ } else {
118+ logger . error ( "Failed sending login magic link" , {
119+ clientIp,
120+ email,
121+ error,
122+ } ) ;
123+ }
124+
125+ const errorMessage =
126+ error instanceof MagicLinkRateLimitError
127+ ? "Failed sending magic link. Please try again shortly."
128+ : "Too many magic link requests. Please try again shortly." ;
129+
130+ const session = await getUserSession ( request ) ;
131+ session . set ( "auth:error" , {
132+ message : errorMessage ,
133+ } ) ;
134+
135+ return redirect ( "/login/magic" , {
136+ headers : {
137+ "Set-Cookie" : await commitSession ( session ) ,
138+ } ,
139+ } ) ;
140+ }
141+
142+ return authenticator . authenticate ( "email-link" , request , {
143+ successRedirect : "/login/magic" ,
144+ failureRedirect : "/login/magic" ,
145+ } ) ;
146+ }
147+ case "reset" :
148+ default : {
149+ data . action satisfies "reset" ;
150+
151+ const session = await getUserSession ( request ) ;
152+ session . unset ( "triggerdotdev:magiclink" ) ;
153+
154+ return redirect ( "/login/magic" , {
155+ headers : {
156+ "Set-Cookie" : await commitSession ( session ) ,
157+ } ,
158+ } ) ;
159+ }
94160 }
95161}
96162
0 commit comments