Skip to content

Commit 408ffbf

Browse files
committed
feat(webapp): inline email validation on login and SSO sign-in forms
Use conform (@conform-to/react + @conform-to/zod) for client-side email validation on the /login inline email form and the /login/sso enterprise email field. Invalid emails now surface the app's standard inline FormError (associated with the field for accessibility) instead of the browser's native tooltip. Both forms still post cross-route to their existing actions (/login/magic, /auth/sso), so server-side errors keep their current paths.
1 parent 9b0505d commit 408ffbf

3 files changed

Lines changed: 60 additions & 10 deletions

File tree

.server-changes/login-sso-ui.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ logic. `/login/magic` becomes confirmation-only and redirects to `/login`
1010
otherwise, so the inline form is the single source of truth and old links
1111
don't 404. `/login/sso` gets refreshed copy, an "Enterprise email address"
1212
placeholder, and an "Ask about SSO" link. The Documentation link is removed
13-
from the shared login layout.
13+
from the shared login layout. Both email fields validate inline with the
14+
standard styled form error instead of the browser's native tooltip.

apps/webapp/app/routes/login._index/route.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
2+
import { parseWithZod } from "@conform-to/zod";
13
import { EnvelopeIcon, LockClosedIcon } from "@heroicons/react/20/solid";
24
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
35
import { Form, useNavigation } from "@remix-run/react";
46
import { GitHubLightIcon } from "@trigger.dev/companyicons";
57
import { motion, useReducedMotion } from "framer-motion";
68
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
9+
import { z } from "zod";
710
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
811
import { LoginPageLayout } from "~/components/LoginPageLayout";
912
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -27,6 +30,16 @@ import { requestUrl } from "~/utils/requestUrl.server";
2730
import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession";
2831
import { cn } from "~/utils/cn";
2932

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+
3043
function LastUsedBadge({ className }: { className?: string }) {
3144
const shouldReduceMotion = useReducedMotion();
3245

@@ -154,6 +167,15 @@ export default function LoginPage() {
154167
navigation.formAction === "/login/magic" &&
155168
navigation.formData?.get("action") === "send";
156169

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+
});
178+
157179
return (
158180
<LoginPageLayout>
159181
<div className="flex w-full flex-col items-center">
@@ -239,19 +261,25 @@ export default function LoginPage() {
239261
<div className="w-full">
240262
{/* Posts to the /login/magic action so all magic-link logic
241263
(rate limiting, SSO auto-discovery, send) stays in one place. */}
242-
<Form action="/login/magic" method="post" className="w-full">
264+
<Form
265+
action="/login/magic"
266+
method="post"
267+
className="w-full"
268+
{...getFormProps(emailForm)}
269+
>
243270
<input type="hidden" name="action" value="send" />
244271
<div className="flex w-full flex-col items-center gap-y-2">
245272
<InputGroup fullWidth>
246273
<Input
247-
type="email"
248-
name="email"
274+
{...getInputProps(emailFields.email, { type: "email" })}
249275
spellCheck={false}
250276
placeholder="Email Address"
251277
variant="large"
252-
required
253278
data-action="email address"
254279
/>
280+
<FormError id={emailFields.email.errorId}>
281+
{emailFields.email.errors}
282+
</FormError>
255283
</InputGroup>
256284
<div className="relative w-full">
257285
{data.lastAuthMethod === "email" && <LastUsedBadge />}

apps/webapp/app/routes/login.sso/route.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
2+
import { parseWithZod } from "@conform-to/zod";
13
import { ArrowLeftIcon } from "@heroicons/react/20/solid";
24
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
35
import { Form, useNavigation } from "@remix-run/react";
46
import { typedjson, useTypedLoaderData } from "remix-typedjson";
7+
import { z } from "zod";
58
import { LoginPageLayout } from "~/components/LoginPageLayout";
69
import { Button, LinkButton } from "~/components/primitives/Buttons";
710
import { Fieldset } from "~/components/primitives/Fieldset";
@@ -58,6 +61,16 @@ const ERROR_MESSAGES: Record<string, string> = {
5861
missing_code: "We couldn't complete sign-in. Try again.",
5962
};
6063

64+
// Client-side validation for the enterprise email field. The form posts
65+
// cross-route to /auth/sso, so there's no same-route action result to hydrate
66+
// from — conform validates the format in the browser and renders the styled
67+
// inline error before submit. Server-side errors keep flowing via ?error=.
68+
const ssoEmailSchema = z.object({
69+
email: z
70+
.string({ required_error: "Enter your enterprise email address" })
71+
.email("Enter a valid email address"),
72+
});
73+
6174
export const meta: MetaFunction = () => [
6275
{ title: "Sign in with SSO – Trigger.dev" },
6376
{ name: "viewport", content: "width=device-width,initial-scale=1" },
@@ -98,9 +111,19 @@ export default function LoginSsoPage() {
98111
const content = CONTENT[reason];
99112
const emailReadOnly = reason === "oauth_blocked";
100113

114+
const [form, fields] = useForm({
115+
id: "login-sso",
116+
defaultValue: { email },
117+
onValidate({ formData }) {
118+
return parseWithZod(formData, { schema: ssoEmailSchema });
119+
},
120+
shouldValidate: "onBlur",
121+
shouldRevalidate: "onInput",
122+
});
123+
101124
return (
102125
<LoginPageLayout>
103-
<Form method="post" action="/auth/sso">
126+
<Form method="post" action="/auth/sso" {...getFormProps(form)}>
104127
<input type="hidden" name="redirectTo" value={redirectTo} />
105128
<input type="hidden" name="flow" value="user_initiated" />
106129
<div className="flex flex-col items-center justify-center">
@@ -113,16 +136,14 @@ export default function LoginSsoPage() {
113136
<Fieldset className="flex w-full flex-col items-center gap-y-2">
114137
<InputGroup>
115138
<Input
116-
type="email"
117-
name="email"
139+
{...getInputProps(fields.email, { type: "email" })}
118140
spellCheck={false}
119141
placeholder="Enterprise email address"
120142
variant="large"
121-
required
122143
autoFocus={!emailReadOnly}
123-
defaultValue={email}
124144
readOnly={emailReadOnly}
125145
/>
146+
<FormError id={fields.email.errorId}>{fields.email.errors}</FormError>
126147
</InputGroup>
127148

128149
<Button

0 commit comments

Comments
 (0)