Skip to content

Commit

Permalink
magic link login
Browse files Browse the repository at this point in the history
  • Loading branch information
nichtsam committed Jan 26, 2025
1 parent 5933d0c commit 3e20f9d
Show file tree
Hide file tree
Showing 12 changed files with 1,494 additions and 11 deletions.
11 changes: 8 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# set this to true to prevent search engines from indexing the website
# default to false for seo safety
DISALLOW_INDEXING="false"

# generate with `openssl rand -hex 32`
SESSION_SECRET="session_secret"
CSRF_SECRET="csrf_secret"
MAGIC_LINK_SECRET="magic_link_secret"

# Github > Settings > Developer settings > New App
# or simply https://github.com/settings/developers
Expand All @@ -18,9 +23,9 @@ TURSO_DB_URL="file:local/dev.db"
# turso db tokens create <database-name>
TURSO_DB_AUTH_TOKEN="MOCK_TURSO_DB_AUTH_TOKEN"

# set this to true to prevent search engines from indexing the website
# default to false for seo safety
DISALLOW_INDEXING="false"
# Resend > API Keys > Create API Key
# https://resend.com/api-keys
RESEND_API_KEY="MOCK_RESEND_API_KEY"

# Sentry > Project Setting > SDK Setup > Client Keys
SENTRY_DNS="your-dns"
48 changes: 48 additions & 0 deletions app/components/emails/magic-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
Html,
Head,
Preview,
Body,
Container,
Heading,
Link,
Text,
} from '@react-email/components'
import { type User } from '#drizzle/schema.ts'

export namespace MagicLink {
export type Props = {
magicLink: string
user?: User
}
}
export function MagicLinkEmail({ magicLink, user }: MagicLink.Props) {
return (
<Html>
<Head />
<Preview>Log in or sign up with your magic link.</Preview>
<Body>
<Container>
<Heading as="h2">
{user
? `Hey ${user.display_name}! Welcome back!`
: `Hey there! Welcome!`}
</Heading>

<Heading as="h3">🎩 Here's your magic link</Heading>
<Link href={magicLink}>👉 {user ? 'Log In' : 'Sign Up'}</Link>
<Text>
If you didn’t request this email, you can safely ignore it.
</Text>
<Text>
This link is only valid for ten minutes.
<br />
If it has expired, you can request a new one.
</Text>
</Container>
</Body>
</Html>
)
}

