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
9 changes: 8 additions & 1 deletion backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as compression from 'compression';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

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

app.use(helmet());
app.use(compression());
app.use(cookieParser());
const configService = app.get(ConfigService);

app.useGlobalPipes(
Expand All @@ -21,6 +23,11 @@ async function bootstrap() {
transformOptions: { enableImplicitConversion: false },
}),
);

const reflector = app.get(require('@nestjs/core').Reflector);
const { JwtAuthGuard } = require('./common/guards/jwt-auth.guard');
const { RolesGuard } = require('./common/guards/roles.guard');
app.useGlobalGuards(new JwtAuthGuard(reflector), new RolesGuard(reflector));
app.enableCors({
origin: '*',
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
Expand Down
51 changes: 49 additions & 2 deletions backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Body, Controller, Post, Res, UnauthorizedException } from '@nestjs/common';
import type { Response } from 'express';
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { TelegramAuthService } from './telegram-auth.service';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { Public } from '@common/decorators/public.decorator';
import { AuthGuard } from '@nestjs/passport';

import {
AuthResponse,
Expand Down Expand Up @@ -44,6 +45,52 @@ export class AuthController {
return { access_token };
}

@Public()
@Post('refresh')
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<AuthResponse> {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) {
throw new UnauthorizedException('No refresh token provided');
}

const { access_token, refresh_token } = await this.authService.refreshToken(refreshToken);

res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});

return { access_token };
}

@Public()
@Get('google')
@UseGuards(AuthGuard('google'))
async googleAuth(@Req() req: Request) {
// Initiates Google OAuth2 flow
}

@Public()
@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleAuthRedirect(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const user = await this.authService.validateGoogleUser(req.user);
const { access_token, refresh_token } = await this.authService.googleLogin(user);

res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});

// Redirect to frontend with access token in query string or handle differently based on frontend setup
// For now returning the token directly.
res.redirect(`http://localhost:4200/auth/login?token=${access_token}`);
}

