From 8686e81f6329aa44e1188d79960a85795c89dd95 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Mon, 27 Oct 2025 23:28:53 +0530 Subject: [PATCH 1/8] chore(github): add permissions block to linting check action --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) 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'] From 3fff76e896831f3db2b4790700a1c168190bc4b0 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Mon, 27 Oct 2025 23:59:29 +0530 Subject: [PATCH 2/8] chore(lint): update parserOptions for both frontend and backend --- apps/backend/eslint.config.mjs | 2 +- apps/frontend/eslint.config.js | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) 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/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'], From bff195f29aad83b5eb3a612898d1f716d5b77aa7 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Tue, 28 Oct 2025 01:37:06 +0530 Subject: [PATCH 3/8] chore(frontend): add --open to npm start script --- apps/frontend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 2df7f15..e687fd1 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", From 1aba74d8203bb50803e11a3435aef7e489cbd05d Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Tue, 28 Oct 2025 01:38:36 +0530 Subject: [PATCH 4/8] feat(ui): setup auth module with components and service --- apps/frontend/src/app/app.config.ts | 12 +- apps/frontend/src/app/app.html | 341 ------------------ apps/frontend/src/app/app.routes.ts | 7 +- apps/frontend/src/app/auth/auth-module.ts | 14 + .../src/app/auth/auth-routing-module.ts | 26 ++ .../src/app/auth/components/login/login.css | 0 .../src/app/auth/components/login/login.html | 1 + .../app/auth/components/login/login.spec.ts | 23 ++ .../src/app/auth/components/login/login.ts | 11 + .../app/auth/components/register/register.css | 0 .../auth/components/register/register.html | 1 + .../auth/components/register/register.spec.ts | 23 ++ .../app/auth/components/register/register.ts | 11 + .../src/app/auth/services/auth.spec.ts | 16 + apps/frontend/src/app/auth/services/auth.ts | 8 + 15 files changed, 149 insertions(+), 345 deletions(-) create mode 100644 apps/frontend/src/app/auth/auth-module.ts create mode 100644 apps/frontend/src/app/auth/auth-routing-module.ts create mode 100644 apps/frontend/src/app/auth/components/login/login.css create mode 100644 apps/frontend/src/app/auth/components/login/login.html create mode 100644 apps/frontend/src/app/auth/components/login/login.spec.ts create mode 100644 apps/frontend/src/app/auth/components/login/login.ts create mode 100644 apps/frontend/src/app/auth/components/register/register.css create mode 100644 apps/frontend/src/app/auth/components/register/register.html create mode 100644 apps/frontend/src/app/auth/components/register/register.spec.ts create mode 100644 apps/frontend/src/app/auth/components/register/register.ts create mode 100644 apps/frontend/src/app/auth/services/auth.spec.ts create mode 100644 apps/frontend/src/app/auth/services/auth.ts 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/auth/auth-module.ts b/apps/frontend/src/app/auth/auth-module.ts new file mode 100644 index 0000000..32cc350 --- /dev/null +++ b/apps/frontend/src/app/auth/auth-module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { AuthRoutingModule } from './auth-routing-module'; + + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + AuthRoutingModule + ] +}) +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..147cfc4 --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.html @@ -0,0 +1 @@ +

login works!

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..dd8bbb3 --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Login } from './login'; + +describe('Login', () => { + let component: Login; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Login] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Login); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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..c39dbb9 --- /dev/null +++ b/apps/frontend/src/app/auth/components/login/login.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-login', + imports: [], + templateUrl: './login.html', + styleUrl: './login.css' +}) +export class Login { + +} 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..6b0ba2e --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.html @@ -0,0 +1 @@ +

