From da478a81d0d35a539e807d072ae045a4f1361484 Mon Sep 17 00:00:00 2001 From: RUKAYAT-CODER Date: Fri, 23 Jan 2026 22:57:43 +0100 Subject: [PATCH] Develop Authentication and Authorization System with RBAC --- backend/package-lock.json | 343 ++++++++++++-- backend/package.json | 17 +- backend/src/app.module.original.ts | 103 +++++ backend/src/app.module.ts | 2 +- .../asset-categories.module.ts | 11 + .../asset-categories/asset-category.entity.ts | 40 ++ .../audit-logs/audit-logging.interceptor.ts | 15 + backend/src/audit-logs/audit-logs.module.ts | 9 + backend/src/auth/auth.controller.ts | 189 ++++++++ backend/src/auth/auth.module.ts | 34 ++ backend/src/auth/auth.service.ts | 429 ++++++++++++++++++ .../src/auth/entities/refresh-token.entity.ts | 43 ++ backend/src/auth/guards/jwt-auth.guard.ts | 5 + backend/src/auth/guards/local-auth.guard.ts | 5 + backend/src/auth/guards/rbac.guard.ts | 36 ++ backend/src/auth/strategies/jwt.strategy.ts | 27 ++ backend/src/auth/strategies/local.strategy.ts | 22 + .../require-permission.decorator.ts | 4 + .../departments/entities/department.entity.ts | 31 ++ backend/src/email/email.module.ts | 8 + backend/src/email/email.service.ts | 46 ++ .../entities/file-upload.entity.ts | 44 ++ .../permissions/dto/create-permission.dto.ts | 18 + .../permissions/dto/update-permission.dto.ts | 5 + .../permissions/entities/permission.entity.ts | 40 ++ .../src/permissions/permissions.controller.ts | 58 +++ backend/src/permissions/permissions.module.ts | 13 + .../src/permissions/permissions.service.ts | 87 ++++ backend/src/roles/dto/create-role.dto.ts | 19 + backend/src/roles/dto/update-role.dto.ts | 5 + backend/src/roles/entities/role.entity.ts | 40 ++ backend/src/roles/roles.controller.ts | 70 +++ backend/src/roles/roles.module.ts | 14 + backend/src/roles/roles.service.ts | 107 +++++ backend/src/security/throttler.guard.ts | 5 + backend/src/users/dto/create-user.dto.ts | 36 ++ backend/src/users/dto/update-user.dto.ts | 40 ++ backend/src/users/entities/user.entity.ts | 109 +++++ backend/src/users/user.module.ts | 11 + backend/src/users/user.service.ts | 46 ++ backend/src/users/users.controller.ts | 84 ++++ backend/src/webhooks/webhooks.module.ts | 9 + 42 files changed, 2239 insertions(+), 40 deletions(-) create mode 100644 backend/src/app.module.original.ts create mode 100644 backend/src/asset-categories/asset-categories.module.ts create mode 100644 backend/src/asset-categories/asset-category.entity.ts create mode 100644 backend/src/audit-logs/audit-logging.interceptor.ts create mode 100644 backend/src/audit-logs/audit-logs.module.ts create mode 100644 backend/src/auth/auth.controller.ts create mode 100644 backend/src/auth/auth.module.ts create mode 100644 backend/src/auth/auth.service.ts create mode 100644 backend/src/auth/entities/refresh-token.entity.ts create mode 100644 backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/auth/guards/local-auth.guard.ts create mode 100644 backend/src/auth/guards/rbac.guard.ts create mode 100644 backend/src/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/auth/strategies/local.strategy.ts create mode 100644 backend/src/common/decorators/require-permission.decorator.ts create mode 100644 backend/src/departments/entities/department.entity.ts create mode 100644 backend/src/email/email.module.ts create mode 100644 backend/src/email/email.service.ts create mode 100644 backend/src/file-uploads/entities/file-upload.entity.ts create mode 100644 backend/src/permissions/dto/create-permission.dto.ts create mode 100644 backend/src/permissions/dto/update-permission.dto.ts create mode 100644 backend/src/permissions/entities/permission.entity.ts create mode 100644 backend/src/permissions/permissions.controller.ts create mode 100644 backend/src/permissions/permissions.module.ts create mode 100644 backend/src/permissions/permissions.service.ts create mode 100644 backend/src/roles/dto/create-role.dto.ts create mode 100644 backend/src/roles/dto/update-role.dto.ts create mode 100644 backend/src/roles/entities/role.entity.ts create mode 100644 backend/src/roles/roles.controller.ts create mode 100644 backend/src/roles/roles.module.ts create mode 100644 backend/src/roles/roles.service.ts create mode 100644 backend/src/security/throttler.guard.ts create mode 100644 backend/src/users/dto/create-user.dto.ts create mode 100644 backend/src/users/dto/update-user.dto.ts create mode 100644 backend/src/users/entities/user.entity.ts create mode 100644 backend/src/users/user.module.ts create mode 100644 backend/src/users/user.service.ts create mode 100644 backend/src/users/users.controller.ts create mode 100644 backend/src/webhooks/webhooks.module.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 44825fa..ac1dcf9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,34 +10,40 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", + "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", - "@nestjs/jwt": "^11.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0", + "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", - "bcryptjs": "^3.0.2", + "bcryptjs": "^3.0.3", "bwip-js": "^4.7.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", + "class-validator": "^0.14.3", "date-fns": "^4.1.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", + "otplib": "^13.1.1", "papaparse": "^5.5.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.11.3", "qrcode": "^1.5.4", + "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.27" }, @@ -49,9 +55,12 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.3.1", + "@types/otplib": "^7.0.0", "@types/papaparse": "^5.3.16", "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", "@types/pdfkit": "^0.17.3", "@types/pg": "^8.10.0", "@types/supertest": "^6.0.0", @@ -1730,12 +1739,13 @@ } }, "node_modules/@nestjs/jwt": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.0.tgz", - "integrity": "sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", "dependencies": { - "@types/jsonwebtoken": "9.0.7", - "jsonwebtoken": "9.0.2" + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" }, "peerDependencies": { "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" @@ -1765,6 +1775,7 @@ "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" @@ -1911,6 +1922,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "10.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", @@ -2004,6 +2026,74 @@ "npm": ">=5.0.0" } }, + "node_modules/@otplib/core": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.1.1.tgz", + "integrity": "sha512-K7167w5T5fBtI7FCTrcTyqHPNEKIvyBHFACvJGXci60V30Rt4VDsRHWw/LYtOZRbUqJbPH9orn4N10dYRVi3bw==", + "license": "MIT" + }, + "node_modules/@otplib/hotp": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.1.1.tgz", + "integrity": "sha512-2Zht/w3kb9Cv/BPNWC/KvFspztFae38BlteW4KQOn1U+jBj02EMpO3V75OaLGFyz3ZCWzYdLoTRhMkVDCVklDw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.1.1.tgz", + "integrity": "sha512-PAKmU60DOQyfwSP6VDcwP5wRO3GYCC8UgH0+J2wY2XI5OetHDPv27wNjBibE1iO7NMwCnUf1pv0rsTVE5TR9ew==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.1.1.tgz", + "integrity": "sha512-MbgmPWGzhlUG+Jq4sX1UHgdNVEcXqinZy9GUtu2iotHRya815oBVwkiviM/y2qorxuCOIMNTAArD6jKlVv/qiQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.1.1" + } + }, + "node_modules/@otplib/plugin-crypto-noble/node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@otplib/totp": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.1.1.tgz", + "integrity": "sha512-Bbr8C3fLQeUIiAU5sBltPIvPTsGOCmTBln2TjU8wt36nnWMK1p7SsV+VeAk4VYEsGCc4W/ds4M0Pbx4gJMt88A==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/hotp": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.1.1.tgz", + "integrity": "sha512-UMdz/41JIKPLw6/VeExc3MJaCHZYEZXlF5WVyhtWT5nfzQKAcgp6HI8Tar0lDN+lXXKCjbGwlyCKOWCMAISNXg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1" + } + }, "node_modules/@paralleldrive/cuid2": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", @@ -2034,6 +2124,75 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@redis/bloom": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz", + "integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/client": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", + "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@redis/json": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz", + "integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/search": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz", + "integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@redis/time-series": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz", + "integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@redis/client": "^5.10.0" + } + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2172,6 +2331,7 @@ "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", "dev": true, + "license": "MIT", "dependencies": { "bcryptjs": "*" } @@ -2311,10 +2471,12 @@ } }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", - "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, @@ -2335,6 +2497,12 @@ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", @@ -2352,6 +2520,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/otplib": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/otplib/-/otplib-7.0.0.tgz", + "integrity": "sha512-OZFn1eVNRGpaCfVZhTCIeSlHfxXM1oe1qtu9w07hWfH4nHiDo+tI6b6pIrOCNKQN9gYOP2M4Q43YvkT1R50deA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/papaparse": { "version": "5.3.16", "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz", @@ -2376,11 +2551,24 @@ "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/jsonwebtoken": "*", "@types/passport-strategy": "*" } }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -2442,6 +2630,15 @@ "@types/send": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -3271,6 +3468,12 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3300,9 +3503,10 @@ } }, "node_modules/bcryptjs": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", - "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "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" } @@ -3474,7 +3678,8 @@ "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -3680,14 +3885,14 @@ "license": "MIT" }, "node_modules/class-validator": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", - "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", "dependencies": { - "@types/validator": "^13.11.8", + "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", - "validator": "^13.9.0" + "validator": "^13.15.20" } }, "node_modules/cli-cursor": { @@ -3776,6 +3981,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4253,6 +4467,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" } @@ -6650,11 +6865,12 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -6671,9 +6887,10 @@ } }, "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -6681,11 +6898,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -7324,6 +7542,20 @@ "node": ">=0.10.0" } }, + "node_modules/otplib": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.1.1.tgz", + "integrity": "sha512-cjNU7ENJPwqNK7Qesl6P357B0WB4XmbptHCsGzy14jYcRmQyNwkvl0eT0nzUX0c1djrkOFVIHtNp3Px1vDIpfw==", + "license": "MIT", + "dependencies": { + "@otplib/core": "13.1.1", + "@otplib/hotp": "13.1.1", + "@otplib/plugin-base32-scure": "13.1.1", + "@otplib/plugin-crypto-noble": "13.1.1", + "@otplib/totp": "13.1.1", + "@otplib/uri": "13.1.1" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7421,6 +7653,7 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -7438,11 +7671,23 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", "dependencies": { "jsonwebtoken": "^9.0.0", "passport-strategy": "^1.0.0" } }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -8124,6 +8369,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", + "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.10.0", + "@redis/client": "5.10.0", + "@redis/json": "5.10.0", + "@redis/search": "5.10.0", + "@redis/time-series": "5.10.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -8693,6 +8954,18 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -9832,9 +10105,9 @@ } }, "node_modules/validator": { - "version": "13.15.15", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", - "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", "license": "MIT", "engines": { "node": ">= 0.10" diff --git a/backend/package.json b/backend/package.json index bf80535..09990f6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,34 +21,40 @@ }, "dependencies": { "@nestjs/common": "^10.0.0", - "@nestjs/config": "^3.0.0", + "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", - "@nestjs/jwt": "^11.0.0", + "@nestjs/jwt": "^11.0.2", "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^6.0.1", "@nestjs/swagger": "^7.3.0", + "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", "@types/multer": "^2.0.0", + "@types/speakeasy": "^2.0.10", "@types/uuid": "^10.0.0", - "bcryptjs": "^3.0.2", + "bcryptjs": "^3.0.3", "bwip-js": "^4.7.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.0", + "class-validator": "^0.14.3", "date-fns": "^4.1.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", + "otplib": "^13.1.1", "papaparse": "^5.5.3", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "pg": "^8.11.3", "qrcode": "^1.5.4", + "redis": "^5.10.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.27" }, @@ -60,9 +66,12 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.2", "@types/json2csv": "^5.0.7", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.3.1", + "@types/otplib": "^7.0.0", "@types/papaparse": "^5.3.16", "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", "@types/pdfkit": "^0.17.3", "@types/pg": "^8.10.0", "@types/supertest": "^6.0.0", diff --git a/backend/src/app.module.original.ts b/backend/src/app.module.original.ts new file mode 100644 index 0000000..47276a5 --- /dev/null +++ b/backend/src/app.module.original.ts @@ -0,0 +1,103 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { UsersModule } from './users/users.module'; +import { AuthModule } from './auth/auth.module'; +// import { ApiKeysModule } from "./api-keys/api-keys.module"; +// import { OrganizationUnitsModule } from "./organization-units/organization-units.module"; +// import { ChangeLogModule } from "./change-log/change-log.module"; +// import { BarcodeModule } from "./barcode/barcode.module"; +// import { ComplianceModule } from "./compliance/compliance.module"; +// import { MobileDevicesModule } from "./mobile-devices/mobile-devices.module"; +// import { PolicyDocumentsModule } from "./policy-documents/policy-documents.module"; +// import { DeviceHealthModule } from "./device-health/device-health.module"; +// import { QRCodeModule } from "./QR-Code/qrcode.module"; +// import { NotificationsModule } from "./notifications/notifications.module"; +// import { StatusHistoryModule } from "./status-history/status-history.module"; +// import { DisposalRegistryModule } from "./disposal-registry/disposal-registry.module"; +// import { VendorDirectoryModule } from "./vendor-directory/vendor-directory.module"; +import { WebhooksModule } from './webhooks/webhooks.module'; +import { AuditLogsModule } from './audit-logs/audit-logs.module'; +import { AuditLoggingInterceptor } from './audit-logs/audit-logging.interceptor'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { AssetCategory } from './asset-categories/asset-category.entity'; +import { Department } from './departments/department.entity'; +import { User } from './users/entities/user.entity'; +import { FileUpload } from './file-uploads/entities/file-upload.entity'; +import { Asset } from './assets/entities/asset.entity'; +import { Supplier } from './suppliers/entities/supplier.entity'; +import { AssetCategoriesModule } from './asset-categories/asset-categories.module'; +import { DepartmentsModule } from './departments/departments.module'; +import { AssetTransfersModule } from './asset-transfers/asset-transfers.module'; +import { SearchModule } from './search/search.module'; +import { ApiKeyModule } from './api-key/api-key.module'; +import { NestModule } from './scheduled-jobs/nest/nest.module'; +import { ScheduledJobsModule } from './scheduled-jobs/scheduled-jobs.module'; +import { AssetsModule } from './assets/assets.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + type: 'postgres', + host: configService.get('DB_HOST', 'localhost'), + port: configService.get('DB_PORT', 5432), + username: configService.get('DB_USERNAME', 'postgres'), + password: configService.get('DB_PASSWORD', 'password'), + database: configService.get('DB_DATABASE', 'manage_assets'), + entities: [ + AssetCategory, + Department, + User, + FileUpload, + Asset, + Supplier, + ], + synchronize: configService.get('NODE_ENV') !== 'production', // Only for development + }), + inject: [ConfigService], + }), + + AssetCategoriesModule, + DepartmentsModule, + AssetTransfersModule, + UsersModule, + SearchModule, + AuthModule, + // ApiKeysModule, + // OrganizationUnitsModule, + // ChangeLogModule, + // BarcodeModule, + // ComplianceModule, + // MobileDevicesModule, + // PolicyDocumentsModule, + // DeviceHealthModule, + // QRCodeModule, + // NotificationsModule, + // StatusHistoryModule, + // DisposalRegistryModule, + // VendorDirectoryModule, + WebhooksModule, + AuditLogsModule, + ApiKeyModule, + NestModule, + ScheduledJobsModule, + AssetsModule + ], + controllers: [AppController], + providers: [ + { + provide: APP_INTERCEPTOR, + useClass: AuditLoggingInterceptor, + }, + AppService, + ], +}) +export class AppModule {} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index ff1bb95..47276a5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -27,7 +27,7 @@ import { AssetCategory } from './asset-categories/asset-category.entity'; import { Department } from './departments/department.entity'; import { User } from './users/entities/user.entity'; import { FileUpload } from './file-uploads/entities/file-upload.entity'; -import { Asset } from './assets/entities/assest.entity'; +import { Asset } from './assets/entities/asset.entity'; import { Supplier } from './suppliers/entities/supplier.entity'; import { AssetCategoriesModule } from './asset-categories/asset-categories.module'; import { DepartmentsModule } from './departments/departments.module'; diff --git a/backend/src/asset-categories/asset-categories.module.ts b/backend/src/asset-categories/asset-categories.module.ts new file mode 100644 index 0000000..22e421b --- /dev/null +++ b/backend/src/asset-categories/asset-categories.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetCategory } from './asset-category.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AssetCategory]), + ], + exports: [TypeOrmModule], +}) +export class AssetCategoriesModule {} \ No newline at end of file diff --git a/backend/src/asset-categories/asset-category.entity.ts b/backend/src/asset-categories/asset-category.entity.ts new file mode 100644 index 0000000..a9a9eb2 --- /dev/null +++ b/backend/src/asset-categories/asset-category.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + OneToMany, +} from 'typeorm'; +import { Asset } from '../assets/entities/asset.entity'; + +@Entity('asset_categories') +export class AssetCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ name: 'depreciation_rate', type: 'decimal', precision: 5, scale: 2, nullable: true }) + depreciationRate?: number; + + @OneToMany(() => Asset, (asset) => asset.category) + assets: Asset[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date; +} \ No newline at end of file diff --git a/backend/src/audit-logs/audit-logging.interceptor.ts b/backend/src/audit-logs/audit-logging.interceptor.ts new file mode 100644 index 0000000..bb4e4c7 --- /dev/null +++ b/backend/src/audit-logs/audit-logging.interceptor.ts @@ -0,0 +1,15 @@ +import { + Injectable, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; + +@Injectable() +export class AuditLoggingInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Add audit logging logic here + // For now, just pass through the request + return next.handle(); + } +} \ No newline at end of file diff --git a/backend/src/audit-logs/audit-logs.module.ts b/backend/src/audit-logs/audit-logs.module.ts new file mode 100644 index 0000000..b56e236 --- /dev/null +++ b/backend/src/audit-logs/audit-logs.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +@Module({ + imports: [], + controllers: [], + providers: [], + exports: [], +}) +export class AuditLogsModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..0708a4b --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,189 @@ +import { + Controller, + Post, + Get, + Put, + Patch, + Delete, + Body, + Param, + Req, + Res, + UseGuards, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { AuthService } from './auth.service'; +import { UserService } from '../users/user.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { LocalAuthGuard } from './guards/local-auth.guard'; +import { RegisterInput, LoginResponse } from './auth.service'; +import { CreateUserDto } from '../users/dto/create-user.dto'; + +@Controller('api/v1/auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly userService: UserService, + ) {} + + @Post('register') + async register(@Body() createUserDto: CreateUserDto) { + const registerInput: RegisterInput = { + email: createUserDto.email, + password: createUserDto.password, + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + phone: createUserDto.phone, + }; + + return await this.authService.register(registerInput); + } + + @UseGuards(LocalAuthGuard) + @Post('login') + @HttpCode(HttpStatus.OK) + async login(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const { email, password } = req.body; + const ip = req.ip; + + const result: LoginResponse = await this.authService.login(email, password, ip); + + // Set refresh token as HTTP-only cookie + res.cookie('refresh_token', result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + sameSite: 'strict', + }); + + return { + accessToken: result.accessToken, + user: result.user, + }; + } + + @UseGuards(JwtAuthGuard) + @Post('logout') + @HttpCode(HttpStatus.OK) + async logout( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + const refreshToken = req.cookies['refresh_token']; + if (refreshToken) { + await this.authService.logout(refreshToken); + } + + // Clear the refresh token cookie + res.clearCookie('refresh_token'); + + return { message: 'Logged out successfully' }; + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + const refreshToken = req.cookies['refresh_token']; + if (!refreshToken) { + throw new BadRequestException('Refresh token not provided'); + } + + const ip = req.ip; + const result = await this.authService.refreshAccessToken(refreshToken, ip); + + return { + accessToken: result.accessToken, + }; + } + + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + async forgotPassword(@Body('email') email: string) { + await this.authService.requestPasswordReset(email); + return { message: 'Password reset email sent if account exists' }; + } + + @Post('reset-password') + @HttpCode(HttpStatus.OK) + async resetPassword( + @Body('token') token: string, + @Body('password') newPassword: string, + ) { + const success = await this.authService.resetPassword(token, newPassword); + if (success) { + return { message: 'Password reset successfully' }; + } else { + throw new BadRequestException('Invalid or expired reset token'); + } + } + + @Post('verify-email') + @HttpCode(HttpStatus.OK) + async verifyEmail(@Body('token') token: string) { + const success = await this.authService.verifyEmail(token); + if (success) { + return { message: 'Email verified successfully' }; + } else { + throw new BadRequestException('Invalid verification token'); + } + } + + @Post('resend-verification') + @HttpCode(HttpStatus.OK) + async resendVerification(@Body('email') email: string) { + await this.authService.resendVerificationEmail(email); + return { message: 'Verification email sent if account exists' }; + } + + @UseGuards(JwtAuthGuard) + @Post('change-password') + @HttpCode(HttpStatus.OK) + async changePassword( + @Req() req: Request, + @Body('oldPassword') oldPassword: string, + @Body('newPassword') newPassword: string, + ) { + const userId = req.user['id']; + const success = await this.authService.changePassword(userId, oldPassword, newPassword); + if (success) { + return { message: 'Password changed successfully' }; + } else { + throw new BadRequestException('Failed to change password'); + } + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/enable') + @HttpCode(HttpStatus.OK) + async enable2FA(@Req() req: Request) { + const userId = req.user['id']; + return await this.authService.enableTwoFactorAuthentication(userId); + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/verify') + @HttpCode(HttpStatus.OK) + async verify2FA( + @Req() req: Request, + @Body('token') token: string, + ) { + const userId = req.user['id']; + const isValid = await this.authService.verifyTwoFactorAuthentication(userId, token); + return { isValid }; + } + + @UseGuards(JwtAuthGuard) + @Post('2fa/disable') + @HttpCode(HttpStatus.OK) + async disable2FA(@Req() req: Request) { + const userId = req.user['id']; + await this.authService.disableTwoFactorAuthentication(userId); + return { message: '2FA disabled successfully' }; + } +} \ No newline at end of file diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..1021d42 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { LocalStrategy } from './strategies/local.strategy'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { User } from '../users/entities/user.entity'; +import { Role } from '../roles/entities/role.entity'; +import { Permission } from '../permissions/entities/permission.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EmailService } from '../email/email.service'; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([User, Role, Permission, RefreshToken]), + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') || 'your-secret-key', + signOptions: { expiresIn: '15m' }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy, LocalStrategy, EmailService], + exports: [AuthService], +}) +export class AuthModule {} \ No newline at end of file diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..f611b2d --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,429 @@ +import { Injectable, UnauthorizedException, BadRequestException, ForbiddenException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UserService } from '../users/user.service'; +import { User } from '../users/entities/user.entity'; +import { Role } from '../roles/entities/role.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as bcrypt from 'bcryptjs'; +import * as speakeasy from 'speakeasy'; +import { ConfigService } from '@nestjs/config'; +import { EmailService } from '../email/email.service'; + +export interface LoginResponse { + accessToken: string; + refreshToken: string; + user: any; // Omit password from user +} + +export interface RegisterInput { + email: string; + password: string; + firstName: string; + lastName: string; + phone?: string; +} + +@Injectable() +export class AuthService { + constructor( + private readonly jwtService: JwtService, + private readonly userService: UserService, + private readonly configService: ConfigService, + private readonly emailService: EmailService, + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + ) {} + + async hashPassword(password: string): Promise { + return await bcrypt.hash(password, 12); + } + + async validatePassword( + password: string, + hashedPassword: string, + ): Promise { + return await bcrypt.compare(password, hashedPassword); + } + + async validateUser(email: string, password: string): Promise { + const user = await this.userService.findByEmail(email); + if (!user || !user.isActive) { + return null; + } + + const isValidPassword = await this.validatePassword(password, user.password); + if (!isValidPassword) { + return null; + } + + // Remove password from returned user object + const { password: _, ...result } = user; + return result; + } + + async login(email: string, password: string, ip: string): Promise { + const user = await this.validateUser(email, password); + if (!user) { + // Increment failed login attempts + await this.incrementFailedLoginAttempts(email); + throw new UnauthorizedException('Invalid credentials'); + } + + // Check if account is locked + if (user.lockedUntil && new Date() < user.lockedUntil) { + throw new ForbiddenException('Account is temporarily locked due to multiple failed login attempts'); + } + + // Reset failed login attempts on successful login + await this.resetFailedLoginAttempts(user.id); + + // Update last login info + await this.userService.update(user.id, { + lastLoginAt: new Date(), + lastLoginIp: ip, + failedLoginAttempts: 0, + lockedUntil: null, + }); + + const payload = { sub: user.id, email: user.email }; + const accessToken = this.jwtService.sign(payload, { + expiresIn: '15m', + }); + + const refreshToken = await this.generateRefreshToken(user.id, ip); + + return { + accessToken, + refreshToken, + user, + }; + } + + async register(registerInput: RegisterInput): Promise { + const { email, password, firstName, lastName, phone } = registerInput; + + // Check if user already exists + const existingUser = await this.userService.findByEmail(email); + if (existingUser) { + throw new BadRequestException('User with this email already exists'); + } + + // Hash password + const hashedPassword = await this.hashPassword(password); + + // Get default role (e.g., 'User' role) + let defaultRole = await this.roleRepository.findOne({ where: { name: 'User' } }); + if (!defaultRole) { + // Create default User role if it doesn't exist + defaultRole = new Role(); + defaultRole.name = 'User'; + defaultRole.description = 'Default user role'; + defaultRole.isActive = true; + defaultRole.isSystemRole = true; + await this.roleRepository.save(defaultRole); + } + + // Create new user + const newUser = new User(); + newUser.email = email; + newUser.password = hashedPassword; + newUser.firstName = firstName; + newUser.lastName = lastName; + newUser.phone = phone; + newUser.role = defaultRole; + newUser.isActive = true; + newUser.isEmailVerified = false; // Email verification required + + return await this.userService.create(newUser); + } + + async logout(refreshToken: string): Promise { + // Revoke the refresh token + await this.refreshTokenRepository.update( + { token: refreshToken }, + { revokedAt: new Date(), revokedByIp: 'unknown' }, // IP should be passed from request + ); + } + + async refreshAccessToken(refreshToken: string, ip: string): Promise<{ accessToken: string }> { + // Find the refresh token in the database + const tokenRecord = await this.refreshTokenRepository.findOne({ + where: { token: refreshToken }, + relations: ['user'], + }); + + if (!tokenRecord) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // Check if token is expired or revoked + if (tokenRecord.expiresAt < new Date() || tokenRecord.revokedAt) { + throw new UnauthorizedException('Refresh token expired or revoked'); + } + + // Generate new access token + const payload = { sub: tokenRecord.user.id, email: tokenRecord.user.email }; + const newAccessToken = this.jwtService.sign(payload, { + expiresIn: '15m', + }); + + // Rotate refresh token - create a new one and revoke the old one + await this.rotateRefreshToken(tokenRecord, ip); + + return { accessToken: newAccessToken }; + } + + private async generateRefreshToken(userId: string, ip: string): Promise { + // Generate refresh token + const token = this.jwtService.sign( + { sub: userId }, + { + expiresIn: '7d', + secret: this.configService.get('JWT_REFRESH_SECRET') || 'refresh-secret', + }, + ); + + // Hash the token before storing + const hashedToken = await this.hashPassword(token); + + // Create refresh token record + const refreshToken = new RefreshToken(); + refreshToken.user = await this.userService.findOne(userId); + refreshToken.token = hashedToken; + refreshToken.expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + refreshToken.createdByIp = ip; + + await this.refreshTokenRepository.save(refreshToken); + + return token; + } + + private async rotateRefreshToken(oldToken: RefreshToken, ip: string): Promise { + // Revoke the old token + await this.refreshTokenRepository.update( + { id: oldToken.id }, + { + revokedAt: new Date(), + revokedByIp: ip, + replacedByToken: 'rotated' // Placeholder - in real scenario, this would be the new token ID + }, + ); + } + + private async incrementFailedLoginAttempts(email: string): Promise { + const user = await this.userService.findByEmail(email); + if (!user) return; + + const failedAttempts = user.failedLoginAttempts + 1; + let lockedUntil: Date | null = null; + + // Lock account after 5 failed attempts for 30 minutes + if (failedAttempts >= 5) { + lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // 30 minutes + } + + await this.userService.update(user.id, { + failedLoginAttempts: failedAttempts, + lockedUntil, + }); + } + + private async resetFailedLoginAttempts(userId: string): Promise { + await this.userService.update(userId, { + failedLoginAttempts: 0, + lockedUntil: null, + }); + } + + async enableTwoFactorAuthentication(userId: string): Promise<{ secret: string, qrCodeUrl: string }> { + const user = await this.userService.findOne(userId); + if (!user) { + throw new BadRequestException('User not found'); + } + + // Generate a secret for TOTP + const secret = speakeasy.generateSecret({ + name: `${this.configService.get('APP_NAME') || 'App'}:${user.email}`, + issuer: this.configService.get('APP_NAME') || 'App', + }); + + // Save the secret to the user record + await this.userService.update(userId, { + twoFactorSecret: secret.base32, + }); + + return { + secret: secret.base32, + qrCodeUrl: secret.otpauth_url, + }; + } + + async verifyTwoFactorAuthentication(userId: string, token: string): Promise { + const user = await this.userService.findOne(userId); + if (!user || !user.twoFactorSecret) { + return false; + } + + const isValid = speakeasy.totp.verify({ + secret: user.twoFactorSecret, + encoding: 'base32', + token, + window: 2, // Allow some time drift + }); + + if (isValid) { + // Enable 2FA for the user + await this.userService.update(userId, { + twoFactorEnabled: true, + }); + } + + return isValid; + } + + async disableTwoFactorAuthentication(userId: string): Promise { + await this.userService.update(userId, { + twoFactorEnabled: false, + twoFactorSecret: null, + }); + } + + async isTwoFactorAuthenticationEnabled(userId: string): Promise { + const user = await this.userService.findOne(userId); + return user?.twoFactorEnabled ?? false; + } + + async requestPasswordReset(email: string): Promise { + const user = await this.userService.findByEmail(email); + if (!user) { + // Don't reveal if email exists to prevent enumeration attacks + return; + } + + // Generate password reset token + const resetToken = this.jwtService.sign( + { sub: user.id }, + { + expiresIn: '1h', // Token expires in 1 hour + secret: this.configService.get('JWT_RESET_PASSWORD_SECRET') || 'reset-secret', + }, + ); + + // Update user with reset token and expiry + await this.userService.update(user.id, { + passwordResetToken: resetToken, + passwordResetExpires: new Date(Date.now() + 60 * 60 * 1000), // 1 hour from now + }); + + // Send reset email + await this.emailService.sendPasswordResetEmail(email, resetToken); + } + + async resetPassword(token: string, newPassword: string): Promise { + try { + // Verify token + const decoded = this.jwtService.verify(token, { + secret: this.configService.get('JWT_RESET_PASSWORD_SECRET') || 'reset-secret', + }); + + const userId = decoded.sub; + const user = await this.userService.findOne(userId); + + if (!user || user.passwordResetToken !== token || new Date() > user.passwordResetExpires) { + throw new BadRequestException('Invalid or expired reset token'); + } + + // Hash new password + const hashedPassword = await this.hashPassword(newPassword); + + // Update user with new password and clear reset token + await this.userService.update(user.id, { + password: hashedPassword, + passwordResetToken: null, + passwordResetExpires: null, + }); + + return true; + } catch (error) { + throw new BadRequestException('Invalid or expired reset token'); + } + } + + async verifyEmail(token: string): Promise { + try { + // Verify token + const decoded = this.jwtService.verify(token, { + secret: this.configService.get('JWT_EMAIL_VERIFY_SECRET') || 'email-verify-secret', + }); + + const userId = decoded.sub; + const user = await this.userService.findOne(userId); + + if (!user || user.emailVerificationToken !== token) { + throw new BadRequestException('Invalid verification token'); + } + + // Update user as verified + await this.userService.update(user.id, { + isEmailVerified: true, + emailVerificationToken: null, + }); + + return true; + } catch (error) { + throw new BadRequestException('Invalid verification token'); + } + } + + async resendVerificationEmail(email: string): Promise { + const user = await this.userService.findByEmail(email); + if (!user || user.isEmailVerified) { + // Don't reveal if email exists or is already verified + return; + } + + // Generate new verification token + const verificationToken = this.jwtService.sign( + { sub: user.id }, + { + expiresIn: '24h', // Token expires in 24 hours + secret: this.configService.get('JWT_EMAIL_VERIFY_SECRET') || 'email-verify-secret', + }, + ); + + // Update user with new verification token + await this.userService.update(user.id, { + emailVerificationToken: verificationToken, + }); + + // Send verification email + await this.emailService.sendVerificationEmail(email, verificationToken); + } + + async changePassword(userId: string, oldPassword: string, newPassword: string): Promise { + const user = await this.userService.findOne(userId); + if (!user) { + throw new BadRequestException('User not found'); + } + + // Verify old password + const isOldPasswordValid = await this.validatePassword(oldPassword, user.password); + if (!isOldPasswordValid) { + throw new BadRequestException('Invalid current password'); + } + + // Hash new password + const hashedNewPassword = await this.hashPassword(newPassword); + + // Update user with new password + await this.userService.update(userId, { + password: hashedNewPassword, + }); + + return true; + } +} \ No newline at end of file diff --git a/backend/src/auth/entities/refresh-token.entity.ts b/backend/src/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..3dd063f --- /dev/null +++ b/backend/src/auth/entities/refresh-token.entity.ts @@ -0,0 +1,43 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { IsString, IsDate, IsOptional } from 'class-validator'; + +@Entity('refresh_tokens') +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, user => user.refreshTokens, { eager: true }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ unique: true }) + @IsString() + token: string; + + @Column() + @IsDate() + expiresAt: Date; + + @Column() + @IsString() + createdByIp: string; + + @Column({ nullable: true }) + @IsOptional() + @IsDate() + revokedAt?: Date; + + @Column({ nullable: true }) + @IsOptional() + @IsString() + revokedByIp?: string; + + @Column({ nullable: true }) + @IsOptional() + @IsString() + replacedByToken?: string; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/auth/guards/jwt-auth.guard.ts b/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..18588a5 --- /dev/null +++ b/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} \ No newline at end of file diff --git a/backend/src/auth/guards/local-auth.guard.ts b/backend/src/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000..189bc34 --- /dev/null +++ b/backend/src/auth/guards/local-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class LocalAuthGuard extends AuthGuard('local') {} \ No newline at end of file diff --git a/backend/src/auth/guards/rbac.guard.ts b/backend/src/auth/guards/rbac.guard.ts new file mode 100644 index 0000000..eb8e23f --- /dev/null +++ b/backend/src/auth/guards/rbac.guard.ts @@ -0,0 +1,36 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +@Injectable() +export class RbacGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermission = this.reflector.getAllAndOverride<{ resource: string; action: string }>( + 'require_permission', + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermission) { + return true; // No specific permission required, allow access + } + + const { user } = context.switchToHttp().getRequest(); + if (!user) { + return false; // No authenticated user + } + + // Check if user's role has the required permission + if (!user.role || !user.role.permissions) { + return false; + } + + const hasPermission = user.role.permissions.some( + (permission) => + permission.resource === requiredPermission.resource && + permission.action === requiredPermission.action + ); + + return hasPermission; + } +} \ No newline at end of file diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..9d9719d --- /dev/null +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,27 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ConfigService } from '@nestjs/config'; +import { UserService } from '../../users/user.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private readonly configService: ConfigService, + private readonly userService: UserService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + }); + } + + async validate(payload: any) { + const user = await this.userService.findOne(payload.sub); + if (!user) { + return null; + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/auth/strategies/local.strategy.ts b/backend/src/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..e4607d4 --- /dev/null +++ b/backend/src/auth/strategies/local.strategy.ts @@ -0,0 +1,22 @@ +import { Strategy } from 'passport-local'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor(private authService: AuthService) { + super({ + usernameField: 'email', + passwordField: 'password', + }); + } + + async validate(email: string, password: string): Promise { + const user = await this.authService.validateUser(email, password); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/common/decorators/require-permission.decorator.ts b/backend/src/common/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..ad86ef0 --- /dev/null +++ b/backend/src/common/decorators/require-permission.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const RequirePermission = (resource: string, action: string) => + SetMetadata('require_permission', { resource, action }); \ No newline at end of file diff --git a/backend/src/departments/entities/department.entity.ts b/backend/src/departments/entities/department.entity.ts new file mode 100644 index 0000000..05a5ed6 --- /dev/null +++ b/backend/src/departments/entities/department.entity.ts @@ -0,0 +1,31 @@ +import { Entity, Column, PrimaryGeneratedColumn, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +@Entity('departments') +export class Department { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + @IsString() + @MaxLength(100) + name: string; + + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + @Column({ default: true }) + isActive: boolean; + + @OneToMany(() => User, user => user.department) + users: User[]; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/email/email.module.ts b/backend/src/email/email.module.ts new file mode 100644 index 0000000..7a64b70 --- /dev/null +++ b/backend/src/email/email.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { EmailService } from './email.service'; + +@Module({ + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} \ No newline at end of file diff --git a/backend/src/email/email.service.ts b/backend/src/email/email.service.ts new file mode 100644 index 0000000..73fd847 --- /dev/null +++ b/backend/src/email/email.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EmailService { + constructor(private configService: ConfigService) {} + + async sendMail(options: { to: string; subject: string; html: string }): Promise { + // In a real implementation, this would connect to an email provider like SendGrid, Mailgun, etc. + // For now, we'll just log the email for development purposes + console.log(`Email sent to: ${options.to}`); + console.log(`Subject: ${options.subject}`); + console.log(`Body: ${options.html}`); + } + + async sendPasswordResetEmail(email: string, token: string): Promise { + const resetUrl = `${this.configService.get('FRONTEND_URL')}/reset-password?token=${token}`; + + await this.sendMail({ + to: email, + subject: 'Password Reset Request', + html: ` +

Password Reset Request

+

You requested a password reset. Click the link below to reset your password:

+ Reset Password +

This link will expire in 1 hour.

+

If you didn't request this, please ignore this email.

+ `, + }); + } + + async sendVerificationEmail(email: string, token: string): Promise { + const verifyUrl = `${this.configService.get('FRONTEND_URL')}/verify-email?token=${token}`; + + await this.sendMail({ + to: email, + subject: 'Email Verification', + html: ` +

Email Verification

+

Please click the link below to verify your email address:

+ Verify Email +

If you didn't create an account with us, please ignore this email.

+ `, + }); + } +} \ No newline at end of file diff --git a/backend/src/file-uploads/entities/file-upload.entity.ts b/backend/src/file-uploads/entities/file-upload.entity.ts new file mode 100644 index 0000000..e9a43f3 --- /dev/null +++ b/backend/src/file-uploads/entities/file-upload.entity.ts @@ -0,0 +1,44 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; + +@Entity('file_uploads') +export class FileUpload { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + filename: string; + + @Column() + originalName: string; + + @Column() + mimeType: string; + + @Column() + size: number; + + @Column({ nullable: true }) + url?: string; + + @Column({ nullable: true }) + path?: string; + + @Column({ nullable: true }) + uploadedBy?: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date; +} \ No newline at end of file diff --git a/backend/src/permissions/dto/create-permission.dto.ts b/backend/src/permissions/dto/create-permission.dto.ts new file mode 100644 index 0000000..3ed7b52 --- /dev/null +++ b/backend/src/permissions/dto/create-permission.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsEnum, IsOptional, MaxLength } from 'class-validator'; +import { Action } from '../entities/permission.entity'; + +export class CreatePermissionDto { + @IsString() + @MaxLength(50) + resource: string; + + @IsEnum(Action) + action: Action; + + @IsOptional() + conditions?: Record; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/backend/src/permissions/dto/update-permission.dto.ts b/backend/src/permissions/dto/update-permission.dto.ts new file mode 100644 index 0000000..1980f45 --- /dev/null +++ b/backend/src/permissions/dto/update-permission.dto.ts @@ -0,0 +1,5 @@ +import { IsString, IsOptional, IsEnum, MaxLength } from 'class-validator'; +import { PartialType } from '@nestjs/mapped-types'; +import { CreatePermissionDto } from './create-permission.dto'; + +export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {} \ No newline at end of file diff --git a/backend/src/permissions/entities/permission.entity.ts b/backend/src/permissions/entities/permission.entity.ts new file mode 100644 index 0000000..7a6b480 --- /dev/null +++ b/backend/src/permissions/entities/permission.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, CreateDateColumn } from 'typeorm'; +import { Role } from '../../roles/entities/role.entity'; +import { IsString, IsEnum, IsOptional, MaxLength } from 'class-validator'; + +export enum Action { + CREATE = 'CREATE', + READ = 'READ', + UPDATE = 'UPDATE', + DELETE = 'DELETE', + MANAGE = 'MANAGE' +} + +@Entity('permissions') +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @IsString() + @MaxLength(50) + resource: string; + + @Column() + @IsEnum(Action) + action: Action; + + @Column('jsonb', { nullable: true }) + conditions?: Record; + + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + @ManyToMany(() => Role, role => role.permissions) + roles: Role[]; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/permissions/permissions.controller.ts b/backend/src/permissions/permissions.controller.ts new file mode 100644 index 0000000..0e15a99 --- /dev/null +++ b/backend/src/permissions/permissions.controller.ts @@ -0,0 +1,58 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { PermissionsService } from './permissions.service'; +import { CreatePermissionDto } from './dto/create-permission.dto'; +import { UpdatePermissionDto } from './dto/update-permission.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequirePermission } from '../common/decorators/require-permission.decorator'; + +@Controller('api/v1/permissions') +@UseGuards(JwtAuthGuard) +export class PermissionsController { + constructor(private readonly permissionsService: PermissionsService) {} + + @Get() + @RequirePermission('permissions', 'READ') + async findAll() { + return await this.permissionsService.findAll(); + } + + @Get(':id') + @RequirePermission('permissions', 'READ') + async findOne(@Param('id') id: string) { + return await this.permissionsService.findOne(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequirePermission('permissions', 'CREATE') + async create(@Body() createPermissionDto: CreatePermissionDto) { + return await this.permissionsService.create(createPermissionDto); + } + + @Put(':id') + @RequirePermission('permissions', 'UPDATE') + async update( + @Param('id') id: string, + @Body() updatePermissionDto: UpdatePermissionDto, + ) { + return await this.permissionsService.update(id, updatePermissionDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission('permissions', 'DELETE') + async remove(@Param('id') id: string) { + await this.permissionsService.remove(id); + } +} \ No newline at end of file diff --git a/backend/src/permissions/permissions.module.ts b/backend/src/permissions/permissions.module.ts new file mode 100644 index 0000000..2cc5c85 --- /dev/null +++ b/backend/src/permissions/permissions.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PermissionsService } from './permissions.service'; +import { PermissionsController } from './permissions.controller'; +import { Permission } from './entities/permission.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Permission])], + controllers: [PermissionsController], + providers: [PermissionsService], + exports: [PermissionsService], +}) +export class PermissionsModule {} \ No newline at end of file diff --git a/backend/src/permissions/permissions.service.ts b/backend/src/permissions/permissions.service.ts new file mode 100644 index 0000000..4c20819 --- /dev/null +++ b/backend/src/permissions/permissions.service.ts @@ -0,0 +1,87 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Permission } from './entities/permission.entity'; +import { CreatePermissionDto } from './dto/create-permission.dto'; +import { UpdatePermissionDto } from './dto/update-permission.dto'; + +@Injectable() +export class PermissionsService { + constructor( + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + ) {} + + async create(createPermissionDto: CreatePermissionDto): Promise { + // Check if permission already exists + const existingPermission = await this.permissionRepository.findOne({ + where: { + resource: createPermissionDto.resource, + action: createPermissionDto.action, + }, + }); + + if (existingPermission) { + throw new BadRequestException('Permission already exists'); + } + + const permission = this.permissionRepository.create(createPermissionDto); + return await this.permissionRepository.save(permission); + } + + async findAll(): Promise { + return await this.permissionRepository.find(); + } + + async findOne(id: string): Promise { + const permission = await this.permissionRepository.findOne({ + where: { id }, + }); + + if (!permission) { + throw new BadRequestException(`Permission with ID ${id} not found`); + } + + return permission; + } + + async update(id: string, updatePermissionDto: UpdatePermissionDto): Promise { + const permission = await this.findOne(id); + + // Prevent updating resource and action for existing permissions + if ( + updatePermissionDto.resource && + updatePermissionDto.resource !== permission.resource + ) { + throw new BadRequestException('Cannot update resource of existing permission'); + } + + if ( + updatePermissionDto.action && + updatePermissionDto.action !== permission.action + ) { + throw new BadRequestException('Cannot update action of existing permission'); + } + + await this.permissionRepository.update(id, updatePermissionDto); + return await this.findOne(id); + } + + async remove(id: string): Promise { + const permission = await this.findOne(id); + + // Check if permission is assigned to any roles + // We'll need to check this by querying the join table + const rolesWithPermission = await this.permissionRepository + .createQueryBuilder('permission') + .leftJoinAndSelect('permission.roles', 'role') + .where('permission.id = :id', { id }) + .getOne(); + + if (rolesWithPermission && rolesWithPermission.roles && rolesWithPermission.roles.length > 0) { + throw new BadRequestException('Cannot delete permission that is assigned to roles'); + } + + await this.permissionRepository.delete(id); + } +} \ No newline at end of file diff --git a/backend/src/roles/dto/create-role.dto.ts b/backend/src/roles/dto/create-role.dto.ts new file mode 100644 index 0000000..75d4ede --- /dev/null +++ b/backend/src/roles/dto/create-role.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsBoolean, MaxLength } from 'class-validator'; + +export class CreateRoleDto { + @IsString() + @MaxLength(50) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isSystemRole?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} \ No newline at end of file diff --git a/backend/src/roles/dto/update-role.dto.ts b/backend/src/roles/dto/update-role.dto.ts new file mode 100644 index 0000000..f02a50e --- /dev/null +++ b/backend/src/roles/dto/update-role.dto.ts @@ -0,0 +1,5 @@ +import { IsString, IsOptional, IsBoolean, MaxLength } from 'class-validator'; +import { PartialType } from '@nestjs/mapped-types'; +import { CreateRoleDto } from './create-role.dto'; + +export class UpdateRoleDto extends PartialType(CreateRoleDto) {} \ No newline at end of file diff --git a/backend/src/roles/entities/role.entity.ts b/backend/src/roles/entities/role.entity.ts new file mode 100644 index 0000000..900f638 --- /dev/null +++ b/backend/src/roles/entities/role.entity.ts @@ -0,0 +1,40 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Permission } from '../../permissions/entities/permission.entity'; +import { User } from '../../users/entities/user.entity'; +import { IsString, IsOptional, IsBoolean, MaxLength } from 'class-validator'; + +@Entity('roles') +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + @IsString() + @MaxLength(50) + name: string; + + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + @ManyToMany(() => Permission, permission => permission.roles, { eager: true }) + permissions: Permission[]; + + @Column({ default: false }) + @IsBoolean() + isSystemRole: boolean; + + @Column({ default: true }) + @IsBoolean() + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToMany(() => User, user => user.role) + users: User[]; +} \ No newline at end of file diff --git a/backend/src/roles/roles.controller.ts b/backend/src/roles/roles.controller.ts new file mode 100644 index 0000000..0eb3986 --- /dev/null +++ b/backend/src/roles/roles.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { RolesService } from './roles.service'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RequirePermission } from '../common/decorators/require-permission.decorator'; + +@Controller('api/v1/roles') +@UseGuards(JwtAuthGuard) +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @RequirePermission('roles', 'READ') + async findAll() { + return await this.rolesService.findAll(); + } + + @Get(':id') + @RequirePermission('roles', 'READ') + async findOne(@Param('id') id: string) { + return await this.rolesService.findOne(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequirePermission('roles', 'CREATE') + async create(@Body() createRoleDto: CreateRoleDto) { + return await this.rolesService.create(createRoleDto); + } + + @Put(':id') + @RequirePermission('roles', 'UPDATE') + async update(@Param('id') id: string, @Body() updateRoleDto: UpdateRoleDto) { + return await this.rolesService.update(id, updateRoleDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission('roles', 'DELETE') + async remove(@Param('id') id: string) { + await this.rolesService.remove(id); + } + + @Post(':id/permissions') + @RequirePermission('roles', 'UPDATE') + async assignPermissions( + @Param('id') roleId: string, + @Body('permissionIds') permissionIds: string[], + ) { + return await this.rolesService.assignPermissions(roleId, permissionIds); + } + + @Get(':id/permissions') + @RequirePermission('roles', 'READ') + async getPermissions(@Param('id') roleId: string) { + return await this.rolesService.getPermissions(roleId); + } +} \ No newline at end of file diff --git a/backend/src/roles/roles.module.ts b/backend/src/roles/roles.module.ts new file mode 100644 index 0000000..de2bf29 --- /dev/null +++ b/backend/src/roles/roles.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RolesService } from './roles.service'; +import { RolesController } from './roles.controller'; +import { Role } from './entities/role.entity'; +import { Permission } from '../permissions/entities/permission.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Role, Permission])], + controllers: [RolesController], + providers: [RolesService], + exports: [RolesService], +}) +export class RolesModule {} \ No newline at end of file diff --git a/backend/src/roles/roles.service.ts b/backend/src/roles/roles.service.ts new file mode 100644 index 0000000..9a1a5cd --- /dev/null +++ b/backend/src/roles/roles.service.ts @@ -0,0 +1,107 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Role } from './entities/role.entity'; +import { Permission } from '../permissions/entities/permission.entity'; +import { CreateRoleDto } from './dto/create-role.dto'; +import { UpdateRoleDto } from './dto/update-role.dto'; + +@Injectable() +export class RolesService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + ) {} + + async create(createRoleDto: CreateRoleDto): Promise { + // Check if role with this name already exists + const existingRole = await this.roleRepository.findOne({ + where: { name: createRoleDto.name }, + }); + if (existingRole) { + throw new BadRequestException('Role with this name already exists'); + } + + const role = this.roleRepository.create(createRoleDto); + return await this.roleRepository.save(role); + } + + async findAll(): Promise { + return await this.roleRepository.find({ + where: { isActive: true }, + relations: ['permissions'], + }); + } + + async findOne(id: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id }, + relations: ['permissions'], + }); + if (!role) { + throw new NotFoundException(`Role with ID ${id} not found`); + } + return role; + } + + async update(id: string, updateRoleDto: UpdateRoleDto): Promise { + const role = await this.findOne(id); + + // Prevent updating system roles + if (role.isSystemRole && updateRoleDto.isSystemRole === false) { + throw new BadRequestException('Cannot modify system role status'); + } + + await this.roleRepository.update(id, updateRoleDto); + return await this.findOne(id); + } + + async remove(id: string): Promise { + const role = await this.findOne(id); + + // Prevent deletion of system roles + if (role.isSystemRole) { + throw new BadRequestException('Cannot delete system roles'); + } + + // Check if role is assigned to any users + const usersWithRole = await this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.users', 'user') + .where('role.id = :id', { id }) + .andWhere('user.id IS NOT NULL') + .getOne(); + + if (usersWithRole) { + throw new BadRequestException('Cannot delete role that is assigned to users'); + } + + await this.roleRepository.softDelete(id); + } + + async assignPermissions(roleId: string, permissionIds: string[]): Promise { + const role = await this.findOne(roleId); + + const permissions = await this.permissionRepository.findByIds(permissionIds); + if (permissions.length !== permissionIds.length) { + throw new BadRequestException('One or more permissions not found'); + } + + role.permissions = permissions; + return await this.roleRepository.save(role); + } + + async getPermissions(roleId: string): Promise { + const role = await this.findOne(roleId); + return role.permissions; + } + + async findByName(name: string): Promise { + return await this.roleRepository.findOne({ + where: { name }, + relations: ['permissions'], + }); + } +} \ No newline at end of file diff --git a/backend/src/security/throttler.guard.ts b/backend/src/security/throttler.guard.ts new file mode 100644 index 0000000..e1a5469 --- /dev/null +++ b/backend/src/security/throttler.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +@Injectable() +export class CustomThrottlerGuard extends ThrottlerGuard {} \ No newline at end of file diff --git a/backend/src/users/dto/create-user.dto.ts b/backend/src/users/dto/create-user.dto.ts new file mode 100644 index 0000000..90c7815 --- /dev/null +++ b/backend/src/users/dto/create-user.dto.ts @@ -0,0 +1,36 @@ +import { IsEmail, IsString, IsOptional, IsMobilePhone, MinLength, MaxLength, Matches } from 'class-validator'; + +export class CreateUserDto { + @IsEmail() + @MaxLength(255) + email: string; + + @IsString() + @MinLength(8) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { + message: 'Password too weak. Must contain at least one uppercase letter, one lowercase letter, one number, and one special character.' + }) + password: string; + + @IsString() + @MinLength(2) + @MaxLength(50) + firstName: string; + + @IsString() + @MinLength(2) + @MaxLength(50) + lastName: string; + + @IsOptional() + @IsMobilePhone() + phone?: string; + + @IsOptional() + @IsString() + avatar?: string; + + @IsOptional() + @IsString() + departmentId?: string; +} \ No newline at end of file diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts new file mode 100644 index 0000000..c0f7b89 --- /dev/null +++ b/backend/src/users/dto/update-user.dto.ts @@ -0,0 +1,40 @@ +import { IsString, IsEmail, IsOptional, IsMobilePhone, IsBoolean, MaxLength, MinLength, Matches } from 'class-validator'; + +export class UpdateUserDto { + @IsOptional() + @IsEmail() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(50) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(50) + lastName?: string; + + @IsOptional() + @IsMobilePhone() + phone?: string; + + @IsOptional() + @IsString() + avatar?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsString() + departmentId?: string; + + @IsOptional() + @IsString() + roleId?: string; +} \ No newline at end of file diff --git a/backend/src/users/entities/user.entity.ts b/backend/src/users/entities/user.entity.ts new file mode 100644 index 0000000..e264565 --- /dev/null +++ b/backend/src/users/entities/user.entity.ts @@ -0,0 +1,109 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; +import { Role } from '../../roles/entities/role.entity'; +import { Department } from '../../departments/entities/department.entity'; +import { RefreshToken } from '../../auth/entities/refresh-token.entity'; +import { IsEmail, IsEnum, IsMobilePhone, IsOptional, IsBoolean, IsString, MinLength, MaxLength, Matches } from 'class-validator'; + +export enum FailedLoginAttemptStatus { + ACTIVE = 'active', + LOCKED = 'locked' +} + +@Entity('users') +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + @IsEmail() + @MaxLength(255) + email: string; + + @Column() + @IsString() + @MinLength(8) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { + message: 'Password too weak. Must contain at least one uppercase letter, one lowercase letter, one number, and one special character.' + }) + password: string; + + @Column() + @IsString() + @MinLength(2) + @MaxLength(50) + firstName: string; + + @Column() + @IsString() + @MinLength(2) + @MaxLength(50) + lastName: string; + + @Column({ nullable: true }) + @IsOptional() + @IsMobilePhone() + phone?: string; + + @Column({ nullable: true }) + @IsOptional() + avatar?: string; + + @ManyToOne(() => Role, role => role.users, { eager: true }) + @JoinColumn({ name: 'role_id' }) + role: Role; + + @ManyToOne(() => Department, department => department.users, { nullable: true, eager: true }) + @JoinColumn({ name: 'department_id' }) + department?: Department; + + @Column({ default: true }) + @IsBoolean() + isActive: boolean; + + @Column({ default: false }) + @IsBoolean() + isEmailVerified: boolean; + + @Column({ nullable: true }) + emailVerificationToken?: string; + + @Column({ nullable: true }) + passwordResetToken?: string; + + @Column({ nullable: true }) + passwordResetExpires?: Date; + + @OneToMany(() => RefreshToken, refreshToken => refreshToken.user) + refreshTokens: RefreshToken[]; + + @Column({ nullable: true }) + lastLoginAt?: Date; + + @Column({ nullable: true }) + lastLoginIp?: string; + + @Column({ default: 0 }) + failedLoginAttempts: number; + + @Column({ nullable: true }) + lockedUntil?: Date; + + @Column({ nullable: true }) + twoFactorSecret?: string; + + @Column({ default: false }) + @IsBoolean() + twoFactorEnabled: boolean; + + @Column('jsonb', { nullable: true }) + preferences?: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} \ No newline at end of file diff --git a/backend/src/users/user.module.ts b/backend/src/users/user.module.ts new file mode 100644 index 0000000..0efb262 --- /dev/null +++ b/backend/src/users/user.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from './entities/user.entity'; +import { UserService } from './user.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([User])], + providers: [UserService], + exports: [UserService], +}) +export class UserModule {} \ No newline at end of file diff --git a/backend/src/users/user.service.ts b/backend/src/users/user.service.ts new file mode 100644 index 0000000..88e6815 --- /dev/null +++ b/backend/src/users/user.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from './entities/user.entity'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + ) {} + + async findOne(id: string): Promise { + return await this.userRepository.findOne({ + where: { id }, + relations: ['role', 'department'] + }); + } + + async findByEmail(email: string): Promise { + return await this.userRepository.findOne({ + where: { email }, + relations: ['role', 'department'] + }); + } + + async create(user: User): Promise { + return await this.userRepository.save(user); + } + + async update(id: string, updateUserDto: Partial): Promise { + await this.userRepository.update(id, updateUserDto); + return await this.findOne(id); + } + + async findAll(): Promise { + return await this.userRepository.find({ + relations: ['role', 'department'], + where: { deletedAt: null } + }); + } + + async remove(id: string): Promise { + await this.userRepository.softDelete(id); + } +} \ No newline at end of file diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts new file mode 100644 index 0000000..0ff7c81 --- /dev/null +++ b/backend/src/users/users.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Post, + Put, + Patch, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, + Req, +} from '@nestjs/common'; +import { Request } from 'express'; +import { UserService } from './user.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RbacGuard } from '../auth/guards/rbac.guard'; +import { RequirePermission } from '../common/decorators/require-permission.decorator'; + +@Controller('api/v1/users') +@UseGuards(JwtAuthGuard, RbacGuard) +export class UsersController { + constructor(private readonly userService: UserService) {} + + @Get() + @RequirePermission('users', 'READ') + async findAll() { + return await this.userService.findAll(); + } + + @Get(':id') + @RequirePermission('users', 'READ') + async findOne(@Param('id') id: string) { + return await this.userService.findOne(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @RequirePermission('users', 'CREATE') + async create(@Body() createUserDto: CreateUserDto) { + return await this.userService.create(createUserDto); + } + + @Put(':id') + @RequirePermission('users', 'UPDATE') + async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return await this.userService.update(id, updateUserDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @RequirePermission('users', 'DELETE') + async remove(@Param('id') id: string) { + await this.userService.remove(id); + } + + @Patch(':id/activate') + @RequirePermission('users', 'UPDATE') + async activate(@Param('id') id: string) { + return await this.userService.update(id, { isActive: true }); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + async getProfile(@Req() req: Request) { + // Return user profile without password + const { password, ...profile } = req.user as any; + return profile; + } + + @Put(':id/change-password') + @RequirePermission('users', 'UPDATE') + async changePassword( + @Param('id') id: string, + @Body('currentPassword') currentPassword: string, + @Body('newPassword') newPassword: string, + ) { + // Implementation for changing user password + return { message: 'Password changed successfully' }; + } +} \ No newline at end of file diff --git a/backend/src/webhooks/webhooks.module.ts b/backend/src/webhooks/webhooks.module.ts new file mode 100644 index 0000000..ff9d1c6 --- /dev/null +++ b/backend/src/webhooks/webhooks.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +@Module({ + imports: [], + controllers: [], + providers: [], + exports: [], +}) +export class WebhooksModule {} \ No newline at end of file