diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c6f5373..5f128e3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,5 +1,8 @@ name: Linting Check +permissions: + contents: read + on: pull_request: branches: ['main'] diff --git a/apps/backend/eslint.config.mjs b/apps/backend/eslint.config.mjs index 462b8ef..c99df86 100644 --- a/apps/backend/eslint.config.mjs +++ b/apps/backend/eslint.config.mjs @@ -20,7 +20,7 @@ export default tseslint.config( }, sourceType: 'commonjs', parserOptions: { - projectService: true, + project: true, tsconfigRootDir: import.meta.dirname, }, }, diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json index f9aa683..2f3f023 100644 --- a/apps/backend/nest-cli.json +++ b/apps/backend/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { + "builder": "webpack", "deleteOutDir": true } } diff --git a/apps/backend/package.json b/apps/backend/package.json index f23a312..201c70a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -26,7 +26,7 @@ "@nestjs/jwt": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.0", - "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "passport-jwt": "^4.0.1", @@ -41,7 +41,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/bcrypt": "^6.0.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -81,7 +81,9 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "@/(.*)$": "/$1" + "@/(.*)$": "/$1", + "@shared/validation/(.*)": "/../../../libs/shared-validation/src/$1", + "@shared/dtos/(.*)": "/../../../libs/shared-dtos/src/$1" } } } diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index 45ff2d5..a99d13d 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -1,10 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from '@/auth/auth.controller'; import { AuthService } from '@/auth/auth.service'; -import { RegisterUserDto } from '@/auth/dto/register-user.dto'; -import { UserResponseDto } from '@/users/user-response.dto'; -import { LoginUserDto } from '@/auth/dto/login-user.dto'; -import { AccessTokenDto } from '@/auth/dto/access-token.dto'; +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 { ConflictException, UnauthorizedException } from '@nestjs/common'; const mockAuthService = { diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 7726eda..f08822d 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -1,9 +1,9 @@ import { Body, Controller, Post } from '@nestjs/common'; -import { RegisterUserDto } from '@/auth/dto/register-user.dto'; +import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto'; import { AuthService } from '@/auth/auth.service'; -import { UserResponseDto } from '@/users/user-response.dto'; -import { LoginUserDto } from '@/auth/dto/login-user.dto'; -import { AccessTokenDto } from '@/auth/dto/access-token.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'; @Controller('auth') export class AuthController { diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 8f88677..4beb45c 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,17 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from '@/auth/auth.service'; import { DataSource } from 'typeorm'; -import { RegisterUserDto } from '@/auth/dto/register-user.dto'; +import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto'; import { User } from '@/users/user.entity'; -import * as bcrypt from 'bcrypt'; +import * as bcrypt from 'bcryptjs'; import { ConflictException, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UsersService } from '@/users/users.service'; import { JwtService } from '@nestjs/jwt'; -import { LoginUserDto } from '@/auth/dto/login-user.dto'; -import { AccessTokenDto } from '@/auth/dto/access-token.dto'; +import { LoginUserDto } from '@shared/dtos/auth/login-user.dto'; +import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto'; -jest.mock('bcrypt', () => ({ +jest.mock('bcryptjs', () => ({ hash: jest.fn(), compare: jest.fn(), })); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 888e11d..b2237c2 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -3,16 +3,16 @@ import { Injectable, UnauthorizedException, } from '@nestjs/common'; -import { RegisterUserDto } from '@/auth/dto/register-user.dto'; -import * as bcrypt from 'bcrypt'; +import { RegisterUserDto } from '@shared/dtos/auth/register-user.dto'; +import * as bcrypt from 'bcryptjs'; import { DataSource } from 'typeorm'; import { User } from '@/users/user.entity'; -import { UserResponseDto } from '@/users/user-response.dto'; +import { UserResponseDto } from '@shared/dtos/user/user-response.dto'; import { ConfigService } from '@nestjs/config'; import { UsersService } from '@/users/users.service'; import { JwtService } from '@nestjs/jwt'; -import { LoginUserDto } from '@/auth/dto/login-user.dto'; -import { AccessTokenDto } from '@/auth/dto/access-token.dto'; +import { LoginUserDto } from '@shared/dtos/auth/login-user.dto'; +import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto'; @Injectable() export class AuthService { diff --git a/apps/backend/src/auth/dto/access-token.dto.ts b/apps/backend/src/auth/dto/access-token.dto.ts deleted file mode 100644 index 3668c60..0000000 --- a/apps/backend/src/auth/dto/access-token.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class AccessTokenDto { - readonly access_token: string; -} diff --git a/apps/backend/src/auth/dto/register-user.dto.ts b/apps/backend/src/auth/dto/register-user.dto.ts deleted file mode 100644 index 02c110e..0000000 --- a/apps/backend/src/auth/dto/register-user.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - IsEmail, - IsNotEmpty, - IsString, - Matches, - MinLength, -} from 'class-validator'; - -export class RegisterUserDto { - @IsString() - @IsNotEmpty() - name: string; - - @IsNotEmpty() - @IsEmail() - email: string; - - @IsNotEmpty() - @MinLength(8, { message: 'Password must be at least 8 characters long.' }) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()]).{8,}$/, { - message: - 'Password is too weak. It must contain at least one uppercase letter, one lowercase letter, one number, and one special character.', - }) - password: string; -} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 8e64f94..050b181 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -11,6 +11,11 @@ async function bootstrap(): Promise { transform: true, }), ); + app.enableCors({ + origin: 'http://localhost:4200', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, + }); await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 7755fc3..77b2283 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -15,7 +15,9 @@ "outDir": "./dist", "baseUrl": "./", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@shared/validation/*": ["../../libs/shared-validation/src/*"], + "@shared/dtos/*": ["../../libs/shared-dtos/src/*"] }, "types": ["node", "jest"], "incremental": true, @@ -26,5 +28,6 @@ "strictBindCallApply": false, "noFallthroughCasesInSwitch": false, "useUnknownInCatchVariables": true - } + }, + "include": ["src/**/*", "../../libs/**/*"] } diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js index 8db07f5..b4eeb93 100644 --- a/apps/frontend/eslint.config.js +++ b/apps/frontend/eslint.config.js @@ -31,6 +31,12 @@ module.exports = tseslint.config( }, ], }, + languageOptions: { + parserOptions: { + project: ['tsconfig.app.json', 'tsconfig.spec.json'], + tsconfigRootDir: __dirname, + }, + }, }, { files: ['**/*.html'], diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2df7f15..ade4fb0 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve --open", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "ng test", @@ -29,6 +29,8 @@ "@angular/forms": "^20.3.0", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index d953f4c..1e7350e 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -1,12 +1,18 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core'; +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, +} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { provideHttpClient } from '@angular/common/http'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes) - ] + provideRouter(routes), + provideHttpClient(), + ], }; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 7528372..67e7bd4 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -1,342 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index dc39edb..4b77712 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -1,3 +1,8 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: 'auth', + loadChildren: () => import('./auth/auth-module').then((m) => m.AuthModule), + }, +]; diff --git a/apps/frontend/src/app/app.spec.ts b/apps/frontend/src/app/app.spec.ts index d6439c4..35b8ec2 100644 --- a/apps/frontend/src/app/app.spec.ts +++ b/apps/frontend/src/app/app.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { App } from './app'; +import { App } from '@/app/app'; describe('App', () => { beforeEach(async () => { @@ -13,11 +13,4 @@ describe('App', () => { const app = fixture.componentInstance; expect(app).toBeTruthy(); }); - - it('should render title', () => { - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend'); - }); }); diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts index ade0fcb..d8511c5 100644 --- a/apps/frontend/src/app/app.ts +++ b/apps/frontend/src/app/app.ts @@ -1,12 +1,10 @@ -import { Component, signal } from '@angular/core'; +import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; @Component({ selector: 'app-root', imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) -export class App { - protected readonly title = signal('frontend'); -} +export class App {} diff --git a/apps/frontend/src/app/auth/auth-module.ts b/apps/frontend/src/app/auth/auth-module.ts new file mode 100644 index 0000000..3bb9a6c --- /dev/null +++ b/apps/frontend/src/app/auth/auth-module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { AuthRoutingModule } from '@/app/auth/auth-routing-module'; +import { ReactiveFormsModule } from '@angular/forms'; + +@NgModule({ + declarations: [], + imports: [CommonModule, AuthRoutingModule, ReactiveFormsModule], +}) +export class AuthModule {} diff --git a/apps/frontend/src/app/auth/auth-routing-module.ts b/apps/frontend/src/app/auth/auth-routing-module.ts new file mode 100644 index 0000000..37cd509 --- /dev/null +++ b/apps/frontend/src/app/auth/auth-routing-module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { Login } from './components/login/login'; +import { Register } from './components/register/register'; + +const routes: Routes = [ + { + path: 'login', + component: Login, + }, + { + path: 'register', + component: Register, + }, + { + path: '', + redirectTo: 'login', + pathMatch: 'full', + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class AuthRoutingModule {} diff --git a/apps/frontend/src/app/auth/components/login/login.css b/apps/frontend/src/app/auth/components/login/login.css new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/app/auth/components/login/login.html b/apps/frontend/src/app/auth/components/login/login.html new file mode 100644 index 0000000..189526d --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.html @@ -0,0 +1,29 @@ +
+ + + @if (email?.invalid && (email?.dirty || email?.touched)) { + @if (email?.errors?.['required']) { + Email is required. + } + @if (email?.errors?.['pattern']) { + Please enter a valid email address. (e.g. user@domain.com) + } + } + + + + @if (password?.invalid && (password?.dirty || password?.touched)) { + @if (password?.errors?.['required']) { + Password is required. + } + @if (password?.errors?.['pattern']) { + {{ passwordValidationMessage }} + } + } + + + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
diff --git a/apps/frontend/src/app/auth/components/login/login.spec.ts b/apps/frontend/src/app/auth/components/login/login.spec.ts new file mode 100644 index 0000000..bbe7503 --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.spec.ts @@ -0,0 +1,139 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Login } from '@/app/auth/components/login/login'; +import { Auth } from '@/app/auth/services/auth'; +import { of, throwError } from 'rxjs'; +import { AccessTokenDto } from '@shared/dtos/auth/access-token.dto'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + + const authServiceSpyObj = jasmine.createSpyObj('Auth', ['login']); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login], + providers: [ + { + provide: Auth, + useValue: authServiceSpyObj, + }, + ], + }).compileComponents(); + + authService = TestBed.inject(Auth) as jasmine.SpyObj; + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should validate required form controls', () => { + expect(component.loginForm.valid).toBeFalse(); + }); + + it('should validate email pattern', () => { + // Invalid + component.email?.setValue('invalid-email'); + expect(component.email?.valid).toBeFalse(); + expect(component.email?.errors?.['pattern']).toBeTruthy(); + + // Valid + component.email?.setValue('valid-email@test.com'); + expect(component.email?.valid).toBeTrue(); + }); + + it('should validate password pattern', () => { + // Invalid + component.password?.setValue('invalid-password'); + expect(component.password?.valid).toBeFalse(); + expect(component.password?.errors?.['pattern']).toBeTruthy(); + + // Valid + component.password?.setValue('ValidPassword@123'); + expect(component.password?.valid).toBeTrue(); + }); + + describe('onSubmit', () => { + it('should call markAllAsTouched on registerForm and return when form is invalid', () => { + // Arrange + component.loginForm.setValue({ + email: '', + password: '', + }); + spyOn(component.loginForm, 'markAllAsTouched'); + + // Act + component.onSubmit(); + + // Assert + expect(component.loginForm.invalid).toBeTrue(); + expect(component.loginForm.markAllAsTouched).toHaveBeenCalled(); + }); + + it('should call login method of Auth service when form is valid', () => { + // Arrange + const mockUserData = { + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.loginForm.setValue(mockUserData); + const mockResponse: AccessTokenDto = { + access_token: 'access_token', + }; + authService.login.and.returnValue(of(mockResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.login).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe(null); + }); + + it('should set the error message received from server on failure', () => { + // Arrange + const mockUserData = { + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.loginForm.setValue(mockUserData); + const errorResponse = { + error: { + message: 'Unable to complete registration.', + }, + }; + authService.login.and.returnValue(throwError(() => errorResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.login).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe('Unable to complete registration.'); + }); + + it('should set the default error message on empty response from server', () => { + // Arrange + const mockUserData = { + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.loginForm.setValue(mockUserData); + const emptyResponse = {}; + authService.login.and.returnValue(throwError(() => emptyResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.login).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe('Login failed. Please try again.'); + }); + }); +}); diff --git a/apps/frontend/src/app/auth/components/login/login.ts b/apps/frontend/src/app/auth/components/login/login.ts new file mode 100644 index 0000000..3efadf8 --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.ts @@ -0,0 +1,62 @@ +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { Auth } from '@/app/auth/services/auth'; +import { + PASSWORD_REGEX_STRING, + PASSWORD_VALIDATION_MESSAGE, +} from '@shared/validation/password.constants'; +import { EMAIL_REGEX_STRING } from '@shared/validation/email.constants'; +import { LoginUserDto } from '@shared/dtos/auth/login-user.dto'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-login', + imports: [ReactiveFormsModule, CommonModule], + templateUrl: './login.html', + styleUrl: './login.css', +}) +export class Login { + private formBuilder = inject(FormBuilder); + private authService = inject(Auth); + + loginForm = this.formBuilder.group({ + email: ['', [Validators.required, Validators.pattern(new RegExp(EMAIL_REGEX_STRING))]], + password: ['', [Validators.required, Validators.pattern(new RegExp(PASSWORD_REGEX_STRING))]], + }); + passwordValidationMessage = PASSWORD_VALIDATION_MESSAGE; + + errorMessage: string | null = null; + + get email() { + return this.loginForm.get('email'); + } + + get password() { + return this.loginForm.get('password'); + } + + onSubmit() { + if (this.loginForm.invalid) { + this.loginForm.markAllAsTouched(); + return; + } + + const userData = this.loginForm.value as LoginUserDto; + this.authService.login(userData).subscribe({ + next: (response) => { + this.errorMessage = null; + localStorage.setItem('access_token', response.access_token); + console.log('Login successful!', response); + }, + error: (err) => { + if (err.error && err.error.message) { + this.errorMessage = Array.isArray(err.error.errorMessage) + ? err.error.message[0] + : err.error.message; + } else { + this.errorMessage = 'Login failed. Please try again.'; + } + }, + }); + } +} diff --git a/apps/frontend/src/app/auth/components/register/register.css b/apps/frontend/src/app/auth/components/register/register.css new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/src/app/auth/components/register/register.html b/apps/frontend/src/app/auth/components/register/register.html new file mode 100644 index 0000000..857f89e --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.html @@ -0,0 +1,35 @@ +
+ + + @if (name?.invalid && (name?.touched || name?.dirty)) { + Name is required. + } + + + + @if (email?.invalid && (email?.touched || email?.dirty)) { + @if (email?.errors?.['required']) { + Email is required. + } + @if (email?.errors?.['pattern']) { + Please enter a valid email address. (e.g. user@domain.com) + } + } + + + + @if (password?.invalid && (password?.touched || password?.dirty)) { + @if (password?.errors?.['required']) { + Password is required. + } + @if (password?.errors?.['pattern']) { + {{ passwordValidationMessage }} + } + } + + + + @if (errorMessage) { +
{{ errorMessage }}
+ } +
diff --git a/apps/frontend/src/app/auth/components/register/register.spec.ts b/apps/frontend/src/app/auth/components/register/register.spec.ts new file mode 100644 index 0000000..723431e --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Register } from '@/app/auth/components/register/register'; +import { Auth } from '@/app/auth/services/auth'; +import { of, throwError } from 'rxjs'; +import { UserResponseDto } from '@shared/dtos/user/user-response.dto'; + +describe('Register', () => { + let component: Register; + let fixture: ComponentFixture; + let authService: jasmine.SpyObj; + + const authServiceSpyObj = jasmine.createSpyObj('Auth', ['register']); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Register], + providers: [ + { + provide: Auth, + useValue: authServiceSpyObj, + }, + ], + }).compileComponents(); + + authService = TestBed.inject(Auth) as jasmine.SpyObj; + + fixture = TestBed.createComponent(Register); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should validate required form controls', () => { + expect(component.registerForm.valid).toBeFalse(); + }); + + it('should validate email pattern', () => { + // Invalid + component.email?.setValue('invalid-email'); + expect(component.email?.valid).toBeFalse(); + expect(component.email?.errors?.['pattern']).toBeTruthy(); + + // Valid + component.email?.setValue('valid-email@test.com'); + expect(component.email?.valid).toBeTrue(); + }); + + it('should validate password pattern', () => { + // Invalid + component.password?.setValue('invalid-password'); + expect(component.password?.valid).toBeFalse(); + expect(component.password?.errors?.['pattern']).toBeTruthy(); + + // Valid + component.password?.setValue('ValidPassword@123'); + expect(component.password?.valid).toBeTrue(); + }); + + describe('onSubmit', () => { + it('should call markAllAsTouched on registerForm and return when form is invalid', () => { + // Arrange + component.registerForm.setValue({ + name: '', + email: '', + password: '', + }); + spyOn(component.registerForm, 'markAllAsTouched'); + + // Act + component.onSubmit(); + + // Assert + expect(component.registerForm.invalid).toBeTrue(); + expect(component.registerForm.markAllAsTouched).toHaveBeenCalled(); + }); + + it('should call register method of Auth service when form is valid', () => { + // Arrange + const mockUserData = { + name: 'Test', + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.registerForm.setValue(mockUserData); + const mockResponse: UserResponseDto = { + id: 1, + name: 'Test', + email: 'email@test.com', + }; + authService.register.and.returnValue(of(mockResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.register).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe(null); + }); + + it('should set the error message received from server on failure', () => { + // Arrange + const mockUserData = { + name: 'Test', + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.registerForm.setValue(mockUserData); + const errorResponse = { + error: { + message: 'Unable to complete registration.', + }, + }; + authService.register.and.returnValue(throwError(() => errorResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.register).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe('Unable to complete registration.'); + }); + + it('should set the default error message on empty response from server', () => { + // Arrange + const mockUserData = { + name: 'Test', + email: 'email@test.com', + password: 'TestPassword@123', + }; + component.registerForm.setValue(mockUserData); + const emptyResponse = {}; + authService.register.and.returnValue(throwError(() => emptyResponse)); + + // Act + component.onSubmit(); + + // Assert + expect(authService.register).toHaveBeenCalledWith(mockUserData); + expect(component.errorMessage).toBe('Registration failed. Please try again.'); + }); + }); +}); diff --git a/apps/frontend/src/app/auth/components/register/register.ts b/apps/frontend/src/app/auth/components/register/register.ts new file mode 100644 index 0000000..0b92791 --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.ts @@ -0,0 +1,66 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + PASSWORD_REGEX_STRING, + PASSWORD_VALIDATION_MESSAGE, +} from '@shared/validation/password.constants'; +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'; + +@Component({ + selector: 'app-register', + imports: [ReactiveFormsModule, CommonModule], + templateUrl: './register.html', + styleUrl: './register.css', +}) +export class Register { + private formBuilder = inject(FormBuilder); + private authService = inject(Auth); + + registerForm = this.formBuilder.group({ + name: ['', [Validators.required]], + email: ['', [Validators.required, Validators.pattern(new RegExp(EMAIL_REGEX_STRING))]], + password: ['', [Validators.required, Validators.pattern(new RegExp(PASSWORD_REGEX_STRING))]], + }); + passwordValidationMessage = PASSWORD_VALIDATION_MESSAGE; + + errorMessage: string | null = null; + + get name() { + return this.registerForm.get('name'); + } + + get email() { + return this.registerForm.get('email'); + } + + get password() { + return this.registerForm.get('password'); + } + + onSubmit() { + if (this.registerForm.invalid) { + this.registerForm.markAllAsTouched(); + return; + } + + const userData = this.registerForm.value as RegisterUserDto; + this.authService.register(userData).subscribe({ + next: (response) => { + this.errorMessage = null; + console.log('Registration successful!', response); + }, + error: (err) => { + if (err.error && err.error.message) { + this.errorMessage = Array.isArray(err.error.message) + ? err.error.message[0] + : err.error.message; + } else { + this.errorMessage = 'Registration failed. Please try again.'; + } + }, + }); + } +} diff --git a/apps/frontend/src/app/auth/services/auth.spec.ts b/apps/frontend/src/app/auth/services/auth.spec.ts new file mode 100644 index 0000000..759ed24 --- /dev/null +++ b/apps/frontend/src/app/auth/services/auth.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { Auth } from '@/app/auth/services/auth'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +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'; + +describe('Auth', () => { + let service: Auth; + let httpMock: HttpTestingController; + + const apiUrl = 'http://localhost:3000/auth'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(Auth); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('register', () => { + it('should POST to /register and return user data', () => { + const mockRegisterData: RegisterUserDto = { + name: 'Test', + email: 'email@test.com', + password: 'TestPassword@123', + }; + + const mockResponse: UserResponseDto = { + id: 1, + name: 'Test', + email: 'email@test.com', + }; + + service.register(mockRegisterData).subscribe((response) => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${apiUrl}/register`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockRegisterData); + + req.flush(mockResponse); + }); + }); + + describe('login', () => { + it('should POST to /login an return acces token', () => { + const mockLoginData: LoginUserDto = { + email: 'email@test.com', + password: 'TestPassword@123', + }; + + const mockResponse: AccessTokenDto = { + access_token: 'access_token', + }; + + service.login(mockLoginData).subscribe((response) => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne(`${apiUrl}/login`); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(mockLoginData); + + req.flush(mockResponse); + }); + }); +}); diff --git a/apps/frontend/src/app/auth/services/auth.ts b/apps/frontend/src/app/auth/services/auth.ts new file mode 100644 index 0000000..ca89bd0 --- /dev/null +++ b/apps/frontend/src/app/auth/services/auth.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +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'; + +@Injectable({ + providedIn: 'root', +}) +export class Auth { + private httpClient = inject(HttpClient); + private apiUrl = 'http://localhost:3000/auth'; + + register(userData: RegisterUserDto): Observable { + return this.httpClient.post(`${this.apiUrl}/register`, userData); + } + + login(userData: LoginUserDto): Observable { + return this.httpClient.post(`${this.apiUrl}/login`, userData); + } +} diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index e4955f2..7292759 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -13,7 +13,13 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@shared/validation/*": ["../../libs/shared-validation/src/*"], + "@shared/dtos/*": ["../../libs/shared-dtos/src/*"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/libs/shared-dtos/src/auth/access-token.dto.ts b/libs/shared-dtos/src/auth/access-token.dto.ts new file mode 100644 index 0000000..d89a916 --- /dev/null +++ b/libs/shared-dtos/src/auth/access-token.dto.ts @@ -0,0 +1,3 @@ +export class AccessTokenDto { + readonly access_token!: string; +} diff --git a/apps/backend/src/auth/dto/login-user.dto.ts b/libs/shared-dtos/src/auth/login-user.dto.ts similarity index 83% rename from apps/backend/src/auth/dto/login-user.dto.ts rename to libs/shared-dtos/src/auth/login-user.dto.ts index 690dcb4..9195871 100644 --- a/apps/backend/src/auth/dto/login-user.dto.ts +++ b/libs/shared-dtos/src/auth/login-user.dto.ts @@ -4,10 +4,10 @@ export class LoginUserDto { @IsNotEmpty() @IsEmail() @MaxLength(255) - email: string; + email!: string; @IsNotEmpty() @IsString() @MaxLength(100) - password: string; + password!: string; } diff --git a/libs/shared-dtos/src/auth/register-user.dto.ts b/libs/shared-dtos/src/auth/register-user.dto.ts new file mode 100644 index 0000000..f6793e7 --- /dev/null +++ b/libs/shared-dtos/src/auth/register-user.dto.ts @@ -0,0 +1,21 @@ +import { IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator'; +import { + PASSWORD_REGEX_STRING, + PASSWORD_VALIDATION_MESSAGE, +} from '@shared/validation/password.constants'; + +export class RegisterUserDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsNotEmpty() + @IsEmail() + email!: string; + + @IsNotEmpty() + @Matches(new RegExp(PASSWORD_REGEX_STRING), { + message: PASSWORD_VALIDATION_MESSAGE, + }) + password!: string; +} diff --git a/apps/backend/src/users/user-response.dto.ts b/libs/shared-dtos/src/user/user-response.dto.ts similarity index 63% rename from apps/backend/src/users/user-response.dto.ts rename to libs/shared-dtos/src/user/user-response.dto.ts index 4ad6e05..dbbbf17 100644 --- a/apps/backend/src/users/user-response.dto.ts +++ b/libs/shared-dtos/src/user/user-response.dto.ts @@ -3,7 +3,7 @@ * Excludes sensitive fields like password hash. */ export class UserResponseDto { - readonly id: number; - readonly name: string; - readonly email: string; + readonly id!: number; + readonly name!: string; + readonly email!: string; } diff --git a/libs/shared-validation/src/email.constants.ts b/libs/shared-validation/src/email.constants.ts new file mode 100644 index 0000000..cc45c5f --- /dev/null +++ b/libs/shared-validation/src/email.constants.ts @@ -0,0 +1,2 @@ +export const EMAIL_REGEX_STRING: string = + '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'; diff --git a/libs/shared-validation/src/password.constants.ts b/libs/shared-validation/src/password.constants.ts new file mode 100644 index 0000000..3fed19a --- /dev/null +++ b/libs/shared-validation/src/password.constants.ts @@ -0,0 +1,5 @@ +export const PASSWORD_REGEX_STRING: string = + '^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()]).{8,}$'; + +export const PASSWORD_VALIDATION_MESSAGE: string = + 'Password must contain at least one uppercase letter, one lowercase letter, one number, one special character, and be at least 8 characters long.'; diff --git a/package-lock.json b/package-lock.json index 79089f4..3d2fc20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@nestjs/jwt": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^11.0.0", - "bcrypt": "^6.0.0", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "passport-jwt": "^4.0.1", @@ -39,7 +39,7 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", - "@types/bcrypt": "^6.0.0", + "@types/bcryptjs": "^2.4.6", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", @@ -72,6 +72,8 @@ "@angular/forms": "^20.3.0", "@angular/platform-browser": "^20.3.0", "@angular/router": "^20.3.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -6923,15 +6925,12 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "license": "MIT" }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -8916,27 +8915,13 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/bcrypt/node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" } }, "node_modules/beasties": { @@ -15866,17 +15851,6 @@ "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",