register works!

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..eac286c --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Register } from './register'; + +describe('Register', () => { + let component: Register; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Register] + }) + .compileComponents(); + + fixture = TestBed.createComponent(Register); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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..94576cb --- /dev/null +++ b/apps/frontend/src/app/auth/components/register/register.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-register', + imports: [], + templateUrl: './register.html', + styleUrl: './register.css' +}) +export class Register { + +} 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..3a04d76 --- /dev/null +++ b/apps/frontend/src/app/auth/services/auth.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { Auth } from './auth'; + +describe('Auth', () => { + let service: Auth; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(Auth); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); 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..a9d7c56 --- /dev/null +++ b/apps/frontend/src/app/auth/services/auth.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class Auth { + +} From e43ea0dfdefc428dc757913dd65877138500b975 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Thu, 6 Nov 2025 19:08:49 +0530 Subject: [PATCH 5/8] chore(monorepo): configure shared library path aliases This commit implements a new shared library (`libs/shared-validation`) and configures the entire monorepo to support it. This was required to share the password validation regex between the frontend and backend. Changes include: - Adding the `@shared/validation` path alias to both the backend and frontend `tsconfig.json` files. - Updating the backend's Jest config (`moduleNameMapper`) to resolve the alias during tests. - Swapping `bcrypt` for `bcryptjs` (a pure-JS library) to resolve a build conflict with Webpack. - Configuring the backend's `nest-cli.json` to use the `webpack` builder, which is required to bundle the aliased `libs` folder for production. --- apps/backend/nest-cli.json | 1 + apps/backend/package.json | 7 ++- apps/backend/src/auth/auth.service.spec.ts | 4 +- apps/backend/src/auth/auth.service.ts | 2 +- apps/backend/tsconfig.json | 5 +- apps/frontend/tsconfig.json | 7 ++- .../src/password.constants.ts | 5 ++ package-lock.json | 56 +++++-------------- 8 files changed, 37 insertions(+), 50 deletions(-) create mode 100644 libs/shared-validation/src/password.constants.ts 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..e008155 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,8 @@ "coverageDirectory": "../coverage", "testEnvironment": "node", "moduleNameMapper": { - "@/(.*)$": "/$1" + "@/(.*)$": "/$1", + "@shared/validation/(.*)": "/../../../libs/shared-validation/src/$1" } } } diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 8f88677..d5046f4 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -3,7 +3,7 @@ import { AuthService } from '@/auth/auth.service'; import { DataSource } from 'typeorm'; import { RegisterUserDto } from '@/auth/dto/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'; @@ -11,7 +11,7 @@ import { JwtService } from '@nestjs/jwt'; import { LoginUserDto } from '@/auth/dto/login-user.dto'; import { AccessTokenDto } from '@/auth/dto/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..a3a9bca 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -4,7 +4,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { RegisterUserDto } from '@/auth/dto/register-user.dto'; -import * as bcrypt from 'bcrypt'; +import * as bcrypt from 'bcryptjs'; import { DataSource } from 'typeorm'; import { User } from '@/users/user.entity'; import { UserResponseDto } from '@/users/user-response.dto'; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 7755fc3..91b23ec 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -15,7 +15,10 @@ "outDir": "./dist", "baseUrl": "./", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@shared/validation/*": [ + "../../libs/shared-validation/src/*" + ] }, "types": ["node", "jest"], "incremental": true, diff --git a/apps/frontend/tsconfig.json b/apps/frontend/tsconfig.json index e4955f2..86498d0 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -13,7 +13,12 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@shared/validation/*": ["../../libs/shared-validation/src/*"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/libs/shared-validation/src/password.constants.ts b/libs/shared-validation/src/password.constants.ts new file mode 100644 index 0000000..43a16e9 --- /dev/null +++ b/libs/shared-validation/src/password.constants.ts @@ -0,0 +1,5 @@ +export const PASSWORD_REGEX_STRING = + '^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()]).{8,}$'; + +export const PASSWORD_VALIDATION_MESSAGE = + '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..93d0b98 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", @@ -6923,15 +6923,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 +8913,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 +15849,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", From 03b36ca7702a7c0ce53513ec8253428909b11cf7 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Thu, 6 Nov 2025 19:12:38 +0530 Subject: [PATCH 6/8] refactor(backend): update dto to use shared validations and remove redundant validations --- apps/backend/src/auth/dto/register-user.dto.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/auth/dto/register-user.dto.ts b/apps/backend/src/auth/dto/register-user.dto.ts index 02c110e..c77e419 100644 --- a/apps/backend/src/auth/dto/register-user.dto.ts +++ b/apps/backend/src/auth/dto/register-user.dto.ts @@ -1,10 +1,8 @@ +import { IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator'; import { - IsEmail, - IsNotEmpty, - IsString, - Matches, - MinLength, -} from 'class-validator'; + PASSWORD_REGEX_STRING, + PASSWORD_VALIDATION_MESSAGE, +} from '@shared/validation/password.constants'; export class RegisterUserDto { @IsString() @@ -16,10 +14,8 @@ export class RegisterUserDto { 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.', + @Matches(new RegExp(PASSWORD_REGEX_STRING), { + message: PASSWORD_VALIDATION_MESSAGE, }) password: string; } From 833a43cbd56afe13d32b34f09225b5f79170154f Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Sun, 21 Dec 2025 00:01:17 +0530 Subject: [PATCH 7/8] chore(monorepo): move dtos from backend to the shared library --- apps/backend/package.json | 3 ++- apps/backend/src/auth/auth.controller.spec.ts | 8 ++++---- apps/backend/src/auth/auth.controller.ts | 8 ++++---- apps/backend/src/auth/auth.service.spec.ts | 6 +++--- apps/backend/src/auth/auth.service.ts | 8 ++++---- apps/backend/src/auth/dto/access-token.dto.ts | 3 --- apps/backend/tsconfig.json | 8 ++++---- apps/frontend/package.json | 2 ++ apps/frontend/tsconfig.json | 3 ++- libs/shared-dtos/src/auth/access-token.dto.ts | 3 +++ .../dto => libs/shared-dtos/src/auth}/login-user.dto.ts | 4 ++-- .../shared-dtos/src/auth}/register-user.dto.ts | 6 +++--- .../shared-dtos/src/user}/user-response.dto.ts | 6 +++--- package-lock.json | 2 ++ 14 files changed, 38 insertions(+), 32 deletions(-) delete mode 100644 apps/backend/src/auth/dto/access-token.dto.ts create mode 100644 libs/shared-dtos/src/auth/access-token.dto.ts rename {apps/backend/src/auth/dto => libs/shared-dtos/src/auth}/login-user.dto.ts (83%) rename {apps/backend/src/auth/dto => libs/shared-dtos/src/auth}/register-user.dto.ts (87%) rename {apps/backend/src/users => libs/shared-dtos/src/user}/user-response.dto.ts (63%) diff --git a/apps/backend/package.json b/apps/backend/package.json index e008155..201c70a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -82,7 +82,8 @@ "testEnvironment": "node", "moduleNameMapper": { "@/(.*)$": "/$1", - "@shared/validation/(.*)": "/../../../libs/shared-validation/src/$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 d5046f4..4beb45c 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,15 +1,15 @@ 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 '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('bcryptjs', () => ({ hash: jest.fn(), diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index a3a9bca..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 { 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/tsconfig.json b/apps/backend/tsconfig.json index 91b23ec..77b2283 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -16,9 +16,8 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"], - "@shared/validation/*": [ - "../../libs/shared-validation/src/*" - ] + "@shared/validation/*": ["../../libs/shared-validation/src/*"], + "@shared/dtos/*": ["../../libs/shared-dtos/src/*"] }, "types": ["node", "jest"], "incremental": true, @@ -29,5 +28,6 @@ "strictBindCallApply": false, "noFallthroughCasesInSwitch": false, "useUnknownInCatchVariables": true - } + }, + "include": ["src/**/*", "../../libs/**/*"] } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e687fd1..ade4fb0 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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/tsconfig.json b/apps/frontend/tsconfig.json index 86498d0..7292759 100644 --- a/apps/frontend/tsconfig.json +++ b/apps/frontend/tsconfig.json @@ -17,7 +17,8 @@ "baseUrl": "./", "paths": { "@/*": ["src/*"], - "@shared/validation/*": ["../../libs/shared-validation/src/*"] + "@shared/validation/*": ["../../libs/shared-validation/src/*"], + "@shared/dtos/*": ["../../libs/shared-dtos/src/*"] } }, "angularCompilerOptions": { 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/apps/backend/src/auth/dto/register-user.dto.ts b/libs/shared-dtos/src/auth/register-user.dto.ts similarity index 87% rename from apps/backend/src/auth/dto/register-user.dto.ts rename to libs/shared-dtos/src/auth/register-user.dto.ts index c77e419..f6793e7 100644 --- a/apps/backend/src/auth/dto/register-user.dto.ts +++ b/libs/shared-dtos/src/auth/register-user.dto.ts @@ -7,15 +7,15 @@ import { export class RegisterUserDto { @IsString() @IsNotEmpty() - name: string; + name!: string; @IsNotEmpty() @IsEmail() - email: string; + email!: string; @IsNotEmpty() @Matches(new RegExp(PASSWORD_REGEX_STRING), { message: PASSWORD_VALIDATION_MESSAGE, }) - password: string; + 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/package-lock.json b/package-lock.json index 93d0b98..3d2fc20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" From 7f03a38b34f901dfa83d66d1bcafd2d739f0d435 Mon Sep 17 00:00:00 2001 From: Zafar Shaikh Date: Sun, 21 Dec 2025 02:14:57 +0530 Subject: [PATCH 8/8] feat(ui|auth): build registration and login forms --- apps/backend/src/main.ts | 5 + apps/frontend/src/app/app.spec.ts | 9 +- apps/frontend/src/app/app.ts | 8 +- apps/frontend/src/app/auth/auth-module.ts | 11 +- .../src/app/auth/components/login/login.html | 30 +++- .../app/auth/components/login/login.spec.ts | 126 ++++++++++++++++- .../src/app/auth/components/login/login.ts | 57 +++++++- .../auth/components/register/register.html | 36 ++++- .../auth/components/register/register.spec.ts | 132 +++++++++++++++++- .../app/auth/components/register/register.ts | 61 +++++++- .../src/app/auth/services/auth.spec.ts | 71 +++++++++- apps/frontend/src/app/auth/services/auth.ts | 21 ++- libs/shared-validation/src/email.constants.ts | 2 + .../src/password.constants.ts | 4 +- 14 files changed, 527 insertions(+), 46 deletions(-) create mode 100644 libs/shared-validation/src/email.constants.ts 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/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 index 32cc350..3bb9a6c 100644 --- a/apps/frontend/src/app/auth/auth-module.ts +++ b/apps/frontend/src/app/auth/auth-module.ts @@ -1,14 +1,11 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { AuthRoutingModule } from './auth-routing-module'; - +import { AuthRoutingModule } from '@/app/auth/auth-routing-module'; +import { ReactiveFormsModule } from '@angular/forms'; @NgModule({ declarations: [], - imports: [ - CommonModule, - AuthRoutingModule - ] + imports: [CommonModule, AuthRoutingModule, ReactiveFormsModule], }) -export class AuthModule { } +export class AuthModule {} diff --git a/apps/frontend/src/app/auth/components/login/login.html b/apps/frontend/src/app/auth/components/login/login.html index 147cfc4..189526d 100644 --- a/apps/frontend/src/app/auth/components/login/login.html +++ b/apps/frontend/src/app/auth/components/login/login.html @@ -1 +1,29 @@ -

login works!

+
+ + + @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 index dd8bbb3..bbe7503 100644 --- a/apps/frontend/src/app/auth/components/login/login.spec.ts +++ b/apps/frontend/src/app/auth/components/login/login.spec.ts @@ -1,16 +1,28 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Login } from './login'; +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] - }) - .compileComponents(); + imports: [Login], + providers: [ + { + provide: Auth, + useValue: authServiceSpyObj, + }, + ], + }).compileComponents(); + + authService = TestBed.inject(Auth) as jasmine.SpyObj; fixture = TestBed.createComponent(Login); component = fixture.componentInstance; @@ -20,4 +32,108 @@ describe('Login', () => { 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 index c39dbb9..3efadf8 100644 --- a/apps/frontend/src/app/auth/components/login/login.ts +++ b/apps/frontend/src/app/auth/components/login/login.ts @@ -1,11 +1,62 @@ -import { Component } from '@angular/core'; +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: [], + imports: [ReactiveFormsModule, CommonModule], templateUrl: './login.html', - styleUrl: './login.css' + 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.html b/apps/frontend/src/app/auth/components/register/register.html index 6b0ba2e..857f89e 100644 --- a/apps/frontend/src/app/auth/components/register/register.html +++ b/apps/frontend/src/app/auth/components/register/register.html @@ -1 +1,35 @@ -

register works!

+
+ + + @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 index eac286c..723431e 100644 --- a/apps/frontend/src/app/auth/components/register/register.spec.ts +++ b/apps/frontend/src/app/auth/components/register/register.spec.ts @@ -1,16 +1,28 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { Register } from './register'; +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] - }) - .compileComponents(); + imports: [Register], + providers: [ + { + provide: Auth, + useValue: authServiceSpyObj, + }, + ], + }).compileComponents(); + + authService = TestBed.inject(Auth) as jasmine.SpyObj; fixture = TestBed.createComponent(Register); component = fixture.componentInstance; @@ -20,4 +32,114 @@ describe('Register', () => { 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 index 94576cb..0b92791 100644 --- a/apps/frontend/src/app/auth/components/register/register.ts +++ b/apps/frontend/src/app/auth/components/register/register.ts @@ -1,11 +1,66 @@ -import { Component } from '@angular/core'; +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: [], + imports: [ReactiveFormsModule, CommonModule], templateUrl: './register.html', - styleUrl: './register.css' + 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 index 3a04d76..759ed24 100644 --- a/apps/frontend/src/app/auth/services/auth.spec.ts +++ b/apps/frontend/src/app/auth/services/auth.spec.ts @@ -1,16 +1,81 @@ import { TestBed } from '@angular/core/testing'; - -import { Auth } from './auth'; +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({}); + 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 index a9d7c56..ca89bd0 100644 --- a/apps/frontend/src/app/auth/services/auth.ts +++ b/apps/frontend/src/app/auth/services/auth.ts @@ -1,8 +1,23 @@ -import { Injectable } from '@angular/core'; +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' + 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/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 index 43a16e9..3fed19a 100644 --- a/libs/shared-validation/src/password.constants.ts +++ b/libs/shared-validation/src/password.constants.ts @@ -1,5 +1,5 @@ -export const PASSWORD_REGEX_STRING = +export const PASSWORD_REGEX_STRING: string = '^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*()]).{8,}$'; -export const PASSWORD_VALIDATION_MESSAGE = +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.';