Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 2 additions & 2 deletions app/routes/dashboard.course.$lessonId.edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const loader: LoaderFunction = async ({ request, params }) => {
}

if (!requireCourseAuthor(user, course)) {
return redirect(`/dashboard/course/${lessonId}`)
return redirect('/dashboard')
}

const lesson = await getLessonById(lessonId)
Expand All @@ -40,7 +40,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const course = await getFirstCourse()

if (!requireCourseAuthor(user, course)) {
return redirect('/dashboard/transactions')
return redirect('/dashboard')
}

const { lessonId } = params
Expand Down
12 changes: 9 additions & 3 deletions app/routes/dashboard.transactions.$transactionId.$action.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,14 @@ import {
} from '~/models/subscription'
import { Field } from '~/components/form-elements'

export const loader: LoaderFunction = async ({ params }) => {
// TODO: block if the current user is not an admin or the author of the course
export const loader: LoaderFunction = async ({ request, params }) => {
const user = await requireUser(request)
const course = await getFirstCourse()

if (!requireCourseAuthor(user, course)) {
return redirect('/dashboard')
}

const { transactionId } = params

if (!transactionId) {
Expand All @@ -50,7 +56,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const course = await getFirstCourse()

if (!requireCourseAuthor(user, course)) {
return redirect('/dashboard/transactions')
return redirect('/dashboard')
}

const { transactionId } = params
Expand Down
2 changes: 1 addition & 1 deletion app/routes/dashboard.transactions.$transactionId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const loader: LoaderFunction = async ({ request, params }) => {
const course = await getFirstCourse()

if (!requireCourseAuthor(user, course)) {
return redirect('/dashboard/transactions')
return redirect('/dashboard')
}

const { transactionId } = params
Expand Down
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'
)
})
})
180 changes: 180 additions & 0 deletions app/utils/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { User, Course, Subscription } from '@prisma/client'
import { describe, it, expect } from 'vitest'
import {
requireAdmin,
requireAuthor,
requireCourseAuthor,
requireActiveSubscription,
} from '../permissions'
import { ROLES, SUBSCRIPTION_STATUS } from '~/models/enum'
import { UserWithSubscriptions } from '~/models/user'

const createMockUser = (overrides: Partial<User>): User =>
({
id: '1',
createdAt: new Date(),
updatedAt: new Date(),
name: 'Test User',
email: 'test@example.com',
phoneNumber: null,
telegram: null,
instagram: null,
role: ROLES.MEMBER,
...overrides,
} as User)

const createMockSubscription = (
overrides: Partial<Subscription>
): Subscription =>
({
id: 'sub-1',
createdAt: new Date(),
updatedAt: new Date(),
authorId: 'author-1',
courseId: 'course-1',
userId: 'user-1',
status: SUBSCRIPTION_STATUS.ACTIVE,
...overrides,
} as Subscription)

const createMockUserWithSubscriptions = (
userOverrides: Partial<User>,
subscriptions: Partial<Subscription>[] = []
): UserWithSubscriptions => {
return {
...createMockUser(userOverrides),
subscriptions: subscriptions.map((sub) => createMockSubscription(sub)),
} as UserWithSubscriptions
}

const createMockCourse = (overrides: Partial<Course>): Course =>
({
id: 'course-1',
createdAt: new Date(),
updatedAt: new Date(),
name: 'Test Course',
description: null,
price: 100000,
authorId: '2',
...overrides,
} as Course)

describe('Permission functions', () => {
const adminUser = createMockUser({ id: '1', role: ROLES.ADMIN })
const authorUser = createMockUser({ id: '2', role: ROLES.AUTHOR })
const memberUser = createMockUser({ id: '3', role: ROLES.MEMBER })

const course = createMockCourse({ id: 'course-1', authorId: '2' })
const otherCourse = createMockCourse({ id: 'course-2', authorId: '99' })

describe('requireAdmin', () => {
it('returns true for admin user', () => {
expect(requireAdmin(adminUser)).toBe(true)
})

it('returns false for author user', () => {
expect(requireAdmin(authorUser)).toBe(false)
})

it('returns false for member user', () => {
expect(requireAdmin(memberUser)).toBe(false)
})
})

describe('requireAuthor', () => {
it('returns true for admin user', () => {
expect(requireAuthor(adminUser)).toBe(true)
})

it('returns true for author user', () => {
expect(requireAuthor(authorUser)).toBe(true)
})

it('returns false for member user', () => {
expect(requireAuthor(memberUser)).toBe(false)
})
})

describe('requireCourseAuthor', () => {
it('returns true for admin regardless of course', () => {
expect(requireCourseAuthor(adminUser, course)).toBe(true)
expect(requireCourseAuthor(adminUser, otherCourse)).toBe(true)
expect(requireCourseAuthor(adminUser)).toBe(true)
})

it('returns true for author of the course', () => {
expect(requireCourseAuthor(authorUser, course)).toBe(true)
})

it('returns false for author of a different course', () => {
expect(requireCourseAuthor(authorUser, otherCourse)).toBe(false)
})

it('returns false for member user', () => {
expect(requireCourseAuthor(memberUser, course)).toBe(false)
})
})

describe('requireActiveSubscription', () => {
const memberWithActiveSubscription = createMockUserWithSubscriptions(
{ id: '3', role: ROLES.MEMBER },
[{ courseId: 'course-1', status: SUBSCRIPTION_STATUS.ACTIVE }]
)

const memberWithInactiveSubscription = createMockUserWithSubscriptions(
{ id: '3', role: ROLES.MEMBER },
[{ courseId: 'course-1', status: SUBSCRIPTION_STATUS.INACTIVE }]
)

const memberWithNoSubscription = createMockUserWithSubscriptions(
{ id: '3', role: ROLES.MEMBER },
[]
)

const authorWithSubscriptions = createMockUserWithSubscriptions(
{ id: '2', role: ROLES.AUTHOR },
[]
)

const adminWithSubscriptions = createMockUserWithSubscriptions(
{ id: '1', role: ROLES.ADMIN },
[]
)

it('returns true for admin regardless of subscription', () => {
expect(requireActiveSubscription(adminWithSubscriptions, course)).toBe(
true
)
})

it('returns true for course author regardless of subscription', () => {
expect(requireActiveSubscription(authorWithSubscriptions, course)).toBe(
true
)
})

it('returns true for member with active subscription to the course', () => {
expect(
requireActiveSubscription(memberWithActiveSubscription, course)
).toBe(true)
})

it('returns false for member with inactive subscription', () => {
expect(
requireActiveSubscription(memberWithInactiveSubscription, course)
).toBe(false)
})

it('returns false for member with no subscription', () => {
expect(requireActiveSubscription(memberWithNoSubscription, course)).toBe(
false
)
})

it('returns false for member with subscription to different course', () => {
expect(
requireActiveSubscription(memberWithActiveSubscription, otherCourse)
).toBe(false)
})
})
})
Loading
Loading