From da737b62fcfa9a2e4536e5fc58a4ad51c5c84124 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Thu, 14 May 2026 17:39:21 -0300 Subject: [PATCH] =?UTF-8?q?FEAT:=20Adicionado=20n=C3=ADveis=20de=20acesso?= =?UTF-8?q?=20e=20altera=C3=A7=C3=B5es=20do=20mesmo=20pelo=20painel=20de?= =?UTF-8?q?=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-dev.err.log | 0 backend-dev.log | 26 +++ backend-start.err.log | 57 +++++++ backend-start.log | 22 +++ package-lock.json | 161 ++++++++++++++++++ package.json | 2 + src/app.module.ts | 4 +- src/infra/database/database.module.ts | 9 + src/infra/database/database.service.ts | 37 ++++ src/modules/admin/admin-access.controller.ts | 25 +++ src/modules/admin/admin-access.service.ts | 103 +++++++++++ src/modules/admin/admin.module.ts | 9 + src/modules/auth/auth-token.service.ts | 5 + src/modules/auth/auth.controller.ts | 1 + src/modules/auth/auth.module.ts | 2 + src/modules/auth/auth.types.ts | 6 + .../auth/providers/ldap-auth.provider.ts | 5 +- .../providers/microsoft-oauth.provider.ts | 5 +- src/modules/auth/user-access.service.ts | 107 ++++++++++++ 19 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 backend-dev.err.log create mode 100644 backend-dev.log create mode 100644 backend-start.err.log create mode 100644 backend-start.log create mode 100644 src/infra/database/database.module.ts create mode 100644 src/infra/database/database.service.ts create mode 100644 src/modules/admin/admin-access.controller.ts create mode 100644 src/modules/admin/admin-access.service.ts create mode 100644 src/modules/admin/admin.module.ts create mode 100644 src/modules/auth/user-access.service.ts diff --git a/backend-dev.err.log b/backend-dev.err.log new file mode 100644 index 0000000..e69de29 diff --git a/backend-dev.log b/backend-dev.log new file mode 100644 index 0000000..33134b2 --- /dev/null +++ b/backend-dev.log @@ -0,0 +1,26 @@ + +> omnichannel-backend@1.0.0 dev +> cross-env NODE_ENV=development nest start --watch + +[17:25:31] Starting compilation in watch mode... + +[17:25:39] Found 0 errors. Watching for file changes. + +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [NestFactory] Starting Nest application... +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [InstanceLoader] DatabaseModule dependencies initialized +27ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [InstanceLoader] AppModule dependencies initialized +11ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [InstanceLoader] AdminModule dependencies initialized +4ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [InstanceLoader] AuthModule dependencies initialized +1ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RoutesResolver] AppController {/}: +17ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/health, GET} route +17ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RoutesResolver] AuthController {/auth}: +2ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/auth/config, GET} route +3ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/auth/login, POST} route +4ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/auth/oauth/microsoft/start, GET} route +2ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/auth/oauth/microsoft/callback, GET} route +3ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RoutesResolver] AdminAccessController {/admin/access}: +2ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/admin/access/options, GET} route +4ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/admin/access/users, GET} route +2ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [RouterExplorer] Mapped {/admin/access/users/:id, PUT} route +4ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [NestApplication] Nest application successfully started +9ms +[Nest] 42064 - 14/05/2026, 17:25:44  LOG [Bootstrap] Backend ouvindo na porta 3001 diff --git a/backend-start.err.log b/backend-start.err.log new file mode 100644 index 0000000..8cc00c6 --- /dev/null +++ b/backend-start.err.log @@ -0,0 +1,57 @@ +[Nest] 4744 - 14/05/2026, 17:25:07  ERROR [ExceptionsHandler] AggregateError [ECONNREFUSED]: + at C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:103:5) + at async Promise.all (index 0) + at async AdminAccessService.getOptions (C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\dist\modules\admin\admin-access.service.js:21:35) + at async C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\@nestjs\core\router\router-execution-context.js:46:28 + at async C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\@nestjs\core\router\router-proxy.js:9:17 { + code: 'ECONNREFUSED', + [errors]: [ + Error: connect ECONNREFUSED ::1:5432 +  at createConnectionError (node:net:1678:14) +  at afterConnectMultiple (node:net:1708:16) { + errno: -4078, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '::1', + port: 5432 + }, + Error: connect ECONNREFUSED 127.0.0.1:5432 +  at createConnectionError (node:net:1678:14) +  at afterConnectMultiple (node:net:1708:16) { + errno: -4078, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 5432 + } + ] +} +[Nest] 4744 - 14/05/2026, 17:25:07  ERROR [ExceptionsHandler] AggregateError [ECONNREFUSED]: + at C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\pg-pool\index.js:45:11 + at process.processTicksAndRejections (node:internal/process/task_queues:103:5) + at async AdminAccessService.listUsers (C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\dist\modules\admin\admin-access.service.js:31:24) + at async C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\@nestjs\core\router\router-execution-context.js:46:28 + at async C:\Users\rafael.lopes\Projetos\DevelopmentSothis\omnichannel\backend\node_modules\@nestjs\core\router\router-proxy.js:9:17 { + code: 'ECONNREFUSED', + [errors]: [ + Error: connect ECONNREFUSED ::1:5432 +  at createConnectionError (node:net:1678:14) +  at afterConnectMultiple (node:net:1708:16) { + errno: -4078, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '::1', + port: 5432 + }, + Error: connect ECONNREFUSED 127.0.0.1:5432 +  at createConnectionError (node:net:1678:14) +  at afterConnectMultiple (node:net:1708:16) { + errno: -4078, + code: 'ECONNREFUSED', + syscall: 'connect', + address: '127.0.0.1', + port: 5432 + } + ] +} diff --git a/backend-start.log b/backend-start.log new file mode 100644 index 0000000..1e2731a --- /dev/null +++ b/backend-start.log @@ -0,0 +1,22 @@ + +> omnichannel-backend@1.0.0 start +> cross-env NODE_ENV=production node dist/main.js + +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [NestFactory] Starting Nest application... +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [InstanceLoader] DatabaseModule dependencies initialized +11ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [InstanceLoader] AppModule dependencies initialized +3ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [InstanceLoader] AdminModule dependencies initialized +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [InstanceLoader] AuthModule dependencies initialized +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RoutesResolver] AppController {/}: +3ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/health, GET} route +2ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RoutesResolver] AuthController {/auth}: +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/auth/config, GET} route +0ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/auth/login, POST} route +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/auth/oauth/microsoft/start, GET} route +2ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/auth/oauth/microsoft/callback, GET} route +2ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RoutesResolver] AdminAccessController {/admin/access}: +0ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/admin/access/options, GET} route +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/admin/access/users, GET} route +0ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [RouterExplorer] Mapped {/admin/access/users/:id, PUT} route +1ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [NestApplication] Nest application successfully started +2ms +[Nest] 4744 - 14/05/2026, 17:24:40  LOG [Bootstrap] Backend ouvindo na porta 3001 diff --git a/package-lock.json b/package-lock.json index 2684339..e025b1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "winston": "^3.19.0", @@ -23,6 +24,7 @@ "@nestjs/cli": "^11.0.21", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.6.2", + "@types/pg": "^8.20.0", "cross-env": "^7.0.3", "typescript": "^6.0.3" } @@ -903,6 +905,18 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -3392,6 +3406,96 @@ "node": ">=8" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3422,6 +3526,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3877,6 +4020,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -4601,6 +4753,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", diff --git a/package.json b/package.json index 6cbc497..a1a233b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "dotenv": "^16.6.1", "jsonwebtoken": "^9.0.3", "ldapts": "^8.1.7", + "pg": "^8.20.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "winston": "^3.19.0", @@ -24,6 +25,7 @@ "@nestjs/cli": "^11.0.21", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.6.2", + "@types/pg": "^8.20.0", "cross-env": "^7.0.3", "typescript": "^6.0.3" } diff --git a/src/app.module.ts b/src/app.module.ts index dd87c1d..c2391c6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; +import { DatabaseModule } from './infra/database/database.module'; +import { AdminModule } from './modules/admin/admin.module'; import { AuthModule } from './modules/auth/auth.module'; @Module({ - imports: [AuthModule], + imports: [DatabaseModule, AuthModule, AdminModule], controllers: [AppController], }) export class AppModule {} diff --git a/src/infra/database/database.module.ts b/src/infra/database/database.module.ts new file mode 100644 index 0000000..9e1b6d6 --- /dev/null +++ b/src/infra/database/database.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { DatabaseService } from './database.service'; + +@Global() +@Module({ + providers: [DatabaseService], + exports: [DatabaseService], +}) +export class DatabaseModule {} diff --git a/src/infra/database/database.service.ts b/src/infra/database/database.service.ts new file mode 100644 index 0000000..b00bb40 --- /dev/null +++ b/src/infra/database/database.service.ts @@ -0,0 +1,37 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { Pool, PoolClient, QueryResultRow } from 'pg'; + +@Injectable() +export class DatabaseService implements OnModuleDestroy { + private readonly pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: Number(process.env.DB_PORT || 5432), + user: process.env.DB_USER || process.env.POSTGRES_USER, + password: process.env.DB_PASSWORD || process.env.POSTGRES_PASSWORD, + database: process.env.DB_NAME || process.env.POSTGRES_DB, + }); + + query(text: string, params?: unknown[]) { + return this.pool.query(text, params); + } + + transaction(handler: (client: PoolClient) => Promise) { + return this.pool.connect().then(async (client) => { + try { + await client.query('BEGIN'); + const result = await handler(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + }); + } + + async onModuleDestroy() { + await this.pool.end(); + } +} diff --git a/src/modules/admin/admin-access.controller.ts b/src/modules/admin/admin-access.controller.ts new file mode 100644 index 0000000..badb8cf --- /dev/null +++ b/src/modules/admin/admin-access.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Get, Param, Put } from '@nestjs/common'; +import { AdminAccessService } from './admin-access.service'; + +@Controller('admin/access') +export class AdminAccessController { + constructor(private readonly adminAccessService: AdminAccessService) {} + + @Get('options') + getOptions() { + return this.adminAccessService.getOptions(); + } + + @Get('users') + listUsers() { + return this.adminAccessService.listUsers(); + } + + @Put('users/:id') + updateUserAccess( + @Param('id') id: string, + @Body() body: { perfilId?: number | null; areaId?: number | null }, + ) { + return this.adminAccessService.updateUserAccess(Number(id), body); + } +} diff --git a/src/modules/admin/admin-access.service.ts b/src/modules/admin/admin-access.service.ts new file mode 100644 index 0000000..d90a3da --- /dev/null +++ b/src/modules/admin/admin-access.service.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../infra/database/database.service'; + +interface AccessUpdateInput { + perfilId?: number | null; + areaId?: number | null; +} + +@Injectable() +export class AdminAccessService { + constructor(private readonly database: DatabaseService) {} + + async getOptions() { + const [profiles, areas] = await Promise.all([ + this.database.query<{ id: number; nome: string }>( + 'SELECT id, nome FROM perfis_acesso ORDER BY nome', + ), + this.database.query<{ id: number; nome: string }>( + 'SELECT id, nome FROM areas WHERE ativo = TRUE ORDER BY nome', + ), + ]); + + return { + profiles: profiles.rows, + areas: areas.rows, + }; + } + + async listUsers() { + const result = await this.database.query( + ` + SELECT + u.id, + u.nome, + u.email, + u.ativo, + COALESCE( + JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', p.id, 'nome', p.nome)) + FILTER (WHERE p.id IS NOT NULL), + '[]' + ) AS perfis, + COALESCE( + JSON_AGG(DISTINCT JSONB_BUILD_OBJECT('id', a.id, 'nome', a.nome, 'principal', ua.principal)) + FILTER (WHERE a.id IS NOT NULL AND ua.ativo = TRUE), + '[]' + ) AS areas + FROM usuarios u + LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id + LEFT JOIN perfis_acesso p ON p.id = up.perfil_id + LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id + LEFT JOIN areas a ON a.id = ua.area_id + GROUP BY u.id + ORDER BY u.nome + `, + ); + + return result.rows.map((user: any) => { + const perfis = Array.isArray(user.perfis) ? user.perfis : []; + const areas = Array.isArray(user.areas) ? user.areas : []; + const primaryArea = areas.find((area) => area.principal) || areas[0] || null; + + return { + id: user.id, + nome: user.nome, + email: user.email, + ativo: user.ativo, + perfis, + areas, + perfilPrincipal: perfis[0] || null, + areaPrincipal: primaryArea, + accessStatus: perfis.length && areas.length ? 'assigned' : 'unassigned', + }; + }); + } + + async updateUserAccess(usuarioId: number, input: AccessUpdateInput) { + await this.database.transaction(async (client) => { + await client.query('DELETE FROM usuarios_perfis WHERE usuario_id = $1', [usuarioId]); + await client.query('DELETE FROM usuarios_areas WHERE usuario_id = $1', [usuarioId]); + + if (input.perfilId) { + await client.query( + 'INSERT INTO usuarios_perfis (usuario_id, perfil_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [usuarioId, input.perfilId], + ); + } + + if (input.areaId) { + await client.query( + ` + INSERT INTO usuarios_areas (usuario_id, area_id, principal, ativo) + VALUES ($1, $2, TRUE, TRUE) + ON CONFLICT (usuario_id, area_id) + DO UPDATE SET principal = TRUE, ativo = TRUE, updated_at = NOW() + `, + [usuarioId, input.areaId], + ); + } + }); + + return this.listUsers().then((users) => users.find((user) => user.id === usuarioId)); + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts new file mode 100644 index 0000000..0aad8cc --- /dev/null +++ b/src/modules/admin/admin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AdminAccessController } from './admin-access.controller'; +import { AdminAccessService } from './admin-access.service'; + +@Module({ + controllers: [AdminAccessController], + providers: [AdminAccessService], +}) +export class AdminModule {} diff --git a/src/modules/auth/auth-token.service.ts b/src/modules/auth/auth-token.service.ts index 6fb1aa4..d5279f7 100644 --- a/src/modules/auth/auth-token.service.ts +++ b/src/modules/auth/auth-token.service.ts @@ -20,6 +20,11 @@ export class AuthTokenService { email: user.email, provider: user.provider, username: user.username, + perfis: user.perfis || [], + profiles: user.profiles || user.perfis || [], + areas: user.areas || [], + areaPrincipal: user.areaPrincipal || null, + accessStatus: user.accessStatus || 'unassigned', }, config.jwtSecret, { diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 78b962c..ba78b7b 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -28,6 +28,7 @@ export class AuthController { redirectUrl.searchParams.set('token', authResult.token); redirectUrl.searchParams.set('provider', authResult.user.provider); + redirectUrl.searchParams.set('user', JSON.stringify(authResult.user)); return response.redirect(redirectUrl.toString()); } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 8f66eb9..09ddf56 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -3,6 +3,7 @@ import { AuthConfigService } from './auth.config'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { AuthTokenService } from './auth-token.service'; +import { UserAccessService } from './user-access.service'; import { LdapAuthProvider } from './providers/ldap-auth.provider'; import { MicrosoftOAuthProvider } from './providers/microsoft-oauth.provider'; import { OAuthStateService } from './providers/oauth-state.service'; @@ -13,6 +14,7 @@ import { OAuthStateService } from './providers/oauth-state.service'; AuthConfigService, AuthService, AuthTokenService, + UserAccessService, LdapAuthProvider, MicrosoftOAuthProvider, OAuthStateService, diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 45da43b..133ef75 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -9,6 +9,12 @@ export interface AuthenticatedUser { email: string | null; username: string; provider: 'ldap' | 'microsoft'; + databaseId?: number; + perfis?: string[]; + profiles?: string[]; + areas?: string[]; + areaPrincipal?: string | null; + accessStatus?: 'assigned' | 'unassigned'; } export interface AuthResult { diff --git a/src/modules/auth/providers/ldap-auth.provider.ts b/src/modules/auth/providers/ldap-auth.provider.ts index c49ffab..25d1c69 100644 --- a/src/modules/auth/providers/ldap-auth.provider.ts +++ b/src/modules/auth/providers/ldap-auth.provider.ts @@ -3,12 +3,14 @@ import { Client } from 'ldapts'; import { AuthConfigService } from '../auth.config'; import { AuthTokenService } from '../auth-token.service'; import { AuthResult, LoginData } from '../auth.types'; +import { UserAccessService } from '../user-access.service'; @Injectable() export class LdapAuthProvider { constructor( private readonly authConfig: AuthConfigService, private readonly authToken: AuthTokenService, + private readonly userAccess: UserAccessService, ) {} async authenticate({ username, password }: LoginData): Promise { @@ -41,7 +43,7 @@ export class LdapAuthProvider { await client.bind(userPrincipal, password); const directoryUser = await this.searchUser(client, username); - const user = { + const providerUser = { id: directoryUser?.email || userPrincipal, name: directoryUser?.name || username, email: @@ -50,6 +52,7 @@ export class LdapAuthProvider { username: directoryUser?.username || username, provider: 'ldap' as const, }; + const user = await this.userAccess.syncAuthenticatedUser(providerUser); return { token: this.authToken.issueToken(user), diff --git a/src/modules/auth/providers/microsoft-oauth.provider.ts b/src/modules/auth/providers/microsoft-oauth.provider.ts index 5443a4e..26279b2 100644 --- a/src/modules/auth/providers/microsoft-oauth.provider.ts +++ b/src/modules/auth/providers/microsoft-oauth.provider.ts @@ -7,6 +7,7 @@ import { import { AuthConfigService } from '../auth.config'; import { AuthTokenService } from '../auth-token.service'; import { AuthResult } from '../auth.types'; +import { UserAccessService } from '../user-access.service'; import { OAuthStateService } from './oauth-state.service'; const MICROSOFT_SCOPE = 'openid profile email User.Read'; @@ -17,6 +18,7 @@ export class MicrosoftOAuthProvider { private readonly authConfig: AuthConfigService, private readonly authToken: AuthTokenService, private readonly oauthState: OAuthStateService, + private readonly userAccess: UserAccessService, ) {} getAuthorizeUrl() { @@ -43,13 +45,14 @@ export class MicrosoftOAuthProvider { const tokenResponse = await this.exchangeCode(query.code); const microsoftUser = await this.getMicrosoftUser(tokenResponse.access_token); const email = microsoftUser.mail || microsoftUser.userPrincipalName; - const user = { + const providerUser = { id: microsoftUser.id || email, name: microsoftUser.displayName || email, email, username: microsoftUser.userPrincipalName || email, provider: 'microsoft' as const, }; + const user = await this.userAccess.syncAuthenticatedUser(providerUser); return { token: this.authToken.issueToken(user), diff --git a/src/modules/auth/user-access.service.ts b/src/modules/auth/user-access.service.ts new file mode 100644 index 0000000..665af48 --- /dev/null +++ b/src/modules/auth/user-access.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common'; +import { PoolClient } from 'pg'; +import { DatabaseService } from '../../infra/database/database.service'; +import { AuthenticatedUser } from './auth.types'; + +interface UserAccessRow { + id: number; + perfis: string[] | null; + areas: string[] | null; + area_principal: string | null; +} + +@Injectable() +export class UserAccessService { + constructor(private readonly database: DatabaseService) {} + + syncAuthenticatedUser(user: AuthenticatedUser): Promise { + return this.database.transaction(async (client) => { + const usuarioId = await this.upsertUser(client, user); + await this.upsertProvider(client, usuarioId, user); + const access = await this.getUserAccess(client, usuarioId); + + const perfis = access.perfis || []; + const areas = access.areas || []; + + return { + ...user, + id: String(usuarioId), + databaseId: usuarioId, + perfis, + profiles: perfis, + areas, + areaPrincipal: access.area_principal, + accessStatus: perfis.length && areas.length ? 'assigned' : 'unassigned', + }; + }); + } + + private async upsertUser(client: PoolClient, user: AuthenticatedUser) { + const email = user.email || null; + const fallbackEmail = `${user.provider}:${user.username}`; + const lookupEmail = email || fallbackEmail; + + const result = await client.query<{ id: number }>( + ` + INSERT INTO usuarios (nome, email, ativo, updated_at) + VALUES ($1, $2, TRUE, NOW()) + ON CONFLICT (email) + DO UPDATE SET + nome = EXCLUDED.nome, + ativo = TRUE, + updated_at = NOW() + RETURNING id + `, + [user.name || user.username, lookupEmail], + ); + + return result.rows[0].id; + } + + private async upsertProvider(client: PoolClient, usuarioId: number, user: AuthenticatedUser) { + await client.query( + ` + INSERT INTO usuarios_provedores (usuario_id, provedor, provedor_user_id) + VALUES ($1, $2, $3) + ON CONFLICT (provedor, provedor_user_id) + DO UPDATE SET usuario_id = EXCLUDED.usuario_id + `, + [usuarioId, user.provider, user.username || user.email || user.id], + ); + } + + private async getUserAccess(client: PoolClient, usuarioId: number) { + const result = await client.query( + ` + SELECT + u.id, + COALESCE( + ARRAY_AGG(DISTINCT p.nome) FILTER (WHERE p.nome IS NOT NULL), + ARRAY[]::VARCHAR[] + ) AS perfis, + COALESCE( + ARRAY_AGG(DISTINCT a.nome) FILTER (WHERE a.nome IS NOT NULL AND ua.ativo = TRUE), + ARRAY[]::VARCHAR[] + ) AS areas, + MAX(a.nome) FILTER (WHERE ua.principal = TRUE AND ua.ativo = TRUE) AS area_principal + FROM usuarios u + LEFT JOIN usuarios_perfis up ON up.usuario_id = u.id + LEFT JOIN perfis_acesso p ON p.id = up.perfil_id + LEFT JOIN usuarios_areas ua ON ua.usuario_id = u.id + LEFT JOIN areas a ON a.id = ua.area_id + WHERE u.id = $1 + GROUP BY u.id + `, + [usuarioId], + ); + + return ( + result.rows[0] || { + id: usuarioId, + perfis: [], + areas: [], + area_principal: null, + } + ); + } +}