Skip to content

Commit 997d819

Browse files
committed
feat(frontend,backend): redesign UI to header-based nav with lesson access control
UI/UX Redesign: - Replace sidebar navigation with header-integrated navigation (Udemy/Coursera style) - Add PageContainer component for consistent max-w-7xl centered layout - Add professional Footer with branding, links, newsletter, and social icons - Implement user dropdown menu with role-based navigation sections - Add mobile hamburger menu with full navigation support Lesson Access Control (Security Fix): - Add backend GET /courses/:id/lessons/:lessonId endpoint with enrollment check - Add getLessonWithAccessControl() in CoursesService - Implement frontend redirect for non-enrolled users attempting to access locked lessons - Allow free preview lessons for non-enrolled users (isFreePreview check) - Show lock icons and "Preview" badges in lesson sidebar Files Changed: - Delete sidebar.tsx, rewrite header.tsx (~966 lines) - Simplify app-shell.tsx, add PageContainer export - Create footer.tsx with Udemy/Coursera-inspired design - Update all pages to use PageContainer wrapper - Update lesson-viewer-page.tsx with access control logic - Update all E2E tests to work with new navigation structure All 171 frontend tests and 215 backend tests passing.
1 parent 777d0ab commit 997d819

22 files changed

+2430
-1926
lines changed

apps/api/src/courses/courses.controller.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from './courses.service';
1717
import { CourseLevel, Course } from './entities/course.entity';
1818
import { Enrollment } from './entities/enrollment.entity';
19+
import { Lesson } from './entities/lesson.entity';
1920
import { CurrentUser } from '../auth/decorators/current-user.decorator';
2021
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
2122
import { User } from '../users/entities/user.entity';
@@ -108,6 +109,24 @@ export class CoursesController {
108109
return { isEnrolled: Boolean(enrollment), progress: enrollment };
109110
}
110111

112+
/**
113+
* Get a specific lesson with access control
114+
* Returns lesson content only if user is enrolled or lesson is free preview
115+
*/
116+
@Get(':id/lessons/:lessonId')
117+
@UseGuards(JwtAuthGuard)
118+
async getLesson(
119+
@Param('id') courseId: string,
120+
@Param('lessonId') lessonId: string,
121+
@CurrentUser() user: User,
122+
): Promise<{ lesson: Lesson; hasAccess: boolean }> {
123+
return this.coursesService.getLessonWithAccessControl(
124+
user.id,
125+
courseId,
126+
lessonId,
127+
);
128+
}
129+
111130
@Post(':id/lessons/:lessonId/complete')
112131
@UseGuards(JwtAuthGuard)
113132
async completeLesson(

apps/api/src/courses/courses.service.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
55
import { CoursesService } from './courses.service';
66
import { Course, CourseLevel } from './entities/course.entity';
77
import { Enrollment } from './entities/enrollment.entity';
8+
import { Lesson } from './entities/lesson.entity';
89

910
interface MockEnrollmentRepository {
1011
find: jest.Mock;
@@ -20,6 +21,14 @@ const mockEnrollmentRepositoryValue: MockEnrollmentRepository = {
2021
save: jest.fn(),
2122
};
2223

24+
interface MockLessonRepository {
25+
findOne: jest.Mock;
26+
}
27+
28+
const mockLessonRepositoryValue: MockLessonRepository = {
29+
findOne: jest.fn(),
30+
};
31+
2332
interface MockCourseRepository {
2433
createQueryBuilder: jest.Mock;
2534
find: jest.Mock;
@@ -89,6 +98,10 @@ describe('CoursesService', () => {
8998
provide: getRepositoryToken(Enrollment),
9099
useValue: mockEnrollmentRepositoryValue,
91100
},
101+
{
102+
provide: getRepositoryToken(Lesson),
103+
useValue: mockLessonRepositoryValue,
104+
},
92105
],
93106
}).compile();
94107

apps/api/src/courses/courses.service.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Injectable,
33
NotFoundException,
44
ConflictException,
5+
ForbiddenException,
56
} from '@nestjs/common';
67
import { InjectRepository } from '@nestjs/typeorm';
78

@@ -10,6 +11,7 @@ import { Repository, Brackets } from 'typeorm';
1011
import { CourseFilterOptions } from './dto/filter-course.dto';
1112
import { Course } from './entities/course.entity';
1213
import { Enrollment } from './entities/enrollment.entity';
14+
import { Lesson } from './entities/lesson.entity';
1315

