fix(webapp): show magic link confirmation instead of reloading login (#4215)

## Summary

Submitting your email on the login page could reload back to an empty
login form instead of showing the "we've sent you a magic link"
confirmation. The magic link email was still sent, so it looked like
nothing happened.

## Root cause

The `/login/magic` route imported a server-only cookie module
(`magicLinkEmailCookie.server.ts`) whose top-level `env.NODE_ENV` read
got bundled into the route's client JS. On the client `env` is
undefined, so the module threw a `TypeError` at module eval, which
aborted Remix's client-side navigation to the confirmation and
hard-reloaded back to `/login`. It only surfaced in production builds
(local dev auto-logs-in, and local prod builds happen to tree-shake the
module out), which is why it slipped through.

## Fix

The email-link strategy already stores the submitted address in the
session (`auth:email`), so the separate cookie was redundant. Deleted
the cookie module and read the address from the session in the loader.
With the module gone, nothing server-only can leak into the client
bundle regardless of tree-shaking.

Verified the confirmation renders with the email address, the SSO
domain-policy redirect (with the email prefilled) still works, and a
production build no longer bundles the module.
This commit is contained in:
James Ritchie
2026-07-09 20:20:06 +01:00
committed by GitHub
parent dc6c98af5e
commit afc8f9e210
4 changed files with 26 additions and 36 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---
Fixed submitting your email on the login page reloading back to an empty form instead of showing the magic link confirmation screen.
@@ -1,15 +0,0 @@
import { createCookie } from "@remix-run/node";
import { env } from "~/env.server";
// Carries the submitted email 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. Lives in a .server
// module: it calls createCookie at import time using server-only env, which
// throws if it ever evaluates in the client bundle.
export const magicLinkEmailCookie = createCookie("magiclink-email", {
maxAge: 60 * 10,
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
});
+15 -21
View File
@@ -34,7 +34,6 @@ import { ssoRedirectForEmail } from "~/services/ssoAutoDiscovery.server";
import { logger, tryCatch } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { extractClientIp } from "~/utils/extractClientIp.server";
import { magicLinkEmailCookie } from "./magicLinkEmailCookie.server";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
@@ -74,12 +73,14 @@ 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 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"));
// The email-link strategy stores the submitted address in the session
// (`auth:email`) alongside the magic-link key, so read it from there to name
// the confirmation — no address in the URL, and no separate cookie to leak
// into the client bundle. Validate before echoing it back.
const emailValue = session.get("auth:email");
const email =
typeof emailCookie === "string" && z.string().email().safeParse(emailCookie).success
? emailCookie
typeof emailValue === "string" && z.string().email().safeParse(emailValue).success
? emailValue
: null;
if (!session.has("triggerdotdev:magiclink")) {
// Throw (not return) so the redirect doesn't widen the loader's return
@@ -215,20 +216,13 @@ export async function action({ request }: ActionFunctionArgs) {
return redirect(ssoRedirect);
}
// 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;
}
// The email-link strategy stores the address in the session (`auth:email`)
// and throws its own redirect Response (with the committed session cookie),
// so return it directly — the confirmation reads the email from the session.
return await authenticator.authenticate("email-link", request, {
successRedirect: "/login/magic",
failureRedirect: "/login",
});
}
case "reset":
default: {
@@ -260,7 +254,7 @@ export default function LoginMagicLinkPage() {
</Header1>
<Fieldset className="flex w-full flex-col items-center gap-y-2">
<InboxArrowDownIcon className="mb-4 h-12 w-12 text-indigo-500" />
<Paragraph className="mb-6 text-center [text-wrap:balance]">
<Paragraph className="mb-6 text-center">
{email ? (
<>
We emailed a magic link to <span className="text-text-bright">{email}</span> to
@@ -18,6 +18,11 @@ const emailStrategy = new EmailLinkStrategy(
secret,
callbackURL: "/magic",
sessionMagicLinkKey: "triggerdotdev:magiclink",
// Pin explicitly to the library default rather than relying on it: the
// /login/magic loader reads the submitted address via
// session.get("auth:email") to name it on the confirmation screen, so a
// future remix-auth-email-link default change can't silently break that.
sessionEmailKey: "auth:email",
},
async ({
email,