diff --git a/.server-changes/login-sso-ui.md b/.server-changes/login-sso-ui.md
new file mode 100644
index 00000000000..60c0209ae85
--- /dev/null
+++ b/.server-changes/login-sso-ui.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: improvement
+---
+
+A cleaner sign-in flow: request a magic link right from the login page, or continue with SSO.
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..4b0d39c44b0 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 } 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";
+import { z } from "zod";
import { GoogleLogo } from "~/assets/logos/GoogleLogo";
import { LoginPageLayout } from "~/components/LoginPageLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -11,7 +14,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";
@@ -24,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();
@@ -143,6 +159,22 @@ 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";
+
+ const [emailForm, emailFields] = useForm({
+ id: "login-email",
+ onValidate({ formData }) {
+ return parseWithZod(formData, { schema: emailSchema });
+ },
+ shouldValidate: "onBlur",
+ shouldRevalidate: "onInput",
+ });
return (
@@ -200,44 +232,80 @@ 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" &&
}
-
+ {(data.showGithubAuth || data.showGoogleAuth || data.showSsoAuth) && (
+
+ )}
+
+ {/* Posts to the /login/magic action so all magic-link logic
+ (rate limiting, SSO auto-discovery, send) stays in one place. */}
+
-
+ >
)}
{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..d18b36ba184 100644
--- a/apps/webapp/app/routes/login.magic/route.tsx
+++ b/apps/webapp/app/routes/login.magic/route.tsx
@@ -1,12 +1,13 @@
-import { ArrowLeftIcon, EnvelopeIcon } from "@heroicons/react/20/solid";
+import { ArrowLeftIcon } from "@heroicons/react/20/solid";
import { InboxArrowDownIcon } from "@heroicons/react/24/solid";
import {
+ createCookie,
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
type MetaFunction,
} from "@remix-run/node";
-import { Form, useNavigation } from "@remix-run/react";
+import { Form } from "@remix-run/react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { LoginPageLayout } from "~/components/LoginPageLayout";
@@ -15,11 +16,7 @@ import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
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 { authenticator } from "~/services/auth.server";
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
import {
@@ -39,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 ?? [])
@@ -64,13 +72,37 @@ 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. 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. 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
+ // successful verify, so the key is still set and the request lands here — the
+ // 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 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
+ // union and the component can't read magicLinkError/email.
+ throw redirect(
+ sanitized === "/" ? "/login" : `/login?redirectTo=${encodeURIComponent(sanitized)}`
+ );
+ }
+
+ const error = session.get("auth:error");
+
const redirectTo = sanitized === "/" ? null : sanitized;
const headers = new Headers();
@@ -92,8 +124,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
return typedjson(
{
- magicLinkSent: session.has("triggerdotdev:magiclink"),
magicLinkError,
+ email,
},
{
headers,
@@ -124,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),
},
@@ -174,7 +206,7 @@ export async function action({ request }: ActionFunctionArgs) {
message: errorMessage,
});
- return redirect("/login/magic", {
+ return redirect("/login", {
headers: {
"Set-Cookie": await commitSession(session),
},
@@ -194,10 +226,20 @@ export async function action({ request }: ActionFunctionArgs) {
return redirect(ssoRedirect);
}
- return authenticator.authenticate("email-link", request, {
- successRedirect: "/login/magic",
- 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: {
@@ -206,7 +248,9 @@ export async function action({ request }: ActionFunctionArgs) {
const session = await getUserSession(request);
session.unset("triggerdotdev:magiclink");
- return redirect("/login/magic", {
+ // The email form now lives on /login, so send "Re-enter email" straight
+ // there rather than bouncing through this route's loader redirect.
+ return redirect("/login", {
headers: {
"Set-Cookie": await commitSession(session),
},
@@ -216,121 +260,53 @@ export async function action({ request }: ActionFunctionArgs) {
}
export default function LoginMagicLinkPage() {
- const { magicLinkSent, magicLinkError } = useTypedLoaderData();
- const navigate = useNavigation();
-
- const isLoading =
- (navigate.state === "loading" || navigate.state === "submitting") &&
- navigate.formAction !== undefined &&
- navigate.formData?.get("action") === "send";
+ const { magicLinkError, email } = useTypedLoaderData();
return (
diff --git a/apps/webapp/app/routes/login.sso/route.tsx b/apps/webapp/app/routes/login.sso/route.tsx
index 8929214f997..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 { ArrowLeftIcon, LockClosedIcon } from "@heroicons/react/20/solid";
+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";
@@ -29,8 +32,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",
@@ -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 (
-