Skip to content

Commit a21e481

Browse files
committed
feat: implement core features including auth, courses, ai-quizzes, and admin dashboard
- Auth: implemented JWT strategies, guards, and login/register flows - Courses: added course management, lesson viewer, and instructor tools - Quizzes: integrated AI quiz generation and interactive quiz player - Admin: developed dashboard for user management and course moderation - UI: updated components with Tailwind CSS v4 and React 19 standards - E2E: added comprehensive tests for critical user flows - Infrastructure: configured CI/CD workflows and monorepo settings
1 parent c7c59d1 commit a21e481

File tree

85 files changed

+7471
-10285
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+7471
-10285
lines changed

.github/actions/setup-node-pnpm/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ inputs:
1414
pnpm-version:
1515
description: pnpm version
1616
required: false
17-
default: '10.24.0'
17+
default: '10.26.0'
1818
working-directory:
1919
description: Working directory for the project
2020
required: true

.github/workflows/security.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ jobs:
9090
- name: Setup pnpm
9191
uses: pnpm/action-setup@v4
9292
with:
93-
version: 10.24.0
93+
version: 10.26.0
9494

9595
- name: Audit ${{ matrix.project }}
9696
working-directory: ${{ matrix.project }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ node_modules
1717
coverage
1818
test-results/
1919
playwright-report/
20+
.jest-localstorage
2021

2122
# Turbo
2223
.turbo

apps/api/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
"start:prod": "node dist/main",
1414
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
1515
"typecheck": "tsc -p tsconfig.json --noEmit",
16-
"test": "jest",
16+
"test": "NODE_OPTIONS='--localstorage-file=.jest-localstorage' jest",
1717
"test:watch": "jest --watch",
1818
"test:cov": "jest --coverage",
1919
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
2020
"test:e2e": "jest --config ./test/jest-e2e.json",
2121
"seed:admin": "ts-node -r tsconfig-paths/register src/scripts/create-admin.ts"
2222
},
2323
"dependencies": {
24-
"@google/genai": "^1.31.0",
24+
"@google/genai": "^1.34.0",
2525
"@nestjs/common": "^11.1.9",
2626
"@nestjs/config": "^4.0.2",
2727
"@nestjs/core": "^11.1.9",
@@ -48,27 +48,27 @@
4848
"devDependencies": {
4949
"@eslint/compat": "^2.0.0",
5050
"@eslint/eslintrc": "^3.3.3",
51-
"@eslint/js": "^9.39.1",
51+
"@eslint/js": "^9.39.2",
5252
"@nestjs/cli": "^11.0.14",
5353
"@nestjs/schematics": "^11.0.9",
5454
"@nestjs/testing": "^11.1.9",
5555
"@swc-node/register": "^1.11.1",
5656
"@swc/cli": "^0.7.9",
57-
"@swc/core": "^1.15.3",
57+
"@swc/core": "^1.15.6",
5858
"@types/bcrypt": "^6.0.0",
5959
"@types/cookie-parser": "^1.4.10",
6060
"@types/express": "^5.0.6",
6161
"@types/express-serve-static-core": "^5.1.0",
6262
"@types/jest": "^30.0.0",
6363
"@types/ms": "^2.1.0",
6464
"@types/multer": "^2.0.0",
65-
"@types/node": "^24.10.1",
65+
"@types/node": "^25.0.3",
6666
"@types/nodemailer": "^7.0.4",
6767
"@types/passport-github2": "^1.2.9",
6868
"@types/passport-google-oauth20": "^2.0.17",
6969
"@types/passport-jwt": "^4.0.1",
7070
"@types/supertest": "^6.0.3",
71-
"eslint": "^9.39.1",
71+
"eslint": "^9.39.2",
7272
"eslint-config-prettier": "^10.1.8",
7373
"eslint-import-resolver-typescript": "^4.4.4",
7474
"eslint-plugin-import-x": "^4.16.1",
@@ -84,7 +84,7 @@
8484
"tsconfig-paths": "^4.2.0",
8585
"tsx": "^4.21.0",
8686
"typescript": "^5.9.3",
87-
"typescript-eslint": "^8.48.1"
87+
"typescript-eslint": "^8.50.0"
8888
},
8989
"jest": {
9090
"moduleFileExtensions": [

apps/api/src/auth/auth.controller.ts

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
Query,
1212
Patch,
1313
Delete,
14+
UnauthorizedException,
1415
} from '@nestjs/common';
1516
import { ConfigService } from '@nestjs/config';
1617

@@ -124,20 +125,33 @@ export class AuthController {
124125
@Req() req: Request,
125126
@Res() res: Response,
126127
): Promise<void> {
127-
const profile = req.user as unknown as OAuthProfile;
128-
const result = await this.authService.validateOAuthLogin(
129-
AuthProvider.GOOGLE,
130-
profile,
131-
);
132128
const frontendUrl = this.configService.get<string>('FRONTEND_URL');
133129

134-
// Set HTTP-only cookie - in monorepo deployment, this cookie works directly
135-
// since frontend and API are on the same domain
136-
res.cookie('access_token', result.access_token, COOKIE_OPTIONS);
137-
138-
// Redirect to frontend callback page
139-
// Token in URL is kept for backward compatibility and as fallback
140-
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
130+
try {
131+
const profile = req.user as unknown as OAuthProfile;
132+
const result = await this.authService.validateOAuthLogin(
133+
AuthProvider.GOOGLE,
134+
profile,
135+
);
136+
137+
// Set HTTP-only cookie - in monorepo deployment, this cookie works directly
138+
// since frontend and API are on the same domain
139+
res.cookie('access_token', result.access_token, COOKIE_OPTIONS);
140+
141+
// Redirect to frontend callback page
142+
// Token in URL is kept for backward compatibility and as fallback
143+
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
144+
} catch (error) {
145+
if (
146+
error instanceof UnauthorizedException &&
147+
error.message.includes('blocked')
148+
) {
149+
res.redirect(`${frontendUrl}/blocked`);
150+
} else {
151+
// Redirect to login with generic error for other issues
152+
res.redirect(`${frontendUrl}/login?error=oauth_failed`);
153+
}
154+
}
141155
}
142156

143157
@Get('github')
@@ -152,24 +166,42 @@ export class AuthController {
152166
@Req() req: Request,
153167
@Res() res: Response,
154168
): Promise<void> {
155-
const result = await this.authService.validateOAuthLogin(
156-
AuthProvider.GITHUB,
157-
req.user as unknown as OAuthProfile,
158-
);
159169
const frontendUrl = this.configService.get<string>('FRONTEND_URL');
160170

161-
// Set HTTP-only cookie - in monorepo deployment, this cookie works directly
162-
res.cookie('access_token', result.access_token, COOKIE_OPTIONS);
163-
164-
// Redirect to frontend callback page
165-
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
171+
try {
172+
const result = await this.authService.validateOAuthLogin(
173+
AuthProvider.GITHUB,
174+
req.user as unknown as OAuthProfile,
175+
);
176+
177+
// Set HTTP-only cookie - in monorepo deployment, this cookie works directly
178+
res.cookie('access_token', result.access_token, COOKIE_OPTIONS);
179+
180+
// Redirect to frontend callback page
181+
res.redirect(`${frontendUrl}/auth/callback?token=${result.access_token}`);
182+
} catch (error) {
183+
if (
184+
error instanceof UnauthorizedException &&
185+
error.message.includes('blocked')
186+
) {
187+
res.redirect(`${frontendUrl}/blocked`);
188+
} else {
189+
// Redirect to login with generic error for other issues
190+
res.redirect(`${frontendUrl}/login?error=oauth_failed`);
191+
}
192+
}
166193
}
167194

168195
@Get('me')
169196
@UseGuards(JwtAuthGuard)
170197
async getProfile(@CurrentUser() user: User): Promise<User> {
171198
// Fetch fresh user data from database to get updated role
172199
const freshUser = await this.authService.getUserById(user.id);
200+
201+
if (!freshUser.isActive) {
202+
throw new UnauthorizedException('Your account has been blocked.');
203+
}
204+
173205
return freshUser;
174206
}
175207

@@ -309,12 +341,15 @@ export class AuthController {
309341
user.id,
310342
UserRole.INSTRUCTOR,
311343
);
312-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
313-
const { password, activationToken, passwordResetToken, ...safeUser } =
314-
updatedUser;
344+
const {
345+
password: _p,
346+
activationToken: _at,
347+
passwordResetToken: _prt,
348+
...safeUser
349+
} = updatedUser;
315350
return {
316351
message: 'Successfully promoted to Instructor. Please return to the app.',
317-
user: safeUser,
352+
user: safeUser as SafeUser,
318353
};
319354
}
320355
}

