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
3 changes: 3 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.1",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
Expand All @@ -45,6 +47,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport": "^1.0.17",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('AuthController', () => {
id: 1,
name: 'Test User',
email: 'test.user@test.com',
access_token: 'SomeReallyLongAccessTokenText',
};
(authService.register as jest.Mock).mockResolvedValue(
mockUserResponseDto,
Expand Down
16 changes: 15 additions & 1 deletion apps/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { Body, Controller, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Post,
UseGuards,
Request,
} from '@nestjs/common';
import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto';
import { AuthService } from '@/auth/auth.service';
import { UserResponseDto } from '@shared/dtos/user/user-response.dto';
import { LoginUserDto } from '@shared/dtos/auth/login-user.dto';
import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Controller('auth')
export class AuthController {
Expand All @@ -20,4 +28,10 @@ export class AuthController {
async login(@Body() loginUserDto: LoginUserDto): Promise<AccessTokenDto> {
return this.authService.login(loginUserDto);
}

@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: { user: { userId: number; email: string } }) {
return req.user;
}
}
5 changes: 4 additions & 1 deletion apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import { AuthService } from '@/auth/auth.service';
import { UsersModule } from '@/users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from '@/auth/jwt.strategy';

@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
Expand All @@ -21,6 +24,6 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
}),
],
controllers: [AuthController],
providers: [AuthService],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}
7 changes: 7 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,17 @@ export class AuthService {
savedUser = await queryRunner.manager.save(user);
await queryRunner.commitTransaction();

const payload = {
sub: savedUser.id,
email: savedUser.email,
};
const accessToken = await this.jwtService.signAsync(payload);

const response: UserResponseDto = {
id: savedUser.id,
name: savedUser.name,
email: savedUser.email,
access_token: accessToken,
};

return response;
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
19 changes: 19 additions & 0 deletions apps/backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { PassportStrategy } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
});
}

validate(payload: { sub: number; email: string }) {
return { userId: payload.sub, email: payload.email };
}
}
7 changes: 4 additions & 3 deletions apps/frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import {
} from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { routes } from '@/app/app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { tokenInterceptor } from '@/app/auth/interceptors/token-interceptor';

export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
provideHttpClient(withInterceptors([tokenInterceptor])),
],
};
7 changes: 7 additions & 0 deletions apps/frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { Routes } from '@angular/router';
import { Dashboard } from '@/app/pages/dashboard/dashboard';
import { authGuard } from '@/app/auth/guards/auth-guard';

export const routes: Routes = [
{
path: 'auth',
loadChildren: () => import('./auth/auth-module').then((m) => m.AuthModule),
},
{
path: 'dashboard',
component: Dashboard,
canActivate: [authGuard],
},
];
8 changes: 5 additions & 3 deletions apps/frontend/src/app/auth/components/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { EMAIL_REGEX_STRING } from '@shared/validation/email.constants';
import { LoginUserDto } from '@shared/dtos/auth/login-user.dto';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';

