1+ import { getFormProps , getInputProps , useForm } from "@conform-to/react" ;
2+ import { parseWithZod } from "@conform-to/zod" ;
13import { EnvelopeIcon , LockClosedIcon } from "@heroicons/react/20/solid" ;
24import type { LoaderFunctionArgs , MetaFunction } from "@remix-run/node" ;
3- import { Form } from "@remix-run/react" ;
5+ import { Form , useNavigation } from "@remix-run/react" ;
46import { GitHubLightIcon } from "@trigger.dev/companyicons" ;
57import { motion , useReducedMotion } from "framer-motion" ;
68import { redirect , typedjson , useTypedLoaderData } from "remix-typedjson" ;
9+ import { z } from "zod" ;
710import { GoogleLogo } from "~/assets/logos/GoogleLogo" ;
811import { LoginPageLayout } from "~/components/LoginPageLayout" ;
912import { Button , LinkButton } from "~/components/primitives/Buttons" ;
1013import { Callout } from "~/components/primitives/Callout" ;
1114import { Fieldset } from "~/components/primitives/Fieldset" ;
1215import { FormError } from "~/components/primitives/FormError" ;
1316import { Header1 } from "~/components/primitives/Headers" ;
17+ import { Input } from "~/components/primitives/Input" ;
18+ import { InputGroup } from "~/components/primitives/InputGroup" ;
1419import { Paragraph } from "~/components/primitives/Paragraph" ;
20+ import { Spinner } from "~/components/primitives/Spinner" ;
1521import { TextLink } from "~/components/primitives/TextLink" ;
1622import { isGithubAuthSupported , isGoogleAuthSupported } from "~/services/auth.server" ;
1723import { getLastAuthMethod } from "~/services/lastAuthMethod.server" ;
@@ -24,6 +30,16 @@ import { requestUrl } from "~/utils/requestUrl.server";
2430import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession" ;
2531import { cn } from "~/utils/cn" ;
2632
33+ // Client-side email validation for the inline magic-link form. Mirrors
34+ // /login/sso: the form posts cross-route to /login/magic, so conform runs
35+ // format validation in the browser and renders the styled inline error.
36+ // Server-side errors still surface via the session-backed authError below.
37+ const emailSchema = z . object ( {
38+ email : z
39+ . string ( { required_error : "Enter your email address" } )
40+ . email ( "Enter a valid email address" ) ,
41+ } ) ;
42+
2743function LastUsedBadge ( { className } : { className ?: string } ) {
2844 const shouldReduceMotion = useReducedMotion ( ) ;
2945
@@ -143,6 +159,22 @@ export async function loader({ request }: LoaderFunctionArgs) {
143159
144160export default function LoginPage ( ) {
145161 const data = useTypedLoaderData < typeof loader > ( ) ;
162+ const navigation = useNavigation ( ) ;
163+ // The inline email form posts to the /login/magic action, so reflect its
164+ // in-flight state here to show the sending spinner.
165+ const isEmailLoading =
166+ ( navigation . state === "submitting" || navigation . state === "loading" ) &&
167+ navigation . formAction === "/login/magic" &&
168+ navigation . formData ?. get ( "action" ) === "send" ;
169+
170+ const [ emailForm , emailFields ] = useForm ( {
171+ id : "login-email" ,
172+ onValidate ( { formData } ) {
173+ return parseWithZod ( formData , { schema : emailSchema } ) ;
174+ } ,
175+ shouldValidate : "onBlur" ,
176+ shouldRevalidate : "onInput" ,
177+ } ) ;
146178
147179 return (
148180 < LoginPageLayout >
@@ -200,44 +232,80 @@ export default function LoginPage() {
200232 </ Form >
201233 </ div >
202234 ) }
203- { ! data . isVercelMarketplace && (
235+ { data . showSsoAuth && ! data . isVercelMarketplace && (
204236 < div className = "relative w-full" >
205- { data . lastAuthMethod === "email " && < LastUsedBadge /> }
237+ { data . lastAuthMethod === "sso " && < LastUsedBadge /> }
206238 < LinkButton
207239 to = {
208240 data . redirectTo
209- ? `/login/magic ?redirectTo=${ encodeURIComponent ( data . redirectTo ) } `
210- : "/login/magic "
241+ ? `/login/sso ?redirectTo=${ encodeURIComponent ( data . redirectTo ) } `
242+ : "/login/sso "
211243 }
212244 variant = "secondary/extra-large"
213245 fullWidth
214- data-action = "continue with email "
246+ data-action = "continue with sso "
215247 className = "text-text-bright"
216248 >
217- < EnvelopeIcon className = "mr-2 size-5 text-text-bright" />
218- Continue with Email
249+ < LockClosedIcon className = "mr-2 size-5 text-text-bright" />
250+ Continue with SSO
219251 </ LinkButton >
220252 </ div >
221253 ) }
222- { data . showSsoAuth && ! data . isVercelMarketplace && (
223- < div className = "flex w-full flex-col items-center gap-y-2 pt-2" >
224- < div className = "h-px w-full bg-charcoal-700" />
225- < div className = "relative inline-flex items-center" >
226- { data . lastAuthMethod === "sso" && < LastUsedBadge className = "translate-x-2" /> }
227- < TextLink
228- to = {
229- data . redirectTo
230- ? `/login/sso?redirectTo=${ encodeURIComponent ( data . redirectTo ) } `
231- : "/login/sso"
232- }
233- className = "inline-flex items-center text-sm"
234- data-action = "continue with sso"
254+ { ! data . isVercelMarketplace && (
255+ < >
256+ { ( data . showGithubAuth || data . showGoogleAuth || data . showSsoAuth ) && (
257+ < div className = "flex w-full items-center gap-3 py-1" >
258+ < div className = "h-px flex-1 bg-charcoal-700" />
259+ < span className = "text-xs uppercase text-text-dimmed" > or</ span >
260+ < div className = "h-px flex-1 bg-charcoal-700" />
261+ </ div >
262+ ) }
263+ < div className = "w-full" >
264+ { /* Posts to the /login/magic action so all magic-link logic
265+ (rate limiting, SSO auto-discovery, send) stays in one place. */ }
266+ < Form
267+ action = "/login/magic"
268+ method = "post"
269+ className = "w-full"
270+ { ...getFormProps ( emailForm ) }
235271 >
236- < LockClosedIcon className = "mr-1.5 size-4" />
237- Sign in with SSO
238- </ TextLink >
272+ < input type = "hidden" name = "action" value = "send" />
273+ < div className = "flex w-full flex-col items-center gap-y-2" >
274+ < InputGroup fullWidth >
275+ < Input
276+ { ...getInputProps ( emailFields . email , { type : "email" } ) }
277+ spellCheck = { false }
278+ placeholder = "Email Address"
279+ variant = "large"
280+ data-action = "email address"
281+ />
282+ < FormError id = { emailFields . email . errorId } >
283+ { emailFields . email . errors }
284+ </ FormError >
285+ </ InputGroup >
286+ < div className = "relative w-full" >
287+ { data . lastAuthMethod === "email" && < LastUsedBadge /> }
288+ < Button
289+ type = "submit"
290+ variant = "primary/large"
291+ disabled = { isEmailLoading }
292+ fullWidth
293+ data-action = "continue with email"
294+ >
295+ { isEmailLoading ? (
296+ < Spinner className = "mr-2 size-5" color = "white" />
297+ ) : (
298+ < EnvelopeIcon className = "mr-2 size-5 text-text-bright" />
299+ ) }
300+ < span className = "text-text-bright" >
301+ { isEmailLoading ? "Sending…" : "Continue with Email" }
302+ </ span >
303+ </ Button >
304+ </ div >
305+ </ div >
306+ </ Form >
239307 </ div >
240- </ div >
308+ </ >
241309 ) }
242310 { data . authError && < FormError > { data . authError } </ FormError > }
243311 </ div >
0 commit comments