Skip to content

Commit 63d7a3a

Browse files
Merge branch 'main' into jules-auth-and-refactor-4713025220278674029
2 parents d1d5fb9 + f63e7a3 commit 63d7a3a

16 files changed

Lines changed: 355 additions & 40 deletions

File tree

backend/src/main.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { ConfigService } from '@nestjs/config';
44
import { AppModule } from './app.module';
55
import helmet from 'helmet';
66
import compression from 'compression';
7+
import cookieParser from 'cookie-parser';
78
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
89

910
async function bootstrap() {
1011
const app = await NestFactory.create(AppModule);
1112

1213
app.use(helmet());
1314
app.use(compression());
15+
app.use(cookieParser());
1416
const configService = app.get(ConfigService);
1517

1618
app.useGlobalPipes(
@@ -21,6 +23,11 @@ async function bootstrap() {
2123
transformOptions: { enableImplicitConversion: false },
2224
}),
2325
);
26+
27+
const reflector = app.get(require('@nestjs/core').Reflector);
28+
const { JwtAuthGuard } = require('./common/guards/jwt-auth.guard');
29+
const { RolesGuard } = require('./common/guards/roles.guard');
30+
app.useGlobalGuards(new JwtAuthGuard(reflector), new RolesGuard(reflector));
2431
app.enableCors({
2532
origin: '*',
2633
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',

backend/src/modules/auth/auth.controller.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { Body, Controller, Post, Res, UnauthorizedException } from '@nestjs/common';
2-
import type { Response } from 'express';
1+
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
2+
import type { Request, Response } from 'express';
33
import { TelegramAuthService } from './telegram-auth.service';
44
import { AuthService } from './auth.service';
55
import { LoginDto } from './dto/login.dto';
66
import { RegisterDto } from './dto/register.dto';
77
import { Public } from '@common/decorators/public.decorator';
8+
import { AuthGuard } from '@nestjs/passport';
89

910
import {
1011
AuthResponse,
@@ -44,6 +45,52 @@ export class AuthController {
4445
return { access_token };
4546
}
4647

48+
@Public()
49+
@Post('refresh')
50+
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<AuthResponse> {
51+
const refreshToken = req.cookies?.refresh_token;
52+
if (!refreshToken) {
53+
throw new UnauthorizedException('No refresh token provided');
54+
}
55+
56+
const { access_token, refresh_token } = await this.authService.refreshToken(refreshToken);
57+
58+
res.cookie('refresh_token', refresh_token, {
59+
httpOnly: true,
60+
secure: process.env.NODE_ENV === 'production',
61+
sameSite: 'strict',
62+
maxAge: 7 * 24 * 60 * 60 * 1000,
63+
});
64+
65+
return { access_token };
66+
}
67+
68+
@Public()
69+
@Get('google')
70+
@UseGuards(AuthGuard('google'))
71+
async googleAuth(@Req() req: Request) {
72+
// Initiates Google OAuth2 flow
73+
}
74+
75+
@Public()
76+
@Get('google/callback')
77+
@UseGuards(AuthGuard('google'))
78+
async googleAuthRedirect(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
79+
const user = await this.authService.validateGoogleUser(req.user);
80+
const { access_token, refresh_token } = await this.authService.googleLogin(user);
81+
82+
res.cookie('refresh_token', refresh_token, {
83+
httpOnly: true,
84+
secure: process.env.NODE_ENV === 'production',
85+
sameSite: 'strict',
86+
maxAge: 7 * 24 * 60 * 60 * 1000,
87+
});
88+
89+
// Redirect to frontend with access token in query string or handle differently based on frontend setup
90+
// For now returning the token directly.
91+
res.redirect(`http://localhost:4200/auth/login?token=${access_token}`);
92+
}
93+
4794
@Post('telegram')
4895
async telegramAuth(
4996
@Body() body: { initData: string },

backend/src/modules/auth/auth.service.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ export class AuthService {
4343
return this.generateTokens(user);
4444
}
4545

46+
async validateGoogleUser(googleUser: any) {
47+
let user = await this.userService.findByEmail(googleUser.email);
48+
if (!user) {
49+
user = await this.userService.create({
50+
email: googleUser.email,
51+
firstName: googleUser.firstName,
52+
lastName: googleUser.lastName,
53+
photoUrl: googleUser.photoUrl,
54+
role: 'user',
55+
username: googleUser.email.split('@')[0],
56+
} as any);
57+
}
58+
return user;
59+
}
60+
61+
async googleLogin(user: any): Promise<AuthResponse & { refresh_token: string }> {
62+
return this.generateTokens(user);
63+
}
64+
4665
async register(registerDto: RegisterDto): Promise<AuthResponse & { refresh_token: string }> {
4766
const existing = await this.userService.findByEmail(registerDto.email);
4867
if (existing) {
@@ -81,4 +100,19 @@ export class AuthService {
81100
refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
82101
};
83102
}
103+
104+
async refreshToken(refreshToken: string): Promise<AuthResponse & { refresh_token: string }> {
105+
try {
106+
const payload = await this.jwtService.verifyAsync(refreshToken);
107+
const user = await this.userService.findByEmail(payload.email);
108+
109+
if (!user) {
110+
throw new UnauthorizedException('User not found');
111+
}
112+
113+
return this.generateTokens(user);
114+
} catch (e) {
115+
throw new UnauthorizedException('Invalid refresh token');
116+
}
117+
}
84118
}

backend/src/modules/auth/strategies/google.strategy.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { PassportStrategy } from '@nestjs/passport';
22
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
33
import { Injectable } from '@nestjs/common';
4-
import { ConfigService } from '@nestjs/config';
4+
import { AppConfigService } from '@common/config/app-config.service';
55

66
@Injectable()
77
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
8-
constructor(private configService: ConfigService) {
8+
constructor(private configService: AppConfigService) {
99
super({
10-
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || 'client-id',
11-
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || 'client-secret',
10+
clientID: process.env.GOOGLE_CLIENT_ID || 'client-id',
11+
clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client-secret',
1212
callbackURL: 'http://localhost:3000/auth/google/callback',
1313
scope: ['email', 'profile'],
1414
});
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { BookingService } from './booking.service';
3+
import { BookingRepository } from '../infrastructure/repositories/booking.repository';
4+
import { Booking } from '../domain/booking.entity';
5+
import { CreateBookingDto } from '../presentation/dto/create-booking.dto';
6+
import { UpdateBookingDto } from '../presentation/dto/update-booking.dto';
7+
8+
describe('BookingService', () => {
9+
let service: BookingService;
10+
let repository: BookingRepository;
11+
12+
const mockBookingRepository = {
13+
findAll: jest.fn(),
14+
create: jest.fn(),
15+
findById: jest.fn(),
16+
update: jest.fn(),
17+
delete: jest.fn(),
18+
};
19+
20+
const mockBooking = new Booking(
21+
'60d5f5070000000000000000',
22+
'John Doe',
23+
new Date(),
24+
'pending',
25+
new Date(),
26+
new Date(),
27+
);
28+
29+
beforeEach(async () => {
30+
const module: TestingModule = await Test.createTestingModule({
31+
providers: [
32+
BookingService,
33+
{
34+
provide: BookingRepository,
35+
useValue: mockBookingRepository,
36+
},
37+
],
38+
}).compile();
39+
40+
service = module.get<BookingService>(BookingService);
41+
repository = module.get<BookingRepository>(BookingRepository);
42+
});
43+
44+
it('should be defined', () => {
45+
expect(service).toBeDefined();
46+
});
47+
48+
describe('findAll', () => {
49+
it('should return an array of bookings', async () => {
50+
const result = [mockBooking];
51+
mockBookingRepository.findAll.mockResolvedValue(result);
52+
53+
expect(await service.findAll()).toBe(result);
54+
expect(repository.findAll).toHaveBeenCalled();
55+
});
56+
});
57+
58+
describe('create', () => {
59+
it('should create a new booking with pending status', async () => {
60+
const createDto: CreateBookingDto = {
61+
customerName: 'John Doe',
62+
date: new Date(),
63+
};
64+
mockBookingRepository.create.mockResolvedValue(mockBooking);
65+
66+
expect(await service.create(createDto)).toBe(mockBooking);
67+
expect(repository.create).toHaveBeenCalledWith({
68+
...createDto,
69+
status: 'pending',
70+
});
71+
});
72+
});
73+
74+
describe('findOne', () => {
75+
it('should return a booking if found', async () => {
76+
mockBookingRepository.findById.mockResolvedValue(mockBooking);
77+
78+
expect(await service.findOne('1')).toBe(mockBooking);
79+
expect(repository.findById).toHaveBeenCalledWith('1');
80+
});
81+
82+
it('should throw an error if booking not found', async () => {
83+
mockBookingRepository.findById.mockResolvedValue(null);
84+
85+
await expect(service.findOne('1')).rejects.toThrow(
86+
'Booking with ID 1 not found',
87+
);
88+
});
89+
});
90+
91+
describe('update', () => {
92+
it('should return updated booking if successful', async () => {
93+
const updateDto: UpdateBookingDto = { customerName: 'Jane Doe' };
94+
const updatedBooking = { ...mockBooking, customerName: 'Jane Doe' };
95+
mockBookingRepository.update.mockResolvedValue(updatedBooking);
96+
97+
expect(await service.update('1', updateDto)).toBe(updatedBooking);
98+
expect(repository.update).toHaveBeenCalledWith('1', updateDto);
99+
});
100+
101+
it('should throw an error if booking not found', async () => {
102+
mockBookingRepository.update.mockResolvedValue(null);
103+
104+
await expect(service.update('1', {})).rejects.toThrow(
105+
'Booking with ID 1 not found',
106+
);
107+
});
108+
});
109+
110+
describe('remove', () => {
111+
it('should delete booking if found', async () => {
112+
mockBookingRepository.delete.mockResolvedValue(true);
113+
114+
await expect(service.remove('1')).resolves.toBeUndefined();
115+
expect(repository.delete).toHaveBeenCalledWith('1');
116+
});
117+
118+
it('should throw an error if booking not found', async () => {
119+
mockBookingRepository.delete.mockResolvedValue(false);
120+
121+
await expect(service.remove('1')).rejects.toThrow(
122+
'Booking with ID 1 not found',
123+
);
124+
});
125+
});
126+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Instructions
2+
3+
- Following Playwright test failed.
4+
- Explain why, be concise, respect Playwright best practices.
5+
- Provide a snippet of code with the fix, if possible.
6+
7+
# Test info
8+
9+
- Name: example.spec.ts >> has title
10+
- Location: tests/example.spec.ts:3:1
11+
12+
# Error details
13+
14+
```
15+
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4200/admin/login
16+
Call log:
17+
- navigating to "http://localhost:4200/admin/login", waiting until "load"
18+
19+
```
20+
21+
# Test source
22+
23+
```ts
24+
1 | import { test, expect } from '@playwright/test';
25+
2 |
26+
3 | test('has title', async ({ page }) => {
27+
> 4 | await page.goto('http://localhost:4200/admin/login');
28+
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4200/admin/login
29+
5 | await expect(page).toHaveTitle('Mavluda Beauty | Medical Luxury Ecosystem');
30+
6 | });
31+
7 |
32+
```

frontend/playwright-report/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,4 @@
8787
<div id='root'></div>
8888
</body>
8989
</html>
90-
<template id="playwrightReportBase64">data:application/zip;base64,UEsDBBQAAAgIAFowh1w60YSPzAIAAPAKAAAZAAAAYTMwYTZlYmE2MzEyZjZiODdlYTUuanNvbs2W207bQBCGX2U1N0kkE9aHOPGiVioVFUjAFVdtqLSxJ4mL47V2x5Ao5N2rNVYJJoA5qG2uNruezzP/b894DdM0w5MEBEifyxAnMvRdbxpORkOUA3Cq83O5QBCAS7koMuybAuM+GXCA0JAB8WNdrZ7E7E14yENf+hi6/jAMEj6ajmx4SpkFz6Vhd2sHCq1+YUz1LeO5Vou0XIADmYolpSoHsa6S2plQluYIwncgVlm5yEG4GweSUteRLo8GDsg8V1Tt2NwvHSA5q1eqpFjVxRYYEyY2JUnzu2ONpszqiptUQ1LTRVoFe9wL93iwx4cXPBTcEwPeH/qj72ARpFcguA3Aohav1uEQp0ojO1bqyhbzIjHwLXErkVGwC/stXVKpkY1hotWNQT2GNvSQN+h8Z9KnsszjOavRrcDBQ3AU3nMvHZBEMp4vMKd6I1ZlTiBcB8xVWhSYgJjKzODmVRc7uwSJVU64pFaCjMKm3INdenzVKAlZTW7FDZtC/zM9CjnDdmIMRw+TDqJnxLDYNtCR24D+lSfjrbKdy+t0ZssjxcawL5NFmu9napbmrSSM/MYLFg1fMP4VLTDYaoHB5ul6HDC5/U8ggDGf3TLbybudPy254zBpVnnMuuvKRrbpsU+f2XqcMxawW8aYvJEpVWf9mSLV7cyJCrG/b7PN5sqQCDzOt+Xp9A5sOKvCt34/7e5gC3rXhLuW3euTOpbXeGGT6nbO5HVWJpIdoixpxW7ZGSZpLDN2Wi5LvWJHsTIrQ7jo9A5g27SjCsnGsIVrYZfbj3jj9fejD3NrcO+W57V2653yf7zQO019YG7Ibtmm4ciXKaFuOfTcfhR4D23wwudnXsuWZsmNufTWNvzOSbMjE9ff2VwzZdoPGrcfDRrt9f/rrpcOoNZK19cZklQaEFBIY6pvsUffbo/YN0pfoT7JE1yC4JaorkCQLnFzufkNUEsDBBQAAAgIAFowh1xQrkUfZQEAAMwCAAALAAAAcmVwb3J0Lmpzb26tkcFu2zAMhl9l4FkJZDuWHb1BLz0VKLAhB0ZmZjeyZEg01sHwuxeyVaSH7rbbL0r8+fHXAiMxdsgIegE0PKN99eFOIYIuVgGRMfDLMBLoomnq+lQ06tyeCgHdHJAH70CXxbk5qnMr4DZYiqB/LZt66kADVhIVXVFVRXlT17YhrGF/+YzJFugdx8nSMU5kjhxBAFPk3Sapf9ocrlJJVWFFqqgadepke2tT+8A2GfcYf+xawBT8GxnOI00f/DjMIwiw3uQ1duhvgezgCHQlwHg7j26P5hFAIc+1AHTO81ZJ7BcBjL+z8jMbn5edyDB1CQm5z9d30BxmEhAozjbvjsxo+pEcZ5cvfwGlLNVBng6yeZFKy1LX8thU7U8Q8Gf7vyfX0TtouV7WvTW5LMCe0YIuxIMkHWb3OEoBN4v3v5uK92GacvUTc02OXwJNeI9I//s0ARSCD59RTjnhZRUwoukHtwFc1g9QSwECPwMUAAAICABaMIdcOtGEj8wCAADwCgAAGQAAAAAAAAAAAAAAtIEAAAAAYTMwYTZlYmE2MzEyZjZiODdlYTUuanNvblBLAQI/AxQAAAgIAFowh1xQrkUfZQEAAMwCAAALAAAAAAAAAAAAAAC0gQMDAAByZXBvcnQuanNvblBLBQYAAAAAAgACAIAAAACRBAAAAAA=</template>
90+
<template id="playwrightReportBase64">data:application/zip;base64,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</template>

frontend/src/app.routes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Routes } from "@angular/router";
22
import { AuthComponent } from "@pages/auth";
33
import { AdminLayoutComponent, UserLayoutComponent } from "@widgets/layouts";
4+
import { adminGuard } from "@core/guards";
45

56
export const routes: Routes = [
67
{ path: "", redirectTo: "user/home", pathMatch: "full" },
@@ -14,7 +15,7 @@ export const routes: Routes = [
1415
{
1516
path: "admin",
1617
component: AdminLayoutComponent,
17-
// canActivate: [adminGuard],
18+
canActivate: [adminGuard],
1819
children: [
1920
{ path: "", redirectTo: "dashboard", pathMatch: "full" },
2021
{

frontend/src/app/app.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
withFetch,
1111
withInterceptors,
1212
} from "@angular/common/http";
13-
import { apiInterceptor, errorInterceptor } from "@core/interceptors";
13+
import { apiInterceptor, errorInterceptor, authInterceptor } from "@core/interceptors";
1414

1515
export const appConfig: ApplicationConfig = {
1616
providers: [
@@ -19,7 +19,7 @@ export const appConfig: ApplicationConfig = {
1919
provideRouter(routes, withHashLocation()),
2020
provideHttpClient(
2121
withFetch(),
22-
withInterceptors([apiInterceptor, errorInterceptor]),
22+
withInterceptors([apiInterceptor, authInterceptor, errorInterceptor]),
2323
),
2424
],
2525
};

frontend/src/core/guards/admin.guard.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { CanActivateFn, Router } from '@angular/router';
22
import { inject } from '@angular/core';
3+
import { AuthService } from '@entities/user';
34

45
export const adminGuard: CanActivateFn = (route, state) => {
56
const router = inject(Router);
6-
// Add actual role check here when auth service is connected
7-
const role = localStorage.getItem('role');
8-
if (role === 'admin') {
7+
const authService = inject(AuthService);
8+
9+
if (authService.isAdmin()) {
910
return true;
1011
}
1112
return router.parseUrl('/admin/login');

0 commit comments

Comments
 (0)