diff --git a/app/routes/dashboard.course.$lessonId.edit.tsx b/app/routes/dashboard.course.$lessonId.edit.tsx index a9dd4a4..7b98489 100644 --- a/app/routes/dashboard.course.$lessonId.edit.tsx +++ b/app/routes/dashboard.course.$lessonId.edit.tsx @@ -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) @@ -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 diff --git a/app/routes/dashboard.transactions.$transactionId.$action.tsx b/app/routes/dashboard.transactions.$transactionId.$action.tsx index 2876f83..ae5d044 100644 --- a/app/routes/dashboard.transactions.$transactionId.$action.tsx +++ b/app/routes/dashboard.transactions.$transactionId.$action.tsx @@ -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) { @@ -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 diff --git a/app/routes/dashboard.transactions.$transactionId.tsx b/app/routes/dashboard.transactions.$transactionId.tsx index 4959883..5345a10 100644 --- a/app/routes/dashboard.transactions.$transactionId.tsx +++ b/app/routes/dashboard.transactions.$transactionId.tsx @@ -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 diff --git a/app/routes/login.tsx b/app/routes/login.tsx index f49d003..55d44a3 100644 --- a/app/routes/login.tsx +++ b/app/routes/login.tsx @@ -7,7 +7,10 @@ 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 @@ -15,6 +18,9 @@ export const loader: LoaderFunction = async ({ request }) => { const error = session.get(auth.sessionErrorKey) as | { message: string } | undefined + + session.set('redirectTo', redirectTo) + return json( { user: session.get('user'), @@ -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)}`, }) } diff --git a/app/routes/magic.tsx b/app/routes/magic.tsx index 04c0136..dc4dc20 100644 --- a/app/routes/magic.tsx +++ b/app/routes/magic.tsx @@ -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. diff --git a/app/services/__tests__/auth.server.test.ts b/app/services/__tests__/auth.server.test.ts new file mode 100644 index 0000000..1ea997d --- /dev/null +++ b/app/services/__tests__/auth.server.test.ts @@ -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' + ) + }) +}) diff --git a/app/utils/__tests__/permissions.test.ts b/app/utils/__tests__/permissions.test.ts new file mode 100644 index 0000000..0abbf48 --- /dev/null +++ b/app/utils/__tests__/permissions.test.ts @@ -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 => + ({ + 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 => + ({ + 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, + subscriptions: Partial[] = [] +): UserWithSubscriptions => { + return { + ...createMockUser(userOverrides), + subscriptions: subscriptions.map((sub) => createMockSubscription(sub)), + } as UserWithSubscriptions +} + +const createMockCourse = (overrides: Partial): 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) + }) + }) +}) diff --git a/e2e/auth-redirect.spec.ts b/e2e/auth-redirect.spec.ts new file mode 100644 index 0000000..cb0d683 --- /dev/null +++ b/e2e/auth-redirect.spec.ts @@ -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') + }) + }) +}) diff --git a/e2e/permission-redirect.spec.ts b/e2e/permission-redirect.spec.ts new file mode 100644 index 0000000..b0660b1 --- /dev/null +++ b/e2e/permission-redirect.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from './base-test' +import { authFixtures } from './fixtures' + +test.describe('Permission redirect for insufficient permissions', () => { + test.describe('Regular member accessing author-only routes', () => { + test.use({ + storageState: authFixtures.member, + }) + + test('redirects to dashboard home when accessing /dashboard/transactions', async ({ + page, + }) => { + await page.goto('/dashboard/transactions') + + await page.waitForURL('**/dashboard') + expect(page.url()).not.toContain('/dashboard/transactions') + expect(page.url()).toContain('/dashboard') + }) + + test('redirects to dashboard home when accessing /dashboard/transactions?status=submitted', async ({ + page, + }) => { + await page.goto('/dashboard/transactions?status=submitted') + + await page.waitForURL('**/dashboard') + expect(page.url()).not.toContain('/dashboard/transactions') + }) + }) + + test.describe('Author accessing author-only routes', () => { + test.use({ + storageState: authFixtures.author, + }) + + test('allows access to /dashboard/transactions', async ({ page }) => { + await page.goto('/dashboard/transactions') + + await expect( + page.getByRole('heading', { name: 'Transaksi' }) + ).toBeVisible() + expect(page.url()).toContain('/dashboard/transactions') + }) + }) + + test.describe('Admin accessing all routes', () => { + test.use({ + storageState: authFixtures.admin, + }) + + test('allows access to /dashboard/transactions', async ({ page }) => { + await page.goto('/dashboard/transactions') + + await expect( + page.getByRole('heading', { name: 'Transaksi' }) + ).toBeVisible() + expect(page.url()).toContain('/dashboard/transactions') + }) + }) +}) diff --git a/playwright-global-setup.ts b/playwright-global-setup.ts index 0c9058b..1f05a8f 100644 --- a/playwright-global-setup.ts +++ b/playwright-global-setup.ts @@ -42,6 +42,9 @@ async function globalSetup() { // course author await loginAs(browser, 'author') + // admin + await loginAs(browser, 'admin') + await browser.close() }