@Post('telegram')
async telegramAuth(
@Body() body: { initData: string },
Expand Down
34 changes: 34 additions & 0 deletions backend/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ export class AuthService {
return this.generateTokens(user);
}

async validateGoogleUser(googleUser: any) {
let user = await this.userService.findByEmail(googleUser.email);
if (!user) {
user = await this.userService.create({
email: googleUser.email,
firstName: googleUser.firstName,
lastName: googleUser.lastName,
photoUrl: googleUser.photoUrl,
role: 'user',
username: googleUser.email.split('@')[0],
} as any);
}
return user;
}

async googleLogin(user: any): Promise<AuthResponse & { refresh_token: string }> {
return this.generateTokens(user);
}

async register(registerDto: RegisterDto): Promise<AuthResponse & { refresh_token: string }> {
const existing = await this.userService.findByEmail(registerDto.email);
if (existing) {
Expand Down Expand Up @@ -81,4 +100,19 @@ export class AuthService {
refresh_token: this.jwtService.sign(payload, { expiresIn: '7d' }),
};
}

async refreshToken(refreshToken: string): Promise<AuthResponse & { refresh_token: string }> {
try {
const payload = await this.jwtService.verifyAsync(refreshToken);
const user = await this.userService.findByEmail(payload.email);

if (!user) {
throw new UnauthorizedException('User not found');
}

return this.generateTokens(user);
} catch (e) {
throw new UnauthorizedException('Invalid refresh token');
}
}
}
8 changes: 4 additions & 4 deletions backend/src/modules/auth/strategies/google.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppConfigService } from '@common/config/app-config.service';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(private configService: ConfigService) {
constructor(private configService: AppConfigService) {
super({
clientID: configService.get<string>('GOOGLE_CLIENT_ID') || 'client-id',
clientSecret: configService.get<string>('GOOGLE_CLIENT_SECRET') || 'client-secret',
clientID: process.env.GOOGLE_CLIENT_ID || 'client-id',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client-secret',
callbackURL: 'http://localhost:3000/auth/google/callback',
scope: ['email', 'profile'],
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: example.spec.ts >> has title
- Location: tests/example.spec.ts:3:1

# Error details

```
Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4200/admin/login
Call log:
- navigating to "http://localhost:4200/admin/login", waiting until "load"

```

# Test source

```ts
1 | import { test, expect } from '@playwright/test';
2 |
3 | test('has title', async ({ page }) => {
> 4 | await page.goto('http://localhost:4200/admin/login');
| ^ Error: page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:4200/admin/login
5 | await expect(page).toHaveTitle('Mavluda Beauty | Medical Luxury Ecosystem');
6 | });
7 |
```
2 changes: 1 addition & 1 deletion frontend/playwright-report/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@
<div id='root'></div>
</body>
</html>
<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>
<template id="playwrightReportBase64">data:application/zip;base64,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</template>
3 changes: 2 additions & 1 deletion frontend/src/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Routes } from "@angular/router";
import { AuthComponent } from "@pages/auth";
import { AdminLayoutComponent, UserLayoutComponent } from "@widgets/layouts";
import { adminGuard } from "@core/guards";

export const routes: Routes = [
{ path: "", redirectTo: "user/home", pathMatch: "full" },
Expand All @@ -14,7 +15,7 @@ export const routes: Routes = [
{
path: "admin",
component: AdminLayoutComponent,
// canActivate: [adminGuard],
canActivate: [adminGuard],
children: [
{ path: "", redirectTo: "dashboard", pathMatch: "full" },
{
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
withFetch,
withInterceptors,
} from "@angular/common/http";
import { apiInterceptor, errorInterceptor } from "@core/interceptors";
import { apiInterceptor, errorInterceptor, authInterceptor } from "@core/interceptors";

export const appConfig: ApplicationConfig = {
providers: [
Expand All @@ -19,7 +19,7 @@ export const appConfig: ApplicationConfig = {
provideRouter(routes, withHashLocation()),
provideHttpClient(
withFetch(),
withInterceptors([apiInterceptor, errorInterceptor]),
withInterceptors([apiInterceptor, authInterceptor, errorInterceptor]),
),
],
};
7 changes: 4 additions & 3 deletions frontend/src/core/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from '@entities/user';

export const adminGuard: CanActivateFn = (route, state) => {
const router = inject(Router);
// Add actual role check here when auth service is connected
const role = localStorage.getItem('role');
if (role === 'admin') {
const authService = inject(AuthService);

if (authService.isAdmin()) {
return true;
}
return router.parseUrl('/admin/login');
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/core/guards/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export const authGuard: CanActivateFn = (route, state) => {
return true;
}

return router.createUrlTree(["/auth/login"]);
return router.createUrlTree(["/auth"]);
};
10 changes: 0 additions & 10 deletions frontend/src/core/interceptors/api.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,8 @@ import { linkServerConvert } from "@shared/lib";

export const apiInterceptor: HttpInterceptorFn = (req, next) => {
if (req.url.startsWith("/")) {
const token =
typeof localStorage !== "undefined"
? localStorage.getItem("token")
: null;
let headers = req.headers;
if (token) {
headers = headers.set("Authorization", `Bearer ${token}`);
}

const apiReq = req.clone({
url: linkServerConvert(req.url),
headers,
});
return next(apiReq);
}
Expand Down
70 changes: 56 additions & 14 deletions frontend/src/core/interceptors/auth.interceptor.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { HttpInterceptorFn, HttpErrorResponse, HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError, switchMap } from 'rxjs';
import { BehaviorSubject, catchError, filter, switchMap, take, throwError } from 'rxjs';
import { Router } from '@angular/router';
import { AuthService } from '@entities/user';

// This is a basic implementation of the auth interceptor.
// It assumes that the token is stored in localStorage.
// You will need a more robust refresh token implementation that doesn't cause infinite loops.
let isRefreshing = false;
let refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);

export const authInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
const http = inject(HttpClient);
const authService = inject(AuthService);
let authReq = req;

// We should not attempt to access localStorage if it is not available (e.g. in SSR).
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('access_token');
const token = localStorage.getItem('token');
if (token) {
authReq = req.clone({
setHeaders: {
Expand All @@ -25,15 +26,56 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {

return next(authReq).pipe(
catchError((error: HttpErrorResponse) => {
// In a real application, you might want to call a refresh token endpoint here.
if (error.status === 401) {
// If the token is invalid, log out the user and redirect to the login page.
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('role');
if (req.url.includes('/auth/refresh') || req.url.includes('/auth/login')) {
authService.logout();
if (router.url.includes('/admin')) {
router.navigate(['/admin/login']);
} else {
router.navigate(['/auth']);
}
return throwError(() => error);
}

if (!isRefreshing) {
isRefreshing = true;
refreshTokenSubject.next(null);

return http.post<{access_token: string}>('/auth/refresh', {}, { withCredentials: true }).pipe(
switchMap((response: any) => {
isRefreshing = false;
localStorage.setItem('token', response.access_token);
refreshTokenSubject.next(response.access_token);
return next(req.clone({
setHeaders: {
Authorization: `Bearer ${response.access_token}`
}
}));
}),
catchError((err) => {
isRefreshing = false;
authService.logout();
if (router.url.includes('/admin')) {
router.navigate(['/admin/login']);
} else {
router.navigate(['/auth']);
}
return throwError(() => err);
})
);
} else {
return refreshTokenSubject.pipe(
filter(token => token != null),
take(1),
switchMap(jwt => {
return next(req.clone({
setHeaders: {
Authorization: `Bearer ${jwt}`
}
}));
})
);
}
router.navigate(['/admin/login']);
}
return throwError(() => error);
})
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/interceptors/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./api.interceptor";
export * from "./auth.interceptor";
export * from "./error.interceptor";
6 changes: 4 additions & 2 deletions frontend/test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"status": "passed",
"failedTests": []
"status": "failed",
"failedTests": [
"a30a6eba6312f6b87ea5-b06063a3e613764d08f8"
]
}
Loading
Loading