Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
15 changes: 12 additions & 3 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ import { auth } from '~/services/auth.server'
import { commitSession, getUserSession } from '~/services/session.server'

export const loader: LoaderFunction = async ({ request }) => {
await auth.isAuthenticated(request, { successRedirect: '/dashboard' })
const url = new URL(request.url)
const redirectTo = url.searchParams.get('redirectTo') ?? '/dashboard'

await auth.isAuthenticated(request, { successRedirect: redirectTo })
const session = await getUserSession(request)
// This session key `auth:magiclink` is the default one used by the EmailLinkStrategy
// you can customize it passing a `sessionMagicLinkKey` when creating an
// instance.
const error = session.get(auth.sessionErrorKey) as
| { message: string }
| undefined

session.set('redirectTo', redirectTo)

return json(
{
user: session.get('user'),
Expand All @@ -30,14 +36,17 @@ export const loader: LoaderFunction = async ({ request }) => {
}

export const action: ActionFunction = async ({ request }) => {
const url = new URL(request.url)
const redirectTo = url.searchParams.get('redirectTo') ?? '/dashboard'

// The success redirect is required in this action, this is where the user is
// going to be redirected after the magic link is sent, note that here the
// user is not yet authenticated, so you can't send it to a private page.
await auth.authenticate('email-link', request, {
successRedirect: '/login',
successRedirect: `/login?redirectTo=${encodeURIComponent(redirectTo)}`,
// If this is not set, any error will be throw and the ErrorBoundary will be
// rendered.
failureRedirect: '/login',
failureRedirect: `/login?redirectTo=${encodeURIComponent(redirectTo)}`,
})
}

Expand Down
12 changes: 7 additions & 5 deletions app/routes/magic.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { LoaderFunction } from '@remix-run/node'
import { auth } from '~/services/auth.server'
import { getUserSession } from '~/services/session.server'

export const loader: LoaderFunction = async ({ request }) => {
const session = await getUserSession(request)
const redirectTo = (session.get('redirectTo') as string) || '/dashboard'

await auth.authenticate('email-link', request, {
// If the user was authenticated, we redirect them to their profile page
// This redirect is optional, if not defined the user will be returnted by
// the `authenticate` function and you can render something on this page
// manually redirect the user.
successRedirect: '/dashboard',
// If the user was authenticated, we redirect them to their saved redirectTo
// or dashboard as fallback.
successRedirect: redirectTo,
// If something failed we take them back to the login page
// This redirect is optional, if not defined any error will be throw and
// the ErrorBoundary will be rendered.
Expand Down
79 changes: 79 additions & 0 deletions app/services/__tests__/auth.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'

describe('Auth redirect URL construction', () => {
it('should construct correct login URL with redirectTo param from pathname', () => {
const request = new Request('http://localhost:3000/dashboard/courses')
const redirectTo = new URL(request.url).pathname

const searchParams = new URLSearchParams([['redirectTo', redirectTo]])
const loginUrl = `/login?${searchParams}`

expect(loginUrl).toBe('/login?redirectTo=%2Fdashboard%2Fcourses')
})

it('should use custom redirectTo when provided', () => {
const redirectTo = '/custom/path'

const searchParams = new URLSearchParams([['redirectTo', redirectTo]])
const loginUrl = `/login?${searchParams}`

expect(loginUrl).toBe('/login?redirectTo=%2Fcustom%2Fpath')
})

it('should handle root path', () => {
const request = new Request('http://localhost:3000/')
const redirectTo = new URL(request.url).pathname

const searchParams = new URLSearchParams([['redirectTo', redirectTo]])
const loginUrl = `/login?${searchParams}`

expect(loginUrl).toBe('/login?redirectTo=%2F')
})

it('should handle paths with query parameters', () => {
const redirectTo = '/dashboard/courses?filter=active'

const searchParams = new URLSearchParams([['redirectTo', redirectTo]])
const loginUrl = `/login?${searchParams}`

expect(loginUrl).toContain('redirectTo=')
expect(decodeURIComponent(loginUrl)).toContain(
'/dashboard/courses?filter=active'
)
})
})

describe('Login redirect URL preservation', () => {
it('should extract redirectTo from URL search params', () => {
const url = new URL(
'http://localhost:3000/login?redirectTo=/dashboard/profile'
)
const redirectTo = url.searchParams.get('redirectTo') ?? '/dashboard'

expect(redirectTo).toBe('/dashboard/profile')
})

it('should default to /dashboard when redirectTo is not provided', () => {
const url = new URL('http://localhost:3000/login')
const redirectTo = url.searchParams.get('redirectTo') ?? '/dashboard'

expect(redirectTo).toBe('/dashboard')
})

it('should preserve redirectTo in success/failure redirects', () => {
const redirectTo = '/dashboard/transactions'
const successRedirect = `/login?redirectTo=${encodeURIComponent(
redirectTo
)}`
const failureRedirect = `/login?redirectTo=${encodeURIComponent(
redirectTo
)}`

expect(successRedirect).toBe(
'/login?redirectTo=%2Fdashboard%2Ftransactions'
)
expect(failureRedirect).toBe(
'/login?redirectTo=%2Fdashboard%2Ftransactions'
)
})
})
58 changes: 58 additions & 0 deletions e2e/auth-redirect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect } from './base-test'
import { authFixtures } from './fixtures'

test.describe('Auto-redirect after authentication', () => {
test.describe('Unauthenticated user', () => {
test.use({
storageState: authFixtures.public,
})

test('redirects to login with redirectTo param when accessing protected route', async ({
page,
}) => {
await page.goto('/dashboard/profile/edit')

await page.waitForURL('**/login**')
const url = new URL(page.url())
expect(url.pathname).toBe('/login')
expect(url.searchParams.get('redirectTo')).toBe('/dashboard/profile/edit')
})

test('login page preserves redirectTo in form action URL', async ({
page,
}) => {
await page.goto('/login?redirectTo=/dashboard/transactions')

await expect(page.getByText('Masuk ke akun Anda')).toBeVisible()

const currentUrl = new URL(page.url())
expect(currentUrl.searchParams.get('redirectTo')).toBe(
'/dashboard/transactions'
)
})
})

test.describe('Authenticated user', () => {
test.use({
storageState: authFixtures.member,
})

test('login page redirects to redirectTo param if already authenticated', async ({
page,
}) => {
await page.goto('/login?redirectTo=/dashboard/profile/edit')

await page.waitForURL('**/dashboard/profile/edit')
expect(page.url()).toContain('/dashboard/profile/edit')
})

test('login page redirects to dashboard by default if already authenticated', async ({
page,
}) => {
await page.goto('/login')

await page.waitForURL('**/dashboard')
expect(page.url()).toContain('/dashboard')
})
})
})
Loading