apps/api/src/auth/auth.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ export class AuthService {
8686
);
8787
}
8888

89+
// Check if account is active (not blocked)
90+
if (!user.isActive) {
91+
throw new UnauthorizedException(
92+
'Your account has been blocked. Please contact support.',
93+
);
94+
}
95+
8996
const payload = { email: user.email, sub: user.id, role: user.role };
9097
return {
9198
access_token: this.jwtService.sign(payload),
@@ -255,6 +262,12 @@ export class AuthService {
255262
// TypeScript narrows user to non-null after the else block above
256263
const validUser = user;
257264

265+
if (!validUser.isActive) {
266+
throw new UnauthorizedException(
267+
'Your account has been blocked. Please contact support.',
268+
);
269+
}
270+
258271
const payload = {
259272
email: validUser.email,
260273
sub: validUser.id,

apps/api/src/auth/guards/optional-jwt-auth.guard.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { AuthGuard } from '@nestjs/passport';
33

44
@Injectable()
55
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
6-
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/no-explicit-any
7-
handleRequest(err: any, user: any) {
6+
override handleRequest<TUser = unknown>(
7+
err: unknown,
8+
user: TUser,
9+
): TUser | null {
810
// If error or no user, return null instead of throwing
911
if (err || !user) {
1012
return null;
1113
}
12-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
1314
return user;
1415
}
1516
}

apps/api/src/auth/strategies/jwt.strategy.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1-
import { Injectable, Logger } from '@nestjs/common';
1+
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44

55
import { Request } from 'express';
66
import { Strategy } from 'passport-jwt';
77

8+
import { User } from '../../users/entities/user.entity';
9+
import { UsersService } from '../../users/users.service';
10+
811
interface JwtPayload {
912
sub: string;
1013
email: string;
@@ -18,14 +21,12 @@ const cookieExtractor = (req: Request): string | null => {
1821
// First try to get token from HTTP-only cookie
1922
const cookies = req.cookies as Record<string, string> | undefined;
2023
if (cookies?.access_token) {
21-
logger.debug('Token found in cookie');
2224
return cookies.access_token;
2325
}
2426

2527
// Fallback to Authorization header (for API clients, testing, etc.)
2628
const authHeader = req.headers.authorization;
2729
if (authHeader?.startsWith('Bearer ')) {
28-
logger.debug('Token found in Authorization header');
2930
return authHeader.substring(7);
3031
}
3132

@@ -41,16 +42,28 @@ const cookieExtractor = (req: Request): string | null => {
4142

4243
@Injectable()
4344
export class JwtStrategy extends PassportStrategy(Strategy) {
44-
constructor(configService: ConfigService) {
45+
constructor(
46+
configService: ConfigService,
47+
private readonly usersService: UsersService,
48+
) {
4549
super({
4650
jwtFromRequest: cookieExtractor,
4751
ignoreExpiration: false,
4852
secretOrKey: configService.get<string>('JWT_SECRET') ?? 'supersecretkey',
4953
});
5054
}
5155

52-
validate(payload: JwtPayload): { id: string; email: string; role: string } {
53-
// Return 'id' instead of 'userId' to match User entity property
54-
return { id: payload.sub, email: payload.email, role: payload.role };
56+
async validate(payload: JwtPayload): Promise<User> {
57+
const user = await this.usersService.findOne(payload.sub);
58+
59+
if (!user) {
60+
throw new UnauthorizedException('User no longer exists');
61+
}
62+
63+
if (!user.isActive) {
64+
throw new UnauthorizedException('User is inactive');
65+
}
66+
67+
return user;
5568
}
5669
}

0 commit comments

Comments
 (0)