1416
export interface EnrolledCourseDto {
1517
id: string;
@@ -58,6 +60,8 @@ export class CoursesService {
5860
private coursesRepository: Repository<Course>,
5961
@InjectRepository(Enrollment)
6062
private enrollmentRepository: Repository<Enrollment>,
63+
@InjectRepository(Lesson)
64+
private lessonRepository: Repository<Lesson>,
6165
) {}
6266

6367
async findAllPublished(
@@ -227,6 +231,49 @@ export class CoursesService {
227231
return enrollment;
228232
}
229233

234+
/**
235+
* Get a specific lesson with access control
236+
* Returns full lesson content only if:
237+
* 1. User is enrolled in the course, OR
238+
* 2. Lesson is marked as free preview
239+
* Otherwise, throws ForbiddenException
240+
*/
241+
async getLessonWithAccessControl(
242+
userId: string,
243+
courseId: string,
244+
lessonId: string,
245+
): Promise<{ lesson: Lesson; hasAccess: boolean }> {
246+
// Find the lesson
247+
const lesson = await this.lessonRepository.findOne({
248+
where: { id: lessonId },
249+
relations: ['section', 'section.course'],
250+
});
251+
252+
if (!lesson) {
253+
throw new NotFoundException('Lesson not found');
254+
}
255+
256+
// Verify the lesson belongs to the specified course
257+
if (lesson.section.course.id !== courseId) {
258+
throw new NotFoundException('Lesson not found in this course');
259+
}
260+
261+
// Check if user is enrolled
262+
const enrollment = await this.checkEnrollment(userId, courseId);
263+
const isEnrolled = Boolean(enrollment);
264+
265+
// Check access: enrolled OR free preview
266+
const hasAccess = isEnrolled || lesson.isFreePreview;
267+
268+
if (!hasAccess) {
269+
throw new ForbiddenException(
270+
'You must enroll in this course to access this lesson',
271+
);
272+
}
273+
274+
return { lesson, hasAccess: true };
275+
}
276+
230277
/**
231278
* Get all enrolled courses for a user with progress calculation
232279
*/
Lines changed: 26 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,36 @@
1-
import { useState } from 'react';
2-
31
import { Outlet } from 'react-router-dom';
42

3+
import { Footer } from './footer';
54
import { Header } from './header';
6-
import { Sidebar } from './sidebar';
75

86
export function AppShell() {
9-
const [sidebarOpen, setSidebarOpen] = useState(false);
10-
11-
const handleOverlayClick = () => {
12-
setSidebarOpen(false);
13-
};
14-
15-
const handleOverlayKeyDown = (e: React.KeyboardEvent) => {
16-
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') {
17-
setSidebarOpen(false);
18-
}
19-
};
20-
217
return (
22-
<div className="flex min-h-screen bg-background text-foreground font-sans">
23-
{/* Mobile overlay */}
24-
{sidebarOpen && (
25-
<div
26-
className="fixed inset-0 bg-black/50 z-40 lg:hidden animate-fade-in"
27-
onClick={handleOverlayClick}
28-
onKeyDown={handleOverlayKeyDown}
29-
role="button"
30-
tabIndex={0}
31-
aria-label="Close sidebar"
32-
/>
33-
)}
8+
<div className="min-h-screen bg-background text-foreground font-sans flex flex-col">
9+
<Header />
10+
<main className="flex-1">
11+
<Outlet />
12+
</main>
13+
<Footer />
14+
</div>
15+
);
16+
}
3417

35-
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
36-
<div className="flex-1 flex flex-col min-w-0">
37-
<Header onMenuClick={() => setSidebarOpen(true)} />
38-
<main className="flex-1 p-4 sm:p-6 overflow-auto">
39-
<Outlet />
40-
</main>
41-
</div>
18+
/**
19+
* Container component for pages that need centered content with max-width.
20+
* Use this wrapper in individual pages for consistent Udemy/Coursera-style layout.
21+
*/
22+
export function PageContainer({
23+
children,
24+
className = '',
25+
}: {
26+
children: React.ReactNode;
27+
className?: string;
28+
}) {
29+
return (
30+
<div
31+
className={`mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 lg:py-8 ${className}`}
32+
>
33+
{children}
4234
</div>
4335
);
4436
}

0 commit comments

Comments
 (0)