From 9b0505d414795042c6139d1b7c08c8676d0219f5 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 15:47:00 +0100 Subject: [PATCH 1/6] feat(webapp): rework login layout and refine SSO sign-in screens - /login: SSO is now a button placed after Google (was a text link); email sign-in is an inline magic-link form under an "or" divider that posts to the /login/magic action, reusing its rate-limit + SSO auto-discovery logic. The "Last used" badge now centers on the email button. - /login/magic is confirmation-only: visits without a pending magic link redirect to /login, keeping the inline form the single source of truth and avoiding a 404 for existing links (including the expired-link callback). - /login/sso: "Welcome" heading, "Sign in with your enterprise account" subtitle, "Enterprise email address" placeholder, no padlock on the button, and an "Ask about SSO" link opposite "All login options". - Remove the Documentation link from the shared login layout. --- .server-changes/login-sso-ui.md | 13 +++ .../webapp/app/components/LoginPageLayout.tsx | 9 -- apps/webapp/app/routes/login._index/route.tsx | 90 +++++++++++++------ apps/webapp/app/routes/login.magic/route.tsx | 23 ++++- apps/webapp/app/routes/login.sso/route.tsx | 42 +++++---- 5 files changed, 119 insertions(+), 58 deletions(-) create mode 100644 .server-changes/login-sso-ui.md diff --git a/.server-changes/login-sso-ui.md b/.server-changes/login-sso-ui.md new file mode 100644 index 00000000000..d9c7a962930 --- /dev/null +++ b/.server-changes/login-sso-ui.md @@ -0,0 +1,13 @@ +--- +area: webapp +type: improvement +--- + +Rework the login page and SSO sign-in screens. On `/login`, SSO is now a +button (after Google) and email sign-in is an inline magic-link form under an +"or" divider that posts to the `/login/magic` action, reusing its existing +logic. `/login/magic` becomes confirmation-only and redirects to `/login` +otherwise, so the inline form is the single source of truth and old links +don't 404. `/login/sso` gets refreshed copy, an "Enterprise email address" +placeholder, and an "Ask about SSO" link. The Documentation link is removed +from the shared login layout. diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index 323faf4ea26..7c4a39b15c7 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -6,11 +6,9 @@ import { MiddayLogo } from "~/assets/logos/MiddayLogo"; import { TldrawLogo } from "~/assets/logos/TldrawLogo"; import { UnkeyLogo } from "~/assets/logos/UnkeyLogo"; import { LogoType } from "./LogoType"; -import { LinkButton } from "./primitives/Buttons"; import { Header3 } from "./primitives/Headers"; import { Paragraph } from "./primitives/Paragraph"; import { TextLink } from "./primitives/TextLink"; -import { BookOpenIcon } from "@heroicons/react/20/solid"; interface QuoteType { quote: string; @@ -53,13 +51,6 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) { - - Documentation -
{children}
diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index ca418e2d7f8..4fa29385a3d 100644 --- a/apps/webapp/app/routes/login._index/route.tsx +++ b/apps/webapp/app/routes/login._index/route.tsx @@ -1,6 +1,6 @@ import { EnvelopeIcon, LockClosedIcon } from "@heroicons/react/20/solid"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; -import { Form } from "@remix-run/react"; +import { Form, useNavigation } from "@remix-run/react"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { motion, useReducedMotion } from "framer-motion"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -11,7 +11,10 @@ import { Callout } from "~/components/primitives/Callout"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormError } from "~/components/primitives/FormError"; import { Header1 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; import { TextLink } from "~/components/primitives/TextLink"; import { isGithubAuthSupported, isGoogleAuthSupported } from "~/services/auth.server"; import { getLastAuthMethod } from "~/services/lastAuthMethod.server"; @@ -143,6 +146,13 @@ export async function loader({ request }: LoaderFunctionArgs) { export default function LoginPage() { const data = useTypedLoaderData(); + const navigation = useNavigation(); + // The inline email form posts to the /login/magic action, so reflect its + // in-flight state here to show the sending spinner. + const isEmailLoading = + (navigation.state === "submitting" || navigation.state === "loading") && + navigation.formAction === "/login/magic" && + navigation.formData?.get("action") === "send"; return ( @@ -200,44 +210,72 @@ export default function LoginPage() { )} - {!data.isVercelMarketplace && ( + {data.showSsoAuth && !data.isVercelMarketplace && (
- {data.lastAuthMethod === "email" && } + {data.lastAuthMethod === "sso" && } - - Continue with Email + + Continue with SSO
)} - {data.showSsoAuth && !data.isVercelMarketplace && ( -
-
-
- {data.lastAuthMethod === "sso" && } - - - Sign in with SSO - + {!data.isVercelMarketplace && ( + <> +
+
+ or +
-
+
+ {/* Posts to the /login/magic action so all magic-link logic + (rate limiting, SSO auto-discovery, send) stays in one place. */} +
+ +
+ + + +
+ {data.lastAuthMethod === "email" && } + +
+
+
+
+ )} {data.authError && {data.authError}}
diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 7e7d544da63..260e41ec103 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -64,13 +64,28 @@ export async function loader({ request }: LoaderFunctionArgs) { }); const session = await getUserSession(request); - const error = session.get("auth:error"); - // Get redirectTo from URL params and store in session if present. - // Sanitize to drop non-page paths (fetcher routes, callbacks) which would - // render blank if the user was sent there post-login. + // The email form now lives inline on /login; this route is only the + // "magic link sent" confirmation. Any visit without a pending magic link + // forwards to /login — keeping the inlined form the single source of truth + // and avoiding an orphaned duplicate page. This also catches the expired- + // link callback in routes/magic.tsx (which redirects here on error): with + // no pending link it lands on /login, where the error surfaces via the + // shared auth:error handling. Checked before reading auth:error so a flashed + // error isn't consumed here before /login can display it. const url = new URL(request.url); const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo")); + if (!session.has("triggerdotdev:magiclink")) { + // Throw (not return) so the redirect doesn't widen the loader's return + // type — otherwise useTypedLoaderData sees TypedResponse in the + // union and the component can't read magicLinkSent/magicLinkError. + throw redirect( + sanitized === "/" ? "/login" : `/login?redirectTo=${encodeURIComponent(sanitized)}` + ); + } + + const error = session.get("auth:error"); + const redirectTo = sanitized === "/" ? null : sanitized; const headers = new Headers(); diff --git a/apps/webapp/app/routes/login.sso/route.tsx b/apps/webapp/app/routes/login.sso/route.tsx index 8929214f997..ea41d0374d7 100644 --- a/apps/webapp/app/routes/login.sso/route.tsx +++ b/apps/webapp/app/routes/login.sso/route.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, LockClosedIcon } from "@heroicons/react/20/solid"; +import { ArrowLeftIcon } from "@heroicons/react/20/solid"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -29,8 +29,8 @@ function parseReason(value: string | null): Reason { const CONTENT: Record = { default: { - heading: "Sign in with SSO", - body: "Enter your work email.", + heading: "Welcome", + body: "Sign in with your enterprise account", }, domain_policy: { heading: "SSO required", @@ -116,7 +116,7 @@ export default function LoginSsoPage() { type="email" name="email" spellCheck={false} - placeholder="Work email" + placeholder="Enterprise email address" variant="large" required autoFocus={!emailReadOnly} @@ -132,11 +132,7 @@ export default function LoginSsoPage() { fullWidth data-action="continue with sso" > - {isLoading ? ( - - ) : ( - - )} + {isLoading && } {isLoading ? "Redirecting…" @@ -149,16 +145,24 @@ export default function LoginSsoPage() { {errorMessage && {errorMessage}} - - All login options - +
+ + All login options + + + Ask about SSO + +
From 408ffbfaf9eee7887bd0554557caed50893580fc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Thu, 2 Jul 2026 16:04:11 +0100 Subject: [PATCH 2/6] 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. --- .server-changes/login-sso-ui.md | 3 +- apps/webapp/app/routes/login._index/route.tsx | 36 ++++++++++++++++--- apps/webapp/app/routes/login.sso/route.tsx | 31 +++++++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.server-changes/login-sso-ui.md b/.server-changes/login-sso-ui.md index d9c7a962930..f0ed9e93bbd 100644 --- a/.server-changes/login-sso-ui.md +++ b/.server-changes/login-sso-ui.md @@ -10,4 +10,5 @@ logic. `/login/magic` becomes confirmation-only and redirects to `/login` otherwise, so the inline form is the single source of truth and old links don't 404. `/login/sso` gets refreshed copy, an "Enterprise email address" placeholder, and an "Ask about SSO" link. The Documentation link is removed -from the shared login layout. +from the shared login layout. Both email fields validate inline with the +standard styled form error instead of the browser's native tooltip. diff --git a/apps/webapp/app/routes/login._index/route.tsx b/apps/webapp/app/routes/login._index/route.tsx index 4fa29385a3d..a88bdda16ce 100644 --- a/apps/webapp/app/routes/login._index/route.tsx +++ b/apps/webapp/app/routes/login._index/route.tsx @@ -1,9 +1,12 @@ +import { getFormProps, getInputProps, useForm } from "@conform-to/react"; +import { parseWithZod } from "@conform-to/zod"; import { EnvelopeIcon, LockClosedIcon } from "@heroicons/react/20/solid"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { GitHubLightIcon } from "@trigger.dev/companyicons"; import { motion, useReducedMotion } from "framer-motion"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { GoogleLogo } from "~/assets/logos/GoogleLogo"; import { LoginPageLayout } from "~/components/LoginPageLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -27,6 +30,16 @@ import { requestUrl } from "~/utils/requestUrl.server"; import { SSO_SESSION_EXPIRED_REASON } from "~/utils/ssoSession"; import { cn } from "~/utils/cn"; +// Client-side email validation for the inline magic-link form. Mirrors +// /login/sso: the form posts cross-route to /login/magic, so conform runs +// format validation in the browser and renders the styled inline error. +// Server-side errors still surface via the session-backed authError below. +const emailSchema = z.object({ + email: z + .string({ required_error: "Enter your email address" }) + .email("Enter a valid email address"), +}); + function LastUsedBadge({ className }: { className?: string }) { const shouldReduceMotion = useReducedMotion(); @@ -154,6 +167,15 @@ export default function LoginPage() { navigation.formAction === "/login/magic" && navigation.formData?.get("action") === "send"; + const [emailForm, emailFields] = useForm({ + id: "login-email", + onValidate({ formData }) { + return parseWithZod(formData, { schema: emailSchema }); + }, + shouldValidate: "onBlur", + shouldRevalidate: "onInput", + }); + return (
@@ -239,19 +261,25 @@ export default function LoginPage() {
{/* Posts to the /login/magic action so all magic-link logic (rate limiting, SSO auto-discovery, send) stays in one place. */} -
+
+ + {emailFields.email.errors} +
{data.lastAuthMethod === "email" && } diff --git a/apps/webapp/app/routes/login.sso/route.tsx b/apps/webapp/app/routes/login.sso/route.tsx index ea41d0374d7..7c9415a0b35 100644 --- a/apps/webapp/app/routes/login.sso/route.tsx +++ b/apps/webapp/app/routes/login.sso/route.tsx @@ -1,7 +1,10 @@ +import { getFormProps, getInputProps, useForm } from "@conform-to/react"; +import { parseWithZod } from "@conform-to/zod"; import { ArrowLeftIcon } from "@heroicons/react/20/solid"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { Form, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { LoginPageLayout } from "~/components/LoginPageLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Fieldset } from "~/components/primitives/Fieldset"; @@ -58,6 +61,16 @@ const ERROR_MESSAGES: Record = { missing_code: "We couldn't complete sign-in. Try again.", }; +// Client-side validation for the enterprise email field. The form posts +// cross-route to /auth/sso, so there's no same-route action result to hydrate +// from — conform validates the format in the browser and renders the styled +// inline error before submit. Server-side errors keep flowing via ?error=. +const ssoEmailSchema = z.object({ + email: z + .string({ required_error: "Enter your enterprise email address" }) + .email("Enter a valid email address"), +}); + export const meta: MetaFunction = () => [ { title: "Sign in with SSO – Trigger.dev" }, { name: "viewport", content: "width=device-width,initial-scale=1" }, @@ -98,9 +111,19 @@ export default function LoginSsoPage() { const content = CONTENT[reason]; const emailReadOnly = reason === "oauth_blocked"; + const [form, fields] = useForm({ + id: "login-sso", + defaultValue: { email }, + onValidate({ formData }) { + return parseWithZod(formData, { schema: ssoEmailSchema }); + }, + shouldValidate: "onBlur", + shouldRevalidate: "onInput", + }); + return ( - +
@@ -113,16 +136,14 @@ export default function LoginSsoPage() {
+ {fields.email.errors} - } - confirmButton={ - - Log in using another option - - } - /> -
- - ) : ( - <> - - Welcome - - - Create an account or login using email - -
- - - - + + We've sent you a magic link! + +
+ + + {email ? ( + <> + We emailed a magic link to {email} to + log you in to your account. + + ) : ( + "We emailed you a magic link to log you in to your account." + )} + + {magicLinkError && {magicLinkError}} + - {isLoading ? ( - - ) : ( - - )} - {isLoading ? ( - Sending… - ) : ( - Send a magic link - )} + Re-enter email - {magicLinkError && {magicLinkError}} -
- - By signing up you agree to our{" "} - - terms - - {" "}and{" "} - - privacy - - {" "}policy. - - - - All login options - - - )} + } + confirmButton={ + + Log in using another option + + } + /> +
From 729decd859e2afdfc1e4e063eb3dafb9dd06430b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Jul 2026 16:10:31 +0100 Subject: [PATCH 5/6] fix(webapp): carry the magic-link email in a cookie instead of the URL - Pass the confirmation email to /login/magic in a short-lived httpOnly cookie rather than a ?email= query param, so the address never lands in access logs, browser history, or error-tracker breadcrumbs. - Send the action's validation-failure, rate-limit, and auth-failure paths straight to /login instead of /login/magic (which the loader guard would bounce again), matching the reset path. --- apps/webapp/app/routes/login.magic/route.tsx | 47 +++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/routes/login.magic/route.tsx b/apps/webapp/app/routes/login.magic/route.tsx index 9ec5beac722..d18b36ba184 100644 --- a/apps/webapp/app/routes/login.magic/route.tsx +++ b/apps/webapp/app/routes/login.magic/route.tsx @@ -1,6 +1,7 @@ import { ArrowLeftIcon } from "@heroicons/react/20/solid"; import { InboxArrowDownIcon } from "@heroicons/react/24/solid"; import { + createCookie, redirect, type ActionFunctionArgs, type LoaderFunctionArgs, @@ -35,6 +36,17 @@ import { logger, tryCatch } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; import { extractClientIp } from "~/utils/extractClientIp.server"; +// The submitted email is carried to the confirmation screen in a short-lived, +// httpOnly cookie rather than the URL, so the address never lands in access +// logs, browser history, or error-tracker breadcrumbs. +const magicLinkEmailCookie = createCookie("magiclink-email", { + maxAge: 60 * 10, + httpOnly: true, + sameSite: "lax", + secure: env.NODE_ENV === "production", + path: "/", +}); + export const meta: MetaFunction = ({ matches }) => { const parentMeta = matches .flatMap((match) => match.meta ?? []) @@ -65,7 +77,7 @@ export async function loader({ request }: LoaderFunctionArgs) { // "magic link sent" confirmation. A visit without a pending magic link // forwards to /login — keeping the inlined form the single source of truth, // avoiding an orphaned duplicate page, and letting /login surface any flashed - // auth:error (e.g. an invalid-email submit that never sent a link). The guard + // auth:error. The guard // runs before reading auth:error so that error isn't consumed here before // /login can show it. An expired/invalid link click (routes/magic.tsx) is // different: the email-link strategy only clears the magic-link key on a @@ -73,10 +85,13 @@ export async function loader({ request }: LoaderFunctionArgs) { // confirmation renders the flashed error as magicLinkError below. const url = new URL(request.url); const sanitized = sanitizeRedirectPath(url.searchParams.get("redirectTo")); - // The submitted address is carried on the success redirect so the - // confirmation can name it. Validate before echoing it back. - const emailParam = url.searchParams.get("email"); - const email = emailParam && z.string().email().safeParse(emailParam).success ? emailParam : null; + // The submitted address is carried in a short-lived cookie (not the URL) so + // the confirmation can name it. Validate before echoing it back. + const emailCookie = await magicLinkEmailCookie.parse(request.headers.get("Cookie")); + const email = + typeof emailCookie === "string" && z.string().email().safeParse(emailCookie).success + ? emailCookie + : null; if (!session.has("triggerdotdev:magiclink")) { // Throw (not return) so the redirect doesn't widen the loader's return // type — otherwise useTypedLoaderData sees TypedResponse in the @@ -141,7 +156,7 @@ export async function action({ request }: ActionFunctionArgs) { message: "Please enter a valid email address.", }); - return redirect("/login/magic", { + return redirect("/login", { headers: { "Set-Cookie": await commitSession(session), }, @@ -191,7 +206,7 @@ export async function action({ request }: ActionFunctionArgs) { message: errorMessage, }); - return redirect("/login/magic", { + return redirect("/login", { headers: { "Set-Cookie": await commitSession(session), }, @@ -211,10 +226,20 @@ export async function action({ request }: ActionFunctionArgs) { return redirect(ssoRedirect); } - return authenticator.authenticate("email-link", request, { - successRedirect: `/login/magic?email=${encodeURIComponent(email)}`, - failureRedirect: "/login/magic", - }); + // authenticator.authenticate throws its redirect Response; attach the + // sent-to email as a short-lived cookie so the confirmation can name it + // without putting the address in the URL. + try { + return await authenticator.authenticate("email-link", request, { + successRedirect: "/login/magic", + failureRedirect: "/login", + }); + } catch (thrown) { + if (thrown instanceof Response) { + thrown.headers.append("Set-Cookie", await magicLinkEmailCookie.serialize(email)); + } + throw thrown; + } } case "reset": default: { From 2605efc1b8bfd40dda672a4084fed075f9243a2b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Jul 2026 17:01:08 +0100 Subject: [PATCH 6/6] Update .server-changes/login-sso-ui.md Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com> --- .server-changes/login-sso-ui.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.server-changes/login-sso-ui.md b/.server-changes/login-sso-ui.md index 2d8322ebb08..60c0209ae85 100644 --- a/.server-changes/login-sso-ui.md +++ b/.server-changes/login-sso-ui.md @@ -3,6 +3,4 @@ area: webapp type: improvement --- -Rework the login and SSO sign-in screens: inline magic-link email form on -`/login`, SSO as a button, `/login/magic` is now confirmation-only, and -refreshed `/login/sso` copy. +A cleaner sign-in flow: request a magic link right from the login page, or continue with SSO.