@Component({
selector: 'app-login',
Expand All @@ -18,6 +19,7 @@ import { CommonModule } from '@angular/common';
export class Login {
private formBuilder = inject(FormBuilder);
private authService = inject(Auth);
private router = inject(Router);

loginForm = this.formBuilder.group({
email: ['', [Validators.required, Validators.pattern(new RegExp(EMAIL_REGEX_STRING))]],
Expand All @@ -43,10 +45,10 @@ export class Login {

const userData = this.loginForm.value as LoginUserDto;
this.authService.login(userData).subscribe({
next: (response) => {
next: () => {
this.errorMessage = null;
localStorage.setItem('access_token', response.access_token);
console.log('Login successful!', response);
this.loginForm.reset();
this.router.navigate(['/dashboard']);
},
Comment on lines +48 to 52
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle the Promise returned by router.navigate().

Router.navigate() returns Promise<boolean>false or a thrown error means navigation was cancelled (e.g., a guard rejected it or the route doesn't exist). Ignoring it means navigation failures are completely silent to the user, leaving them on the login page with a reset, empty form and no feedback.

🛡️ Proposed fix
-        this.loginForm.reset();
-        this.router.navigate(['/dashboard']);
+        this.router.navigate(['/dashboard']).then((navigated) => {
+          if (navigated) {
+            this.loginForm.reset();
+          } else {
+            this.errorMessage = 'Navigation failed. Please try again.';
+          }
+        }).catch(() => {
+          this.errorMessage = 'Navigation failed. Please try again.';
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
next: () => {
this.errorMessage = null;
localStorage.setItem('access_token', response.access_token);
console.log('Login successful!', response);
this.loginForm.reset();
this.router.navigate(['/dashboard']);
},
next: () => {
this.errorMessage = null;
this.router.navigate(['/dashboard']).then((navigated) => {
if (navigated) {
this.loginForm.reset();
} else {
this.errorMessage = 'Navigation failed. Please try again.';
}
}).catch(() => {
this.errorMessage = 'Navigation failed. Please try again.';
});
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/app/auth/components/login/login.ts` around lines 48 - 52,
The success handler currently calls this.router.navigate(['/dashboard']) and
ignores its Promise, so navigation failures are silent after
this.loginForm.reset() and clearing this.errorMessage; update the next handler
(the arrow function in the subscribe success block) to handle the Promise
returned by this.router.navigate(['/dashboard']) — add .then(...) to confirm
navigation and optionally clear/reset state or .catch(...) (or a second .then
boolean check) to restore the form and set this.errorMessage with a
user-friendly message when navigation returns false or throws; reference the
next handler, this.router.navigate, this.loginForm.reset, and this.errorMessage
when implementing the change.

error: (err) => {
if (err.error && err.error.message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('Register', () => {
id: 1,
name: 'Test',
email: 'email@test.com',
access_token: 'access_token',
};
authService.register.and.returnValue(of(mockResponse));

Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/src/app/auth/components/register/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { EMAIL_REGEX_STRING } from '@shared/validation/email.constants';
import { Auth } from '@/app/auth/services/auth';
import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto';
import { Router } from '@angular/router';

@Component({
selector: 'app-register',
Expand All @@ -18,6 +19,7 @@ import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto';
export class Register {
private formBuilder = inject(FormBuilder);
private authService = inject(Auth);
private router = inject(Router);

registerForm = this.formBuilder.group({
name: ['', [Validators.required]],
Expand Down Expand Up @@ -48,9 +50,10 @@ export class Register {

const userData = this.registerForm.value as RegisterUserDto;
this.authService.register(userData).subscribe({
next: (response) => {
next: () => {
this.errorMessage = null;
console.log('Registration successful!', response);
this.registerForm.reset();
this.router.navigate(['/dashboard']);
},
error: (err) => {
if (err.error && err.error.message) {
Expand Down
17 changes: 17 additions & 0 deletions apps/frontend/src/app/auth/guards/auth-guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { CanActivateFn } from '@angular/router';

import { authGuard } from './auth-guard';

describe('authGuard', () => {
const executeGuard: CanActivateFn = (...guardParameters) =>
TestBed.runInInjectionContext(() => authGuard(...guardParameters));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(executeGuard).toBeTruthy();
});
});
15 changes: 15 additions & 0 deletions apps/frontend/src/app/auth/guards/auth-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { CanActivateFn, Router } from '@angular/router';
import { Auth } from '@/app/auth/services/auth';
import { inject } from '@angular/core';

export const authGuard: CanActivateFn = () => {
const authService = inject(Auth);
const router = inject(Router);

if (authService.isAuthenticated()) {
return true;
}

router.navigate(['/auth/login']);
return false;
};
17 changes: 17 additions & 0 deletions apps/frontend/src/app/auth/interceptors/token-interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { HttpInterceptorFn } from '@angular/common/http';

import { tokenInterceptor } from './token-interceptor';

describe('tokenInterceptor', () => {
const interceptor: HttpInterceptorFn = (req, next) =>
TestBed.runInInjectionContext(() => tokenInterceptor(req, next));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(interceptor).toBeTruthy();
});
});
18 changes: 18 additions & 0 deletions apps/frontend/src/app/auth/interceptors/token-interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { Auth } from '@/app/auth/services/auth';
import { inject } from '@angular/core';

export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth);
const token = authService.getToken();

if (token) {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(clonedRequest);
}
Comment on lines +9 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Token is attached to ALL outgoing HTTP requests — restrict to API origin to prevent token leakage.

If the app ever issues requests to third-party endpoints (analytics, CDN, OAuth redirects, etc.), the Authorization: Bearer header will be forwarded to those hosts. Scope the header attachment to your own API URL.

🔒 Proposed fix
+import { environment } from '@/environments/environment';

 export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
   const authService = inject(Auth);
   const token = authService.getToken();

-  if (token) {
+  if (token && req.url.startsWith(environment.apiUrl)) {
     const clonedRequest = req.clone({
       setHeaders: {
         Authorization: `Bearer ${token}`,
       },
     });
     return next(clonedRequest);
   }
   return next(req);
 };

Adjust the condition to match your actual API base URL constant. Even a simple req.url.startsWith('/api') guards against unintended cross-origin leakage.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (token) {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(clonedRequest);
}
import { environment } from '@/environments/environment';
export const tokenInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(Auth);
const token = authService.getToken();
if (token && req.url.startsWith(environment.apiUrl)) {
const clonedRequest = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
return next(clonedRequest);
}
return next(req);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/app/auth/interceptors/token-interceptor.ts` around lines 9
- 16, The interceptor currently attaches the Authorization header to every
outgoing request; update the logic in token-interceptor.ts (inside the intercept
method where `token`, `req.clone`, and `next(clonedRequest)` are used) to only
add the header when the request targets your API origin — e.g. check `token &&
req.url.startsWith(API_BASE_URL)` or verify same-origin (`new URL(req.url,
location.origin).origin === API_BASE_URL`) before calling `req.clone({
setHeaders: { Authorization: ... } })` and `next(clonedRequest)`; otherwise call
`next(req)` unmodified.

return next(req);
};
1 change: 1 addition & 0 deletions apps/frontend/src/app/auth/services/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('Auth', () => {
id: 1,
name: 'Test',
email: 'email@test.com',
access_token: 'access_token',
};

service.register(mockRegisterData).subscribe((response) => {
Expand Down
29 changes: 26 additions & 3 deletions apps/frontend/src/app/auth/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,43 @@ import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto';
import { UserResponseDto } from '@shared/dtos/user/user-response.dto';
import { LoginUserDto } from '@shared/dtos/auth/login-user.dto';
import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto';
import { Observable } from 'rxjs';
import { Observable, tap } from 'rxjs';

@Injectable({
providedIn: 'root',
})
export class Auth {
private httpClient = inject(HttpClient);
private apiUrl = `${environment.apiUrl}/auth`;
private readonly TOKEN_KEY = 'access_token';

register(userData: RegisterUserDto): Observable<UserResponseDto> {
return this.httpClient.post<UserResponseDto>(`${this.apiUrl}/register`, userData);
return this.httpClient
.post<UserResponseDto>(`${this.apiUrl}/register`, userData)
.pipe(tap((response: UserResponseDto) => this.setSession(response)));
}

login(userData: LoginUserDto): Observable<AccessTokenDto> {
return this.httpClient.post<AccessTokenDto>(`${this.apiUrl}/login`, userData);
return this.httpClient
.post<AccessTokenDto>(`${this.apiUrl}/login`, userData)
.pipe(tap((response: AccessTokenDto) => this.setSession(response)));
}

logout() {
localStorage.removeItem(this.TOKEN_KEY);
}

isAuthenticated(): boolean {
return !!localStorage.getItem(this.TOKEN_KEY);
}
Comment on lines +34 to +36
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isAuthenticated() doesn't validate token expiry — expired tokens pass the guard

This only checks for the presence of a token. An expired JWT stored in localStorage will return true, letting the authGuard admit the user. The Dashboard component then issues the API call, receives a 401, and only logs the error — leaving the user stranded without a redirect to /login.

Consider decoding the token and checking its exp claim:

🛡️ Proposed fix
-  isAuthenticated(): boolean {
-    return !!localStorage.getItem(this.TOKEN_KEY);
-  }
+  isAuthenticated(): boolean {
+    const token = localStorage.getItem(this.TOKEN_KEY);
+    if (!token) return false;
+    try {
+      const payload = JSON.parse(atob(token.split('.')[1]));
+      return payload.exp * 1000 > Date.now();
+    } catch {
+      return false;
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
isAuthenticated(): boolean {
return !!localStorage.getItem(this.TOKEN_KEY);
}
isAuthenticated(): boolean {
const token = localStorage.getItem(this.TOKEN_KEY);
if (!token) return false;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 > Date.now();
} catch {
return false;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/frontend/src/app/auth/services/auth.ts` around lines 34 - 36,
isAuthenticated currently only checks presence of a token (TOKEN_KEY) and will
return true for expired JWTs; update isAuthenticated() to decode the stored JWT,
verify the exp claim against current time, remove the token from localStorage
and return false if expired, and only return true if a valid, unexpired token
exists; reference the isAuthenticated() method and TOKEN_KEY, ensure behavior
aligns with authGuard so expired tokens cause a false result (and trigger a
redirect to /login).


getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}

private setSession(authResult: UserResponseDto | AccessTokenDto) {
if (authResult.access_token) {
localStorage.setItem(this.TOKEN_KEY, authResult.access_token);
}
}
}
Empty file.
5 changes: 5 additions & 0 deletions apps/frontend/src/app/pages/dashboard/dashboard.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div style="padding: 2rem">
<h1>Welcome to Dashboard</h1>
<p>If you see your profile data below, the Interceptor works! 🎉</p>
<pre>{{ profileData | json }}</pre>
</div>
27 changes: 27 additions & 0 deletions apps/frontend/src/app/pages/dashboard/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { Dashboard } from './dashboard';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';

describe('Dashboard', () => {
let component: Dashboard;
let fixture: ComponentFixture<Dashboard>;
let httpMock: HttpTestingController;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Dashboard],
providers: [provideHttpClientTesting()],
}).compileComponents();

fixture = TestBed.createComponent(Dashboard);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
fixture.detectChanges();
});

it('should create', () => {
httpMock.expectOne('/auth/profile').flush({ id: 1, name: 'Test', email: 'test@test.com' });
expect(component).toBeTruthy();
});
});
Loading