Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/login-sso-ui.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 0 additions & 9 deletions apps/webapp/app/components/LoginPageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,13 +51,6 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
<a href="https://trigger.dev">
<LogoType className="w-36" />
</a>
<LinkButton
to="https://trigger.dev/docs"
variant={"tertiary/small"}
LeadingIcon={BookOpenIcon}
>
Documentation
</LinkButton>
</div>
<div className="flex h-full max-w-sm items-center justify-center">{children}</div>
<Paragraph variant="small" className="text-center">
Expand Down
118 changes: 93 additions & 25 deletions apps/webapp/app/routes/login._index/route.tsx
Comment thread
samejr marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
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";
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";
Expand All @@ -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();

Expand Down Expand Up @@ -143,6 +159,22 @@ export async function loader({ request }: LoaderFunctionArgs) {

export default function LoginPage() {
const data = useTypedLoaderData<typeof loader>();
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 (
<LoginPageLayout>
Expand Down Expand Up @@ -200,44 +232,80 @@ export default function LoginPage() {
</Form>
</div>
)}
{!data.isVercelMarketplace && (
{data.showSsoAuth && !data.isVercelMarketplace && (
<div className="relative w-full">
{data.lastAuthMethod === "email" && <LastUsedBadge />}
{data.lastAuthMethod === "sso" && <LastUsedBadge />}
<LinkButton
to={
data.redirectTo
? `/login/magic?redirectTo=${encodeURIComponent(data.redirectTo)}`
: "/login/magic"
? `/login/sso?redirectTo=${encodeURIComponent(data.redirectTo)}`
: "/login/sso"
}
variant="secondary/extra-large"
fullWidth
data-action="continue with email"
data-action="continue with sso"
className="text-text-bright"
>
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
Continue with Email
<LockClosedIcon className="mr-2 size-5 text-text-bright" />
Continue with SSO
</LinkButton>
</div>
)}
{data.showSsoAuth && !data.isVercelMarketplace && (
<div className="flex w-full flex-col items-center gap-y-2 pt-2">
<div className="h-px w-full bg-charcoal-700" />
<div className="relative inline-flex items-center">
{data.lastAuthMethod === "sso" && <LastUsedBadge className="translate-x-2" />}
<TextLink
to={
data.redirectTo
? `/login/sso?redirectTo=${encodeURIComponent(data.redirectTo)}`
: "/login/sso"
}
className="inline-flex items-center text-sm"
data-action="continue with sso"
{!data.isVercelMarketplace && (
<>
{(data.showGithubAuth || data.showGoogleAuth || data.showSsoAuth) && (
<div className="flex w-full items-center gap-3 py-1">
<div className="h-px flex-1 bg-charcoal-700" />
<span className="text-xs uppercase text-text-dimmed">or</span>
<div className="h-px flex-1 bg-charcoal-700" />
</div>
)}
<div className="w-full">
{/* Posts to the /login/magic action so all magic-link logic
(rate limiting, SSO auto-discovery, send) stays in one place. */}
<Form
action="/login/magic"
method="post"
className="w-full"
{...getFormProps(emailForm)}
>
<LockClosedIcon className="mr-1.5 size-4" />
Sign in with SSO
</TextLink>
<input type="hidden" name="action" value="send" />
<div className="flex w-full flex-col items-center gap-y-2">
<InputGroup fullWidth>
<Input
{...getInputProps(emailFields.email, { type: "email" })}
spellCheck={false}
placeholder="Email Address"
variant="large"
data-action="email address"
/>
<FormError id={emailFields.email.errorId}>
{emailFields.email.errors}
</FormError>
</InputGroup>
<div className="relative w-full">
{data.lastAuthMethod === "email" && <LastUsedBadge />}
<Button
type="submit"
variant="primary/large"
disabled={isEmailLoading}
fullWidth
data-action="continue with email"
>
{isEmailLoading ? (
<Spinner className="mr-2 size-5" color="white" />
) : (
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
)}
<span className="text-text-bright">
{isEmailLoading ? "Sending…" : "Continue with Email"}
</span>
</Button>
</div>
</div>
</Form>
</div>
</div>
</>
)}
{data.authError && <FormError>{data.authError}</FormError>}
</div>
Expand Down
Loading