export default MagicLinkEmail
2 changes: 1 addition & 1 deletion app/components/status-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface StatusButtonProps extends ButtonProps {
export const StatusButton = forwardRef<HTMLButtonElement, StatusButtonProps>(
({ status, className, children, ...props }, ref) => {
const statusIcon = {
success: <Icon name="check-circled" />,
success: <Icon name="check-circled" className="text-green-600" />,
pending: <Icon name="update" className="animate-spin" />,
error: (
<Icon
Expand Down
29 changes: 29 additions & 0 deletions app/components/ui/separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"

Check warning on line 2 in app/components/ui/separator.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

`@radix-ui/react-separator` import should occur before import of `react`

import { cn } from "#app/utils/ui.ts"

const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName

export { Separator }
92 changes: 92 additions & 0 deletions app/routes/_auth+/auth.magic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { redirect } from 'react-router'
import { getUserId, login } from '#app/utils/auth/auth.server.ts'
import { createAuthenticator } from '#app/utils/auth/magic-link.server.ts'
import { onboardingCookie } from '#app/utils/auth/onboarding.server.ts'
import { db } from '#app/utils/db.server.ts'
import { getRedirect } from '#app/utils/redirect.server.ts'
import { mergeHeaders } from '#app/utils/request.server.ts'
import { ServerTiming } from '#app/utils/timings.server.ts'
import { redirectWithToast } from '#app/utils/toast.server.ts'
import { type Route } from './+types/auth.magic'

export const loader = async ({ request }: Route.LoaderArgs) => {
const authenticator = createAuthenticator(request)
const timing = new ServerTiming()

timing.time('authenticate email', 'Authenticate email')
const authResult = await authenticator
.authenticate('email-link', request)
.then(
(data) =>
({
success: true,
data,
}) as const,
(error) =>
({
success: false,
error,
}) as const,
)
timing.timeEnd('authenticate email')

if (!authResult.success) {
console.error(authResult.error)

throw redirect('/login')
}

const email = authResult.data

const { redirectTo, discardHeaders } = getRedirect(request) ?? {}
const headers = new Headers()
mergeHeaders(headers, discardHeaders)

timing.time('get user id', 'Get user id in database')
const userId = await getUserId(request)
timing.timeEnd('get user id')

// logged in
if (userId) {
headers.append('Server-Timing', timing.toString())
return redirectWithToast(
'/settings/profile',
{
type: 'error',
title: 'Already Signed In',
message: `You are already signed in with an account.`,
},
{ headers },
)
}

timing.time('get email owner', 'Get email owner in database')
const emailOwner = await db.query.userTable.findFirst({
where: (userTable, { eq }) => eq(userTable.email, email),
})
timing.timeEnd('get email owner')

if (emailOwner) {
headers.append('Server-Timing', timing.toString())
return await login(
{
request,
redirectTo,
userId: emailOwner.id,
},
{ headers },
)
}

// real new user, send to onboarding
headers.append(
'set-cookie',
await onboardingCookie.serialize({
type: 'magic-link',
email,
}),
)

headers.append('Server-Timing', timing.toString())
throw redirect('/onboarding', { headers })
}
91 changes: 90 additions & 1 deletion app/routes/_auth+/login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { type SEOHandle } from '@nasa-gcn/remix-seo'
import { useSearchParams } from 'react-router'
import {
data,
Form,
redirect,

Check warning on line 7 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

'redirect' is defined but never used. Allowed unused vars must match /^ignored/u
useActionData,
useLoaderData,

Check warning on line 9 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

'useLoaderData' is defined but never used. Allowed unused vars must match /^ignored/u
useSearchParams,
} from 'react-router'
import { z } from 'zod'
import { Field } from '#app/components/forms.tsx'
import { StatusButton } from '#app/components/status-button.tsx'
import {
Card,
CardContent,
Expand All @@ -12,7 +24,13 @@ import {
ProviderConnectionForm,
providerNames,
} from '#app/utils/auth/connections.tsx'
import { createAuthenticator } from '#app/utils/auth/magic-link.server.ts'
import { useIsPending } from '#app/utils/ui.ts'
import { type Route } from './+types/login'
import { parse } from 'cookie'

Check warning on line 30 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

`cookie` import should occur before import of `react-router`

Check warning on line 30 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

'parse' is defined but never used. Allowed unused vars must match /^ignored/u
import { Separator } from '#app/components/ui/separator.tsx'

Check warning on line 31 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

`#app/components/ui/separator.tsx` import should occur before import of `#app/utils/auth/auth.server.ts`
import { createToastHeaders, getToast } from '#app/utils/toast.server.ts'

Check warning on line 32 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

`#app/utils/toast.server.ts` import should occur before import of `#app/utils/ui.ts`

Check warning on line 32 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

'getToast' is defined but never used. Allowed unused vars must match /^ignored/u
import { combineHeaders } from '#app/utils/request.server.ts'

Check warning on line 33 in app/routes/_auth+/login.tsx

View workflow job for this annotation

GitHub Actions / 🧶 ESLint

`#app/utils/request.server.ts` import should occur before import of `#app/utils/ui.ts`

export const handle: SEOHandle = {
getSitemapEntries: () => null,
Expand All @@ -34,6 +52,37 @@ export async function loader({ request }: Route.LoaderArgs) {
return null
}

export async function action({ request }: Route.ActionArgs) {
const formData = await request.clone().formData()
const submission = parseWithZod(formData, {
schema: MagicLinkLoginSchema,
})

if (submission.status !== 'success') {
return data(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : 200 },
)
}

const authenticator = createAuthenticator(request)
const authHeaders = (await authenticator
.authenticate('email-link', request)
.catch((headers) => headers)) as Headers

const toastHeaders = await createToastHeaders({
title: '✨ Magic Link has been sent',
message: `sent to ${submission.value.email}`,
})

return data(
{ result: submission.reply() },
{
headers: combineHeaders(authHeaders, toastHeaders),
},
)
}

export default function Login() {
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
Expand All @@ -46,6 +95,8 @@ export default function Login() {
<CardDescription>Choose your path</CardDescription>
</CardHeader>
<CardContent>
<MagicLinkLogin />
<Separator className="my-4" />
<ul className="flex flex-col gap-y-2">
{providerNames.map((providerName) => (
<li key={providerName}>
Expand All @@ -61,3 +112,41 @@ export default function Login() {
</div>
)
}

export const MagicLinkLoginSchema = z.object({
email: z.string().email(),
})

function MagicLinkLogin() {
const isPending = useIsPending()
const actionData = useActionData<typeof action>()
const [form, fields] = useForm({
id: 'magic-link-login-form',
constraint: getZodConstraint(MagicLinkLoginSchema),
lastResult: actionData?.result,
shouldRevalidate: 'onBlur',
onValidate: ({ formData }) =>
parseWithZod(formData, { schema: MagicLinkLoginSchema }),
})

return (
<Form method="post" {...getFormProps(form)}>
<Field
labelProps={{ children: 'Email' }}
inputProps={{
...getInputProps(fields.email, { type: 'email' }),
autoComplete: 'email',
}}
errors={fields.email.errors}
/>
<StatusButton
type="submit"
className="w-full"
status={isPending ? 'pending' : (form.status ?? 'idle')}
disabled={isPending}
>
Email a login link
</StatusButton>
</Form>
)
}
24 changes: 24 additions & 0 deletions app/utils/auth/magic-link.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { EmailLinkStrategy } from '@nichtsam/remix-auth-email-link'
import { Authenticator } from 'remix-auth'
import { sendMagicLinkEmail } from '../email.server'
import { env } from '../env.server'
import { getDomainUrl } from '../request.server'

export const createAuthenticator = (request: Request) => {
const authenticator = new Authenticator<string>()
const magicEndpoint = new URL('/auth/magic', getDomainUrl(request))
authenticator.use(
new EmailLinkStrategy(
{
secret: env.MAGIC_LINK_SECRET,
shouldValidateSessionMagicLink: true,
magicEndpoint,
sendEmail: ({ email, magicLink }) =>
sendMagicLinkEmail({ email, magicLink, domain: magicEndpoint.host }),
},
async ({ email }) => email,
),
)

return authenticator
}
34 changes: 34 additions & 0 deletions app/utils/email.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render } from '@react-email/components'
import { Resend } from 'resend'
import MagicLinkEmail from '#app/components/emails/magic-link.tsx'
import { db } from './db.server'
import { env } from './env.server'

const resend = new Resend(env.RESEND_API_KEY)

export async function sendMagicLinkEmail({
email,
magicLink,
}: {
email: string
magicLink: string
domain: string
}) {
const subject = `🎩 Here's your magic link for nichtsam.com`
const from = 'nichtsam <[email protected]>'
const to = email

const user = await db.query.userTable.findFirst({
where: (record, { eq }) => eq(record.email, email),
})

const react = <MagicLinkEmail magicLink={magicLink} user={user} />

await resend.emails.send({
subject,
from,
to,
react,
text: await render(react, { plainText: true }),
})
}
Loading

0 comments on commit 3e20f9d

Please sign in to comment.