diff --git a/.env.dev b/.env.dev index a4170f6..16afe72 100644 --- a/.env.dev +++ b/.env.dev @@ -8,3 +8,7 @@ DB_SSL=false PORT=9501 CORS_ORIGIN=https://ardev.maskantech.in NODE_ENV=dev +JWT_SECRET=aeroresolve_jwt_access_secret_key_dev_2026_@#! +JWT_REFRESH_SECRET=aeroresolve_jwt_refresh_secret_key_dev_2026_@#! +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d diff --git a/.env.test b/.env.test index 8643368..74fd7be 100644 --- a/.env.test +++ b/.env.test @@ -8,3 +8,7 @@ DB_SSL=false PORT=9502 CORS_ORIGIN=https://artest.maskantech.in NODE_ENV=test +JWT_SECRET=aeroresolve_jwt_access_secret_key_test_2026_@#! +JWT_REFRESH_SECRET=aeroresolve_jwt_refresh_secret_key_test_2026_@#! +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d diff --git a/.env.uat b/.env.uat index b4412a2..9206436 100644 --- a/.env.uat +++ b/.env.uat @@ -8,3 +8,7 @@ DB_SSL=false PORT=9501 CORS_ORIGIN=https://aruat.maskantech.in NODE_ENV=uat +JWT_SECRET=aeroresolve_jwt_access_secret_key_uat_2026_@#! +JWT_REFRESH_SECRET=aeroresolve_jwt_refresh_secret_key_uat_2026_@#! +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d diff --git a/package-lock.json b/package-lock.json index 98ce338..0eeb831 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,17 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^12.0.1", + "@nestjs/passport": "^12.0.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.3", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -28,9 +34,12 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", @@ -2373,6 +2382,19 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-12.0.1.tgz", + "integrity": "sha512-AOvjo0JIuVMNr+6JgbEz4fP+4afclGR5ffZnHtjOFs4+HhzerUYKcIVUL06u5uQYQi/ra8WHO+NZR7OFdQeGvA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0" + } + }, "node_modules/@nestjs/mapped-types": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", @@ -2393,6 +2415,16 @@ } } }, + "node_modules/@nestjs/passport": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-12.0.0.tgz", + "integrity": "sha512-FM9ioo2Dyj+cC7aeO4g19ijAXoRaHlW/GQZFbgXsM4Z9pyQedA8yOseb7ivfqlPwUKJlH9PFgeWniu6c0vb6dw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^11.0.0 || ^12.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.28", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.28.tgz", @@ -2786,6 +2818,13 @@ "@babel/types": "^7.28.2" } }, + "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" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2807,6 +2846,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -2920,6 +2969,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "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": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2927,16 +2986,53 @@ "dev": true, "license": "MIT" }, + "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/node": { "version": "24.13.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "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-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "node_modules/@types/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", @@ -4202,6 +4298,15 @@ "node": ">=6.0.0" } }, + "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/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4344,6 +4449,12 @@ "ieee754": "^1.1.13" } }, + "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==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4780,6 +4891,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -5057,6 +5187,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "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" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7364,6 +7503,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "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": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "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", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "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": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7466,6 +7648,42 @@ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7480,6 +7698,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8230,6 +8454,42 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "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", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "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-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8307,6 +8567,11 @@ "node": ">=8" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -8893,7 +9158,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10242,7 +10506,6 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -10349,6 +10612,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 28983cd..2d10e00 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,17 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^12.0.1", + "@nestjs/passport": "^12.0.0", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.3", + "bcryptjs": "^3.0.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cookie-parser": "^1.4.7", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "pg": "^8.22.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -46,9 +52,12 @@ "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", "@nestjs/testing": "^11.0.1", + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.10", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^7.0.0", "cross-env": "^10.1.0", "eslint": "^9.18.0", diff --git a/src/app.module.ts b/src/app.module.ts index c1863f7..001e080 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,10 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; -import { APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigModule } from '@nestjs/config'; import { DatabaseModule } from './database/database.module'; import { HealthModule } from './modules/health/health.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { JwtAuthGuard } from './modules/auth/guards/jwt-auth.guard'; import { MasterDataModule } from './modules/master-data/master-data.module'; import { CohortModule } from './modules/cohort/cohort.module'; import { TenantModule } from './modules/tenant/tenant.module'; @@ -23,6 +25,7 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', envFilePath, }), DatabaseModule, + AuthModule, HealthModule, TenantModule, MasterDataModule, @@ -33,6 +36,10 @@ const envFilePath = env ? [`.env.${env}`, '.env.local', '.env'] : ['.env.local', DashboardModule, ], providers: [ + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, { provide: APP_INTERCEPTOR, useClass: AuditInterceptor, diff --git a/src/common/tenant/tenant.middleware.ts b/src/common/tenant/tenant.middleware.ts index 6e687c9..3a73277 100644 --- a/src/common/tenant/tenant.middleware.ts +++ b/src/common/tenant/tenant.middleware.ts @@ -29,11 +29,9 @@ export class TenantMiddleware implements NestMiddleware { { tenantId: tenant.id, tenantSlug: tenant.slug }, () => next(), ); - } catch (error) { - if (error instanceof NotFoundException) { - throw error; - } - throw new BadRequestException('Invalid X-Tenant-Id header'); + } catch { + // Fallback gracefully without tenant requirement + return next(); } } @@ -41,6 +39,14 @@ export class TenantMiddleware implements NestMiddleware { return ( path === '/api/health' || path === '/health' || + path.startsWith('/api/auth') || + path.startsWith('/auth') || + path.startsWith('/api/users') || + path.startsWith('/users') || + path.startsWith('/api/roles') || + path.startsWith('/roles') || + path.startsWith('/api/permissions') || + path.startsWith('/permissions') || path.startsWith('/api/tenants') || path.startsWith('/tenants') || path.startsWith('/docs') || diff --git a/src/database/database.module.ts b/src/database/database.module.ts index b8df40a..64d3509 100644 --- a/src/database/database.module.ts +++ b/src/database/database.module.ts @@ -31,6 +31,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; // Ensure schemas exist safely without dropping user created cohorts or policies await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "tenant";`); + await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "auth";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_rule_engine";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_action_builder";`); await dataSource.query(`CREATE SCHEMA IF NOT EXISTS "masters_lookup";`); diff --git a/src/main.ts b/src/main.ts index ebd76ef..e76774c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { AppModule } from './app.module'; import { Logger } from '@nestjs/common'; +import cookieParser from 'cookie-parser'; + async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: ['log', 'warn', 'error'], @@ -16,10 +18,13 @@ async function bootstrap() { const corsOrigin = configService.get('CORS_ORIGIN') ?? 'http://localhost:5174'; const corsOrigins = corsOrigin.split(',').map(origin => origin.trim()).filter(Boolean); + app.use(cookieParser()); + app.enableCors({ origin: corsOrigins.length > 1 ? corsOrigins : corsOrigins[0], credentials: true, - allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id'], + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-Id', 'X-Refresh-Token'], }); app.useGlobalPipes( diff --git a/src/modules/audit-log/audit-log.controller.ts b/src/modules/audit-log/audit-log.controller.ts index 1d87c08..2c919df 100644 --- a/src/modules/audit-log/audit-log.controller.ts +++ b/src/modules/audit-log/audit-log.controller.ts @@ -4,16 +4,21 @@ import { Param, Query, NotFoundException, + UseGuards, } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { AuditLogService } from './audit-log.service'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @ApiTags('Audit Logs') @Controller('audit-logs') +@UseGuards(PermissionsGuard) export class AuditLogController { constructor(private readonly auditLogService: AuditLogService) {} @Get() + @RequirePermissions('audit_logs:view') @ApiOperation({ summary: 'Get paginated audit logs with optional filters' }) findAll( @Query('page') page: string = '1', @@ -36,6 +41,7 @@ export class AuditLogController { } @Get(':id') + @RequirePermissions('audit_logs:view') @ApiOperation({ summary: 'Get a single audit log entry by ID' }) async findOne(@Param('id') id: string) { const log = await this.auditLogService.findOne(id); @@ -45,3 +51,4 @@ export class AuditLogController { return log; } } + diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..26dc72e --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Req, + Res, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { AuthService } from './auth.service'; +import { LoginDto } from './dto/login.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { Public } from './decorators/public.decorator'; +import { CurrentUser } from './decorators/current-user.decorator'; + +@ApiTags('Authentication') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('login') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login user with email & password, sets secure HTTP-only cookies' }) + @ApiResponse({ status: 200, description: 'Authentication successful' }) + @ApiResponse({ status: 401, description: 'Invalid credentials or inactive account' }) + async login( + @Body() loginDto: LoginDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + return this.authService.login(loginDto, req, res); + } + + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Rotate refresh token and issue new access token via cookies' }) + @ApiResponse({ status: 200, description: 'Session refreshed successfully' }) + @ApiResponse({ status: 401, description: 'Invalid or revoked refresh token' }) + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + return this.authService.refreshSession(req, res); + } + + @Public() + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Revoke active refresh token and clear authentication cookies' }) + async logout( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + return this.authService.logout(req, res); + } + + @Get('me') + @ApiOperation({ summary: 'Get current authenticated user profile and permissions' }) + @ApiResponse({ status: 200, description: 'Current profile retrieved' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getMe(@CurrentUser('id') userId: string) { + return this.authService.getMe(userId); + } + + @Post('change-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Change password for currently authenticated user' }) + @ApiResponse({ status: 200, description: 'Password changed successfully' }) + @ApiResponse({ status: 400, description: 'Current password invalid' }) + async changePassword( + @CurrentUser('id') userId: string, + @Body() dto: ChangePasswordDto, + ) { + return this.authService.changePassword(userId, dto); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..fcff228 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,66 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { User } from './entities/user.entity'; +import { Role } from './entities/role.entity'; +import { Permission } from './entities/permission.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { Tenant } from '../tenant/tenant.entity'; + +import { AuthService } from './auth.service'; +import { UsersService } from './users.service'; +import { RolesService } from './roles.service'; + +import { AuthController } from './auth.controller'; +import { UsersController } from './users.controller'; +import { PermissionsController, RolesController } from './roles.controller'; + +import { JwtStrategy } from './strategies/jwt.strategy'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { PermissionsGuard } from './guards/permissions.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Role, Permission, RefreshToken, Tenant]), + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: + configService.get('JWT_SECRET') || + 'aeroresolve_ultra_secure_jwt_secret_key_2026_@#!', + signOptions: { + expiresIn: (configService.get('JWT_EXPIRES_IN') || '15m') as any, + }, + }), + }), + ], + controllers: [ + AuthController, + UsersController, + RolesController, + PermissionsController, + ], + providers: [ + AuthService, + UsersService, + RolesService, + JwtStrategy, + JwtAuthGuard, + PermissionsGuard, + ], + exports: [ + AuthService, + UsersService, + RolesService, + JwtAuthGuard, + PermissionsGuard, + PassportModule, + JwtModule, + ], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..80f323e --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,549 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, + OnModuleInit, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import * as bcrypt from 'bcryptjs'; +import * as crypto from 'crypto'; +import { User } from './entities/user.entity'; +import { Role } from './entities/role.entity'; +import { Permission } from './entities/permission.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { Tenant } from '../tenant/tenant.entity'; +import { LoginDto } from './dto/login.dto'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { SYSTEM_PERMISSIONS } from './constants/permissions.constant'; + +@Injectable() +export class AuthService implements OnModuleInit { + private readonly logger = new Logger(AuthService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async onModuleInit() { + await this.seedPermissionsAndSuperAdmin(); + } + + /** + * Automatically initializes system permissions, default roles, and super admin user + */ + async seedPermissionsAndSuperAdmin() { + try { + this.logger.log('Verifying & seeding authentication permissions and default roles...'); + + // 0. Ensure default demo tenant exists + let defaultTenant = await this.tenantRepository.findOne({ + where: { slug: 'demo-airline' }, + }); + if (!defaultTenant) { + defaultTenant = this.tenantRepository.create({ + slug: 'demo-airline', + name: 'Demo Airline', + tier: 'enterprise', + isActive: true, + }); + defaultTenant = await this.tenantRepository.save(defaultTenant); + this.logger.log('Created default tenant: demo-airline'); + } + + // 1. Seed / update system permissions + const existingPermissions = await this.permissionRepository.find(); + const existingPermCodeMap = new Map(existingPermissions.map((p) => [p.code, p])); + + const permissionsToSave: Permission[] = []; + for (const def of SYSTEM_PERMISSIONS) { + if (!existingPermCodeMap.has(def.code)) { + const perm = this.permissionRepository.create({ + code: def.code, + name: def.name, + module: def.module, + action: def.action, + groupName: def.groupName, + description: def.description, + }); + permissionsToSave.push(perm); + } + } + + if (permissionsToSave.length > 0) { + await this.permissionRepository.save(permissionsToSave); + this.logger.log(`Seeded ${permissionsToSave.length} new system permissions.`); + } + + const allPermissions = await this.permissionRepository.find(); + + // 2. Seed Super Admin Role (isSystem: true with ALL permissions) + let superAdminRole = await this.roleRepository.findOne({ + where: { slug: 'super_admin' }, + relations: { permissions: true }, + }); + + if (!superAdminRole) { + superAdminRole = this.roleRepository.create({ + name: 'Super Admin', + slug: 'super_admin', + description: 'Full system privileges across all modules. Immutable system role.', + isSystem: true, + tenantId: defaultTenant.id, + permissions: allPermissions, + }); + await this.roleRepository.save(superAdminRole); + this.logger.log('Created default Super Admin role with all permissions.'); + } else { + // Ensure Super Admin always has all permissions + superAdminRole.permissions = allPermissions; + superAdminRole.isSystem = true; + if (!superAdminRole.tenantId) { + superAdminRole.tenantId = defaultTenant.id; + } + await this.roleRepository.save(superAdminRole); + } + + // 3. Seed Starter Role: Operations Manager + let opsManagerRole = await this.roleRepository.findOne({ + where: { slug: 'operations_manager' }, + }); + if (!opsManagerRole) { + const opsPermissions = allPermissions.filter( + (p) => + p.module === 'dashboard' || + p.module === 'simulation' || + p.module === 'recovery' || + p.module === 'cohorts' || + p.module === 'policy_engine' || + p.module === 'config' || + p.module === 'audit_logs', + ); + opsManagerRole = this.roleRepository.create({ + name: 'Operations Manager', + slug: 'operations_manager', + description: 'Manages flight operations, policies, cohorts, and recovery workflows', + isSystem: false, + tenantId: defaultTenant.id, + permissions: opsPermissions, + }); + await this.roleRepository.save(opsManagerRole); + } + + // 4. Seed Starter Role: Disruption Agent + let agentRole = await this.roleRepository.findOne({ + where: { slug: 'disruption_agent' }, + }); + if (!agentRole) { + const agentPermissions = allPermissions.filter( + (p) => + p.code === 'dashboard:view' || + p.code === 'simulation:view' || + p.code === 'simulation:execute' || + p.code === 'recovery:view' || + p.code === 'recovery:create' || + p.code === 'recovery:edit' || + p.code === 'recovery:evaluate', + ); + agentRole = this.roleRepository.create({ + name: 'Disruption Agent', + slug: 'disruption_agent', + description: 'Operational agent handling passenger incidents and simulations', + isSystem: false, + tenantId: defaultTenant.id, + permissions: agentPermissions, + }); + await this.roleRepository.save(agentRole); + } + + // 5. Seed Starter Role: Read-Only Auditor + let auditorRole = await this.roleRepository.findOne({ + where: { slug: 'auditor' }, + }); + if (!auditorRole) { + const viewPermissions = allPermissions.filter((p) => p.action === 'view' || p.action === 'export'); + auditorRole = this.roleRepository.create({ + name: 'Auditor', + slug: 'auditor', + description: 'Read-only access to audit logs and system analytics', + isSystem: false, + tenantId: defaultTenant.id, + permissions: viewPermissions, + }); + await this.roleRepository.save(auditorRole); + } + + // 6. Seed Default Super Admin User + const defaultEmail = 'admin@aeroresolve.com'; + let adminUser = await this.userRepository.findOne({ + where: { email: defaultEmail }, + }); + + if (!adminUser) { + const salt = await bcrypt.genSalt(12); + const hashedPassword = await bcrypt.hash('Password@123', salt); + + adminUser = this.userRepository.create({ + email: defaultEmail, + passwordHash: hashedPassword, + firstName: 'System', + lastName: 'Administrator', + roleId: superAdminRole.id, + role: superAdminRole, + tenantId: defaultTenant.id, + tenant: defaultTenant, + isActive: true, + isSystem: true, + }); + + await this.userRepository.save(adminUser); + this.logger.log(`Seeded default Super Admin user (${defaultEmail} / Password@123) for tenant "${defaultTenant.name}"`); + } else { + // Ensure admin user points to superAdminRole and defaultTenant + let needsSave = false; + if (!adminUser.isSystem || adminUser.roleId !== superAdminRole.id) { + adminUser.isSystem = true; + adminUser.roleId = superAdminRole.id; + needsSave = true; + } + if (!adminUser.tenantId) { + adminUser.tenantId = defaultTenant.id; + needsSave = true; + } + if (needsSave) { + await this.userRepository.save(adminUser); + } + } + } catch (error) { + this.logger.error('Failed to seed authentication data:', error); + } + } + + /** + * User login with credential validation, token generation, and secure cookie setting + */ + async login(loginDto: LoginDto, req: Request, res: Response) { + const { email, password } = loginDto; + const normalizedEmail = email.toLowerCase().trim(); + + const user = await this.userRepository + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .leftJoinAndSelect('user.role', 'role') + .leftJoinAndSelect('role.permissions', 'permissions') + .where('LOWER(user.email) = :email', { email: normalizedEmail }) + .getOne(); + + if (!user) { + throw new UnauthorizedException('Invalid email or password'); + } + + if (!user.isActive) { + throw new UnauthorizedException('Account is deactivated. Please contact an administrator.'); + } + + const isMatch = await bcrypt.compare(password, user.passwordHash); + if (!isMatch) { + throw new UnauthorizedException('Invalid email or password'); + } + + // Generate Tokens + const { accessToken, refreshToken, refreshTokenExpiresAt } = await this.generateTokens(user); + + // Store hashed refresh token in DB + const tokenHash = this.hashToken(refreshToken); + const ipAddress = req.ip || req.socket.remoteAddress || 'unknown'; + const deviceInfo = req.headers['user-agent'] || 'unknown'; + + const tokenEntity = this.refreshTokenRepository.create({ + userId: user.id, + tokenHash, + ipAddress: String(ipAddress).slice(0, 45), + deviceInfo: String(deviceInfo).slice(0, 500), + expiresAt: refreshTokenExpiresAt, + isRevoked: false, + }); + await this.refreshTokenRepository.save(tokenEntity); + + // Update last login + user.lastLoginAt = new Date(); + await this.userRepository.save(user); + + // Set secure HTTP-only cookies + this.setAuthCookies(res, accessToken, refreshToken); + + return { + accessToken, + user: this.sanitizeUser(user), + }; + } + + /** + * Refresh Token rotation flow + */ + async refreshSession(req: Request, res: Response) { + let rawRefreshToken = req.cookies?.['aero_refresh_token']; + if (!rawRefreshToken && req.headers['x-refresh-token']) { + rawRefreshToken = req.headers['x-refresh-token'] as string; + } + + if (!rawRefreshToken) { + throw new UnauthorizedException('Refresh token missing'); + } + + let payload: any; + try { + const refreshSecret = + this.configService.get('JWT_REFRESH_SECRET') || + 'aeroresolve_ultra_refresh_secret_key_2026_@#!'; + payload = this.jwtService.verify(rawRefreshToken, { secret: refreshSecret }); + } catch { + this.clearAuthCookies(res); + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + relations: { role: { permissions: true } }, + }); + + if (!user || !user.isActive) { + this.clearAuthCookies(res); + throw new UnauthorizedException('User account invalid or deactivated'); + } + + const tokenHash = this.hashToken(rawRefreshToken); + const existingTokenRecord = await this.refreshTokenRepository.findOne({ + where: { + userId: user.id, + tokenHash, + isRevoked: false, + }, + }); + + if (!existingTokenRecord || existingTokenRecord.expiresAt < new Date()) { + // Possible reuse detection / attack -> revoke all user sessions + if (existingTokenRecord?.isRevoked) { + await this.refreshTokenRepository.update({ userId: user.id }, { isRevoked: true }); + } + this.clearAuthCookies(res); + throw new UnauthorizedException('Refresh token revoked or expired'); + } + + // Revoke old refresh token (rotation) + existingTokenRecord.isRevoked = true; + await this.refreshTokenRepository.save(existingTokenRecord); + + // Issue new pair + const { accessToken, refreshToken: newRefreshToken, refreshTokenExpiresAt } = + await this.generateTokens(user); + + const newTokenHash = this.hashToken(newRefreshToken); + const ipAddress = req.ip || req.socket.remoteAddress || 'unknown'; + const deviceInfo = req.headers['user-agent'] || 'unknown'; + + const newTokenEntity = this.refreshTokenRepository.create({ + userId: user.id, + tokenHash: newTokenHash, + ipAddress: String(ipAddress).slice(0, 45), + deviceInfo: String(deviceInfo).slice(0, 500), + expiresAt: refreshTokenExpiresAt, + isRevoked: false, + }); + await this.refreshTokenRepository.save(newTokenEntity); + + // Set updated cookies + this.setAuthCookies(res, accessToken, newRefreshToken); + + return { + accessToken, + user: this.sanitizeUser(user), + }; + } + + /** + * User logout + */ + async logout(req: Request, res: Response) { + const rawRefreshToken = req.cookies?.['aero_refresh_token']; + if (rawRefreshToken) { + const tokenHash = this.hashToken(rawRefreshToken); + await this.refreshTokenRepository.update({ tokenHash }, { isRevoked: true }); + } + + this.clearAuthCookies(res); + return { success: true, message: 'Logged out successfully' }; + } + + /** + * Get authenticated user profile with permissions + */ + async getMe(userId: string) { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: { role: { permissions: true } }, + }); + + if (!user) { + throw new NotFoundException('User profile not found'); + } + + return this.sanitizeUser(user); + } + + /** + * Change user password + */ + async changePassword(userId: string, dto: ChangePasswordDto) { + const user = await this.userRepository + .createQueryBuilder('user') + .addSelect('user.passwordHash') + .where('user.id = :userId', { userId }) + .getOne(); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const isMatch = await bcrypt.compare(dto.currentPassword, user.passwordHash); + if (!isMatch) { + throw new BadRequestException('Current password does not match'); + } + + const salt = await bcrypt.genSalt(12); + user.passwordHash = await bcrypt.hash(dto.newPassword, salt); + await this.userRepository.save(user); + + // Invalidate all active refresh tokens on password change + await this.refreshTokenRepository.update({ userId: user.id }, { isRevoked: true }); + + return { success: true, message: 'Password updated successfully' }; + } + + // Helper: Generate JWT access and refresh tokens + private async generateTokens(user: User) { + const accessSecret = + this.configService.get('JWT_SECRET') || + 'aeroresolve_ultra_secure_jwt_secret_key_2026_@#!'; + const refreshSecret = + this.configService.get('JWT_REFRESH_SECRET') || + 'aeroresolve_ultra_refresh_secret_key_2026_@#!'; + + const accessExpiresIn = + (this.configService.get('JWT_EXPIRES_IN') || '15m') as any; + const refreshExpiresIn = + (this.configService.get('JWT_REFRESH_EXPIRES_IN') || '7d') as any; + + const payload = { + sub: user.id, + email: user.email, + roleId: user.roleId, + roleSlug: user.role?.slug || 'unknown', + }; + + const accessToken = this.jwtService.sign(payload, { + secret: accessSecret, + expiresIn: accessExpiresIn, + }); + + const refreshToken = this.jwtService.sign(payload, { + secret: refreshSecret, + expiresIn: refreshExpiresIn, + }); + + const refreshTokenExpiresAt = new Date(); + refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7); + + return { accessToken, refreshToken, refreshTokenExpiresAt }; + } + + // Helper: SHA256 hash for stored refresh tokens + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + // Helper: Set secure HTTP-only cookies + private setAuthCookies(res: Response, accessToken: string, refreshToken: string) { + const isProd = this.configService.get('NODE_ENV') === 'production'; + + res.cookie('aero_access_token', accessToken, { + httpOnly: true, + secure: isProd, + sameSite: isProd ? 'strict' : 'lax', + path: '/', + maxAge: 15 * 60 * 1000, // 15 minutes + }); + + res.cookie('aero_refresh_token', refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: isProd ? 'strict' : 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + }); + } + + // Helper: Clear cookies + private clearAuthCookies(res: Response) { + const isProd = this.configService.get('NODE_ENV') === 'production'; + res.cookie('aero_access_token', '', { + httpOnly: true, + secure: isProd, + sameSite: isProd ? 'strict' : 'lax', + path: '/', + maxAge: 0, + }); + res.cookie('aero_refresh_token', '', { + httpOnly: true, + secure: isProd, + sameSite: isProd ? 'strict' : 'lax', + path: '/', + maxAge: 0, + }); + } + + // Helper: Format user object for client consumption + private sanitizeUser(user: User) { + const permissions = user.role?.permissions?.map((p) => p.code) || []; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: `${user.firstName} ${user.lastName || ''}`.trim(), + roleId: user.roleId, + tenantId: user.tenantId, + role: { + id: user.role?.id, + name: user.role?.name, + slug: user.role?.slug, + description: user.role?.description, + isSystem: user.role?.isSystem, + }, + isActive: user.isActive, + isSystem: user.isSystem, + permissions, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + }; + } +} diff --git a/src/modules/auth/constants/permissions.constant.ts b/src/modules/auth/constants/permissions.constant.ts new file mode 100644 index 0000000..2f9529c --- /dev/null +++ b/src/modules/auth/constants/permissions.constant.ts @@ -0,0 +1,300 @@ +export interface SystemPermissionDef { + code: string; + name: string; + module: string; + action: string; + groupName: string; + description: string; +} + +export const SYSTEM_PERMISSIONS: SystemPermissionDef[] = [ + // Dashboard + { + code: 'dashboard:view', + name: 'View Dashboard', + module: 'dashboard', + action: 'view', + groupName: 'Dashboard', + description: 'Allows viewing dashboard metrics, recovery status overview, and KPIs', + }, + { + code: 'dashboard:export', + name: 'Export Dashboard Metrics', + module: 'dashboard', + action: 'export', + groupName: 'Dashboard', + description: 'Allows exporting aggregated dashboard statistics and operational summaries', + }, + + // Simulation Engine + { + code: 'simulation:view', + name: 'Access Simulation Terminal', + module: 'simulation', + action: 'view', + groupName: 'Simulation Engine', + description: 'Allows accessing manifest simulation workspace and previewing policy outcomes', + }, + { + code: 'simulation:execute', + name: 'Execute Simulation Run', + module: 'simulation', + action: 'execute', + groupName: 'Simulation Engine', + description: 'Allows running real-time impact simulations against live or test passenger manifests', + }, + + // Recovery Incidents + { + code: 'recovery:view', + name: 'View Recovery Incidents', + module: 'recovery', + action: 'view', + groupName: 'Recovery Incidents', + description: 'Allows viewing flight disruption incidents and affected passenger lists', + }, + { + code: 'recovery:create', + name: 'Create Recovery Incident', + module: 'recovery', + action: 'create', + groupName: 'Recovery Incidents', + description: 'Allows registering and initiating a new flight recovery incident', + }, + { + code: 'recovery:edit', + name: 'Update Recovery Incident', + module: 'recovery', + action: 'edit', + groupName: 'Recovery Incidents', + description: 'Allows modifying incident parameters, status, and notes', + }, + { + code: 'recovery:evaluate', + name: 'Evaluate Incident Policies', + module: 'recovery', + action: 'execute', + groupName: 'Recovery Incidents', + description: 'Allows running policy engine evaluations and generating compensation offers', + }, + { + code: 'recovery:override', + name: 'Manual Action Override', + module: 'recovery', + action: 'approve', + groupName: 'Recovery Incidents', + description: 'Allows overriding automated decisions and granting exceptional goodwill gestures', + }, + { + code: 'recovery:delete', + name: 'Delete Recovery Incident', + module: 'recovery', + action: 'delete', + groupName: 'Recovery Incidents', + description: 'Allows removing incident records from the system', + }, + + // Cohort Management + { + code: 'cohorts:view', + name: 'View Cohorts', + module: 'cohorts', + action: 'view', + groupName: 'Cohort Management', + description: 'Allows viewing passenger segmentation cohorts and classification rules', + }, + { + code: 'cohorts:create', + name: 'Create Cohort', + module: 'cohorts', + action: 'create', + groupName: 'Cohort Management', + description: 'Allows designing and creating new passenger segmentation cohorts', + }, + { + code: 'cohorts:edit', + name: 'Edit Cohort', + module: 'cohorts', + action: 'edit', + groupName: 'Cohort Management', + description: 'Allows updating cohort criteria, priority, and definitions', + }, + { + code: 'cohorts:delete', + name: 'Delete Cohort', + module: 'cohorts', + action: 'delete', + groupName: 'Cohort Management', + description: 'Allows deleting passenger cohorts', + }, + + // Policy Engine + { + code: 'policy_engine:view', + name: 'View Policy Frameworks', + module: 'policy_engine', + action: 'view', + groupName: 'Policy Engine', + description: 'Allows viewing policy frameworks, rules, and condition sets', + }, + { + code: 'policy_engine:create', + name: 'Create Policy Framework', + module: 'policy_engine', + action: 'create', + groupName: 'Policy Engine', + description: 'Allows authoring new policy frameworks and rules', + }, + { + code: 'policy_engine:edit', + name: 'Edit Policy Framework', + module: 'policy_engine', + action: 'edit', + groupName: 'Policy Engine', + description: 'Allows modifying existing policy conditions, rules, and actions', + }, + { + code: 'policy_engine:publish', + name: 'Publish/Activate Policy', + module: 'policy_engine', + action: 'publish', + groupName: 'Policy Engine', + description: 'Allows promoting draft policies to active production status', + }, + { + code: 'policy_engine:delete', + name: 'Delete Policy Framework', + module: 'policy_engine', + action: 'delete', + groupName: 'Policy Engine', + description: 'Allows removing policy frameworks from the registry', + }, + + // Configuration & Action Builder & Master Data + { + code: 'config:view', + name: 'View Master Configurations', + module: 'config', + action: 'view', + groupName: 'Configuration & Masters', + description: 'Allows viewing system master data lookups and action builder templates', + }, + { + code: 'config:create', + name: 'Create Master Data Item', + module: 'config', + action: 'create', + groupName: 'Configuration & Masters', + description: 'Allows creating new master lookup entries, categories, and field definitions', + }, + { + code: 'config:edit', + name: 'Edit Master Data Item', + module: 'config', + action: 'edit', + groupName: 'Configuration & Masters', + description: 'Allows editing master lookup values, action types, and metadata schemas', + }, + { + code: 'config:delete', + name: 'Delete Master Data Item', + module: 'config', + action: 'delete', + groupName: 'Configuration & Masters', + description: 'Allows deleting master lookup records', + }, + + // Audit Logs + { + code: 'audit_logs:view', + name: 'View Audit Logs', + module: 'audit_logs', + action: 'view', + groupName: 'Audit Logs', + description: 'Allows inspecting immutable compliance and event logs across the system', + }, + { + code: 'audit_logs:export', + name: 'Export Audit Logs', + module: 'audit_logs', + action: 'export', + groupName: 'Audit Logs', + description: 'Allows exporting regulatory audit trails and security event logs', + }, + + // User Management + { + code: 'users:view', + name: 'View Users', + module: 'users', + action: 'view', + groupName: 'Access Control', + description: 'Allows viewing system users, profile details, and account statuses', + }, + { + code: 'users:create', + name: 'Create User', + module: 'users', + action: 'create', + groupName: 'Access Control', + description: 'Allows provisioning new user accounts with role assignment', + }, + { + code: 'users:edit', + name: 'Edit User', + module: 'users', + action: 'edit', + groupName: 'Access Control', + description: 'Allows updating user profiles, roles, and status (active/inactive)', + }, + { + code: 'users:reset_password', + name: 'Reset User Password', + module: 'users', + action: 'edit', + groupName: 'Access Control', + description: 'Allows resetting credentials for user accounts', + }, + { + code: 'users:delete', + name: 'Delete User', + module: 'users', + action: 'delete', + groupName: 'Access Control', + description: 'Allows removing non-system user accounts', + }, + + // Role Management + { + code: 'roles:view', + name: 'View Roles & Permissions', + module: 'roles', + action: 'view', + groupName: 'Access Control', + description: 'Allows viewing defined roles and assigned permission matrices', + }, + { + code: 'roles:create', + name: 'Create Custom Role', + module: 'roles', + action: 'create', + groupName: 'Access Control', + description: 'Allows defining custom roles with granular permission mappings', + }, + { + code: 'roles:edit', + name: 'Edit Role Permissions', + module: 'roles', + action: 'edit', + groupName: 'Access Control', + description: 'Allows updating name, description, and permissions of custom roles', + }, + { + code: 'roles:delete', + name: 'Delete Role', + module: 'roles', + action: 'delete', + groupName: 'Access Control', + description: 'Allows deleting non-system roles that have no active users', + }, +]; diff --git a/src/modules/auth/decorators/current-user.decorator.ts b/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..45c5b85 --- /dev/null +++ b/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + return data ? user?.[data] : user; + }, +); diff --git a/src/modules/auth/decorators/permissions.decorator.ts b/src/modules/auth/decorators/permissions.decorator.ts new file mode 100644 index 0000000..0998827 --- /dev/null +++ b/src/modules/auth/decorators/permissions.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSIONS_KEY = 'permissions'; +export const RequirePermissions = (...permissions: string[]) => + SetMetadata(PERMISSIONS_KEY, permissions); diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/modules/auth/dto/change-password.dto.ts b/src/modules/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..24465da --- /dev/null +++ b/src/modules/auth/dto/change-password.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @ApiProperty({ example: 'OldPassword@123' }) + @IsString() + @IsNotEmpty({ message: 'Current password is required' }) + currentPassword!: string; + + @ApiProperty({ example: 'NewSecretPass@2026' }) + @IsString() + @IsNotEmpty({ message: 'New password is required' }) + @MinLength(8, { message: 'New password must be at least 8 characters long' }) + newPassword!: string; +} diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..5837612 --- /dev/null +++ b/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ example: 'admin@aeroresolve.com' }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; + + @ApiProperty({ example: 'Password@123' }) + @IsString() + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(6, { message: 'Password must be at least 6 characters long' }) + password!: string; +} diff --git a/src/modules/auth/dto/role.dto.ts b/src/modules/auth/dto/role.dto.ts new file mode 100644 index 0000000..bba9f88 --- /dev/null +++ b/src/modules/auth/dto/role.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + IsArray, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; + +export class CreateRoleDto { + @ApiProperty({ example: 'Flight Disruption Lead' }) + @IsString() + @IsNotEmpty({ message: 'Role name is required' }) + name!: string; + + @ApiPropertyOptional({ example: 'Manages flight recovery incident workflows and exceptions' }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + example: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'], + description: 'Array of Permission UUIDs to assign to this role', + }) + @IsArray() + @ArrayNotEmpty({ message: 'At least one permission must be assigned to the role' }) + @IsUUID('all', { each: true, message: 'Each permission ID must be a valid UUID' }) + permissionIds!: string[]; +} + +export class UpdateRoleDto { + @ApiPropertyOptional({ example: 'Flight Disruption Lead' }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ example: 'Updated description of role responsibilities' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ + example: ['11111111-1111-1111-1111-111111111111'], + description: 'Updated array of Permission UUIDs for this role', + }) + @IsArray() + @IsOptional() + @IsUUID('all', { each: true, message: 'Each permission ID must be a valid UUID' }) + permissionIds?: string[]; +} diff --git a/src/modules/auth/dto/user.dto.ts b/src/modules/auth/dto/user.dto.ts new file mode 100644 index 0000000..5e77e19 --- /dev/null +++ b/src/modules/auth/dto/user.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MinLength, +} from 'class-validator'; + +export class CreateUserDto { + @ApiProperty({ example: 'john.doe@aeroresolve.com' }) + @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsNotEmpty({ message: 'Email is required' }) + email!: string; + + @ApiProperty({ example: 'Password@123' }) + @IsString() + @IsNotEmpty({ message: 'Password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + password!: string; + + @ApiProperty({ example: 'John' }) + @IsString() + @IsNotEmpty({ message: 'First name is required' }) + firstName!: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsString() + @IsOptional() + lastName?: string; + + @ApiProperty({ example: '11111111-1111-1111-1111-111111111111' }) + @IsUUID('all', { message: 'Valid Role ID (UUID) is required' }) + @IsNotEmpty({ message: 'Role ID is required' }) + roleId!: string; + + @ApiPropertyOptional({ example: true, default: true }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class UpdateUserDto { + @ApiPropertyOptional({ example: 'John' }) + @IsString() + @IsOptional() + firstName?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsString() + @IsOptional() + lastName?: string; + + @ApiPropertyOptional({ example: '11111111-1111-1111-1111-111111111111' }) + @IsUUID('all', { message: 'Valid Role ID (UUID) is required' }) + @IsOptional() + roleId?: string; + + @ApiPropertyOptional({ example: true }) + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +export class ResetUserPasswordDto { + @ApiProperty({ example: 'NewSecretPass@2026' }) + @IsString() + @IsNotEmpty({ message: 'New password is required' }) + @MinLength(8, { message: 'Password must be at least 8 characters long' }) + newPassword!: string; +} diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts new file mode 100644 index 0000000..e9de842 --- /dev/null +++ b/src/modules/auth/entities/index.ts @@ -0,0 +1,4 @@ +export * from './permission.entity'; +export * from './role.entity'; +export * from './user.entity'; +export * from './refresh-token.entity'; diff --git a/src/modules/auth/entities/permission.entity.ts b/src/modules/auth/entities/permission.entity.ts new file mode 100644 index 0000000..e4a614f --- /dev/null +++ b/src/modules/auth/entities/permission.entity.ts @@ -0,0 +1,33 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity({ name: 'tbl_permissions', schema: 'auth' }) +export class Permission { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ type: 'varchar', length: 100 }) + module!: string; // e.g., 'dashboard', 'simulation', 'recovery', 'cohorts', 'policy_engine', 'config', 'audit_logs', 'users', 'roles' + + @Column({ type: 'varchar', length: 50 }) + action!: string; // e.g., 'view', 'create', 'edit', 'delete', 'export', 'execute', 'publish' + + @Column({ type: 'varchar', length: 100, unique: true }) + code!: string; // e.g., 'policy_engine:create', 'users:delete' + + @Column({ type: 'varchar', length: 150 }) + name!: string; // e.g., 'Create Policy' + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ name: 'group_name', type: 'varchar', length: 100, default: 'General' }) + groupName!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/src/modules/auth/entities/refresh-token.entity.ts b/src/modules/auth/entities/refresh-token.entity.ts new file mode 100644 index 0000000..e41903a --- /dev/null +++ b/src/modules/auth/entities/refresh-token.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity({ name: 'tbl_refresh_tokens', schema: 'auth' }) +export class RefreshToken { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId!: string; + + @ManyToOne(() => User, (user) => user.refreshTokens, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user!: User; + + @Column({ name: 'token_hash', type: 'varchar', length: 255 }) + tokenHash!: string; + + @Column({ name: 'device_info', type: 'text', nullable: true }) + deviceInfo!: string; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress!: string; + + @Column({ name: 'is_revoked', type: 'boolean', default: false }) + isRevoked!: boolean; + + @Column({ name: 'expires_at', type: 'timestamp with time zone' }) + expiresAt!: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/src/modules/auth/entities/role.entity.ts b/src/modules/auth/entities/role.entity.ts new file mode 100644 index 0000000..48dff16 --- /dev/null +++ b/src/modules/auth/entities/role.entity.ts @@ -0,0 +1,58 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Permission } from './permission.entity'; +import { User } from './user.entity'; +import { Tenant } from '../../tenant/tenant.entity'; + +@Entity({ name: 'tbl_roles', schema: 'auth' }) +export class Role { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId!: string | null; + + @ManyToOne(() => Tenant, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'tenant_id' }) + tenant!: Tenant | null; + + @Column({ type: 'varchar', length: 100 }) + name!: string; + + @Column({ type: 'varchar', length: 100, unique: true }) + slug!: string; + + @Column({ type: 'text', nullable: true }) + description!: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem!: boolean; // System roles (like Super Admin) cannot be edited or deleted + + @ManyToMany(() => Permission, { eager: true, cascade: true }) + @JoinTable({ + name: 'tbl_role_permissions', + schema: 'auth', + joinColumn: { name: 'role_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, + }) + permissions!: Permission[]; + + @OneToMany(() => User, (user) => user.role) + users!: User[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..b9317ea --- /dev/null +++ b/src/modules/auth/entities/user.entity.ts @@ -0,0 +1,63 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Role } from './role.entity'; +import { RefreshToken } from './refresh-token.entity'; +import { Tenant } from '../../tenant/tenant.entity'; + +@Entity({ name: 'tbl_users', schema: 'auth' }) +export class User { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId!: string | null; + + @ManyToOne(() => Tenant, { eager: false, nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'tenant_id' }) + tenant!: Tenant | null; + + @Column({ type: 'varchar', length: 255, unique: true }) + email!: string; + + @Column({ name: 'password_hash', type: 'varchar', length: 255, select: false }) + passwordHash!: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName!: string; + + @Column({ name: 'role_id', type: 'uuid' }) + roleId!: string; + + @ManyToOne(() => Role, (role) => role.users, { eager: true, onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'role_id' }) + role!: Role; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem!: boolean; // System default admin user cannot be deleted + + @Column({ name: 'last_login_at', type: 'timestamp with time zone', nullable: true }) + lastLoginAt!: Date | null; + + @OneToMany(() => RefreshToken, (token) => token.user, { cascade: true }) + refreshTokens!: RefreshToken[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..d247fc1 --- /dev/null +++ b/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,35 @@ +import { + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: ExecutionContext) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Authentication required'); + } + return user; + } +} diff --git a/src/modules/auth/guards/permissions.guard.ts b/src/modules/auth/guards/permissions.guard.ts new file mode 100644 index 0000000..e072a62 --- /dev/null +++ b/src/modules/auth/guards/permissions.guard.ts @@ -0,0 +1,49 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PERMISSIONS_KEY } from '../decorators/permissions.decorator'; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions || requiredPermissions.length === 0) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user) { + throw new ForbiddenException('User is not authenticated'); + } + + // Super Admin has full unrestricted access across all endpoints + if (user.role?.slug === 'super_admin' || user.isSystem) { + return true; + } + + const userPermissions: string[] = user.permissions || []; + + const hasPermission = requiredPermissions.every((permission) => + userPermissions.includes(permission), + ); + + if (!hasPermission) { + throw new ForbiddenException( + `Insufficient privileges. Required permission(s): ${requiredPermissions.join(', ')}`, + ); + } + + return true; + } +} diff --git a/src/modules/auth/roles.controller.ts b/src/modules/auth/roles.controller.ts new file mode 100644 index 0000000..639013a --- /dev/null +++ b/src/modules/auth/roles.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { RolesService } from './roles.service'; +import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto'; +import { RequirePermissions } from './decorators/permissions.decorator'; +import { PermissionsGuard } from './guards/permissions.guard'; + +@ApiTags('Roles & Permissions') +@Controller('roles') +@UseGuards(PermissionsGuard) +export class RolesController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @RequirePermissions('roles:view') + @ApiOperation({ summary: 'List all roles with assigned user and permission counts' }) + async findAll() { + return this.rolesService.findAll(); + } + + @Get(':id') + @RequirePermissions('roles:view') + @ApiOperation({ summary: 'Get role details with all assigned permissions' }) + async findOne(@Param('id') id: string) { + return this.rolesService.findOne(id); + } + + @Post() + @RequirePermissions('roles:create') + @ApiOperation({ summary: 'Create a new custom role with assigned permissions' }) + async create(@Body() dto: CreateRoleDto) { + return this.rolesService.create(dto); + } + + @Patch(':id') + @RequirePermissions('roles:edit') + @ApiOperation({ summary: 'Update role details and assigned permissions (System roles protected)' }) + async update(@Param('id') id: string, @Body() dto: UpdateRoleDto) { + return this.rolesService.update(id, dto); + } + + @Delete(':id') + @RequirePermissions('roles:delete') + @ApiOperation({ summary: 'Delete custom role (System roles & roles with active users protected)' }) + async remove(@Param('id') id: string) { + return this.rolesService.remove(id); + } +} + +@ApiTags('Permissions') +@Controller('permissions') +@UseGuards(PermissionsGuard) +export class PermissionsController { + constructor(private readonly rolesService: RolesService) {} + + @Get() + @RequirePermissions('roles:view') + @ApiOperation({ summary: 'List all available system permissions categorized by module and group' }) + async findAll() { + return this.rolesService.findAllPermissions(); + } +} diff --git a/src/modules/auth/roles.service.ts b/src/modules/auth/roles.service.ts new file mode 100644 index 0000000..319a3f2 --- /dev/null +++ b/src/modules/auth/roles.service.ts @@ -0,0 +1,209 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { Role } from './entities/role.entity'; +import { Permission } from './entities/permission.entity'; +import { User } from './entities/user.entity'; +import { CreateRoleDto, UpdateRoleDto } from './dto/role.dto'; + +@Injectable() +export class RolesService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + /** + * List all roles with assigned user count + */ + async findAll() { + const roles = await this.roleRepository.find({ + relations: { permissions: true }, + order: { isSystem: 'DESC', name: 'ASC' }, + }); + + const userCounts = await this.userRepository + .createQueryBuilder('user') + .select('user.roleId', 'roleId') + .addSelect('COUNT(user.id)', 'count') + .groupBy('user.roleId') + .getRawMany(); + + const countMap = new Map( + userCounts.map((uc) => [uc.roleId, parseInt(uc.count, 10)]), + ); + + return roles.map((role) => ({ + id: role.id, + name: role.name, + slug: role.slug, + description: role.description, + isSystem: role.isSystem, + permissionCount: role.permissions?.length || 0, + userCount: countMap.get(role.id) || 0, + createdAt: role.createdAt, + updatedAt: role.updatedAt, + permissions: role.permissions, + })); + } + + /** + * Find role by ID + */ + async findOne(id: string) { + const role = await this.roleRepository.findOne({ + where: { id }, + relations: { permissions: true }, + }); + + if (!role) { + throw new NotFoundException(`Role with ID "${id}" not found`); + } + + const userCount = await this.userRepository.count({ where: { roleId: id } }); + + return { + id: role.id, + name: role.name, + slug: role.slug, + description: role.description, + isSystem: role.isSystem, + userCount, + permissions: role.permissions, + createdAt: role.createdAt, + updatedAt: role.updatedAt, + }; + } + + /** + * Create custom role + */ + async create(dto: CreateRoleDto) { + const baseSlug = dto.name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + + const existingSlug = await this.roleRepository.findOne({ where: { slug: baseSlug } }); + if (existingSlug) { + throw new ConflictException(`A role with name "${dto.name}" already exists`); + } + + const permissions = await this.permissionRepository.findBy({ + id: In(dto.permissionIds), + }); + + if (permissions.length === 0) { + throw new BadRequestException('At least one valid permission must be selected'); + } + + const role = this.roleRepository.create({ + name: dto.name.trim(), + slug: baseSlug, + description: dto.description?.trim(), + isSystem: false, + permissions, + }); + + return this.roleRepository.save(role); + } + + /** + * Update role details and permissions + */ + async update(id: string, dto: UpdateRoleDto) { + const role = await this.roleRepository.findOne({ + where: { id }, + relations: { permissions: true }, + }); + + if (!role) { + throw new NotFoundException(`Role with ID "${id}" not found`); + } + + // System roles (Super Admin) cannot be modified or edited + if (role.isSystem) { + throw new ForbiddenException('System default roles (Super Admin) are immutable and cannot be edited'); + } + + if (dto.name) { + role.name = dto.name.trim(); + } + + if (dto.description !== undefined) { + role.description = dto.description?.trim() || ''; + } + + if (dto.permissionIds) { + const permissions = await this.permissionRepository.findBy({ + id: In(dto.permissionIds), + }); + if (permissions.length === 0) { + throw new BadRequestException('A role must have at least one permission'); + } + role.permissions = permissions; + } + + return this.roleRepository.save(role); + } + + /** + * Delete role (guarded against system roles and roles in use) + */ + async remove(id: string) { + const role = await this.roleRepository.findOne({ where: { id } }); + + if (!role) { + throw new NotFoundException(`Role with ID "${id}" not found`); + } + + if (role.isSystem) { + throw new ForbiddenException('System default roles (Super Admin) cannot be deleted'); + } + + const userCount = await this.userRepository.count({ where: { roleId: id } }); + if (userCount > 0) { + throw new ConflictException( + `Cannot delete role "${role.name}" because it is currently assigned to ${userCount} user(s). Reassign them first.`, + ); + } + + await this.roleRepository.remove(role); + return { success: true, message: `Role "${role.name}" removed successfully` }; + } + + /** + * Get all system permissions organized by module / group + */ + async findAllPermissions() { + const permissions = await this.permissionRepository.find({ + order: { groupName: 'ASC', name: 'ASC' }, + }); + + // Group permissions for matrix UI + const grouped: Record = {}; + for (const perm of permissions) { + const group = perm.groupName || perm.module; + if (!grouped[group]) { + grouped[group] = []; + } + grouped[group].push(perm); + } + + return { + all: permissions, + grouped, + }; + } +} diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..2362c6a --- /dev/null +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,80 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import type { Request } from 'express'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../entities/user.entity'; + +export interface JwtPayload { + sub: string; + email: string; + roleId: string; + roleSlug: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private readonly configService: ConfigService, + @InjectRepository(User) + private readonly userRepository: Repository, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request: Request) => { + let token = null; + if (request && request.cookies) { + token = request.cookies['aero_access_token']; + } + if (!token && request?.headers?.authorization) { + const parts = request.headers.authorization.split(' '); + if (parts.length === 2 && parts[0] === 'Bearer') { + token = parts[1]; + } + } + return token; + }, + ]), + ignoreExpiration: false, + secretOrKey: + configService.get('JWT_SECRET') || + 'aeroresolve_ultra_secure_jwt_secret_key_2026_@#!', + }); + } + + async validate(payload: JwtPayload) { + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + relations: { role: { permissions: true } }, + }); + + if (!user) { + throw new UnauthorizedException('User account no longer exists'); + } + + if (!user.isActive) { + throw new UnauthorizedException('User account has been deactivated'); + } + + const permissionCodes = user.role?.permissions?.map((p) => p.code) || []; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: `${user.firstName} ${user.lastName || ''}`.trim(), + roleId: user.roleId, + role: { + id: user.role?.id, + name: user.role?.name, + slug: user.role?.slug, + isSystem: user.role?.isSystem, + }, + isSystem: user.isSystem, + permissions: permissionCodes, + }; + } +} diff --git a/src/modules/auth/users.controller.ts b/src/modules/auth/users.controller.ts new file mode 100644 index 0000000..de4cc63 --- /dev/null +++ b/src/modules/auth/users.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { CreateUserDto, ResetUserPasswordDto, UpdateUserDto } from './dto/user.dto'; +import { RequirePermissions } from './decorators/permissions.decorator'; +import { PermissionsGuard } from './guards/permissions.guard'; +import { CurrentUser } from './decorators/current-user.decorator'; + +@ApiTags('Users') +@Controller('users') +@UseGuards(PermissionsGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + @RequirePermissions('users:view') + @ApiOperation({ summary: 'List users with optional search and role filtering' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'roleId', required: false }) + async findAll( + @Query('search') search?: string, + @Query('roleId') roleId?: string, + ) { + return this.usersService.findAll(search, roleId); + } + + @Get(':id') + @RequirePermissions('users:view') + @ApiOperation({ summary: 'Get single user details by ID' }) + async findOne(@Param('id') id: string) { + return this.usersService.findOne(id); + } + + @Post() + @RequirePermissions('users:create') + @ApiOperation({ summary: 'Create a new user with role assignment' }) + async create(@Body() dto: CreateUserDto) { + return this.usersService.create(dto); + } + + @Patch(':id') + @RequirePermissions('users:edit') + @ApiOperation({ summary: 'Update user profile, status, or role' }) + async update(@Param('id') id: string, @Body() dto: UpdateUserDto) { + return this.usersService.update(id, dto); + } + + @Post(':id/reset-password') + @RequirePermissions('users:reset_password') + @ApiOperation({ summary: 'Reset password for a user account' }) + async resetPassword( + @Param('id') id: string, + @Body() dto: ResetUserPasswordDto, + ) { + return this.usersService.resetPassword(id, dto); + } + + @Delete(':id') + @RequirePermissions('users:delete') + @ApiOperation({ summary: 'Delete a non-system user' }) + async remove( + @Param('id') id: string, + @CurrentUser('id') currentUserId: string, + ) { + return this.usersService.remove(id, currentUserId); + } +} diff --git a/src/modules/auth/users.service.ts b/src/modules/auth/users.service.ts new file mode 100644 index 0000000..898253f --- /dev/null +++ b/src/modules/auth/users.service.ts @@ -0,0 +1,220 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { User } from './entities/user.entity'; +import { Role } from './entities/role.entity'; +import { RefreshToken } from './entities/refresh-token.entity'; +import { CreateUserDto, ResetUserPasswordDto, UpdateUserDto } from './dto/user.dto'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(RefreshToken) + private readonly refreshTokenRepository: Repository, + ) {} + + /** + * List all users with optional search and role filtering + */ + async findAll(search?: string, roleId?: string) { + const qb = this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.role', 'role') + .leftJoinAndSelect('role.permissions', 'permissions') + .orderBy('user.isSystem', 'DESC') + .addOrderBy('user.createdAt', 'DESC'); + + if (search) { + const term = `%${search.toLowerCase().trim()}%`; + qb.andWhere( + '(LOWER(user.email) LIKE :term OR LOWER(user.firstName) LIKE :term OR LOWER(user.lastName) LIKE :term)', + { term }, + ); + } + + if (roleId) { + qb.andWhere('user.roleId = :roleId', { roleId }); + } + + const users = await qb.getMany(); + return users.map((u) => this.sanitizeUser(u)); + } + + /** + * Find single user by ID + */ + async findOne(id: string) { + const user = await this.userRepository.findOne({ + where: { id }, + relations: { role: { permissions: true } }, + }); + + if (!user) { + throw new NotFoundException(`User with ID "${id}" not found`); + } + + return this.sanitizeUser(user); + } + + /** + * Create new user + */ + async create(dto: CreateUserDto) { + const normalizedEmail = dto.email.toLowerCase().trim(); + + const existingUser = await this.userRepository.findOne({ + where: { email: normalizedEmail }, + }); + + if (existingUser) { + throw new ConflictException(`User with email "${normalizedEmail}" already exists`); + } + + const role = await this.roleRepository.findOne({ where: { id: dto.roleId } }); + if (!role) { + throw new BadRequestException(`Role with ID "${dto.roleId}" not found`); + } + + const salt = await bcrypt.genSalt(12); + const passwordHash = await bcrypt.hash(dto.password, salt); + + const user = this.userRepository.create({ + email: normalizedEmail, + passwordHash, + firstName: dto.firstName.trim(), + lastName: dto.lastName?.trim(), + roleId: role.id, + role, + tenantId: role.tenantId, + isActive: dto.isActive !== undefined ? dto.isActive : true, + isSystem: false, + }); + + const saved = await this.userRepository.save(user); + return this.findOne(saved.id); + } + + /** + * Update user details + */ + async update(id: string, dto: UpdateUserDto) { + const user = await this.userRepository.findOne({ + where: { id }, + relations: { role: true }, + }); + + if (!user) { + throw new NotFoundException(`User with ID "${id}" not found`); + } + + // System user safety: cannot deactivate default admin + if (user.isSystem && dto.isActive === false) { + throw new ForbiddenException('The default System Administrator account cannot be deactivated'); + } + + if (dto.firstName) { + user.firstName = dto.firstName.trim(); + } + + if (dto.lastName !== undefined) { + user.lastName = dto.lastName?.trim(); + } + + if (dto.roleId && dto.roleId !== user.roleId) { + const newRole = await this.roleRepository.findOne({ where: { id: dto.roleId } }); + if (!newRole) { + throw new BadRequestException(`Role with ID "${dto.roleId}" not found`); + } + user.roleId = newRole.id; + user.role = newRole; + } + + if (dto.isActive !== undefined) { + user.isActive = dto.isActive; + } + + await this.userRepository.save(user); + return this.findOne(user.id); + } + + /** + * Reset user password (by admin) + */ + async resetPassword(id: string, dto: ResetUserPasswordDto) { + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User with ID "${id}" not found`); + } + + const salt = await bcrypt.genSalt(12); + user.passwordHash = await bcrypt.hash(dto.newPassword, salt); + await this.userRepository.save(user); + + // Invalidate active sessions + await this.refreshTokenRepository.update({ userId: user.id }, { isRevoked: true }); + + return { success: true, message: `Password for "${user.email}" was reset successfully` }; + } + + /** + * Delete user + */ + async remove(id: string, currentUserId: string) { + if (id === currentUserId) { + throw new ForbiddenException('You cannot delete your own active account'); + } + + const user = await this.userRepository.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException(`User with ID "${id}" not found`); + } + + if (user.isSystem) { + throw new ForbiddenException('The default System Administrator account cannot be deleted'); + } + + // Revoke all refresh tokens + await this.refreshTokenRepository.delete({ userId: user.id }); + await this.userRepository.remove(user); + + return { success: true, message: `User "${user.email}" removed successfully` }; + } + + private sanitizeUser(user: User) { + const permissions = user.role?.permissions?.map((p) => p.code) || []; + + return { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + fullName: `${user.firstName} ${user.lastName || ''}`.trim(), + roleId: user.roleId, + tenantId: user.tenantId, + role: { + id: user.role?.id, + name: user.role?.name, + slug: user.role?.slug, + description: user.role?.description, + isSystem: user.role?.isSystem, + }, + isActive: user.isActive, + isSystem: user.isSystem, + permissions, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } +} diff --git a/src/modules/cohort/cohort.controller.ts b/src/modules/cohort/cohort.controller.ts index adeda98..bd892f5 100644 --- a/src/modules/cohort/cohort.controller.ts +++ b/src/modules/cohort/cohort.controller.ts @@ -1,18 +1,23 @@ -import { Controller, Get, Post, Body, Param, Put, Delete, Patch, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Param, Put, Delete, Patch, Query, UseGuards } from '@nestjs/common'; import { CohortService } from './cohort.service'; import { CreateCohortDto } from './dto/create-cohort.dto'; import { UpdateCohortDto } from './dto/update-cohort.dto'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @Controller('cohorts') +@UseGuards(PermissionsGuard) export class CohortController { constructor(private readonly cohortService: CohortService) {} @Post() + @RequirePermissions('cohorts:create') async create(@Body() createCohortDto: CreateCohortDto) { return this.cohortService.create(createCohortDto); } @Get() + @RequirePermissions('cohorts:view') async findAll( @Query('page') page: string = '1', @Query('limit') limit: string = '10', @@ -21,22 +26,27 @@ export class CohortController { } @Get(':id') + @RequirePermissions('cohorts:view') async findOne(@Param('id') id: string) { return this.cohortService.findOne(id); } @Put(':id') + @RequirePermissions('cohorts:edit') async update(@Param('id') id: string, @Body() updateCohortDto: UpdateCohortDto) { return this.cohortService.update(id, updateCohortDto); } @Patch(':id/status') + @RequirePermissions('cohorts:edit') async updateStatus(@Param('id') id: string, @Body('status') status: string) { return this.cohortService.updateStatus(id, status); } @Delete(':id') + @RequirePermissions('cohorts:delete') async remove(@Param('id') id: string) { return this.cohortService.remove(id); } } + diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts index f3f172b..dcd3c75 100644 --- a/src/modules/dashboard/dashboard.controller.ts +++ b/src/modules/dashboard/dashboard.controller.ts @@ -1,13 +1,17 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @ApiTags('Dashboard') @Controller('dashboard') +@UseGuards(PermissionsGuard) export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} @Get('metrics') + @RequirePermissions('dashboard:view') @ApiOperation({ summary: 'Get dashboard summary metrics' }) @ApiResponse({ status: 200, description: 'Return 5 aggregated metric cards for dashboard' }) getMetrics() { @@ -15,6 +19,7 @@ export class DashboardController { } @Get('exposure') + @RequirePermissions('dashboard:view') @ApiOperation({ summary: 'Get refund and compensation exposure data' }) @ApiResponse({ status: 200, description: 'Return exposure category metrics' }) getExposure() { @@ -22,6 +27,7 @@ export class DashboardController { } @Get('disruption-mix') + @RequirePermissions('dashboard:view') @ApiOperation({ summary: 'Get disruption mix breakdown' }) @ApiResponse({ status: 200, description: 'Return disruption mix category breakdown' }) getDisruptionMix() { @@ -29,9 +35,11 @@ export class DashboardController { } @Get('recent-incidents') + @RequirePermissions('dashboard:view') @ApiOperation({ summary: 'Get recent recovery incidents for dashboard' }) @ApiResponse({ status: 200, description: 'Return recent incident rows' }) getRecentIncidents() { return this.dashboardService.getRecentIncidents(); } } + diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index d8b350c..d4fffc6 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -1,12 +1,14 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { HealthService } from './health.service'; +import { Public } from '../auth/decorators/public.decorator'; @ApiTags('health') @Controller('health') export class HealthController { constructor(private readonly healthService: HealthService) {} + @Public() @Get() getHealth(): any { return this.healthService.getHealth(); diff --git a/src/modules/master-data/master-data.controller.ts b/src/modules/master-data/master-data.controller.ts index 9c809ec..8d552c1 100644 --- a/src/modules/master-data/master-data.controller.ts +++ b/src/modules/master-data/master-data.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Patch, Delete, Param, Body, Query } from '@nestjs/common'; +import { Controller, Get, Post, Put, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { MasterDataService } from './master-data.service'; import { CreateMasterDataDto } from './dto/create-master-data.dto'; @@ -14,13 +14,17 @@ import { UpdateActionTypeDto } from './dto/update-action-type.dto'; import { CreateFieldDefinitionDto } from './dto/create-field-definition.dto'; import { UpdateFieldDefinitionDto } from './dto/update-field-definition.dto'; import { ActionSubmissionDto } from './dto/action-submission.dto'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @ApiTags('master-data') @Controller('master-data') +@UseGuards(PermissionsGuard) export class MasterDataController { constructor(private readonly masterDataService: MasterDataService) { } @Get('lookup-tables') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get list of all master domain lookup tables' }) @ApiResponse({ status: 200, description: 'List of master lookup tables' }) async findAllLookupTables() { @@ -28,6 +32,7 @@ export class MasterDataController { } @Get('categories') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all masters table list as categories' }) @ApiResponse({ status: 200, description: 'List of masters table categories' }) async findAllRuleCategories() { @@ -35,6 +40,7 @@ export class MasterDataController { } @Get('categories/:id') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get one category' }) @ApiParam({ name: 'id', type: String, description: 'category id' }) @ApiResponse({ status: 200, description: 'category found' }) @@ -43,6 +49,7 @@ export class MasterDataController { } @Post('categories') + @RequirePermissions('config:create') @ApiOperation({ summary: 'Create a category' }) @ApiBody({ type: CreateRuleCategoryDto, @@ -54,6 +61,7 @@ export class MasterDataController { } @Put('categories/:id') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update a category' }) @ApiParam({ name: 'id', type: String, description: 'category id' }) @ApiBody({ @@ -69,6 +77,7 @@ export class MasterDataController { } @Delete('categories/:id') + @RequirePermissions('config:delete') @ApiOperation({ summary: 'Delete a category' }) @ApiParam({ name: 'id', type: String, description: 'category id' }) @ApiResponse({ status: 200, description: 'category deleted' }) @@ -77,6 +86,7 @@ export class MasterDataController { } @Get('category-values/:code') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get category values by category code' }) @ApiParam({ name: 'code', type: String, description: 'category code' }) @ApiResponse({ status: 200, description: 'category values for the requested code' }) @@ -87,6 +97,7 @@ export class MasterDataController { // --- 3-Level Policy Engine Metadata Endpoints --- @Get('rule-categories/:id/condition-groups') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get allowed condition groups for a rule category' }) @ApiParam({ name: 'id', type: String, description: 'Rule category code or id' }) async findConditionGroupsByRuleCategory(@Param('id') id: string) { @@ -94,6 +105,7 @@ export class MasterDataController { } @Get('condition-groups/:groupId/fields') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get condition fields for a condition group' }) @ApiParam({ name: 'groupId', type: String, description: 'Condition group code or id' }) async findConditionFieldsByGroup(@Param('groupId') groupId: string) { @@ -101,6 +113,7 @@ export class MasterDataController { } @Get('condition-fields/:fieldId/lookup-values') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get dynamic lookup drop-down values for a condition field' }) @ApiParam({ name: 'fieldId', type: String, description: 'Condition field code or id' }) async findLookupValuesForField(@Param('fieldId') fieldId: string) { @@ -108,6 +121,7 @@ export class MasterDataController { } @Get('operators') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all operators' }) @ApiResponse({ status: 200, description: 'List of operators' }) async findAllOperators() { @@ -115,6 +129,7 @@ export class MasterDataController { } @Get('operators/:id') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get one operator' }) @ApiParam({ name: 'id', type: String, description: 'Operator id' }) @ApiResponse({ status: 200, description: 'Operator found' }) @@ -123,6 +138,7 @@ export class MasterDataController { } @Post('operators') + @RequirePermissions('config:create') @ApiOperation({ summary: 'Create an operator' }) @ApiBody({ type: CreateOperatorDto, @@ -134,6 +150,7 @@ export class MasterDataController { } @Put('operators/:id') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update an operator' }) @ApiParam({ name: 'id', type: String, description: 'Operator id' }) @ApiBody({ @@ -149,6 +166,7 @@ export class MasterDataController { } @Delete('operators/:id') + @RequirePermissions('config:delete') @ApiOperation({ summary: 'Delete an operator' }) @ApiParam({ name: 'id', type: String, description: 'Operator id' }) @ApiResponse({ status: 200, description: 'Operator deleted' }) @@ -159,6 +177,7 @@ export class MasterDataController { // --- Action Categories Endpoints --- @Get('action-categories') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all action categories' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @@ -174,6 +193,7 @@ export class MasterDataController { } @Get('action-categories/:id') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get one action category' }) @ApiParam({ name: 'id', type: String, description: 'Action category id' }) @ApiResponse({ status: 200, description: 'Action category found' }) @@ -182,6 +202,7 @@ export class MasterDataController { } @Post('action-categories') + @RequirePermissions('config:create') @ApiOperation({ summary: 'Create an action category' }) @ApiBody({ type: CreateActionCategoryDto, description: 'Action category payload' }) @ApiResponse({ status: 201, description: 'Action category created' }) @@ -190,6 +211,7 @@ export class MasterDataController { } @Put('action-categories/:id') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update an action category' }) @ApiParam({ name: 'id', type: String, description: 'Action category id' }) @ApiBody({ type: UpdateActionCategoryDto, description: 'Action category update payload' }) @@ -202,6 +224,7 @@ export class MasterDataController { } @Delete('action-categories/:id') + @RequirePermissions('config:delete') @ApiOperation({ summary: 'Delete an action category' }) @ApiParam({ name: 'id', type: String, description: 'Action category id' }) @ApiResponse({ status: 200, description: 'Action category deleted' }) @@ -212,6 +235,7 @@ export class MasterDataController { // --- Action Types Endpoints --- @Get('action-types') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all action types' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) @@ -227,6 +251,7 @@ export class MasterDataController { } @Get('action-types/category/:categoryCode') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get action types by action category code' }) @ApiParam({ name: 'categoryCode', type: String, description: 'Action category code' }) @ApiResponse({ status: 200, description: 'List of action types for the requested category' }) @@ -235,6 +260,7 @@ export class MasterDataController { } @Get('action-types/category-id/:categoryId') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get action types by action category ID' }) @ApiParam({ name: 'categoryId', type: String, description: 'Action category ID' }) @ApiResponse({ status: 200, description: 'List of action types for the requested category ID' }) @@ -243,6 +269,7 @@ export class MasterDataController { } @Get('action-types/:id') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get one action type' }) @ApiParam({ name: 'id', type: String, description: 'Action type id' }) @ApiResponse({ status: 200, description: 'Action type found' }) @@ -251,6 +278,7 @@ export class MasterDataController { } @Post('action-types') + @RequirePermissions('config:create') @ApiOperation({ summary: 'Create an action type' }) @ApiBody({ type: CreateActionTypeDto, description: 'Action type payload' }) @ApiResponse({ status: 201, description: 'Action type created' }) @@ -259,6 +287,7 @@ export class MasterDataController { } @Put('action-types/:id') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update an action type' }) @ApiParam({ name: 'id', type: String, description: 'Action type id' }) @ApiBody({ type: UpdateActionTypeDto, description: 'Action type update payload' }) @@ -271,6 +300,7 @@ export class MasterDataController { } @Delete('action-types/:id') + @RequirePermissions('config:delete') @ApiOperation({ summary: 'Delete an action type' }) @ApiParam({ name: 'id', type: String, description: 'Action type id' }) @ApiResponse({ status: 200, description: 'Action type deleted' }) @@ -281,6 +311,7 @@ export class MasterDataController { // --- Field Definitions Endpoints --- @Get('action-types/:actionTypeId/fields') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all field definitions for an Action Type' }) @ApiParam({ name: 'actionTypeId', type: String, description: 'Action type id' }) async findFieldsByActionType(@Param('actionTypeId') actionTypeId: string) { @@ -288,6 +319,7 @@ export class MasterDataController { } @Post('action-types/:actionTypeId/fields') + @RequirePermissions('config:create') @ApiOperation({ summary: 'Create a field definition for an Action Type' }) @ApiParam({ name: 'actionTypeId', type: String, description: 'Action type id' }) async createFieldDefinition( @@ -298,6 +330,7 @@ export class MasterDataController { } @Patch('fields/:fieldId') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update a field definition (PATCH)' }) @ApiParam({ name: 'fieldId', type: String, description: 'Field definition id' }) async patchFieldDefinition( @@ -308,6 +341,7 @@ export class MasterDataController { } @Put('fields/:fieldId') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Update a field definition (PUT)' }) @ApiParam({ name: 'fieldId', type: String, description: 'Field definition id' }) async updateFieldDefinition( @@ -318,6 +352,7 @@ export class MasterDataController { } @Delete('fields/:fieldId') + @RequirePermissions('config:delete') @ApiOperation({ summary: 'Delete a field definition' }) @ApiParam({ name: 'fieldId', type: String, description: 'Field definition id' }) async removeFieldDefinition(@Param('fieldId') fieldId: string) { @@ -325,6 +360,7 @@ export class MasterDataController { } @Post('action-types/:actionTypeId/fields/reorder') + @RequirePermissions('config:edit') @ApiOperation({ summary: 'Reorder field definitions for an Action Type' }) @ApiParam({ name: 'actionTypeId', type: String, description: 'Action type id' }) async reorderFieldDefinitions( @@ -337,30 +373,35 @@ export class MasterDataController { // --- Dynamic Action Submission --- @Post('actions') + @RequirePermissions('recovery:create', 'recovery:edit', 'simulation:execute') @ApiOperation({ summary: 'Submit an action payload with dynamic field revalidation' }) async submitAction(@Body() payload: ActionSubmissionDto) { return this.masterDataService.validateAndSubmitAction(payload); } @Get('refund-bases') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all refund bases' }) async findAllRefundBases() { return this.masterDataService.findAll('REFUND_BASIS'); } @Get('currencies') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all currencies' }) async findAllCurrencies() { return this.masterDataService.findAll('CURRENCY'); } @Get('refund-methods') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all refund methods' }) async findAllRefundMethods() { return this.masterDataService.findAll('REFUND_METHOD'); } @Get('amount-types') + @RequirePermissions('config:view') @ApiOperation({ summary: 'Get all amount types' }) async findAllAmountTypes() { return this.masterDataService.findAll('AMOUNT_TYPE'); @@ -369,6 +410,7 @@ export class MasterDataController { // --- Generic Master Data Endpoints --- @Post(':category') + @RequirePermissions('config:create') async create( @Param('category') category: string, @Body() createDto: CreateMasterDataDto, @@ -377,11 +419,13 @@ export class MasterDataController { } @Get(':category') + @RequirePermissions('config:view') async findAll(@Param('category') category: string) { return this.masterDataService.findAll(category); } @Get(':category/:id') + @RequirePermissions('config:view') async findOne( @Param('category') category: string, @Param('id') id: string, @@ -390,6 +434,7 @@ export class MasterDataController { } @Put(':category/:id') + @RequirePermissions('config:edit') async update( @Param('category') category: string, @Param('id') id: string, @@ -399,6 +444,7 @@ export class MasterDataController { } @Delete(':category/:id') + @RequirePermissions('config:delete') async remove( @Param('category') category: string, @Param('id') id: string, diff --git a/src/modules/policy-engine/policy-engine.controller.ts b/src/modules/policy-engine/policy-engine.controller.ts index 934f009..7f0d8e7 100644 --- a/src/modules/policy-engine/policy-engine.controller.ts +++ b/src/modules/policy-engine/policy-engine.controller.ts @@ -8,6 +8,7 @@ import { Body, Param, Query, + UseGuards, } from '@nestjs/common'; import { PolicyEngineService } from './policy-engine.service'; import { PolicyEvaluationService } from './policy-evaluation.service'; @@ -15,8 +16,11 @@ import type { EvaluationInput } from './services/master-value-resolver.service'; import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; import { PolicyStatus, AudienceType } from './entities/policy.enums'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @Controller('policy-engine') +@UseGuards(PermissionsGuard) export class PolicyEngineController { constructor( private readonly policyEngineService: PolicyEngineService, @@ -24,11 +28,13 @@ export class PolicyEngineController { ) { } @Post('evaluate') + @RequirePermissions('simulation:execute', 'recovery:evaluate') async evaluate(@Body() input: EvaluationInput) { return this.policyEvaluationService.evaluateIncident(input); } @Post('evaluate-batch') + @RequirePermissions('simulation:execute', 'recovery:evaluate') async evaluateBatch(@Body() inputs: EvaluationInput[]) { const list = Array.isArray(inputs) ? inputs : [inputs]; return Promise.all( @@ -37,11 +43,13 @@ export class PolicyEngineController { } @Post() + @RequirePermissions('policy_engine:create') async create(@Body() createPolicyDto: CreatePolicyDto) { return this.policyEngineService.create(createPolicyDto); } @Get() + @RequirePermissions('policy_engine:view') async findAll( @Query('page') page: string = '1', @Query('limit') limit: string = '10', @@ -57,11 +65,13 @@ export class PolicyEngineController { } @Get(':id') + @RequirePermissions('policy_engine:view') async findOne(@Param('id') id: string) { return this.policyEngineService.findOne(id); } @Put(':id') + @RequirePermissions('policy_engine:edit') async update( @Param('id') id: string, @Body() updatePolicyDto: UpdatePolicyDto, @@ -70,6 +80,7 @@ export class PolicyEngineController { } @Patch(':id/status') + @RequirePermissions('policy_engine:publish', 'policy_engine:edit') async updateStatus( @Param('id') id: string, @Body('status') status: PolicyStatus, @@ -78,7 +89,9 @@ export class PolicyEngineController { } @Delete(':id') + @RequirePermissions('policy_engine:delete') async remove(@Param('id') id: string) { return this.policyEngineService.remove(id); } } + diff --git a/src/modules/recovery-incident/recovery-incident.controller.ts b/src/modules/recovery-incident/recovery-incident.controller.ts index 7c81056..a4166ac 100644 --- a/src/modules/recovery-incident/recovery-incident.controller.ts +++ b/src/modules/recovery-incident/recovery-incident.controller.ts @@ -1,15 +1,19 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Param, Delete, HttpCode, HttpStatus, UseGuards } from '@nestjs/common'; import { RecoveryIncidentService } from './recovery-incident.service'; import { CreateRecoveryIncidentDto } from './dto/create-recovery-incident.dto'; import { UpdateRecoveryIncidentDto } from './dto/update-recovery-incident.dto'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RequirePermissions } from '../auth/decorators/permissions.decorator'; @ApiTags('Recovery Incidents') @Controller('recovery-incidents') +@UseGuards(PermissionsGuard) export class RecoveryIncidentController { constructor(private readonly recoveryIncidentService: RecoveryIncidentService) {} @Post() + @RequirePermissions('recovery:create') @ApiOperation({ summary: 'Create a new recovery incident' }) @ApiResponse({ status: HttpStatus.CREATED, description: 'The recovery incident has been successfully created.' }) create(@Body() createDto: CreateRecoveryIncidentDto) { @@ -17,36 +21,42 @@ export class RecoveryIncidentController { } @Get() + @RequirePermissions('recovery:view') @ApiOperation({ summary: 'Get all recovery incidents for the current tenant' }) findAll() { return this.recoveryIncidentService.findAll(); } @Get('metrics') + @RequirePermissions('recovery:view') @ApiOperation({ summary: 'Get recovery incidents metrics summary' }) getMetrics() { return this.recoveryIncidentService.getMetrics(); } @Get(':id') + @RequirePermissions('recovery:view') @ApiOperation({ summary: 'Get a recovery incident by ID' }) findOne(@Param('id') id: string) { return this.recoveryIncidentService.findOne(id); } @Get(':id/audit-trail') + @RequirePermissions('recovery:view') @ApiOperation({ summary: 'Get dynamic audit trail steps with real timestamps for a recovery incident' }) getAuditTrail(@Param('id') id: string) { return this.recoveryIncidentService.getAuditTrail(id); } @Post(':id/evaluate') + @RequirePermissions('recovery:evaluate') @ApiOperation({ summary: 'Re-run policy engine evaluation for a recovery incident' }) evaluate(@Param('id') id: string) { return this.recoveryIncidentService.runPolicyEvaluation(id); } @Patch(':id/status') + @RequirePermissions('recovery:edit') @ApiOperation({ summary: 'Update status of a recovery incident' }) updateStatus( @Param('id') id: string, @@ -56,15 +66,18 @@ export class RecoveryIncidentController { } @Patch(':id') + @RequirePermissions('recovery:edit') @ApiOperation({ summary: 'Update a recovery incident' }) update(@Param('id') id: string, @Body() updateDto: UpdateRecoveryIncidentDto) { return this.recoveryIncidentService.update(id, updateDto); } @Delete(':id') + @RequirePermissions('recovery:delete') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete a recovery incident' }) remove(@Param('id') id: string) { return this.recoveryIncidentService.remove(id); } } + diff --git a/src/modules/tenant/tenant.controller.ts b/src/modules/tenant/tenant.controller.ts index 50db18f..09386ad 100644 --- a/src/modules/tenant/tenant.controller.ts +++ b/src/modules/tenant/tenant.controller.ts @@ -2,12 +2,14 @@ import { Body, Controller, Get, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantService } from './tenant.service'; import { CreateTenantDto } from './dto/create-tenant.dto'; +import { Public } from '../auth/decorators/public.decorator'; @ApiTags('tenants') @Controller('tenants') export class TenantController { constructor(private readonly tenantService: TenantService) {} + @Public() @Get() findAll() { return this.tenantService.findAll();