Skip to content
Merged
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
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