diff --git a/backend/package-lock.json b/backend/package-lock.json index be6137424..b6954a862 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -58,6 +58,7 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", + "botbuilder": "^4.23.2", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "connect-redis": "^7.1.1", @@ -2358,12 +2359,13 @@ } }, "node_modules/@azure/core-auth": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.7.2.tgz", - "integrity": "sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.9.0.tgz", + "integrity": "sha512-FPwHpZywuyasDSLMqJ6fhbOK3TqUdviZNF8OqRGA4W5Ewib2lEEZ+pBsYcBa88B2NGO/SEnYPGhyBqNlE8ilSw==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", - "@azure/core-util": "^1.1.0", + "@azure/core-util": "^1.11.0", "tslib": "^2.6.2" }, "engines": { @@ -2518,14 +2520,15 @@ } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.16.1.tgz", - "integrity": "sha512-ExPSbgjwCoht6kB7B4MeZoBAxcQSIl29r/bPeazZJx50ej4JJCByimLOrZoIsurISNyJQQHf30b3JfqC3Hb88A==", + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.19.1.tgz", + "integrity": "sha512-zHeoI3NCs53lLBbWNzQycjnYKsA1CVKlnzSNuSFcUDwBp8HHVObePxrM7HaX+Ha5Ks639H7chNC9HOaIhNS03w==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", - "@azure/core-auth": "^1.4.0", + "@azure/core-auth": "^1.8.0", "@azure/core-tracing": "^1.0.1", - "@azure/core-util": "^1.9.0", + "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", @@ -2602,9 +2605,10 @@ } }, "node_modules/@azure/core-util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.9.0.tgz", - "integrity": "sha512-AfalUQ1ZppaKuxPPMsFEUdX6GZPB3d9paR9d/TTL7Ow2De8cJaC7ibi7kWVlFAVPCYo31OcnGymc0R89DX8Oaw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.11.0.tgz", + "integrity": "sha512-DxOSLua+NdpWoSqULhjDyAZTXFdP/LKkqtYuxxz1SCN289zk3OG8UOpnCQAz/tygyACBtWp/BoO72ptK7msY8g==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "tslib": "^2.6.2" @@ -2625,46 +2629,60 @@ } }, "node_modules/@azure/identity": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.3.0.tgz", - "integrity": "sha512-LHZ58/RsIpIWa4hrrE2YuJ/vzG1Jv9f774RfTTAVDZDriubvJ0/S5u4pnw4akJDlS0TiJb6VMphmVUFsWmgodQ==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.9.1.tgz", + "integrity": "sha512-986D7Cf1AOwYqSDtO/FnMAyk/Jc8qpftkGsxuehoh4F85MhQ4fICBGX/44+X1y78lN4Sqib3Bsoaoh/FvOGgmg==", + "license": "MIT", "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.5.0", + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", - "@azure/core-rest-pipeline": "^1.1.0", + "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", - "@azure/core-util": "^1.3.0", + "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", - "@azure/msal-browser": "^3.11.1", - "@azure/msal-node": "^2.9.2", - "events": "^3.0.0", - "jws": "^4.0.0", - "open": "^8.0.0", - "stoppable": "^1.1.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", "tslib": "^2.2.0" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@azure/identity/node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "node_modules/@azure/identity/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@azure/identity/node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "node_modules/@azure/identity/node_modules/@azure/msal-node": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.5.1.tgz", + "integrity": "sha512-dkgMYM5B6tI88r/oqf5bYd93WkenQpaWwiszJDk7avVjso8cmuKRTW97dA1RMi6RhihZFLtY1VtWxU9+sW2T5g==", + "license": "MIT", "dependencies": { - "jwa": "^2.0.0", - "safe-buffer": "^5.0.1" + "@azure/msal-common": "15.5.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@azure/identity/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/@azure/keyvault-keys": { @@ -2700,30 +2718,33 @@ } }, "node_modules/@azure/msal-browser": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.18.0.tgz", - "integrity": "sha512-jvK5bDUWbpOaJt2Io/rjcaOVcUzkqkrCme/WntdV1SMUc67AiTcEdKuY6G/nMQ7N5Cfsk9SfpugflQwDku53yg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.11.0.tgz", + "integrity": "sha512-0p5Ut3wORMP+975AKvaSPIO4UytgsfAvJ7RxaTx+nkP+Hpkmm93AuiMkBWKI2x9tApU/SLgIyPz/ZwLYUIWb5Q==", + "license": "MIT", "dependencies": { - "@azure/msal-common": "14.13.0" + "@azure/msal-common": "15.5.1" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "14.13.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.13.0.tgz", - "integrity": "sha512-b4M/tqRzJ4jGU91BiwCsLTqChveUEyFK3qY2wGfZ0zBswIBZjAxopx5CYt5wzZFKuN15HqRDYXQbztttuIC3nA==", + "version": "15.5.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.5.1.tgz", + "integrity": "sha512-oxK0khbc4Bg1bKQnqDr7ikULhVL2OHgSrIq0Vlh4b6+hm4r0lr6zPMQE8ZvmacJuh+ZZGKBM5iIObhF1q1QimQ==", + "license": "MIT", "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-node": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.10.0.tgz", - "integrity": "sha512-JxsSE0464a8IA/+q5EHKmchwNyUFJHtCH00tSXsLaOddwLjG6yVvTH6lGgPcWMhO7YWUXj/XVgVgeE9kZtsPUQ==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-2.16.2.tgz", + "integrity": "sha512-An7l1hEr0w1HMMh1LU+rtDtqL7/jw74ORlc9Wnh06v7TU/xpG39/Zdr1ZJu3QpjUfKJ+E0/OXMW8DRSWTlh7qQ==", + "license": "MIT", "dependencies": { - "@azure/msal-common": "14.13.0", + "@azure/msal-common": "14.16.0", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" }, @@ -2731,6 +2752,15 @@ "node": ">=16" } }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "14.16.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.0.tgz", + "integrity": "sha512-1KOZj9IpcDSwpNiQNjt0jDYZpQvNZay7QAEi/5DLubay40iGYtLzya/jbjRPLyOTZhEKyL1MzPuw2HqBCjceYA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@azure/msal-node/node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -9759,9 +9789,10 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -10157,6 +10188,15 @@ "@types/webidl-conversions": "*" } }, + "node_modules/@types/ws": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-6.0.4.tgz", + "integrity": "sha512-PpPrX7SZW9re6+Ha8ojZG4Se8AZXgf0GK6zmfqEuCsY49LFDNXO3SByp44X3dFEqtB73lkCDAdUazhAjVPiNwg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/xml-encryption": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", @@ -10909,6 +10949,12 @@ "node": ">=0.4.0" } }, + "node_modules/adaptivecards": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/adaptivecards/-/adaptivecards-1.2.3.tgz", + "integrity": "sha512-amQ5OSW3OpIkrxVKLjxVBPk/T49yuOtnqs1z5ZPfZr0+OpTovzmiHbyoAGDIsu5SNYHwOZFp/3LGOnRaALFa/g==", + "license": "MIT" + }, "node_modules/adm-zip": { "version": "0.5.12", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.12.tgz", @@ -11769,6 +11815,245 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/botbuilder": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botbuilder/-/botbuilder-4.23.2.tgz", + "integrity": "sha512-E3UjkPlAmT8TidZIAW1ucVRejz0KBbWEn0wNxJ37GncPl8txhmWvs21xn3hBrifSR4++Y6q/hp/A5cHFQcFGJw==", + "license": "MIT", + "dependencies": { + "@azure/core-http": "^3.0.4", + "@azure/msal-node": "^2.13.1", + "axios": "^1.7.7", + "botbuilder-core": "4.23.2", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-connector": "4.23.2", + "botframework-schema": "4.23.2", + "botframework-streaming": "4.23.2", + "dayjs": "^1.11.13", + "filenamify": "^6.0.0", + "fs-extra": "^11.2.0", + "htmlparser2": "^9.0.1", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botbuilder-core": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botbuilder-core/-/botbuilder-core-4.23.2.tgz", + "integrity": "sha512-GwrfkfbEJqCLnhDVc6uKlzKtrptfYTxQxHYfF22s1AxTKdTiA9vsDN9rXq8We7QUPXFOF1ylF1e87k0fQ3Sf+A==", + "license": "MIT", + "dependencies": { + "botbuilder-dialogs-adaptive-runtime-core": "4.23.2-preview", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-connector": "4.23.2", + "botframework-schema": "4.23.2", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botbuilder-core/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botbuilder-dialogs-adaptive-runtime-core": { + "version": "4.23.2-preview", + "resolved": "https://registry.npmjs.org/botbuilder-dialogs-adaptive-runtime-core/-/botbuilder-dialogs-adaptive-runtime-core-4.23.2-preview.tgz", + "integrity": "sha512-+b5oHSDNodYXPnQbub+hTNmQLtBB4hj/ZW73g4Sqv5oAdqHoK/dX181UpiFAvDpHGe8Kx3SNYtRHJIj71u4t0Q==", + "license": "MIT", + "dependencies": { + "dependency-graph": "^1.0.0" + } + }, + "node_modules/botbuilder-stdlib": { + "version": "4.23.2-internal", + "resolved": "https://registry.npmjs.org/botbuilder-stdlib/-/botbuilder-stdlib-4.23.2-internal.tgz", + "integrity": "sha512-5WAu59gCZX3lz2NNw28q+IlAAFIQjXij0wXmN8qh+Tg4PQOCl+5P3hoYqcHIWtGd5Kgn+dpaHtBIewl2LaOXKQ==", + "license": "MIT" + }, + "node_modules/botbuilder/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/botbuilder/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-connector": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-connector/-/botframework-connector-4.23.2.tgz", + "integrity": "sha512-G4gDpEHhA8AUKbgHMJ1LUjsuDlRPFEcXnH8ouxLI0opT2p1LcUSAAgS4hoOrkaylr04zxrUI0nEWkuWDiWDwzw==", + "license": "MIT", + "dependencies": { + "@azure/core-http": "^3.0.4", + "@azure/identity": "^4.4.1", + "@azure/msal-node": "^2.13.1", + "@types/jsonwebtoken": "9.0.6", + "axios": "^1.7.7", + "base64url": "^3.0.0", + "botbuilder-stdlib": "4.23.2-internal", + "botframework-schema": "4.23.2", + "buffer": "^6.0.3", + "cross-fetch": "^4.0.0", + "https-proxy-agent": "^7.0.5", + "jsonwebtoken": "^9.0.2", + "node-fetch": "^2.7.0", + "openssl-wrapper": "^0.3.4", + "rsa-pem-from-mod-exp": "^0.8.6", + "zod": "^3.23.8" + } + }, + "node_modules/botframework-connector/node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/botframework-connector/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/botframework-connector/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/botframework-schema": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-schema/-/botframework-schema-4.23.2.tgz", + "integrity": "sha512-eO1fmvfCEVJfnqNNAerQU8CHp0FMYTyE459ztNx2k1QJYMl/ds+LNNkGIUlQQFsdVbi2umadK+6hL2a9kqXMqQ==", + "license": "MIT", + "dependencies": { + "adaptivecards": "1.2.3", + "uuid": "^10.0.0", + "zod": "^3.23.8" + } + }, + "node_modules/botframework-schema/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-streaming": { + "version": "4.23.2", + "resolved": "https://registry.npmjs.org/botframework-streaming/-/botframework-streaming-4.23.2.tgz", + "integrity": "sha512-UBF0puC2RX8Z0dkN/ag9BuSNWdB5MUtobZLzeaH1h5t7QAYAVNk/SrsUgkBwUsqWpwaZqU+vrGOeByLShDcvaQ==", + "license": "MIT", + "dependencies": { + "@types/node": "18.19.47", + "@types/ws": "^6.0.3", + "uuid": "^10.0.0", + "ws": "^7.5.10" + } + }, + "node_modules/botframework-streaming/node_modules/@types/node": { + "version": "18.19.47", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.47.tgz", + "integrity": "sha512-1f7dB3BL/bpd9tnDJrrHb66Y+cVrhxSOTGorRNdHwYTUlTay3HuTDPKo9a/4vX9pMQkhYBcAbL4jQdNlhCFP9A==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/botframework-streaming/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/botframework-streaming/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/botframework-streaming/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", @@ -11896,6 +12181,21 @@ "uuid": "^9.0.0" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-require": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.2.tgz", @@ -12548,6 +12848,15 @@ "node": ">=12.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -12629,6 +12938,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/dc-polyfill": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/dc-polyfill/-/dc-polyfill-0.1.6.tgz", @@ -12768,6 +13083,34 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -12786,11 +13129,15 @@ } }, "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/define-properties": { @@ -12850,6 +13197,15 @@ "node": ">= 0.8" } }, + "node_modules/dependency-graph": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -12923,6 +13279,47 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dompurify": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", @@ -12931,6 +13328,20 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", @@ -14251,6 +14662,33 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/filename-reserved-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz", + "integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/filenamify": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz", + "integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==", + "license": "MIT", + "dependencies": { + "filename-reserved-regex": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -15435,6 +15873,25 @@ ], "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -15948,6 +16405,39 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -16475,7 +16965,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -18523,16 +19012,33 @@ } }, "node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.1.tgz", + "integrity": "sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==", + "license": "MIT", "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open/node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18557,6 +19063,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openssl-wrapper": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz", + "integrity": "sha512-iITsrx6Ho8V3/2OVtmZzzX8wQaKAaFXEJQdzoPUZDtyf5jWFlqo+h+OhGT4TATQ47f9ACKHua8nw7Qoy85aeKQ==", + "license": "MIT" + }, "node_modules/opentracing": { "version": "0.14.7", "resolved": "https://registry.npmjs.org/opentracing/-/opentracing-0.14.7.tgz", @@ -20542,6 +21054,24 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" }, + "node_modules/rsa-pem-from-mod-exp": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/rsa-pem-from-mod-exp/-/rsa-pem-from-mod-exp-0.8.6.tgz", + "integrity": "sha512-c5ouQkOvGHF1qomUUDJGFcXsomeSO2gbEs6hVhMAtlkE1CuaZase/WzoaKFG/EZQuNmq6pw/EMCeEnDvOgCJYQ==", + "license": "MIT" + }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -21416,15 +21946,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/stoppable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", - "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", - "engines": { - "node": ">=4", - "npm": ">=6" - } - }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -23208,7 +23729,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -24639,9 +25159,10 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index b2c0d751a..0e01ad129 100644 --- a/backend/package.json +++ b/backend/package.json @@ -175,6 +175,7 @@ "axios": "^1.6.7", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", + "botbuilder": "^4.23.2", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", "connect-redis": "^7.1.1", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 441d3ce4c..296753915 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -98,6 +98,7 @@ import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service"; import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; declare module "@fastify/request-context" { interface RequestContextData { @@ -241,6 +242,7 @@ declare module "fastify" { kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory; + microsoftTeams: TMicrosoftTeamsServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index dc0e5ee67..c52dd4725 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -423,6 +423,16 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TMicrosoftTeamsIntegrations, + TMicrosoftTeamsIntegrationsInsert, + TMicrosoftTeamsIntegrationsUpdate +} from "@app/db/schemas/microsoft-teams-integrations"; +import { + TProjectMicrosoftTeamsConfigs, + TProjectMicrosoftTeamsConfigsInsert, + TProjectMicrosoftTeamsConfigsUpdate +} from "@app/db/schemas/project-microsoft-teams-configs"; declare module "knex" { namespace Knex { @@ -994,5 +1004,15 @@ declare module "knex/types/tables" { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate >; + [TableName.MicrosoftTeamsIntegrations]: KnexOriginal.CompositeTableType< + TMicrosoftTeamsIntegrations, + TMicrosoftTeamsIntegrationsInsert, + TMicrosoftTeamsIntegrationsUpdate + >; + [TableName.ProjectMicrosoftTeamsConfigs]: KnexOriginal.CompositeTableType< + TProjectMicrosoftTeamsConfigs, + TProjectMicrosoftTeamsConfigsInsert, + TProjectMicrosoftTeamsConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts new file mode 100644 index 000000000..47782182d --- /dev/null +++ b/backend/src/db/migrations/20250422125635_microsoft-teams-workflow-integration.ts @@ -0,0 +1,123 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +const encryptedMicrosoftTeamsAppIdColumn = "encryptedMicrosoftTeamsAppId"; +const encryptedMicrosoftTeamsClientSecretColumn = "encryptedMicrosoftTeamsClientSecret"; +const encryptedMicrosoftTeamsBotIdColumn = "encryptedMicrosoftTeamsBotId"; + +export async function up(knex: Knex): Promise { + const superAdminHasEncryptedMicrosoftTeamsClientIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsAppIdColumn + ); + const superAdminHasEncryptedMicrosoftTeamsClientSecretColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsClientSecretColumn + ); + const superAdminHasEncryptedMicrosoftTeamsBotIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsBotIdColumn + ); + + if (!superAdminHasEncryptedMicrosoftTeamsClientIdColumn || !superAdminHasEncryptedMicrosoftTeamsClientSecretColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (table) => { + if (!superAdminHasEncryptedMicrosoftTeamsClientIdColumn) { + table.binary(encryptedMicrosoftTeamsAppIdColumn).nullable(); + } + if (!superAdminHasEncryptedMicrosoftTeamsClientSecretColumn) { + table.binary(encryptedMicrosoftTeamsClientSecretColumn).nullable(); + } + if (!superAdminHasEncryptedMicrosoftTeamsBotIdColumn) { + table.binary(encryptedMicrosoftTeamsBotIdColumn).nullable(); + } + }); + } + + if (!(await knex.schema.hasColumn(TableName.WorkflowIntegrations, "status"))) { + await knex.schema.alterTable(TableName.WorkflowIntegrations, (table) => { + table.enu("status", ["pending", "installed", "failed"]).notNullable().defaultTo("installed"); // defaults to installed so we can have backwards compatibility with existing workflow integrations + }); + } + + if (!(await knex.schema.hasTable(TableName.MicrosoftTeamsIntegrations))) { + await knex.schema.createTable(TableName.MicrosoftTeamsIntegrations, (table) => { + table.uuid("id", { primaryKey: true }).notNullable(); + table.foreign("id").references("id").inTable(TableName.WorkflowIntegrations).onDelete("CASCADE"); // the ID itself is the workflow integration ID + + table.string("internalTeamsAppId").nullable(); + table.string("tenantId").notNullable(); + table.binary("encryptedAccessToken").nullable(); + table.binary("encryptedBotAccessToken").nullable(); + + table.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.MicrosoftTeamsIntegrations); + } + + if (!(await knex.schema.hasTable(TableName.ProjectMicrosoftTeamsConfigs))) { + await knex.schema.createTable(TableName.ProjectMicrosoftTeamsConfigs, (tb) => { + tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + tb.string("projectId").notNullable().unique(); + tb.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + tb.uuid("microsoftTeamsIntegrationId").notNullable(); + tb.foreign("microsoftTeamsIntegrationId") + .references("id") + .inTable(TableName.MicrosoftTeamsIntegrations) + .onDelete("CASCADE"); + tb.boolean("isAccessRequestNotificationEnabled").notNullable().defaultTo(false); + tb.boolean("isSecretRequestNotificationEnabled").notNullable().defaultTo(false); + + tb.jsonb("accessRequestChannels").notNullable(); // {teamId: string, channelIds: string[]} + tb.jsonb("secretRequestChannels").notNullable(); // {teamId: string, channelIds: string[]} + tb.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.ProjectMicrosoftTeamsConfigs); + } +} + +export async function down(knex: Knex): Promise { + const hasEncryptedMicrosoftTeamsClientIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsAppIdColumn + ); + const hasEncryptedMicrosoftTeamsClientSecretColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsClientSecretColumn + ); + const hasEncryptedMicrosoftTeamsBotIdColumn = await knex.schema.hasColumn( + TableName.SuperAdmin, + encryptedMicrosoftTeamsBotIdColumn + ); + + if (hasEncryptedMicrosoftTeamsClientIdColumn || hasEncryptedMicrosoftTeamsClientSecretColumn) { + await knex.schema.alterTable(TableName.SuperAdmin, (table) => { + if (hasEncryptedMicrosoftTeamsClientIdColumn) { + table.dropColumn(encryptedMicrosoftTeamsAppIdColumn); + } + if (hasEncryptedMicrosoftTeamsClientSecretColumn) { + table.dropColumn(encryptedMicrosoftTeamsClientSecretColumn); + } + if (hasEncryptedMicrosoftTeamsBotIdColumn) { + table.dropColumn(encryptedMicrosoftTeamsBotIdColumn); + } + }); + } + if (await knex.schema.hasColumn(TableName.WorkflowIntegrations, "status")) { + await knex.schema.alterTable(TableName.WorkflowIntegrations, (table) => { + table.dropColumn("status"); + }); + } + + if (await knex.schema.hasTable(TableName.ProjectMicrosoftTeamsConfigs)) { + await knex.schema.dropTableIfExists(TableName.ProjectMicrosoftTeamsConfigs); + await dropOnUpdateTrigger(knex, TableName.ProjectMicrosoftTeamsConfigs); + } + if (await knex.schema.hasTable(TableName.MicrosoftTeamsIntegrations)) { + await knex.schema.dropTableIfExists(TableName.MicrosoftTeamsIntegrations); + await dropOnUpdateTrigger(knex, TableName.MicrosoftTeamsIntegrations); + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index bde35002f..533f9b898 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,7 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional(), + altNames: z.string().nullable().optional(), caCertId: z.string().uuid(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 8543417cf..d5f69e2fa 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -57,6 +57,7 @@ export * from "./kms-keys"; export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; +export * from "./microsoft-teams-integrations"; export * from "./models"; export * from "./oidc-configs"; export * from "./org-bots"; diff --git a/backend/src/db/schemas/kmip-org-server-certificates.ts b/backend/src/db/schemas/kmip-org-server-certificates.ts index 66e5dcbd6..c23da626b 100644 --- a/backend/src/db/schemas/kmip-org-server-certificates.ts +++ b/backend/src/db/schemas/kmip-org-server-certificates.ts @@ -13,7 +13,7 @@ export const KmipOrgServerCertificatesSchema = z.object({ id: z.string().uuid(), orgId: z.string().uuid(), commonName: z.string(), - altNames: z.string(), + altNames: z.string().nullable().optional(), serialNumber: z.string(), keyAlgorithm: z.string(), issuedAt: z.date(), diff --git a/backend/src/db/schemas/microsoft-teams-integrations.ts b/backend/src/db/schemas/microsoft-teams-integrations.ts new file mode 100644 index 000000000..d0a89429d --- /dev/null +++ b/backend/src/db/schemas/microsoft-teams-integrations.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const MicrosoftTeamsIntegrationsSchema = z.object({ + id: z.string().uuid(), + internalTeamsAppId: z.string().nullable().optional(), + tenantId: z.string(), + encryptedAccessToken: zodBuffer.nullable().optional(), + encryptedBotAccessToken: zodBuffer.nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TMicrosoftTeamsIntegrations = z.infer; +export type TMicrosoftTeamsIntegrationsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TMicrosoftTeamsIntegrationsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 95561c14a..b0992f368 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -146,7 +146,9 @@ export enum TableName { KmipOrgServerCertificates = "kmip_org_server_certificates", KmipClientCertificates = "kmip_client_certificates", SecretRotationV2 = "secret_rotations_v2", - SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" + SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings", + MicrosoftTeamsIntegrations = "microsoft_teams_integrations", + ProjectMicrosoftTeamsConfigs = "project_microsoft_teams_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 181df25f0..216b50847 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,9 +30,9 @@ export const OidcConfigsSchema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), lastUsed: z.date().nullable().optional(), + manageGroupMemberships: z.boolean().default(false), encryptedOidcClientId: zodBuffer, encryptedOidcClientSecret: zodBuffer, - manageGroupMemberships: z.boolean().default(false), jwtSignatureAlgorithm: z.string().default("RS256") }); diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..902c564a7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,6 +23,7 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/project-microsoft-teams-configs.ts b/backend/src/db/schemas/project-microsoft-teams-configs.ts new file mode 100644 index 000000000..27d0f7ef3 --- /dev/null +++ b/backend/src/db/schemas/project-microsoft-teams-configs.ts @@ -0,0 +1,29 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectMicrosoftTeamsConfigsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + microsoftTeamsIntegrationId: z.string().uuid(), + isAccessRequestNotificationEnabled: z.boolean().default(false), + isSecretRequestNotificationEnabled: z.boolean().default(false), + accessRequestChannels: z.unknown(), + secretRequestChannels: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectMicrosoftTeamsConfigs = z.infer; +export type TProjectMicrosoftTeamsConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectMicrosoftTeamsConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-reminder-recipients.ts b/backend/src/db/schemas/secret-reminder-recipients.ts new file mode 100644 index 000000000..3a132b367 --- /dev/null +++ b/backend/src/db/schemas/secret-reminder-recipients.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretReminderRecipientsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + secretId: z.string().uuid(), + userId: z.string().uuid(), + projectId: z.string() +}); + +export type TSecretReminderRecipients = z.infer; +export type TSecretReminderRecipientsInsert = Omit, TImmutableDBKeys>; +export type TSecretReminderRecipientsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/super-admin.ts b/backend/src/db/schemas/super-admin.ts index 01aac280b..ec35042ad 100644 --- a/backend/src/db/schemas/super-admin.ts +++ b/backend/src/db/schemas/super-admin.ts @@ -26,7 +26,10 @@ export const SuperAdminSchema = z.object({ encryptedSlackClientSecret: zodBuffer.nullable().optional(), authConsentContent: z.string().nullable().optional(), pageFrameContent: z.string().nullable().optional(), - adminIdentityIds: z.string().array().nullable().optional() + adminIdentityIds: z.string().array().nullable().optional(), + encryptedMicrosoftTeamsAppId: zodBuffer.nullable().optional(), + encryptedMicrosoftTeamsClientSecret: zodBuffer.nullable().optional(), + encryptedMicrosoftTeamsBotId: zodBuffer.nullable().optional() }); export type TSuperAdmin = z.infer; diff --git a/backend/src/db/schemas/workflow-integrations.ts b/backend/src/db/schemas/workflow-integrations.ts index ae1ae9a25..cab02fced 100644 --- a/backend/src/db/schemas/workflow-integrations.ts +++ b/backend/src/db/schemas/workflow-integrations.ts @@ -14,7 +14,8 @@ export const WorkflowIntegrationsSchema = z.object({ orgId: z.string().uuid(), description: z.string().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + status: z.string().default("installed") }); export type TWorkflowIntegrations = z.infer; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 3606b4bdc..9dda4313c 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -6,13 +6,15 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; -import { triggerSlackNotification } from "@app/services/slack/slack-fns"; -import { SlackTriggerFeature } from "@app/services/slack/slack-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -67,6 +69,8 @@ type TSecretApprovalRequestServiceFactoryDep = { >; kmsService: Pick; projectSlackConfigDAL: Pick; + microsoftTeamsService: Pick; + projectMicrosoftTeamsConfigDAL: Pick; }; export type TAccessApprovalRequestServiceFactory = ReturnType; @@ -84,6 +88,8 @@ export const accessApprovalRequestServiceFactory = ({ smtpService, userDAL, kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL, projectSlackConfigDAL }: TSecretApprovalRequestServiceFactoryDep) => { const createAccessApprovalRequest = async ({ @@ -219,24 +225,31 @@ export const accessApprovalRequestServiceFactory = ({ const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const approvalUrl = `${cfg.SITE_URL}/secret-manager/${project.id}/approval`; - await triggerSlackNotification({ - projectId: project.id, - projectSlackConfigDAL, - projectDAL, - kmsService, - notification: { - type: SlackTriggerFeature.ACCESS_REQUEST, - payload: { - projectName: project.name, - requesterFullName, - isTemporary, - requesterEmail: requestedByUser.email as string, - secretPath, - environment: envSlug, - permissions: accessTypes, - approvalUrl, - note - } + console.log("triggering workflow integration notification"); + await triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.ACCESS_REQUEST, + payload: { + projectName: project.name, + requesterFullName, + isTemporary, + requesterEmail: requestedByUser.email as string, + secretPath, + environment: envSlug, + permissions: accessTypes, + approvalUrl, + note + } + }, + projectId: project.id + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL } }); diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 91464bc0b..ed8ea6494 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -29,6 +29,7 @@ import { TSecretSyncRaw, TUpdateSecretSyncDTO } from "@app/services/secret-sync/secret-sync-types"; +import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; @@ -246,8 +247,9 @@ export enum EventType { GET_SLACK_INTEGRATION = "get-slack-integration", UPDATE_SLACK_INTEGRATION = "update-slack-integration", DELETE_SLACK_INTEGRATION = "delete-slack-integration", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", + GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "get-project-workflow-integration-config", + + UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "update-project-workflow-integration-config", INTEGRATION_SYNCED = "integration-synced", CREATE_CMEK = "create-cmek", UPDATE_CMEK = "update-cmek", @@ -1968,22 +1970,24 @@ interface GetSlackIntegration { }; } -interface UpdateProjectSlackConfig { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG; +interface UpdateProjectWorkflowIntegrationConfig { + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; - slackIntegrationId: string; + integrationId: string; + integration: WorkflowIntegration; isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; + accessRequestChannels?: string | { teamId: string; channelIds: string[] }; isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + secretRequestChannels?: string | { teamId: string; channelIds: string[] }; }; } -interface GetProjectSlackConfig { - type: EventType.GET_PROJECT_SLACK_CONFIG; +interface GetProjectWorkflowIntegrationConfig { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; + integration: WorkflowIntegration; }; } interface IntegrationSyncedEvent { @@ -2668,8 +2672,8 @@ export type Event = | UpdateSlackIntegration | DeleteSlackIntegration | GetSlackIntegration - | UpdateProjectSlackConfig - | GetProjectSlackConfig + | UpdateProjectWorkflowIntegrationConfig + | GetProjectWorkflowIntegrationConfig | IntegrationSyncedEvent | CreateCmekEvent | UpdateCmekEvent diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 3f4af174b..180c89cd1 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -29,7 +29,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogsRetentionDays: 0, auditLogStreams: false, auditLogStreamLimit: 3, - samlSSO: false, + samlSSO: true, hsm: false, oidcSSO: false, scim: false, @@ -38,7 +38,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: false, caCrl: false, instanceUserManagement: false, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 2f340626b..262e8e5cf 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -17,9 +17,13 @@ import { groupBy, pick, unique } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { EnforcementLevel } from "@app/lib/types"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -52,8 +56,6 @@ import { import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; -import { triggerSlackNotification } from "@app/services/slack/slack-fns"; -import { SlackTriggerFeature } from "@app/services/slack/slack-types"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -126,6 +128,8 @@ type TSecretApprovalRequestServiceFactoryDep = { secretApprovalPolicyDAL: Pick; projectSlackConfigDAL: Pick; licenseService: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + microsoftTeamsService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -155,7 +159,9 @@ export const secretApprovalRequestServiceFactory = ({ secretVersionTagV2BridgeDAL, licenseService, projectSlackConfigDAL, - resourceMetadataDAL + resourceMetadataDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }: TSecretApprovalRequestServiceFactoryDep) => { const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => { if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); @@ -1171,21 +1177,28 @@ export const secretApprovalRequestServiceFactory = ({ const env = await projectEnvDAL.findOne({ id: policy.envId }); const user = await userDAL.findById(secretApprovalRequest.committerUserId); - await triggerSlackNotification({ - projectId, - projectDAL, - kmsService, - projectSlackConfigDAL, - notification: { - type: SlackTriggerFeature.SECRET_APPROVAL, - payload: { - userEmail: user.email as string, - environment: env.name, - secretPath, - projectId, - requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] + + await triggerWorkflowIntegrationNotification({ + input: { + projectId, + notification: { + type: TriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] + } } + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService } }); @@ -1503,21 +1516,28 @@ export const secretApprovalRequestServiceFactory = ({ const user = await userDAL.findById(secretApprovalRequest.committerUserId); const env = await projectEnvDAL.findOne({ id: policy.envId }); - await triggerSlackNotification({ - projectId, - projectDAL, - kmsService, - projectSlackConfigDAL, - notification: { - type: SlackTriggerFeature.SECRET_APPROVAL, - payload: { - userEmail: user.email as string, - environment: env.name, - secretPath, - projectId, - requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] + + await triggerWorkflowIntegrationNotification({ + input: { + projectId, + notification: { + type: TriggerFeature.SECRET_APPROVAL, + payload: { + userEmail: user.email as string, + environment: env.name, + secretPath, + projectId, + requestId: secretApprovalRequest.id, + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] + } } + }, + dependencies: { + projectDAL, + kmsService, + projectSlackConfigDAL, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL } }); diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts new file mode 100644 index 000000000..58d229d96 --- /dev/null +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -0,0 +1,94 @@ +import { MicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; +import { sendSlackNotification } from "@app/services/slack/slack-fns"; + +import { logger } from "../logger"; +import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types"; + +export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => { + try { + const { projectId, notification } = dto.input; + const { projectDAL, projectSlackConfigDAL, kmsService, projectMicrosoftTeamsConfigDAL, microsoftTeamsService } = + dto.dependencies; + + const project = await projectDAL.findById(projectId); + + if (!project) { + return; + } + + const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); + const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); + + if (slackConfig) { + if (notification.type === TriggerFeature.ACCESS_REQUEST) { + const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; + if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { + await sendSlackNotification({ + orgId: project.orgId, + notification, + kmsService, + targetChannelIds, + slackIntegration: slackConfig + }).catch((error) => { + logger.error(error, "Error sending Slack notification"); + }); + } + } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { + const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || []; + if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) { + await sendSlackNotification({ + orgId: project.orgId, + notification, + kmsService, + targetChannelIds, + slackIntegration: slackConfig + }).catch((error) => { + logger.error(error, "Error sending Slack notification"); + }); + } + } + } + + console.log("microsoftTeamsConfig", microsoftTeamsConfig); + if (microsoftTeamsConfig) { + if (notification.type === TriggerFeature.ACCESS_REQUEST) { + if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { + const { success, data } = MicrosoftTeamsChannelsSchema.safeParse(microsoftTeamsConfig.accessRequestChannels); + + console.log("success", success); + console.log("data", data); + + if (success && data) { + await microsoftTeamsService + .sendNotification({ + notification, + target: data, + tenantId: microsoftTeamsConfig.tenantId + }) + .catch((error) => { + logger.error(error, "Error sending Microsoft Teams notification"); + }); + } + } + } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { + if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) { + const { success, data } = MicrosoftTeamsChannelsSchema.safeParse(microsoftTeamsConfig.secretRequestChannels); + + if (success && data) { + await microsoftTeamsService + .sendNotification({ + notification, + target: data, + tenantId: microsoftTeamsConfig.tenantId + }) + .catch((error) => { + logger.error(error, "Error sending Microsoft Teams notification"); + }); + } + } + } + } + } catch (error) { + logger.error(error, "Error triggering workflow integration notification"); + } +}; diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts new file mode 100644 index 000000000..c18ecb496 --- /dev/null +++ b/backend/src/lib/workflow-integrations/types.ts @@ -0,0 +1,51 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal"; + +export enum TriggerFeature { + SECRET_APPROVAL = "secret-approval", + ACCESS_REQUEST = "access-request" +} + +export type TNotification = + | { + type: TriggerFeature.SECRET_APPROVAL; + payload: { + userEmail: string; + environment: string; + secretPath: string; + requestId: string; + projectId: string; + secretKeys: string[]; + }; + } + | { + type: TriggerFeature.ACCESS_REQUEST; + payload: { + requesterFullName: string; + requesterEmail: string; + isTemporary: boolean; + secretPath: string; + environment: string; + projectName: string; + permissions: string[]; + approvalUrl: string; + note?: string; + }; + }; + +export type TTriggerWorkflowNotificationDTO = { + input: { + projectId: string; + notification: TNotification; + }; + dependencies: { + projectDAL: Pick; + projectSlackConfigDAL: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + kmsService: Pick; + microsoftTeamsService: Pick; + }; +}; diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index ad5291a13..fb076d8cb 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -111,6 +111,10 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => { return; } + if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { + return; + } + const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET); if (!authMode) return; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c9f2811b5..cd6fe41be 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -171,6 +171,9 @@ import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; +import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -419,6 +422,8 @@ export const registerRoutes = async ( const projectGatewayDAL = projectGatewayDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); + const microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDALFactory(db); + const projectMicrosoftTeamsConfigDAL = projectMicrosoftTeamsConfigDALFactory(db); const permissionService = permissionServiceFactory({ permissionDAL, @@ -659,6 +664,15 @@ export const registerRoutes = async ( orgDAL, externalGroupOrgRoleMappingDAL }); + + const microsoftTeamsService = microsoftTeamsServiceFactory({ + microsoftTeamsIntegrationDAL, + permissionService, + workflowIntegrationDAL, + kmsService, + serverCfgDAL: superAdminDAL + }); + const superAdminService = superAdminServiceFactory({ userDAL, identityDAL, @@ -672,7 +686,8 @@ export const registerRoutes = async ( orgService, keyStore, licenseService, - kmsService + kmsService, + microsoftTeamsService }); const orgAdminService = orgAdminServiceFactory({ @@ -996,6 +1011,8 @@ export const registerRoutes = async ( certificateTemplateDAL, projectSlackConfigDAL, slackIntegrationDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, smtpService @@ -1179,7 +1196,9 @@ export const registerRoutes = async ( accessApprovalPolicyApproverDAL, projectSlackConfigDAL, kmsService, - groupDAL + groupDAL, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL }); const secretReplicationService = secretReplicationServiceFactory({ @@ -1576,6 +1595,7 @@ export const registerRoutes = async ( await dailyResourceCleanUp.startCleanUp(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await kmsService.startService(); + await microsoftTeamsService.start(); // inject all services server.decorate("services", { @@ -1667,7 +1687,8 @@ export const registerRoutes = async ( kmip: kmipService, kmipOperation: kmipOperationService, gateway: gatewayService, - secretRotationV2: secretRotationV2Service + secretRotationV2: secretRotationV2Service, + microsoftTeams: microsoftTeamsService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 6eb1804f1..a55aa2ba4 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -27,7 +27,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { createdAt: true, updatedAt: true, encryptedSlackClientId: true, - encryptedSlackClientSecret: true + encryptedSlackClientSecret: true, + encryptedMicrosoftTeamsAppId: true, + encryptedMicrosoftTeamsClientSecret: true, + encryptedMicrosoftTeamsBotId: true }).extend({ isMigrationModeOn: z.boolean(), defaultAuthOrgSlug: z.string().nullable(), @@ -74,6 +77,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }), slackClientId: z.string().optional(), slackClientSecret: z.string().optional(), + microsoftTeamsAppId: z.string().optional(), + microsoftTeamsClientSecret: z.string().optional(), + microsoftTeamsBotId: z.string().optional(), authConsentContent: z .string() .trim() @@ -197,15 +203,22 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/integrations/slack/config", + url: "/integrations", config: { rateLimit: readLimit }, schema: { response: { 200: z.object({ - clientId: z.string(), - clientSecret: z.string() + slack: z.object({ + clientId: z.string(), + clientSecret: z.string() + }), + microsoftTeams: z.object({ + appId: z.string(), + clientSecret: z.string(), + botId: z.string() + }) }) } }, @@ -215,9 +228,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); }, handler: async () => { - const adminSlackConfig = await server.services.superAdmin.getAdminSlackConfig(); + const adminIntegrationsConfig = await server.services.superAdmin.getAdminIntegrationsConfig(); - return adminSlackConfig; + return adminIntegrationsConfig; } }); diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 50fd33840..d2ba35a7e 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -47,6 +47,7 @@ import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; import { registerWorkflowIntegrationRouter } from "./workflow-integration-router"; +import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); @@ -79,6 +80,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (workflowIntegrationRouter) => { await workflowIntegrationRouter.register(registerWorkflowIntegrationRouter); await workflowIntegrationRouter.register(registerSlackRouter, { prefix: "/slack" }); + await workflowIntegrationRouter.register(registerMicrosoftTeamsRouter, { prefix: "/microsoft-teams" }); }, { prefix: "/workflow-integrations" } ); diff --git a/backend/src/server/routes/v1/microsoft-teams-router.ts b/backend/src/server/routes/v1/microsoft-teams-router.ts new file mode 100644 index 000000000..0234d22b4 --- /dev/null +++ b/backend/src/server/routes/v1/microsoft-teams-router.ts @@ -0,0 +1,260 @@ +import { z } from "zod"; + +import { MicrosoftTeamsIntegrationsSchema, WorkflowIntegrationsSchema } from "@app/db/schemas"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { WorkflowIntegrationStatus } from "@app/services/workflow-integration/workflow-integration-types"; + +const sanitizedMicrosoftTeamsIntegrationSchema = WorkflowIntegrationsSchema.pick({ + id: true, + description: true, + slug: true, + integration: true +}).merge( + MicrosoftTeamsIntegrationsSchema.pick({ + tenantId: true + }).extend({ + status: z.nativeEnum(WorkflowIntegrationStatus) + }) +); + +export const registerMicrosoftTeamsRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + tenantId: z.string(), + slug: z.string() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.createMicrosoftTeamsIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return microsoftTeamsIntegration; + } + }); + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegrations = await server.services.microsoftTeams.getMicrosoftTeamsIntegrationsByOrg({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return microsoftTeamsIntegrations; + } + }); + + server.route({ + method: "POST", + url: "/:id/installation-status", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + id: z.string() + }) + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + await server.services.microsoftTeams.checkInstallationStatus({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + workflowIntegrationId: req.params.id + }); + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const deletedMicrosoftTeamsIntegration = await server.services.microsoftTeams.deleteMicrosoftTeamsIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + return deletedMicrosoftTeamsIntegration; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.getMicrosoftTeamsIntegrationById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + return microsoftTeamsIntegration; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + id: z.string() + }), + body: z.object({ + slug: slugSchema({ max: 64 }).optional(), + description: z.string().optional() + }), + response: { + 200: sanitizedMicrosoftTeamsIntegrationSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const microsoftTeamsIntegration = await server.services.microsoftTeams.updateMicrosoftTeamsIntegration({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id, + ...req.body + }); + + return microsoftTeamsIntegration; + } + }); + + server.route({ + method: "GET", + url: "/:workflowIntegrationId/teams", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workflowIntegrationId: z.string() + }), + response: { + 200: z + .object({ + teamId: z.string(), + teamName: z.string(), + channels: z + .object({ + channelName: z.string(), + channelId: z.string() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const teams = await server.services.microsoftTeams.getTeams({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + workflowIntegrationId: req.params.workflowIntegrationId + }); + + return teams; + } + }); + + server.route({ + method: "POST", + url: "/message-endpoint", + schema: { + body: z.any(), + response: { + 200: z.any() + } + }, + handler: async (req, res) => { + await server.services.microsoftTeams.handleMessageEndpoint(req, res); + } + }); +}; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 407bdef1f..c949ab1f0 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -12,6 +12,7 @@ import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; +import { ProjectMicrosoftTeamsConfigsSchema } from "@app/db/schemas/project-microsoft-teams-configs"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; @@ -19,8 +20,9 @@ import { re2Validator } from "@app/lib/zod"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { MicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; -import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; +import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; import { sanitizedServiceTokenSchema } from "../v2/service-token-router"; @@ -615,88 +617,172 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:workspaceId/slack-config", + url: "/:workspaceId/workflow-integration-config/:integration", config: { rateLimit: readLimit }, schema: { params: z.object({ - workspaceId: z.string().trim() + workspaceId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration) }), response: { - 200: ProjectSlackConfigsSchema.pick({ - id: true, - slackIntegrationId: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true - }) + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string() + }) + ) + ]) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const slackConfig = await server.services.project.getProjectSlackConfig({ - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actor: req.permission.type, - actorOrgId: req.permission.orgId, - projectId: req.params.workspaceId - }); - - if (slackConfig) { - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: req.params.workspaceId, - event: { - type: EventType.GET_PROJECT_SLACK_CONFIG, - metadata: { - id: slackConfig.id - } - } - }); - } - - return slackConfig; - } - }); - - server.route({ - method: "PUT", - url: "/:workspaceId/slack-config", - config: { - rateLimit: readLimit - }, - schema: { - params: z.object({ - workspaceId: z.string().trim() - }), - body: z.object({ - slackIntegrationId: z.string(), - isAccessRequestNotificationEnabled: z.boolean(), - accessRequestChannels: validateSlackChannelsField, - isSecretRequestNotificationEnabled: z.boolean(), - secretRequestChannels: validateSlackChannelsField - }), - response: { - 200: ProjectSlackConfigsSchema.pick({ - id: true, - slackIntegrationId: true, - isAccessRequestNotificationEnabled: true, - accessRequestChannels: true, - isSecretRequestNotificationEnabled: true, - secretRequestChannels: true - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const slackConfig = await server.services.project.updateProjectSlackConfig({ + const config = await server.services.project.getProjectWorkflowIntegrationConfig({ actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, projectId: req.params.workspaceId, + integration: req.params.integration + }); + + if (config) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG, + metadata: { + id: config.id, + integration: config.integration + } + } + }); + } + + return config; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/workflow-integrations/:integration/:integrationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration), + integrationId: z.string() + }) + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const deletedIntegration = await server.services.project.deleteProjectWorkflowIntegration({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.projectId, + integration: req.params.integration, + integrationId: req.params.integrationId + }); + + return deletedIntegration; + } + }); + + server.route({ + method: "PUT", + url: "/:workspaceId/workflow-integration/:integration", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim(), + integration: z.nativeEnum(WorkflowIntegration) + }), + body: z.object({ + integrationId: z.string(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string().or( + z + .object({ + teamId: z.string(), + channelIds: z.string().array() + }) + .optional() + ), + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string().or( + z + .object({ + teamId: z.string(), + channelIds: z.string().array() + }) + .optional() + ) + }), + response: { + 200: z.discriminatedUnion("integration", [ + ProjectSlackConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + accessRequestChannels: true, + isSecretRequestNotificationEnabled: true, + secretRequestChannels: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.SLACK), + integrationId: z.string() + }) + ), + ProjectMicrosoftTeamsConfigsSchema.pick({ + id: true, + isAccessRequestNotificationEnabled: true, + isSecretRequestNotificationEnabled: true + }).merge( + z.object({ + integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), + integrationId: z.string(), + accessRequestChannels: MicrosoftTeamsChannelsSchema, + secretRequestChannels: MicrosoftTeamsChannelsSchema + }) + ) + ]) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workflowIntegrationConfig = await server.services.project.updateProjectWorkflowIntegration({ + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.params.workspaceId, + integration: req.params.integration, ...req.body }); @@ -704,19 +790,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, projectId: req.params.workspaceId, event: { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG, + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG, metadata: { - id: slackConfig.id, - slackIntegrationId: slackConfig.slackIntegrationId, - isAccessRequestNotificationEnabled: slackConfig.isAccessRequestNotificationEnabled, - accessRequestChannels: slackConfig.accessRequestChannels, - isSecretRequestNotificationEnabled: slackConfig.isSecretRequestNotificationEnabled, - secretRequestChannels: slackConfig.secretRequestChannels + id: workflowIntegrationConfig.id, + integrationId: workflowIntegrationConfig.integrationId, + integration: workflowIntegrationConfig.integration, + isAccessRequestNotificationEnabled: workflowIntegrationConfig.isAccessRequestNotificationEnabled, + accessRequestChannels: workflowIntegrationConfig.accessRequestChannels, + isSecretRequestNotificationEnabled: workflowIntegrationConfig.isSecretRequestNotificationEnabled, + secretRequestChannels: workflowIntegrationConfig.secretRequestChannels } } }); - return slackConfig; + return workflowIntegrationConfig; } }); diff --git a/backend/src/server/routes/v1/workflow-integration-router.ts b/backend/src/server/routes/v1/workflow-integration-router.ts index 839d7b056..937a43843 100644 --- a/backend/src/server/routes/v1/workflow-integration-router.ts +++ b/backend/src/server/routes/v1/workflow-integration-router.ts @@ -7,7 +7,8 @@ const sanitizedWorkflowIntegrationSchema = WorkflowIntegrationsSchema.pick({ id: true, description: true, slug: true, - integration: true + integration: true, + status: true }); export const registerWorkflowIntegrationRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts new file mode 100644 index 000000000..f25a28fa1 --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -0,0 +1,442 @@ +/* eslint-disable class-methods-use-this */ +import axios from "axios"; +import { TeamsActivityHandler, TurnContext } from "botbuilder"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/types"; + +import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow-integration-dal"; +import { WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; +import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; + +export const getMicrosoftTeamsAccessToken = async ({ + tenantId, + clientId, + clientSecret, + getBotFrameworkToken = false +}: { + tenantId: string; + clientId: string; + clientSecret: string; + getBotFrameworkToken?: boolean; +}) => { + const details = getBotFrameworkToken + ? { + uri: "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token", + scope: "https://api.botframework.com/.default" + } + : { + uri: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, + scope: "https://graph.microsoft.com/.default" + }; + + const tokenResponse = await axios.post<{ access_token: string }>( + details.uri, + new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + scope: details.scope, + grant_type: "client_credentials" + }) + ); + + return tokenResponse.data.access_token; +}; + +export const isBotInstalledInTenant = async ({ + tenantId, + botAppId, + botAppPassword, + botId +}: { + tenantId: string; + botAppId: string; + botAppPassword: string; + botId: string; +}) => { + try { + const botAccessToken = await getMicrosoftTeamsAccessToken({ + tenantId, + clientId: botAppId.toString(), + clientSecret: botAppPassword.toString(), + getBotFrameworkToken: true + }).catch(() => null); + + const accessToken = await getMicrosoftTeamsAccessToken({ + tenantId, + clientId: botAppId.toString(), + clientSecret: botAppPassword.toString() + }).catch(() => null); + + if (!botAccessToken || !accessToken) { + return { + accessToken: null, + botAccessToken: null, + installed: false, + internalId: null + } as const; + } + + const appsResponse = await axios + .get<{ value: { id: string; displayName: string; distributionMethod: string; externalId: string }[] }>( + "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps", + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ) + .catch((error) => { + logger.error(error, "Error fetching installed apps"); + return null; + }); + + if (!appsResponse) { + return { + installed: false, + internalId: null, + accessToken, + botAccessToken + } as const; + } + + const botInstalledInTenant = appsResponse.data.value.find((a) => a.externalId === botId); + + if (!botInstalledInTenant) { + return { + installed: false, + internalId: null, + accessToken, + botAccessToken + } as const; + } + + return { + installed: true, + internalId: botInstalledInTenant.id, + accessToken, + botAccessToken + } as const; + } catch (error) { + logger.error(error, "Error fetching installed apps"); + return { + installed: false, + internalId: null, + accessToken: null, + botAccessToken: null + } as const; + } +}; + +export const buildTeamsPayload = (notification: TNotification) => { + const appCfg = getConfig(); + + switch (notification.type) { + case TriggerFeature.SECRET_APPROVAL: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "Secret approval request", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `A secret approval request has been opened by ${payload.userEmail}.`, + wrap: true + }, + { + type: "FactSet", + facts: [ + { + title: "Environment", + value: payload.environment + }, + { + title: "Secret path", + value: payload.secretPath || "/" + }, + { + title: `Secret Key${payload.secretKeys.length > 1 ? "s" : ""}`, + value: payload.secretKeys.join(", ") + } + ] + } + ], + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: `${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${payload.requestId}` + } + ] + }; + + return { + adaptiveCard + }; + } + + case TriggerFeature.ACCESS_REQUEST: { + const { payload } = notification; + + const adaptiveCard = { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.5", + body: [ + { + type: "TextBlock", + text: "New access approval request pending for review", + weight: "Bolder", + size: "Large" + }, + { + type: "TextBlock", + text: `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ + payload.isTemporary ? "temporary" : "permanent" + } access to path '${payload.secretPath}' in the ${payload.environment} environment of ${ + payload.projectName + } project.`, + wrap: true + }, + { + type: "TextBlock", + text: `The following permissions are requested: ${payload.permissions.join(", ")}`, + wrap: true + }, + payload.note + ? { + type: "TextBlock", + text: `**User Note**: ${payload.note}`, + wrap: true + } + : null + ].filter(Boolean), + actions: [ + { + type: "Action.OpenUrl", + title: "View request in Infisical", + url: payload.approvalUrl + } + ] + }; + + return { + adaptiveCard + }; + } + + default: { + throw new BadRequestError({ + message: "Teams notification type not supported." + }); + } + } +}; + +export class TeamsBot extends TeamsActivityHandler { + private botAppId: string; + + private botAppPassword: string; + + private workflowIntegrationDAL: Pick; + + private microsoftTeamsIntegrationDAL: Pick; + + constructor({ + botAppId, + botAppPassword, + workflowIntegrationDAL, + microsoftTeamsIntegrationDAL + }: { + botAppId: string; + botAppPassword: string; + workflowIntegrationDAL: Pick; + microsoftTeamsIntegrationDAL: Pick; + }) { + super(); + + this.botAppId = botAppId; + this.botAppPassword = botAppPassword; + this.workflowIntegrationDAL = workflowIntegrationDAL; + this.microsoftTeamsIntegrationDAL = microsoftTeamsIntegrationDAL; + + // We know when a bot is added, but we can't know when it's fully removed from the tenant. + this.onTeamsMembersAddedEvent(async (membersAdded, _, context) => { + const botWasAdded = membersAdded.some((member) => member.id === context.activity.recipient.id); + + if (botWasAdded && context.activity.conversation.tenantId) { + const microsoftTeamIntegration = await this.microsoftTeamsIntegrationDAL.findOne({ + tenantId: context.activity.conversation.tenantId + }); + + await this.workflowIntegrationDAL + .update( + { + id: microsoftTeamIntegration.id, + status: WorkflowIntegrationStatus.PENDING + }, + { + status: WorkflowIntegrationStatus.INSTALLED + } + ) + .catch((error) => { + logger.error(error, "Microsoft Teams Workflow Integration: Failed to update workflow integration"); + }); + + // This is required in order for the bot to send proactive messages, which is required for the bot to pass the bot release validation step. + await context.sendActivity( + "👋 Thanks for installing the Infisical app! You can now use the bot to send notifications to your selected teams." + ); + } + }); + } + + async run(context: TurnContext) { + console.log("Running Microsoft Teams bot", { + context + }); + await super.run(context); + } + + async sendMessageToChannel(tenantId: string, channelId: string, teamId: string, notification: TNotification) { + const { adaptiveCard } = buildTeamsPayload(notification); + + const botToken = await getMicrosoftTeamsAccessToken({ + tenantId, + clientId: this.botAppId, + clientSecret: this.botAppPassword, + getBotFrameworkToken: true + }); + + const adaptiveCardActivity = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: adaptiveCard + } + ], + conversation: { + id: channelId, + isGroup: true + }, + channelData: { + channel: { + id: channelId + }, + team: { + id: teamId + } + } + }; + + await axios.post( + `https://smba.trafficmanager.net/amer/v3/conversations/${channelId}/activities`, + adaptiveCardActivity, + { + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/json" + } + } + ); + } + + // todo: filter out teams that the bot is not a member of + async getTeamsAndChannels(tenantId: string, internalAppId: string) { + try { + const token = await getMicrosoftTeamsAccessToken({ + tenantId, + clientId: this.botAppId, + clientSecret: this.botAppPassword + }); + + const teamsResponse = await axios + .get<{ value: { displayName: string; id: string }[] }>(`https://graph.microsoft.com/v1.0/teams`, { + headers: { + Authorization: `Bearer ${token}` + } + }) + .catch((error) => { + logger.error(error, "Microsoft Teams Workflow Integration: Failed to fetch teams"); + throw error; + }); + + const teams = teamsResponse.data.value; + const result = []; + + for await (const team of teams) { + try { + // Get installed apps for this team + const installedAppsResponse = await axios.get<{ value: { teamsAppDefinition: { teamsAppId: string } }[] }>( + `https://graph.microsoft.com/v1.0/teams/${team.id}/installedApps?$expand=teamsAppDefinition`, + { + headers: { + Authorization: `Bearer ${token}` + } + } + ); + + if (!installedAppsResponse.data.value.some((app) => app.teamsAppDefinition.teamsAppId === internalAppId)) { + // eslint-disable-next-line no-continue + continue; + } + } catch (error) { + // eslint-disable-next-line no-continue + continue; // skip this team if we can't determine if the bot is installed + } + + const channelsResponse = await axios + .get<{ value: { displayName: string; id: string }[] }>( + `https://graph.microsoft.com/v1.0/teams/${team.id}/channels`, + { + headers: { + Authorization: `Bearer ${token}` + } + } + ) + .catch((error) => { + logger.error(error, "Microsoft Teams Workflow Integration: Failed to fetch channels"); + throw error; + }); + + const channels = channelsResponse.data.value.map((channel) => ({ + channelName: channel.displayName, + channelId: channel.id + })); + + result.push({ + teamId: team.id, + teamName: team.displayName, + channels + }); + } + + return result; + } catch (error) { + logger.error(error, "Microsoft Teams Workflow Integration: Error fetching teams and channels"); + throw error; + } + } +} + +export const MicrosoftTeamsChannelsSchema = z + .object({ + teamId: z.string(), + channelIds: z.array(z.string()).min(1) + }) + .optional() + .refine((data) => data === undefined || data?.channelIds.length <= 5, { + message: "You can only select up to 5 microsoft teams channels" + }); diff --git a/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts b/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts new file mode 100644 index 000000000..7935d60cc --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-integration-dal.ts @@ -0,0 +1,62 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TMicrosoftTeamsIntegrations, TWorkflowIntegrations } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TMicrosoftTeamsIntegrationDALFactory = ReturnType; + +export const microsoftTeamsIntegrationDALFactory = (db: TDbClient) => { + const microsoftTeamsIntegrationOrm = ormify(db, TableName.MicrosoftTeamsIntegrations); + + const findByIdWithWorkflowIntegrationDetails = async (id: string, tx?: Knex) => { + try { + return await (tx || db.replicaNode())(TableName.MicrosoftTeamsIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.MicrosoftTeamsIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.MicrosoftTeamsIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("status").withSchema(TableName.WorkflowIntegrations)) + .where(`${TableName.WorkflowIntegrations}.id`, id) + .first(); + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID with Workflow integration details" }); + } + }; + + const findWithWorkflowIntegrationDetails = async ( + filter: Partial & Partial, + tx?: Knex + ) => { + try { + return await (tx || db.replicaNode())(TableName.MicrosoftTeamsIntegrations) + .join( + TableName.WorkflowIntegrations, + `${TableName.MicrosoftTeamsIntegrations}.id`, + `${TableName.WorkflowIntegrations}.id` + ) + .select(selectAllTableCols(TableName.MicrosoftTeamsIntegrations)) + .select(db.ref("orgId").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("description").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("integration").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("slug").withSchema(TableName.WorkflowIntegrations)) + .select(db.ref("status").withSchema(TableName.WorkflowIntegrations)) + .where(filter); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Workflow integration details" }); + } + }; + + return { + ...microsoftTeamsIntegrationOrm, + findByIdWithWorkflowIntegrationDetails, + findWithWorkflowIntegrationDetails + }; +}; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts new file mode 100644 index 000000000..70d3f3052 --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -0,0 +1,608 @@ +import { ForbiddenError } from "@casl/ability"; +import { + CloudAdapter, + ConfigurationBotFrameworkAuthentication, + ConfigurationServiceClientCredentialFactory, + Request, + Response +} from "botbuilder"; +import { FastifyReply, FastifyRequest } from "fastify"; + +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { TKmsServiceFactory } from "../kms/kms-service"; +import { KmsDataKey } from "../kms/kms-types"; +import { TSuperAdminDALFactory } from "../super-admin/super-admin-dal"; +import { TWorkflowIntegrationDALFactory } from "../workflow-integration/workflow-integration-dal"; +import { WorkflowIntegration, WorkflowIntegrationStatus } from "../workflow-integration/workflow-integration-types"; +import { isBotInstalledInTenant, TeamsBot } from "./microsoft-teams-fns"; +import { TMicrosoftTeamsIntegrationDALFactory } from "./microsoft-teams-integration-dal"; +import { + TCheckInstallationStatusDTO, + TCreateMicrosoftTeamsIntegrationDTO, + TDeleteMicrosoftTeamsIntegrationIntegrationDTO, + TGetMicrosoftTeamsIntegrationByIdDTO, + TGetMicrosoftTeamsIntegrationByOrgDTO, + TGetTeamsDTO, + TSendNotificationDTO, + TUpdateMicrosoftTeamsIntegrationDTO +} from "./microsoft-teams-types"; + +function requestBodyToRecord(body: unknown): Record { + // if body is null or undefined, return an empty object + if (body === null || body === undefined) { + return {}; + } + + // if body is not an object or is an array, return an empty object + if (typeof body !== "object" || Array.isArray(body)) { + return {}; + } + + // at this point, we know body is an object, so safe to cast + return body as Record; +} + +type TMicrosoftTeamsServiceFactoryDep = { + microsoftTeamsIntegrationDAL: Pick< + TMicrosoftTeamsIntegrationDALFactory, + | "deleteById" + | "updateById" + | "create" + | "findOne" + | "findById" + | "findByIdWithWorkflowIntegrationDetails" + | "findWithWorkflowIntegrationDetails" + >; + permissionService: Pick; + kmsService: Pick; + workflowIntegrationDAL: Pick< + TWorkflowIntegrationDALFactory, + "transaction" | "create" | "updateById" | "deleteById" | "update" | "findOne" + >; + serverCfgDAL: Pick; +}; + +export type TMicrosoftTeamsServiceFactory = ReturnType; + +const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000"; + +export const microsoftTeamsServiceFactory = ({ + permissionService, + serverCfgDAL, + kmsService, + microsoftTeamsIntegrationDAL, + workflowIntegrationDAL +}: TMicrosoftTeamsServiceFactoryDep) => { + let teamsBot: TeamsBot | null = null; + let adapter: CloudAdapter | null = null; + + const initializeTeamsBot = async ({ botAppId, botAppPassword }: { botAppId: string; botAppPassword: string }) => { + logger.info("Initializing Microsoft Teams bot"); + teamsBot = new TeamsBot({ + botAppId, + botAppPassword, + workflowIntegrationDAL, + microsoftTeamsIntegrationDAL + }); + + adapter = new CloudAdapter( + new ConfigurationBotFrameworkAuthentication( + {}, + new ConfigurationServiceClientCredentialFactory({ + MicrosoftAppId: botAppId, + MicrosoftAppPassword: botAppPassword, + MicrosoftAppType: "MultiTenant" + }) + ) + ); + }; + + const start = async () => { + try { + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + + if ( + serverCfg?.encryptedMicrosoftTeamsAppId && + serverCfg?.encryptedMicrosoftTeamsClientSecret && + serverCfg?.encryptedMicrosoftTeamsBotId + ) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + + await initializeTeamsBot({ + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString() + }); + } + } catch (err) { + logger.error(err, "Error initializing Microsoft Teams bot on startup"); + } + }; + + const checkInstallationStatus = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + workflowIntegrationId + }: TCheckInstallationStatusDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(workflowIntegrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${workflowIntegrationId} not found` + }); + } + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if ( + !serverCfg.encryptedMicrosoftTeamsAppId || + !serverCfg.encryptedMicrosoftTeamsClientSecret || + !serverCfg.encryptedMicrosoftTeamsBotId + ) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + const decryptedBotId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsBotId); + + const teamsBotInfo = await isBotInstalledInTenant({ + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString(), + botId: decryptedBotId.toString() + }); + + if (!teamsBotInfo.installed) { + if (microsoftTeamsIntegration.status === WorkflowIntegrationStatus.INSTALLED) { + await workflowIntegrationDAL.updateById(microsoftTeamsIntegration.id, { + status: WorkflowIntegrationStatus.PENDING + }); + } + + throw new BadRequestError({ + message: "Microsoft Teams bot is not installed in the configured Microsoft Teams Tenant" + }); + } + + if (microsoftTeamsIntegration.status !== WorkflowIntegrationStatus.INSTALLED) { + await workflowIntegrationDAL.updateById(microsoftTeamsIntegration.id, { + status: WorkflowIntegrationStatus.INSTALLED + }); + } + }; + + const createMicrosoftTeamsIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + slug, + description, + tenantId + }: TCreateMicrosoftTeamsIntegrationDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + const { encryptedMicrosoftTeamsAppId, encryptedMicrosoftTeamsClientSecret, encryptedMicrosoftTeamsBotId } = + serverCfg; + + if (!encryptedMicrosoftTeamsAppId || !encryptedMicrosoftTeamsClientSecret || !encryptedMicrosoftTeamsBotId) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + + const integration = await workflowIntegrationDAL.transaction(async (tx) => { + const workflowIntegration = await workflowIntegrationDAL.create( + { + description, + orgId: actorOrgId, + slug, + integration: WorkflowIntegration.MICROSOFT_TEAMS, + status: WorkflowIntegrationStatus.PENDING + }, + tx + ); + + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.create( + { + // @ts-expect-error id is kept as fixed because it is always equal to the workflow integration ID + id: workflowIntegration.id, + tenantId + }, + tx + ); + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const botAppId = decryptWithRoot(encryptedMicrosoftTeamsAppId); + const botAppPassword = decryptWithRoot(encryptedMicrosoftTeamsClientSecret); + const botId = decryptWithRoot(encryptedMicrosoftTeamsBotId); + + const teamsBotInfo = await isBotInstalledInTenant({ + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: botAppId.toString(), + botAppPassword: botAppPassword.toString(), + botId: botId.toString() + }); + if (teamsBotInfo.installed) { + const { encryptor: orgDataKeyEncryptor } = await kmsService.createCipherPairWithDataKey({ + orgId: workflowIntegration.orgId, + type: KmsDataKey.Organization + }); + const { cipherTextBlob: encryptedAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(teamsBotInfo.accessToken, "utf8") + }); + + const { cipherTextBlob: encryptedBotAccessToken } = orgDataKeyEncryptor({ + plainText: Buffer.from(teamsBotInfo.botAccessToken, "utf8") + }); + await microsoftTeamsIntegrationDAL.updateById( + microsoftTeamsIntegration.id, + { + internalTeamsAppId: teamsBotInfo.internalId, + encryptedAccessToken, + encryptedBotAccessToken + }, + tx + ); + + await workflowIntegrationDAL.updateById( + workflowIntegration.id, + { + status: WorkflowIntegrationStatus.INSTALLED + }, + tx + ); + } + + return { + ...workflowIntegration, + status: workflowIntegration.status as WorkflowIntegrationStatus, + tenantId: microsoftTeamsIntegration.tenantId + }; + }); + + return integration; + }; + + const getMicrosoftTeamsIntegrationsByOrg = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TGetMicrosoftTeamsIntegrationByOrgDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + + const microsoftTeamsIntegrations = await microsoftTeamsIntegrationDAL.findWithWorkflowIntegrationDetails({ + orgId: actorOrgId + }); + + return microsoftTeamsIntegrations.map((integration) => ({ + ...integration, + status: integration.status as WorkflowIntegrationStatus, + tenantId: integration.tenantId + })); + }; + + const getMicrosoftTeamsIntegrationById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TGetMicrosoftTeamsIntegrationByIdDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: "Microsoft Teams integration not found." + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + return { + ...microsoftTeamsIntegration, + status: microsoftTeamsIntegration.status as WorkflowIntegrationStatus + }; + }; + + const updateMicrosoftTeamsIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + slug, + description + }: TUpdateMicrosoftTeamsIntegrationDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + + const updatedIntegration = await workflowIntegrationDAL.transaction(async (tx) => { + await workflowIntegrationDAL.updateById( + microsoftTeamsIntegration.id, + { + slug, + description + }, + tx + ); + + const integration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails( + microsoftTeamsIntegration.id, + tx + ); + + if (!integration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${microsoftTeamsIntegration.id} not found` + }); + } + + return { + ...integration, + status: integration.status as WorkflowIntegrationStatus + }; + }); + + return updatedIntegration; + }; + + const deleteMicrosoftTeamsIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id + }: TDeleteMicrosoftTeamsIntegrationIntegrationDTO) => { + const microsoftTeamsIntegration = await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(id); + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${id} not found` + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + microsoftTeamsIntegration.orgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + await workflowIntegrationDAL.deleteById(id); + + return { + ...microsoftTeamsIntegration, + status: microsoftTeamsIntegration.status as WorkflowIntegrationStatus + }; + }; + + const getTeams = async ({ actorId, actor, actorOrgId, actorAuthMethod, workflowIntegrationId }: TGetTeamsDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(workflowIntegrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID ${workflowIntegrationId} not found` + }); + } + + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to get teams and channels because the Microsoft Teams bot is uninitialized" + }); + } + + const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); + if (!serverCfg) { + throw new BadRequestError({ + message: "Failed to get server configuration." + }); + } + + if ( + !serverCfg.encryptedMicrosoftTeamsAppId || + !serverCfg.encryptedMicrosoftTeamsClientSecret || + !serverCfg.encryptedMicrosoftTeamsBotId + ) { + throw new BadRequestError({ + message: "Microsoft Teams app ID, client secret, or bot ID is not set" + }); + } + + const decryptWithRoot = kmsService.decryptWithRootKey(); + const decryptedAppId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsClientSecret); + const decryptedBotId = decryptWithRoot(serverCfg.encryptedMicrosoftTeamsBotId); + + const { installed, internalId } = await isBotInstalledInTenant({ + tenantId: microsoftTeamsIntegration.tenantId, + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString(), + botId: decryptedBotId.toString() + }); + + if (!installed) { + throw new BadRequestError({ + message: "Microsoft Teams bot is not installed in the configured Microsoft Teams Tenant" + }); + } + + const teams = await teamsBot.getTeamsAndChannels(microsoftTeamsIntegration.tenantId, internalId); + + return teams; + }; + + const handleMessageEndpoint = async (req: FastifyRequest, res: FastifyReply) => { + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to handle message endpoint because the Microsoft Teams bot is uninitialized" + }); + } + + // We need to manually build a Response object because the BotFrameworkAdapter expects a Response object. We are using FastifyReply as the underlying socket. + const response: Response = { + socket: res.raw.socket, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + end(...args: any[]): unknown { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + res.raw.end(...args); + return this; + }, + + header(name: string, value: unknown): unknown { + res.raw.setHeader(name, value as string); + return this; + }, + + send(...args: unknown[]): unknown { + // For the first argument, which is typically the body + if (args.length > 0) { + const body = args[0]; + + if (typeof body === "string" || Buffer.isBuffer(body)) { + res.raw.write(body); + } else if (body !== null && body !== undefined) { + const json = JSON.stringify(body); + if (!res.raw.headersSent && !res.raw.getHeader("content-type")) { + res.raw.setHeader("content-type", "application/json"); + } + res.raw.write(json); + } + } + + const lastArg = args[args.length - 1]; + if (typeof lastArg === "function") { + lastArg(); + } + + return this; + }, + + status(code: number): unknown { + res.raw.statusCode = code; + return this; + } + }; + + const request: Request = { + body: requestBodyToRecord(req.body), + headers: req.headers, + method: req.method + }; + + await adapter.process(request, response, async (context) => { + await teamsBot?.run(context); + }); + }; + + const sendNotification = async ({ tenantId, target, notification }: TSendNotificationDTO) => { + if (!teamsBot || !adapter) { + throw new BadRequestError({ + message: "Unable to send notification because the Microsoft Teams bot is uninitialized" + }); + } + + for await (const channelId of target.channelIds) { + await teamsBot.sendMessageToChannel(tenantId, channelId, target.teamId, notification); + } + }; + + return { + getMicrosoftTeamsIntegrationsByOrg, + getMicrosoftTeamsIntegrationById, + updateMicrosoftTeamsIntegration, + deleteMicrosoftTeamsIntegration, + createMicrosoftTeamsIntegration, + initializeTeamsBot, + getTeams, + handleMessageEndpoint, + start, + sendNotification, + checkInstallationStatus + }; +}; diff --git a/backend/src/services/microsoft-teams/microsoft-teams-types.ts b/backend/src/services/microsoft-teams/microsoft-teams-types.ts new file mode 100644 index 000000000..1699d1a8b --- /dev/null +++ b/backend/src/services/microsoft-teams/microsoft-teams-types.ts @@ -0,0 +1,36 @@ +import { TOrgPermission } from "@app/lib/types"; +import { TNotification } from "@app/lib/workflow-integrations/types"; + +export type TGetMicrosoftTeamsIntegrationByOrgDTO = Omit; + +export type TCreateMicrosoftTeamsIntegrationDTO = Omit & { + slug: string; + description?: string; + tenantId: string; +}; + +export type TCheckInstallationStatusDTO = { workflowIntegrationId: string } & Omit; + +export type TGetMicrosoftTeamsIntegrationByIdDTO = { id: string } & Omit; + +export type TUpdateMicrosoftTeamsIntegrationDTO = { id: string; slug?: string; description?: string } & Omit< + TOrgPermission, + "orgId" +>; + +export type TGetTeamsDTO = Omit & { + workflowIntegrationId: string; +}; + +export type TDeleteMicrosoftTeamsIntegrationIntegrationDTO = { + id: string; +} & Omit; + +export type TSendNotificationDTO = { + tenantId: string; + target: { + teamId: string; + channelIds: string[]; + }; + notification: TNotification; +}; diff --git a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts new file mode 100644 index 000000000..918b96e89 --- /dev/null +++ b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType; + +export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => { + const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs); + + const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { + return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs) + .join( + TableName.MicrosoftTeamsIntegrations, + `${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`, + `${TableName.MicrosoftTeamsIntegrations}.id` + ) + .where("projectId", "=", projectId) + .select( + selectAllTableCols(TableName.ProjectMicrosoftTeamsConfigs), + selectAllTableCols(TableName.MicrosoftTeamsIntegrations) + ) + .first(); + }; + + return { ...projectMicrosoftTeamsConfigOrm, getIntegrationDetailsByProject }; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9b7c29c1b..ce0e376b3 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -43,6 +43,9 @@ import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; +import { MicrosoftTeamsChannelsSchema } from "../microsoft-teams/microsoft-teams-fns"; +import { TMicrosoftTeamsIntegrationDALFactory } from "../microsoft-teams/microsoft-teams-integration-dal"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; import { TPkiAlertDALFactory } from "../pki-alert/pki-alert-dal"; @@ -60,9 +63,11 @@ import { fnDeleteProjectSecretReminders } from "../secret/secret-fns"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; +import { validateSlackChannelsField } from "../slack/slack-auth-validators"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; +import { WorkflowIntegration } from "../workflow-integration/workflow-integration-types"; import { TProjectDALFactory } from "./project-dal"; import { assignWorkspaceKeysToMembers, bootstrapSshProject, createProjectKey } from "./project-fns"; import { TProjectQueueFactory } from "./project-queue"; @@ -70,9 +75,10 @@ import { TProjectSshConfigDALFactory } from "./project-ssh-config-dal"; import { TCreateProjectDTO, TDeleteProjectDTO, + TDeleteProjectWorkflowIntegration, TGetProjectDTO, TGetProjectKmsKey, - TGetProjectSlackConfig, + TGetProjectWorkflowIntegrationConfig, TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertificateTemplatesDTO, @@ -91,8 +97,8 @@ import { TUpdateProjectDTO, TUpdateProjectKmsDTO, TUpdateProjectNameDTO, - TUpdateProjectSlackConfig, TUpdateProjectVersionLimitDTO, + TUpdateProjectWorkflowIntegration, TUpgradeProjectDTO } from "./project-types"; @@ -121,8 +127,19 @@ type TProjectServiceFactoryDep = { "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" >; groupProjectDAL: Pick; - projectSlackConfigDAL: Pick; + projectSlackConfigDAL: Pick< + TProjectSlackConfigDALFactory, + "findOne" | "transaction" | "updateById" | "create" | "delete" + >; + projectMicrosoftTeamsConfigDAL: Pick< + TProjectMicrosoftTeamsConfigDALFactory, + "findOne" | "transaction" | "updateById" | "create" | "delete" + >; slackIntegrationDAL: Pick; + microsoftTeamsIntegrationDAL: Pick< + TMicrosoftTeamsIntegrationDALFactory, + "findById" | "findByIdWithWorkflowIntegrationDetails" + >; projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; @@ -195,7 +212,9 @@ export const projectServiceFactory = ({ kmsService, projectBotDAL, projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, slackIntegrationDAL, + microsoftTeamsIntegrationDAL, projectTemplateService, groupProjectDAL, smtpService @@ -1327,13 +1346,14 @@ export const projectServiceFactory = ({ return { secretManagerKmsKey: kmsKey }; }; - const getProjectSlackConfig = async ({ + const getProjectWorkflowIntegrationConfig = async ({ actorId, actor, actorOrgId, actorAuthMethod, - projectId - }: TGetProjectSlackConfig) => { + projectId, + integration + }: TGetProjectWorkflowIntegrationConfig) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1352,23 +1372,56 @@ export const projectServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); - return projectSlackConfigDAL.findOne({ - projectId: project.id - }); + if (integration === WorkflowIntegration.SLACK) { + const config = await projectSlackConfigDAL.findOne({ + projectId: project.id + }); + + if (!config) { + throw new NotFoundError({ + message: `Workflow integration config for project '${projectId}' and integration '${integration}' not found` + }); + } + + return { + ...config, + integration, + integrationId: config.slackIntegrationId + }; + } + + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + const config = await projectMicrosoftTeamsConfigDAL.findOne({ + projectId: project.id + }); + + if (!config) { + throw new NotFoundError({ + message: `Workflow integration config for project '${projectId}' and integration '${integration}' not found` + }); + } + + return { + ...config, + integration, + integrationId: config.microsoftTeamsIntegrationId + }; + } }; - const updateProjectSlackConfig = async ({ + const updateProjectWorkflowIntegration = async ({ actorId, actor, actorOrgId, actorAuthMethod, projectId, - slackIntegrationId, + integration, + integrationId, isAccessRequestNotificationEnabled, accessRequestChannels, isSecretRequestNotificationEnabled, secretRequestChannels - }: TUpdateProjectSlackConfig) => { + }: TUpdateProjectWorkflowIntegration) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1376,17 +1429,177 @@ export const projectServiceFactory = ({ }); } - const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(slackIntegrationId); + if (integration === WorkflowIntegration.SLACK) { + const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); + const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); - if (!slackIntegration) { - throw new NotFoundError({ - message: `Slack integration with ID '${slackIntegrationId}' not found` + const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); + + if (!slackIntegration) { + throw new NotFoundError({ + message: `Slack integration with ID '${integrationId}' not found` + }); + } + + if (slackIntegration.orgId !== actorOrgId) { + throw new ForbiddenRequestError({ + message: "Selected slack integration is not in the same organization" + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + + if (slackIntegration.orgId !== project.orgId) { + throw new ForbiddenRequestError({ + message: "Selected slack integration is not in the same organization" + }); + } + + const updatedWorkflowIntegration = await projectSlackConfigDAL.transaction(async (tx) => { + const slackConfig = await projectSlackConfigDAL.findOne( + { + projectId + }, + tx + ); + + if (slackConfig) { + return projectSlackConfigDAL.updateById( + slackConfig.id, + { + slackIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels + }, + tx + ); + } + + return projectSlackConfigDAL.create( + { + projectId, + slackIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels + }, + tx + ); + }); + + return { + ...updatedWorkflowIntegration, + accessRequestChannels: sanitizedAccessRequestChannels, + secretRequestChannels: sanitizedSecretRequestChannels, + integrationId: slackIntegration.id, + integration: WorkflowIntegration.SLACK + } as const; + } + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + if (isAccessRequestNotificationEnabled && !accessRequestChannels) { + throw new BadRequestError({ + message: "Access request channels are required when access request notifications are enabled" + }); + } + + if (isSecretRequestNotificationEnabled && !secretRequestChannels) { + throw new BadRequestError({ + message: "Secret request channels are required when secret request notifications are enabled" + }); + } + + if (!secretRequestChannels && !accessRequestChannels) { + throw new BadRequestError({ + message: "At least one of access request channels or secret request channels is required" + }); + } + + const sanitizedAccessRequestChannels = MicrosoftTeamsChannelsSchema.parse(accessRequestChannels); + const sanitizedSecretRequestChannels = MicrosoftTeamsChannelsSchema.parse(secretRequestChannels); + + const microsoftTeamsIntegration = + await microsoftTeamsIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); + + if (!microsoftTeamsIntegration) { + throw new NotFoundError({ + message: `Microsoft Teams integration with ID '${integrationId}' not found` + }); + } + + const updatedWorkflowIntegration = await projectMicrosoftTeamsConfigDAL.transaction(async (tx) => { + const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.findOne( + { + projectId + }, + tx + ); + + if (microsoftTeamsConfig) { + return projectMicrosoftTeamsConfigDAL.updateById( + microsoftTeamsConfig.id, + { + microsoftTeamsIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels || {}, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels || {} + }, + tx + ); + } + + return projectMicrosoftTeamsConfigDAL.create( + { + projectId, + microsoftTeamsIntegrationId: integrationId, + isAccessRequestNotificationEnabled, + accessRequestChannels: sanitizedAccessRequestChannels || {}, + isSecretRequestNotificationEnabled, + secretRequestChannels: sanitizedSecretRequestChannels || {} + }, + tx + ); + }); + + return { + ...updatedWorkflowIntegration, + accessRequestChannels: sanitizedAccessRequestChannels, + secretRequestChannels: sanitizedSecretRequestChannels, + integrationId: microsoftTeamsIntegration.id, + integration: WorkflowIntegration.MICROSOFT_TEAMS + } as const; } - if (slackIntegration.orgId !== actorOrgId) { - throw new ForbiddenRequestError({ - message: "Selected slack integration is not in the same organization" + throw new BadRequestError({ + message: `Integration type '${integration as string}' not supported` + }); + }; + + const deleteProjectWorkflowIntegration = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId, + integrationId, + integration + }: TDeleteProjectWorkflowIntegration) => { + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ + message: `Project with ID '${projectId}' not found` }); } @@ -1399,47 +1612,28 @@ export const projectServiceFactory = ({ actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Settings); - if (slackIntegration.orgId !== project.orgId) { - throw new ForbiddenRequestError({ - message: "Selected slack integration is not in the same organization" + if (integration === WorkflowIntegration.SLACK) { + const [deletedIntegration] = await projectSlackConfigDAL.delete({ + projectId, + slackIntegrationId: integrationId }); + + return deletedIntegration; } - return projectSlackConfigDAL.transaction(async (tx) => { - const slackConfig = await projectSlackConfigDAL.findOne( - { - projectId - }, - tx - ); + if (integration === WorkflowIntegration.MICROSOFT_TEAMS) { + const [deletedIntegration] = await projectMicrosoftTeamsConfigDAL.delete({ + projectId, + microsoftTeamsIntegrationId: integrationId + }); - if (slackConfig) { - return projectSlackConfigDAL.updateById( - slackConfig.id, - { - slackIntegrationId, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels - }, - tx - ); - } + return deletedIntegration; + } - return projectSlackConfigDAL.create( - { - projectId, - slackIntegrationId, - isAccessRequestNotificationEnabled, - accessRequestChannels, - isSecretRequestNotificationEnabled, - secretRequestChannels - }, - tx - ); + throw new BadRequestError({ + message: `Integration with ID '${integrationId}' not found` }); }; @@ -1548,8 +1742,9 @@ export const projectServiceFactory = ({ getProjectKmsBackup, loadProjectKmsBackup, getProjectKmsKeys, - getProjectSlackConfig, - updateProjectSlackConfig, + getProjectWorkflowIntegrationConfig, + updateProjectWorkflowIntegration, + deleteProjectWorkflowIntegration, requestProjectAccess, searchProjects }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 444f6309c..5bedac60e 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -8,6 +8,7 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { WorkflowIntegration } from "../workflow-integration/workflow-integration-types"; enum KmsType { External = "external", @@ -159,14 +160,22 @@ export type TListProjectSshCertificatesDTO = { limit: number; } & TProjectPermission; -export type TGetProjectSlackConfig = TProjectPermission; +export type TGetProjectWorkflowIntegrationConfig = TProjectPermission & { + integration: WorkflowIntegration; +}; -export type TUpdateProjectSlackConfig = { - slackIntegrationId: string; +export type TUpdateProjectWorkflowIntegration = { + integrationId: string; + integration: WorkflowIntegration; isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; + accessRequestChannels?: string | { teamId: string; channelIds: string[] }; isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + secretRequestChannels?: string | { teamId: string; channelIds: string[] }; +} & TProjectPermission; + +export type TDeleteProjectWorkflowIntegration = { + integrationId: string; + integration: WorkflowIntegration; } & TProjectPermission; export type TBootstrapSshProjectDTO = { diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 6c84c0e76..8419439b1 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -3,12 +3,13 @@ import { WebClient } from "@slack/web-api"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectSlackConfigDALFactory } from "./project-slack-config-dal"; -import { SlackTriggerFeature, TSlackNotification } from "./slack-types"; +import { TSendSlackNotificationDTO } from "./slack-types"; export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { @@ -41,11 +42,11 @@ export const fetchSlackChannels = async (botKey: string) => { return slackChannels; }; -const buildSlackPayload = (notification: TSlackNotification) => { +const buildSlackPayload = (notification: TNotification) => { const appCfg = getConfig(); switch (notification.type) { - case SlackTriggerFeature.SECRET_APPROVAL: { + case TriggerFeature.SECRET_APPROVAL: { const { payload } = notification; const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} @@ -79,7 +80,7 @@ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId payloadBlocks }; } - case SlackTriggerFeature.ACCESS_REQUEST: { + case TriggerFeature.ACCESS_REQUEST: { const { payload } = notification; const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ payload.isTemporary ? "temporary" : "permanent" @@ -125,51 +126,24 @@ User Note: ${payload.note}` } }; -export const triggerSlackNotification = async ({ - projectId, +export const sendSlackNotification = async ({ + orgId, notification, - projectSlackConfigDAL, - projectDAL, - kmsService -}: { - projectId: string; - notification: TSlackNotification; - projectSlackConfigDAL: Pick; - projectDAL: Pick; - kmsService: Pick; -}) => { - const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); - const project = await projectDAL.findById(projectId); - const slackIntegration = await projectSlackConfigDAL.getIntegrationDetailsByProject(project.id); - - if (!slackIntegration) { - return; - } - - let targetChannelIds: string[] = []; - if (notification.type === SlackTriggerFeature.ACCESS_REQUEST) { - targetChannelIds = slackIntegration.accessRequestChannels?.split(", ") || []; - if (!targetChannelIds.length || !slackIntegration.isAccessRequestNotificationEnabled) { - return; - } - } else if (notification.type === SlackTriggerFeature.SECRET_APPROVAL) { - targetChannelIds = slackIntegration.secretRequestChannels?.split(", ") || []; - if (!targetChannelIds.length || !slackIntegration.isSecretRequestNotificationEnabled) { - return; - } - } - + kmsService, + targetChannelIds, + slackIntegration +}: TSendSlackNotificationDTO) => { const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, - orgId: project.orgId + orgId }); - const botKey = orgDataKeyDecryptor({ cipherTextBlob: slackIntegration.encryptedBotAccessToken }).toString("utf8"); - const slackWebClient = new WebClient(botKey); + const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); + for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients await slackWebClient.chat diff --git a/backend/src/services/slack/slack-types.ts b/backend/src/services/slack/slack-types.ts index 3e8354adf..20395b1cd 100644 --- a/backend/src/services/slack/slack-types.ts +++ b/backend/src/services/slack/slack-types.ts @@ -1,4 +1,8 @@ +import { TSlackIntegrations } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; +import { TNotification } from "@app/lib/workflow-integrations/types"; + +import { TKmsServiceFactory } from "../kms/kms-service"; export type TGetSlackInstallUrlDTO = { slug: string; @@ -48,34 +52,10 @@ export type TReinstallSlackIntegrationDTO = { slackBotUserId: string; }; -export enum SlackTriggerFeature { - SECRET_APPROVAL = "secret-approval", - ACCESS_REQUEST = "access-request" -} - -export type TSlackNotification = - | { - type: SlackTriggerFeature.SECRET_APPROVAL; - payload: { - userEmail: string; - environment: string; - secretPath: string; - requestId: string; - projectId: string; - secretKeys: string[]; - }; - } - | { - type: SlackTriggerFeature.ACCESS_REQUEST; - payload: { - requesterFullName: string; - requesterEmail: string; - isTemporary: boolean; - secretPath: string; - environment: string; - projectName: string; - permissions: string[]; - approvalUrl: string; - note?: string; - }; - }; +export type TSendSlackNotificationDTO = { + orgId: string; + notification: TNotification; + kmsService: Pick; + targetChannelIds: string[]; + slackIntegration: TSlackIntegrations; +}; diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 317348cac..e413646d3 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -20,6 +20,7 @@ import { KMS_ROOT_CONFIG_UUID } from "../kms/kms-fns"; import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; +import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; import { TOrgServiceFactory } from "../org/org-service"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; @@ -47,6 +48,7 @@ type TSuperAdminServiceFactoryDep = { orgService: Pick; keyStore: Pick; licenseService: Pick; + microsoftTeamsService: Pick; }; export type TSuperAdminServiceFactory = ReturnType; @@ -77,7 +79,8 @@ export const superAdminServiceFactory = ({ licenseService, identityAccessTokenDAL, identityTokenAuthDAL, - identityOrgMembershipDAL + identityOrgMembershipDAL, + microsoftTeamsService }: TSuperAdminServiceFactoryDep) => { const initServerCfg = async () => { // TODO(akhilmhdh): bad pattern time less change this later to me itself @@ -125,7 +128,13 @@ export const superAdminServiceFactory = ({ }; const updateServerCfg = async ( - data: TSuperAdminUpdate & { slackClientId?: string; slackClientSecret?: string }, + data: TSuperAdminUpdate & { + slackClientId?: string; + slackClientSecret?: string; + microsoftTeamsAppId?: string; + microsoftTeamsClientSecret?: string; + microsoftTeamsBotId?: string; + }, userId: string ) => { const updatedData = data; @@ -192,10 +201,46 @@ export const superAdminServiceFactory = ({ updatedData.slackClientSecret = undefined; } + if (data.microsoftTeamsAppId) { + const encryptedClientId = encryptWithRoot(Buffer.from(data.microsoftTeamsAppId)); + + updatedData.encryptedMicrosoftTeamsAppId = encryptedClientId; + updatedData.microsoftTeamsAppId = undefined; + } + + if (data.microsoftTeamsClientSecret) { + const encryptedClientSecret = encryptWithRoot(Buffer.from(data.microsoftTeamsClientSecret)); + + updatedData.encryptedMicrosoftTeamsClientSecret = encryptedClientSecret; + updatedData.microsoftTeamsClientSecret = undefined; + } + + if (data.microsoftTeamsBotId) { + const encryptedBotId = encryptWithRoot(Buffer.from(data.microsoftTeamsBotId)); + + updatedData.encryptedMicrosoftTeamsBotId = encryptedBotId; + updatedData.microsoftTeamsBotId = undefined; + } const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, updatedData); await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg)); + if ( + updatedServerCfg.encryptedMicrosoftTeamsAppId && + updatedServerCfg.encryptedMicrosoftTeamsClientSecret && + updatedServerCfg.encryptedMicrosoftTeamsBotId + ) { + const decryptWithRoot = kmsService.decryptWithRootKey(); + decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsBotId); // validate that we're able to decrypt the bot ID + const decryptedAppId = decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsAppId); + const decryptedAppPassword = decryptWithRoot(updatedServerCfg.encryptedMicrosoftTeamsClientSecret); + + await microsoftTeamsService.initializeTeamsBot({ + botAppId: decryptedAppId.toString(), + botAppPassword: decryptedAppPassword.toString() + }); + } + return updatedServerCfg; }; @@ -488,29 +533,40 @@ export const superAdminServiceFactory = ({ await userDAL.updateById(userId, { superAdmin: true }); }; - const getAdminSlackConfig = async () => { + const getAdminIntegrationsConfig = async () => { const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID); if (!serverCfg) { throw new NotFoundError({ name: "AdminConfig", message: "Admin config not found" }); } - let clientId = ""; - let clientSecret = ""; - const decrypt = kmsService.decryptWithRootKey(); - if (serverCfg.encryptedSlackClientId) { - clientId = decrypt(serverCfg.encryptedSlackClientId).toString(); - } + const slackClientId = serverCfg.encryptedSlackClientId ? decrypt(serverCfg.encryptedSlackClientId).toString() : ""; + const slackClientSecret = serverCfg.encryptedSlackClientSecret + ? decrypt(serverCfg.encryptedSlackClientSecret).toString() + : ""; - if (serverCfg.encryptedSlackClientSecret) { - clientSecret = decrypt(serverCfg.encryptedSlackClientSecret).toString(); - } + const microsoftAppId = serverCfg.encryptedMicrosoftTeamsAppId + ? decrypt(serverCfg.encryptedMicrosoftTeamsAppId).toString() + : ""; + const microsoftClientSecret = serverCfg.encryptedMicrosoftTeamsClientSecret + ? decrypt(serverCfg.encryptedMicrosoftTeamsClientSecret).toString() + : ""; + const microsoftBotId = serverCfg.encryptedMicrosoftTeamsBotId + ? decrypt(serverCfg.encryptedMicrosoftTeamsBotId).toString() + : ""; return { - clientId, - clientSecret + slack: { + clientSecret: slackClientSecret, + clientId: slackClientId + }, + microsoftTeams: { + appId: microsoftAppId, + clientSecret: microsoftClientSecret, + botId: microsoftBotId + } }; }; @@ -578,7 +634,7 @@ export const superAdminServiceFactory = ({ getUsers, deleteUser, getIdentities, - getAdminSlackConfig, + getAdminIntegrationsConfig, updateRootEncryptionStrategy, getConfiguredEncryptionStrategies, grantServerAdminAccessToUser, diff --git a/backend/src/services/workflow-integration/workflow-integration-types.ts b/backend/src/services/workflow-integration/workflow-integration-types.ts index 9ae56b840..e86dafc48 100644 --- a/backend/src/services/workflow-integration/workflow-integration-types.ts +++ b/backend/src/services/workflow-integration/workflow-integration-types.ts @@ -1,7 +1,14 @@ import { TOrgPermission } from "@app/lib/types"; export enum WorkflowIntegration { - SLACK = "slack" + SLACK = "slack", + MICROSOFT_TEAMS = "microsoft-teams" +} + +export enum WorkflowIntegrationStatus { + PENDING = "pending", + INSTALLED = "installed", + FAILED = "failed" } export type TGetWorkflowIntegrationsByOrg = Omit; diff --git a/frontend/src/components/v2/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx index 203ca3b2c..ad10314b9 100644 --- a/frontend/src/components/v2/Accordion/Accordion.tsx +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -47,8 +47,10 @@ AccordionTrigger.displayName = "AccordionTrigger"; export const AccordionContent = forwardRef< HTMLDivElement, - AccordionPrimitive.AccordionContentProps ->(({ children, className, ...props }, forwardedRef) => ( + AccordionPrimitive.AccordionContentProps & { + childrenClassName?: string; + } +>(({ children, className, childrenClassName, ...props }, forwardedRef) => ( -
{children}
+
{children}
)); diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index d43fbc080..bc812e18e 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -4,13 +4,12 @@ export { useAdminRemoveIdentitySuperAdminAccess, useCreateAdminUser, useRemoveUserServerAdminAccess, - useUpdateAdminSlackConfig, useUpdateServerConfig, useUpdateServerEncryptionStrategy } from "./mutation"; export { useAdminGetUsers, - useGetAdminSlackConfig, + useGetAdminIntegrationsConfig, useGetServerConfig, useGetServerRootKmsEncryptionDetails } from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index b3e1e37b4..2c88d4fd8 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -6,11 +6,10 @@ import { organizationKeys } from "../organization/queries"; import { User } from "../users/types"; import { adminQueryKeys, adminStandaloneKeys } from "./queries"; import { - AdminSlackConfig, RootKeyEncryptionStrategy, TCreateAdminUserDTO, TServerConfig, - TUpdateAdminSlackConfigDTO + TUpdateServerConfigDTO } from "./types"; export const useCreateAdminUser = () => { @@ -34,11 +33,7 @@ export const useCreateAdminUser = () => { export const useUpdateServerConfig = () => { const queryClient = useQueryClient(); - return useMutation< - TServerConfig, - object, - Partial - >({ + return useMutation({ mutationFn: async (opt) => { const { data } = await apiRequest.patch<{ config: TServerConfig }>( "/api/v1/admin/config", @@ -48,6 +43,7 @@ export const useUpdateServerConfig = () => { }, onSuccess: (data) => { queryClient.setQueryData(adminQueryKeys.serverConfig(), data); + queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminIntegrationsConfig() }); queryClient.invalidateQueries({ queryKey: adminQueryKeys.serverConfig() }); queryClient.invalidateQueries({ queryKey: organizationKeys.getUserOrganizations }); } @@ -119,23 +115,6 @@ export const useAdminGrantServerAdminAccess = () => { }); }; -export const useUpdateAdminSlackConfig = () => { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: async (dto) => { - const { data } = await apiRequest.put( - "/api/v1/admin/integrations/slack/config", - dto - ); - - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: adminQueryKeys.getAdminSlackConfig() }); - } - }); -}; - export const useUpdateServerEncryptionStrategy = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index b24841dbd..1d44a93d0 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -7,7 +7,7 @@ import { User } from "../types"; import { AdminGetIdentitiesFilters, AdminGetUsersFilters, - AdminSlackConfig, + AdminIntegrationsConfig, TGetServerRootKmsEncryptionDetails, TServerConfig } from "./types"; @@ -22,7 +22,7 @@ export const adminQueryKeys = { getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, - getAdminSlackConfig: () => ["admin-slack-config"] as const, + getAdminIntegrationsConfig: () => ["admin-integrations-config"] as const, getServerEncryptionStrategies: () => ["server-encryption-strategies"] as const }; @@ -95,13 +95,11 @@ export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => { }); }; -export const useGetAdminSlackConfig = () => { +export const useGetAdminIntegrationsConfig = () => { return useQuery({ - queryKey: adminQueryKeys.getAdminSlackConfig(), + queryKey: adminQueryKeys.getAdminIntegrationsConfig(), queryFn: async () => { - const { data } = await apiRequest.get( - "/api/v1/admin/integrations/slack/config" - ); + const { data } = await apiRequest.get("/api/v1/admin/integrations"); return data; } diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 11f2cf44f..4533b5963 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -26,6 +26,14 @@ export type TServerConfig = { pageFrameContent?: string; }; +export type TUpdateServerConfigDTO = { + slackClientId?: string; + slackClientSecret?: string; + microsoftTeamsAppId?: string; + microsoftTeamsClientSecret?: string; + microsoftTeamsBotId?: string; +} & Partial; + export type TCreateAdminUserDTO = { email: string; password: string; @@ -42,11 +50,6 @@ export type TCreateAdminUserDTO = { salt: string; }; -export type TUpdateAdminSlackConfigDTO = { - clientId: string; - clientSecret: string; -}; - export type AdminGetUsersFilters = { limit: number; searchTerm: string; @@ -58,9 +61,16 @@ export type AdminGetIdentitiesFilters = { searchTerm: string; }; -export type AdminSlackConfig = { - clientId: string; - clientSecret: string; +export type AdminIntegrationsConfig = { + slack: { + clientId: string; + clientSecret: string; + }; + microsoftTeams: { + appId: string; + clientSecret: string; + botId: string; + }; }; export type TGetServerRootKmsEncryptionDetails = { diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 159e840ec..3c95468d6 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -93,8 +93,10 @@ export const eventToNameMap: { [K in EventType]: string } = { "Create certificate template EST configuration", [EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG]: "Update certificate template EST configuration", - [EventType.UPDATE_PROJECT_SLACK_CONFIG]: "Update project slack configuration", - [EventType.GET_PROJECT_SLACK_CONFIG]: "Get project slack configuration", + [EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG]: + "Update project workflow integration configuration", + [EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG]: + "Get project workflow integration configuration", [EventType.INTEGRATION_SYNCED]: "Integration sync", [EventType.CREATE_SHARED_SECRET]: "Create shared secret", [EventType.DELETE_SHARED_SECRET]: "Delete shared secret", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 15adb0272..3043ee4e0 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -97,8 +97,8 @@ export enum EventType { CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", - UPDATE_PROJECT_SLACK_CONFIG = "update-project-slack-config", - GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", + UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "update-project-workflow-integration-config", + GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG = "get-project-workflow-integration-config", INTEGRATION_SYNCED = "integration-synced", CREATE_SHARED_SECRET = "create-shared-secret", DELETE_SHARED_SECRET = "delete-shared-secret", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index a18974f2e..d78f4f173 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,6 +1,7 @@ import { CaStatus } from "../ca"; import { IdentityTrustedIp } from "../identities/types"; import { PkiItemType } from "../pkiCollections/constants"; +import { WorkflowIntegration } from "../workflowIntegrations/types"; import { ActorType, EventType, UserAgentType } from "./enums"; export type TGetAuditLogsFilter = { @@ -781,11 +782,12 @@ interface GetCertificateTemplateEstConfig { }; } -interface UpdateProjectSlackConfig { - type: EventType.UPDATE_PROJECT_SLACK_CONFIG; +interface UpdateProjectWorkflowIntegrationConfig { + type: EventType.UPDATE_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; - slackIntegrationId: string; + integrationId: string; + integration: WorkflowIntegration; isAccessRequestNotificationEnabled: boolean; accessRequestChannels: string; isSecretRequestNotificationEnabled: boolean; @@ -793,10 +795,11 @@ interface UpdateProjectSlackConfig { }; } -interface GetProjectSlackConfig { - type: EventType.GET_PROJECT_SLACK_CONFIG; +interface GetProjectWorkflowIntegrationConfig { + type: EventType.GET_PROJECT_WORKFLOW_INTEGRATION_CONFIG; metadata: { id: string; + integration: WorkflowIntegration; }; } @@ -892,8 +895,8 @@ export type Event = | UpdateCertificateTemplateEstConfig | CreateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig - | UpdateProjectSlackConfig - | GetProjectSlackConfig + | UpdateProjectWorkflowIntegrationConfig + | GetProjectWorkflowIntegrationConfig | IntegrationSyncedEvent; export type AuditLog = { diff --git a/frontend/src/hooks/api/workflowIntegrations/index.ts b/frontend/src/hooks/api/workflowIntegrations/index.ts index e4730bd7d..f4e57d7cc 100644 --- a/frontend/src/hooks/api/workflowIntegrations/index.ts +++ b/frontend/src/hooks/api/workflowIntegrations/index.ts @@ -1,13 +1,2 @@ -export { - useDeleteSlackIntegration, - useUpdateProjectSlackConfig, - useUpdateSlackIntegration -} from "./mutation"; -export { - fetchSlackInstallUrl, - fetchSlackReinstallUrl, - useGetSlackIntegrationById, - useGetSlackIntegrationChannels, - useGetSlackIntegrations, - useGetWorkflowIntegrations -} from "./queries"; +export * from "./mutation"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx index e87b0dd6b..bfd929a72 100644 --- a/frontend/src/hooks/api/workflowIntegrations/mutation.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/mutation.tsx @@ -5,8 +5,13 @@ import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/query-keys"; import { workflowIntegrationKeys } from "./queries"; import { + TCheckMicrosoftTeamsIntegrationInstallationStatusDTO, + TCreateMicrosoftTeamsIntegrationDTO, + TDeleteMicrosoftTeamsIntegrationIntegrationDTO, + TDeleteProjectWorkflowIntegrationDTO, TDeleteSlackIntegrationDTO, - TUpdateProjectSlackConfigDTO, + TUpdateMicrosoftTeamsIntegrationDTO, + TUpdateProjectWorkflowIntegrationConfigDTO, TUpdateSlackIntegrationDTO } from "./types"; @@ -28,6 +33,47 @@ export const useUpdateSlackIntegration = () => { }); }; +export const useUpdateMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch( + `/api/v1/workflow-integrations/microsoft-teams/${dto.id}`, + dto + ); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; +export const useCreateMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/workflow-integrations/microsoft-teams", dto); + + return data; + }, + onSuccess: (_, { orgId }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; + export const useDeleteSlackIntegration = () => { const queryClient = useQueryClient(); @@ -44,21 +90,86 @@ export const useDeleteSlackIntegration = () => { }); }; -export const useUpdateProjectSlackConfig = () => { +export const useDeleteMicrosoftTeamsIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete( + `/api/v1/workflow-integrations/microsoft-teams/${dto.id}` + ); + + return data; + }, + onSuccess: (_, { orgId, id }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; + +export const useUpdateProjectWorkflowIntegrationConfig = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (dto: TUpdateProjectSlackConfigDTO) => { + mutationFn: async (dto: TUpdateProjectWorkflowIntegrationConfigDTO) => { const { data } = await apiRequest.put( - `/api/v1/workspace/${dto.workspaceId}/slack-config`, + `/api/v1/workspace/${dto.workspaceId}/workflow-integration/${dto.integration}`, dto ); return data; }, - onSuccess: (_, { workspaceId }) => { + onSuccess: (_, { workspaceId, integration }) => { queryClient.invalidateQueries({ - queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId) + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration) }); } }); }; + +export const useDeleteProjectWorkflowIntegration = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: TDeleteProjectWorkflowIntegrationDTO) => { + const { data } = await apiRequest.delete( + `/api/v1/workspace/${dto.projectId}/workflow-integrations/${dto.integration}/${dto.integrationId}` + ); + + return data; + }, + onSuccess: (_, { projectId, integration }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(projectId, integration) + }); + } + }); +}; + +export const useCheckMicrosoftTeamsIntegrationInstallationStatus = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto: TCheckMicrosoftTeamsIntegrationInstallationStatusDTO) => { + const { data } = await apiRequest.post( + `/api/v1/workflow-integrations/microsoft-teams/${dto.workflowIntegrationId}/installation-status` + ); + + return data; + }, + onSuccess: (_, { workflowIntegrationId, orgId }) => { + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(workflowIntegrationId) + }); + queryClient.invalidateQueries({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId) + }); + queryClient.invalidateQueries({ queryKey: workflowIntegrationKeys.getIntegrations(orgId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/workflowIntegrations/queries.tsx b/frontend/src/hooks/api/workflowIntegrations/queries.tsx index bcf092c73..2cba52a8a 100644 --- a/frontend/src/hooks/api/workflowIntegrations/queries.tsx +++ b/frontend/src/hooks/api/workflowIntegrations/queries.tsx @@ -2,12 +2,27 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { SlackIntegration, SlackIntegrationChannel, WorkflowIntegration } from "./types"; +import { + MicrosoftTeamsIntegration, + MicrosoftTeamsIntegrationTeam, + SlackIntegration, + SlackIntegrationChannel, + WorkflowIntegration +} from "./types"; export const workflowIntegrationKeys = { getIntegrations: (orgId?: string) => [{ orgId }, "workflow-integrations"], getSlackIntegrations: (orgId?: string) => [{ orgId }, "slack-workflow-integrations"], getSlackIntegration: (id?: string) => [{ id }, "slack-workflow-integration"], + getMicrosoftTeamsIntegrations: (orgId?: string) => [ + { orgId }, + "microsoft-teams-workflow-integrations" + ], + getMicrosoftTeamsIntegration: (id?: string) => [{ id }, "microsoft-teams-workflow-integration"], + getMicrosoftTeamsIntegrationTeams: (id?: string) => [ + { id }, + "microsoft-teams-workflow-integration-teams" + ], getSlackIntegrationChannels: (id?: string) => [{ id }, "slack-workflow-integration-channels"] }; @@ -66,13 +81,50 @@ export const fetchWorkflowIntegrations = async () => { return data; }; -export const useGetSlackIntegrations = (orgId?: string) => +export const fetchMicrosoftTeamsIntegrations = async () => { + const { data } = await apiRequest.get( + "/api/v1/workflow-integrations/microsoft-teams" + ); + + return data; +}; + +export const fetchMicrosoftTeamsIntegrationById = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/microsoft-teams/${id}` + ); + + return data; +}; +export const fetchMicrosoftTeamsIntegrationTeams = async (id?: string) => { + const { data } = await apiRequest.get( + `/api/v1/workflow-integrations/microsoft-teams/${id}/teams` + ); + + return data; +}; + +export const useGetMicrosoftTeamsIntegrations = (orgId?: string) => useQuery({ - queryKey: workflowIntegrationKeys.getSlackIntegrations(orgId), - queryFn: () => fetchSlackIntegrations(), + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrations(orgId), + queryFn: () => fetchMicrosoftTeamsIntegrations(), enabled: Boolean(orgId) }); +export const useGetMicrosoftTeamsIntegrationById = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegration(id), + queryFn: () => fetchMicrosoftTeamsIntegrationById(id), + enabled: Boolean(id) + }); + +export const useGetMicrosoftTeamsIntegrationTeams = (id?: string) => + useQuery({ + queryKey: workflowIntegrationKeys.getMicrosoftTeamsIntegrationTeams(id), + queryFn: () => fetchMicrosoftTeamsIntegrationTeams(id), + enabled: Boolean(id) + }); + export const useGetSlackIntegrationById = (id?: string) => useQuery({ queryKey: workflowIntegrationKeys.getSlackIntegration(id), diff --git a/frontend/src/hooks/api/workflowIntegrations/types.ts b/frontend/src/hooks/api/workflowIntegrations/types.ts index e30193776..f0f978192 100644 --- a/frontend/src/hooks/api/workflowIntegrations/types.ts +++ b/frontend/src/hooks/api/workflowIntegrations/types.ts @@ -1,14 +1,25 @@ export enum WorkflowIntegrationPlatform { - SLACK = "slack" + SLACK = "slack", + MICROSOFT_TEAMS = "microsoft-teams" } export type WorkflowIntegration = { id: string; slug: string; description: string; + status: WorkflowIntegrationStatus; integration: WorkflowIntegrationPlatform; }; +export type MicrosoftTeamsIntegrationTeam = { + teamId: string; + teamName: string; + channels: { + channelId: string; + channelName: string; + }[]; +}; + export type SlackIntegration = { id: string; slug: string; @@ -16,6 +27,18 @@ export type SlackIntegration = { teamName: string; }; +export enum WorkflowIntegrationStatus { + Pending = "pending", + Installed = "installed" +} + +export type MicrosoftTeamsIntegration = { + id: string; + slug: string; + description: string; + tenantId: string; +}; + export type SlackIntegrationChannel = { id: string; name: string; @@ -28,25 +51,88 @@ export type TUpdateSlackIntegrationDTO = { description?: string; }; +export type TUpdateMicrosoftTeamsIntegrationDTO = { + id: string; + orgId: string; + slug?: string; + description?: string; +}; + +export type TCreateMicrosoftTeamsIntegrationDTO = { + orgId: string; + tenantId: string; + slug: string; + description?: string; +}; + export type TDeleteSlackIntegrationDTO = { id: string; orgId: string; }; -export type ProjectSlackConfig = { +export type TDeleteMicrosoftTeamsIntegrationIntegrationDTO = { id: string; - slackIntegrationId: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; + orgId: string; }; -export type TUpdateProjectSlackConfigDTO = { - workspaceId: string; - slackIntegrationId: string; - isAccessRequestNotificationEnabled: boolean; - accessRequestChannels: string; - isSecretRequestNotificationEnabled: boolean; - secretRequestChannels: string; +export type ProjectWorkflowIntegrationConfig = + | { + id: string; + integration: WorkflowIntegrationPlatform.SLACK; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + } + | { + id: string; + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels: { + teamId: string; + channelIds: string[]; + }; + secretRequestChannels: { + teamId: string; + channelIds: string[]; + }; + }; + +export type TUpdateProjectWorkflowIntegrationConfigDTO = + | { + integration: WorkflowIntegrationPlatform.SLACK; + workspaceId: string; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + accessRequestChannels: string; + isSecretRequestNotificationEnabled: boolean; + secretRequestChannels: string; + } + | { + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS; + workspaceId: string; + integrationId: string; + isAccessRequestNotificationEnabled: boolean; + isSecretRequestNotificationEnabled: boolean; + accessRequestChannels?: { + teamId: string; + channelIds: string[]; + }; + secretRequestChannels?: { + teamId: string; + channelIds: string[]; + }; + }; +export type TDeleteProjectWorkflowIntegrationDTO = { + projectId: string; + integration: WorkflowIntegrationPlatform; + integrationId: string; +}; + +export type TCheckMicrosoftTeamsIntegrationInstallationStatusDTO = { + workflowIntegrationId: string; + orgId: string; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index c0f5f027d..da9a060e6 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -24,9 +24,9 @@ export { useGetWorkspaceIndexStatus, useGetWorkspaceIntegrations, useGetWorkspaceSecrets, - useGetWorkspaceSlackConfig, useGetWorkspaceUserDetails, useGetWorkspaceUsers, + useGetWorkspaceWorkflowIntegrationConfig, useListWorkspaceCas, useListWorkspaceCertificates, useListWorkspaceCertificateTemplates, diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..15ddb2e23 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -20,7 +20,10 @@ import { TSshCertificateTemplate } from "../sshCertificateTemplates/types"; import { TSshHost } from "../sshHost/types"; import { userKeys } from "../users/query-keys"; import { TWorkspaceUser } from "../users/types"; -import { ProjectSlackConfig } from "../workflowIntegrations/types"; +import { + ProjectWorkflowIntegrationConfig, + WorkflowIntegrationPlatform +} from "../workflowIntegrations/types"; import { workspaceKeys } from "./query-keys"; import { CreateEnvironmentDTO, @@ -874,12 +877,18 @@ export const useListWorkspaceSshCertificateTemplates = (projectId: string) => { }); }; -export const useGetWorkspaceSlackConfig = ({ workspaceId }: { workspaceId: string }) => { +export const useGetWorkspaceWorkflowIntegrationConfig = ({ + workspaceId, + integration +}: { + workspaceId: string; + integration: WorkflowIntegrationPlatform; +}) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceSlackConfig(workspaceId), + queryKey: workspaceKeys.getWorkspaceWorkflowIntegrationConfig(workspaceId, integration), queryFn: async () => { - const { data } = await apiRequest.get( - `/api/v1/workspace/${workspaceId}/slack-config` + const { data } = await apiRequest.get( + `/api/v1/workspace/${workspaceId}/workflow-integration-config/${integration}` ); return data; diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..45472e3ba 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -1,6 +1,7 @@ import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "@app/hooks/api/workspace/types"; import type { CaStatus } from "../ca"; +import { WorkflowIntegrationPlatform } from "../workflowIntegrations/types"; export const workspaceKeys = { getWorkspaceById: (workspaceId: string) => ["workspaces", { workspaceId }] as const, @@ -53,8 +54,10 @@ export const workspaceKeys = { [{ workspaceId }, "workspace-pki-collections"] as const, getWorkspaceCertificateTemplates: (workspaceId: string) => [{ workspaceId }, "workspace-certificate-templates"] as const, - getWorkspaceSlackConfig: (workspaceId: string) => - [{ workspaceId }, "workspace-slack-config"] as const, + getWorkspaceWorkflowIntegrationConfig: ( + workspaceId: string, + integration: WorkflowIntegrationPlatform + ) => [{ workspaceId, integration }, "workspace-workflow-integration-config"] as const, getWorkspaceSshCas: (projectId: string) => [{ projectId }, "workspace-ssh-cas"] as const, allWorkspaceSshCertificates: (projectId: string) => [{ projectId }, "workspace-ssh-certificates"] as const, diff --git a/frontend/src/index.css b/frontend/src/index.css index 1c5b0c465..b1b3077e1 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -198,3 +198,19 @@ html { top: 0.5rem; @apply text-sm text-gray-500 opacity-50; } + +.accordion { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + transition: grid-template-rows 200ms ease !important; + animation: unset; +} + +.accordion[data-state="open"] { + grid-template-rows: 1fr; +} + +.accordion > div { + min-height: 0px; +} \ No newline at end of file diff --git a/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx index 9bb005ce4..2ef4f7329 100644 --- a/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/IntegrationPanel.tsx @@ -1,166 +1,24 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useGetAdminIntegrationsConfig } from "@app/hooks/api"; -import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; -import { useGetAdminSlackConfig, useUpdateServerConfig } from "@app/hooks/api"; - -const slackFormSchema = z.object({ - clientId: z.string(), - clientSecret: z.string() -}); - -type TSlackForm = z.infer; - -const getCustomSlackAppCreationUrl = () => - `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( - JSON.stringify({ - display_information: { - name: "Infisical", - description: "Get real-time Infisical updates in Slack", - background_color: "#c2d62b", - long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. - - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. - - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. - - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. - - By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` - }, - features: { - app_home: { - home_tab_enabled: false, - messages_tab_enabled: false, - messages_tab_read_only_enabled: true - }, - bot_user: { - display_name: "Infisical", - always_online: true - } - }, - oauth_config: { - redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`], - scopes: { - bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"] - } - }, - settings: { - org_deploy_enabled: false, - socket_mode_enabled: false, - token_rotation_enabled: false - } - }) - )}`; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; +import { SlackIntegrationForm } from "./SlackIntegrationForm"; export const IntegrationPanel = () => { - const { - control, - handleSubmit, - setValue, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(slackFormSchema) - }); - - const { data: adminSlackConfig } = useGetAdminSlackConfig(); - const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); - const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle(); - const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle(); - - useEffect(() => { - if (adminSlackConfig) { - setValue("clientId", adminSlackConfig.clientId); - setValue("clientSecret", adminSlackConfig.clientSecret); - } - }, [adminSlackConfig]); - - const onSlackFormSubmit = async (data: TSlackForm) => { - await updateAdminServerConfig({ - slackClientId: data.clientId, - slackClientSecret: data.clientSecret - }); - - createNotification({ - text: "Updated admin slack configuration", - type: "success" - }); - }; + const { data: adminIntegrationsConfig } = useGetAdminIntegrationsConfig(); return ( -
-
-
Slack Integration
-
- Step 1: Create your Infisical Slack App +
+
+
Integrations
+
+ Configure your instance-wide settings to enable integration with Slack and Microsoft + Teams.
-
- -
-
- Step 2: Configure your instance-wide settings to enable integration with Slack. Copy the - values from the App Credentials page of your custom Slack App. -
- ( - - setIsSlackClientIdFocused.on()} - onBlur={() => setIsSlackClientIdFocused.off()} - onChange={(e) => field.onChange(e.target.value)} - /> - - )} - /> - ( - - setIsSlackClientSecretFocused.on()} - onBlur={() => setIsSlackClientSecretFocused.off()} - onChange={(e) => field.onChange(e.target.value)} - /> - - )} - />
- - +
+ + +
+
); }; diff --git a/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..7546e7e2a --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,176 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { BsMicrosoftTeams } from "react-icons/bs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useUpdateServerConfig } from "@app/hooks/api"; +import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types"; + +const microsoftTeamsFormSchema = z.object({ + appId: z.string(), + clientSecret: z.string(), + botId: z.string() +}); + +type TMicrosoftTeamsForm = z.infer; + +type Props = { + adminIntegrationsConfig?: AdminIntegrationsConfig; +}; + +export const MicrosoftTeamsIntegrationForm = ({ adminIntegrationsConfig }: Props) => { + const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + const [isMicrosoftTeamsAppIdFocused, setIsMicrosoftTeamsAppIdFocused] = useToggle(); + const [isMicrosoftTeamsClientSecretFocused, setIsMicrosoftTeamsClientSecretFocused] = useToggle(); + const [isMicrosoftTeamsBotIdFocused, setIsMicrosoftTeamsBotIdFocused] = useToggle(); + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(microsoftTeamsFormSchema) + }); + + const onSubmit = async (data: TMicrosoftTeamsForm) => { + await updateAdminServerConfig({ + microsoftTeamsAppId: data.appId, + microsoftTeamsClientSecret: data.clientSecret, + microsoftTeamsBotId: data.botId + }); + + createNotification({ + text: "Updated admin Microsoft Teams configuration", + type: "success" + }); + }; + + useEffect(() => { + if (adminIntegrationsConfig) { + setValue("appId", adminIntegrationsConfig.microsoftTeams.appId); + setValue("clientSecret", adminIntegrationsConfig.microsoftTeams.clientSecret); + setValue("botId", adminIntegrationsConfig.microsoftTeams.botId); + } + }, [adminIntegrationsConfig]); + + return ( +
+ + + +
+ +
Microsoft Teams Integration
+
+
+ +
+
+ Step 1: Create Microsoft Teams bot +
+
+ +
+
+ Step 2: Configure your instance-wide settings to enable integration with Microsoft + Teams. Copy the App ID and Client Secret from your Microsoft Teams bot's App + Registration page. The Client Secret is the password for the bot. +
+ ( + + setIsMicrosoftTeamsAppIdFocused.on()} + onBlur={() => setIsMicrosoftTeamsAppIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + ( + + setIsMicrosoftTeamsClientSecretFocused.on()} + onBlur={() => setIsMicrosoftTeamsClientSecretFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + + ( + + setIsMicrosoftTeamsBotIdFocused.on()} + onBlur={() => setIsMicrosoftTeamsBotIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx b/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx new file mode 100644 index 000000000..63da0e384 --- /dev/null +++ b/frontend/src/pages/admin/OverviewPage/components/SlackIntegrationForm.tsx @@ -0,0 +1,189 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { BsSlack } from "react-icons/bs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input +} from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useUpdateServerConfig } from "@app/hooks/api"; +import { AdminIntegrationsConfig } from "@app/hooks/api/admin/types"; + +const getCustomSlackAppCreationUrl = () => + `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent( + JSON.stringify({ + display_information: { + name: "Infisical", + description: "Get real-time Infisical updates in Slack", + background_color: "#c2d62b", + long_description: `This Slack application is designed specifically for use with your self-hosted Infisical instance, allowing seamless integration between your Infisical projects and your Slack workspace. With this integration, your team can stay up-to-date with the latest events, changes, and notifications directly inside Slack. + - Notifications: Receive real-time updates and alerts about critical events in your Infisical projects. Whether it's a new project being created, updates to secrets, or changes to your team's configuration, you will be promptly notified within the designated Slack channels of your choice. + - Customization: Tailor the notifications to your team's specific needs by configuring which types of events trigger alerts and in which channels they are sent. + - Collaboration: Keep your entire team in the loop with notifications that help facilitate more efficient collaboration by ensuring that everyone is aware of important developments in your Infisical projects. + + By integrating Infisical with Slack, you can enhance your workflow by combining the power of secure secrets management with the communication capabilities of Slack.` + }, + features: { + app_home: { + home_tab_enabled: false, + messages_tab_enabled: false, + messages_tab_read_only_enabled: true + }, + bot_user: { + display_name: "Infisical", + always_online: true + } + }, + oauth_config: { + redirect_urls: [`${window.origin}/api/v1/workflow-integrations/slack/oauth_redirect`], + scopes: { + bot: ["chat:write.public", "chat:write", "channels:read", "groups:read"] + } + }, + settings: { + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false + } + }) + )}`; + +const slackFormSchema = z.object({ + clientId: z.string(), + clientSecret: z.string() +}); + +type TSlackForm = z.infer; + +type Props = { + adminIntegrationsConfig?: AdminIntegrationsConfig; +}; + +export const SlackIntegrationForm = ({ adminIntegrationsConfig }: Props) => { + const { mutateAsync: updateAdminServerConfig } = useUpdateServerConfig(); + const [isSlackClientIdFocused, setIsSlackClientIdFocused] = useToggle(); + const [isSlackClientSecretFocused, setIsSlackClientSecretFocused] = useToggle(); + + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(slackFormSchema) + }); + + const onSubmit = async (data: TSlackForm) => { + await updateAdminServerConfig({ + slackClientId: data.clientId, + slackClientSecret: data.clientSecret + }); + + createNotification({ + text: "Updated admin slack configuration", + type: "success" + }); + }; + + useEffect(() => { + if (adminIntegrationsConfig) { + setValue("clientId", adminIntegrationsConfig.slack.clientId); + setValue("clientSecret", adminIntegrationsConfig.slack.clientSecret); + } + }, [adminIntegrationsConfig]); + + return ( +
+ + + +
+ +
Slack Integration
+
+
+ +
+
+ Step 1: Create your Infisical Slack App +
+
+ +
+
+ Step 2: Configure your instance-wide settings to enable integration with Slack. Copy + the values from the App Credentials page of your custom Slack App. +
+ ( + + setIsSlackClientIdFocused.on()} + onBlur={() => setIsSlackClientIdFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> + ( + + setIsSlackClientSecretFocused.on()} + onBlur={() => setIsSlackClientSecretFocused.off()} + onChange={(e) => field.onChange(e.target.value)} + /> + + )} + /> +
+ +
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx index 1d12b50dd..d4934ce7e 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/AddWorkflowIntegrationForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { BsMicrosoftTeams } from "react-icons/bs"; import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -6,6 +7,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { Modal, ModalContent } from "@app/components/v2"; import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; import { SlackIntegrationForm } from "./SlackIntegrationForm"; type Props = { @@ -20,9 +22,14 @@ enum WizardSteps { const PLATFORM_LIST = [ { - icon: faSlack, + icon: , platform: WorkflowIntegrationPlatform.SLACK, title: "Slack" + }, + { + icon: , + platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + title: "Microsoft Teams" } ]; @@ -38,7 +45,10 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { return ( handleFormReset(state)}> - + char.toUpperCase()) ?? "workflow"} integration`} + className="my-4" + > {wizardStep === WizardSteps.SelectPlatform && ( { } }} > - +
{icon}
{title}
))} @@ -86,6 +96,18 @@ export const AddWorkflowIntegrationForm = ({ isOpen, onToggle }: Props) => { onToggle(false)} /> )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + onToggle(false)} /> + + )} diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx index a9af20247..48cfa2243 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/IntegrationFormDetails.tsx @@ -1,6 +1,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; import { SlackIntegrationForm } from "./SlackIntegrationForm"; type Props = { @@ -20,6 +21,9 @@ export const IntegrationFormDetails = ({ isOpen, id, onOpenChange, workflowPlatf {workflowPlatform === WorkflowIntegrationPlatform.SLACK && ( onOpenChange(false)} /> )} + {workflowPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + onOpenChange(false)} /> + )} ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..671ee02dd --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,154 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { + useCreateMicrosoftTeamsIntegration, + useGetMicrosoftTeamsIntegrationById, + useUpdateMicrosoftTeamsIntegration +} from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +type Props = { + id?: string; + onClose: () => void; +}; + +const microsoftTeamsFormSchema = z.object({ + slug: slugSchema({ min: 1, field: "Alias" }), + tenantId: z.string().min(1, { message: "Tenant ID is required" }).trim(), + description: z.string().optional() +}); + +type TMicrosoftTeamsFormData = z.infer; + +export const MicrosoftTeamsIntegrationForm = ({ id, onClose }: Props) => { + const { + control, + handleSubmit, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(microsoftTeamsFormSchema) + }); + + const { currentOrg } = useOrganization(); + const { data: microsoftTeamsIntegration } = useGetMicrosoftTeamsIntegrationById(id); + const { mutateAsync: createMicrosoftTeamsIntegration } = useCreateMicrosoftTeamsIntegration(); + const { mutateAsync: updateMicrosoftTeamsIntegration } = useUpdateMicrosoftTeamsIntegration(); + + useEffect(() => { + if (microsoftTeamsIntegration) { + setValue("slug", microsoftTeamsIntegration.slug); + setValue("description", microsoftTeamsIntegration.description ?? ""); + setValue("tenantId", microsoftTeamsIntegration.tenantId); + } + }, [microsoftTeamsIntegration]); + + const handleSlackFormSubmit = async ({ + slug, + description, + tenantId + }: TMicrosoftTeamsFormData) => { + if (id && microsoftTeamsIntegration) { + if (!currentOrg) { + return; + } + + if (tenantId !== microsoftTeamsIntegration.tenantId) { + createNotification({ + text: "Tenant ID cannot be changed", + type: "error" + }); + return; + } + + await updateMicrosoftTeamsIntegration({ + id, + orgId: currentOrg.id, + slug, + description + }); + + createNotification({ + text: "Successfully updated Microsoft Teams integration", + type: "success" + }); + + onClose(); + } else { + await createMicrosoftTeamsIntegration({ + orgId: currentOrg.id, + tenantId, + slug, + description + }); + + onClose(); + createNotification({ + text: "Successfully created Microsoft Teams integration", + type: "success" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + {!microsoftTeamsIntegration && ( + ( + + + + )} + /> + )} + ( + + + + )} + /> + {microsoftTeamsIntegration && ( + + + + )} +
+ + +
+ + ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx index e3b59489c..e3612a4dd 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgWorkflowIntegrationTab/OrgWorkflowIntegrationTab.tsx @@ -7,6 +7,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Badge, Button, DeleteActionModal, DropdownMenu, @@ -27,14 +28,31 @@ import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { fetchSlackReinstallUrl, + useCheckMicrosoftTeamsIntegrationInstallationStatus, + useDeleteMicrosoftTeamsIntegration, useDeleteSlackIntegration, useGetWorkflowIntegrations } from "@app/hooks/api"; -import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { + WorkflowIntegrationPlatform, + WorkflowIntegrationStatus +} from "@app/hooks/api/workflowIntegrations/types"; import { AddWorkflowIntegrationForm } from "./AddWorkflowIntegrationForm"; import { IntegrationFormDetails } from "./IntegrationFormDetails"; +const renderStatus = (status: WorkflowIntegrationStatus) => { + if (status === WorkflowIntegrationStatus.Installed) { + return Installed; + } + + if (status === WorkflowIntegrationStatus.Pending) { + return Pending; + } + + return Failed; +}; + export const OrgWorkflowIntegrationTab = withPermission( () => { const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ @@ -48,6 +66,9 @@ export const OrgWorkflowIntegrationTab = withPermission( useGetWorkflowIntegrations(currentOrg?.id); const { mutateAsync: deleteSlackIntegration } = useDeleteSlackIntegration(); + const { mutateAsync: deleteMicrosoftTeamsIntegration } = useDeleteMicrosoftTeamsIntegration(); + const { mutateAsync: checkMicrosoftTeamsInstallationStatus } = + useCheckMicrosoftTeamsIntegrationInstallationStatus(); const handleRemoveIntegration = async () => { if (!currentOrg) { @@ -62,6 +83,13 @@ export const OrgWorkflowIntegrationTab = withPermission( }); } + if (platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + await deleteMicrosoftTeamsIntegration({ + id, + orgId: currentOrg?.id + }); + } + handlePopUpClose("removeIntegration"); createNotification({ text: "Successfully deleted integration", @@ -69,27 +97,37 @@ export const OrgWorkflowIntegrationTab = withPermission( }); }; - const triggerReinstall = async (platform: WorkflowIntegrationPlatform, id: string) => { - if (platform === WorkflowIntegrationPlatform.SLACK) { - try { - const slackReinstallUrl = await fetchSlackReinstallUrl({ - id - }); + const triggerSlackReinstall = async (id: string) => { + try { + const slackReinstallUrl = await fetchSlackReinstallUrl({ + id + }); - if (slackReinstallUrl) { - window.location.href = slackReinstallUrl; - } - } catch (err) { - if (axios.isAxiosError(err)) { - createNotification({ - text: (err.response?.data as { message: string })?.message, - type: "error" - }); - } + if (slackReinstallUrl) { + window.location.href = slackReinstallUrl; + } + } catch (err) { + if (axios.isAxiosError(err)) { + createNotification({ + text: (err.response?.data as { message: string })?.message, + type: "error" + }); } } }; + const triggerMicrosoftTeamsInstallationStatusCheck = async (id: string) => { + await checkMicrosoftTeamsInstallationStatus({ + workflowIntegrationId: id, + orgId: currentOrg?.id + }); + + createNotification({ + text: "The Microsoft Teams bot is successfully installed in your Microsoft Teams tenant", + type: "success" + }); + }; + return (
@@ -117,6 +155,7 @@ export const OrgWorkflowIntegrationTab = withPermission( Provider Alias + Status @@ -139,6 +178,7 @@ export const OrgWorkflowIntegrationTab = withPermission(
{workflowIntegration.integration.toUpperCase()}
{workflowIntegration.slug} + {renderStatus(workflowIntegration.status)} @@ -159,29 +199,56 @@ export const OrgWorkflowIntegrationTab = withPermission( > More details - - {(isAllowed) => ( - { - e.stopPropagation(); - triggerReinstall( - workflowIntegration.integration, - workflowIntegration.id - ); - }} - > - Reinstall - - )} - + {workflowIntegration.integration === WorkflowIntegrationPlatform.SLACK && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + + await triggerSlackReinstall(workflowIntegration.integration); + }} + > + Reinstall + + )} + + )} + + {workflowIntegration.integration === + WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + {(isAllowed) => ( + { + e.stopPropagation(); + + await triggerMicrosoftTeamsInstallationStatusCheck( + workflowIntegration.id + ); + }} + > + Check Installation Status + + )} + + )} + ; +export const renderProvider = (integration: WorkflowIntegrationPlatform) => { + if (integration === WorkflowIntegrationPlatform.SLACK) { + return ; + } + + if (integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + return ; + } + + return null; +}; export const WorkflowIntegrationTab = () => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWorkflowIntegration", + "removeIntegration", + "editIntegration" + ] as const); + const { currentWorkspace } = useWorkspace(); - const { data: slackConfig, isPending: isSlackConfigLoading } = useGetWorkspaceSlackConfig({ - workspaceId: currentWorkspace?.id ?? "" - }); - const { data: slackIntegrations } = useGetSlackIntegrations(currentWorkspace?.orgId); - const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectSlackConfig(); - const { - control, - watch, - handleSubmit, - setValue, - formState: { isDirty, isSubmitting } - } = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - isAccessRequestNotificationEnabled: false, - accessRequestChannels: [], - isSecretRequestNotificationEnabled: false, - secretRequestChannels: [] - } - }); + const { mutateAsync: deleteIntegration } = useDeleteProjectWorkflowIntegration(); - const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); - const selectedSlackIntegrationId = watch("slackIntegrationId"); - const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); - - const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); - const slackChannelIdToName = Object.fromEntries( - (slackChannels || []).map((channel) => [channel.id, channel.name]) - ); - const sortedSlackChannels = slackChannels?.sort((a, b) => - a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - ); - - const handleIntegrationSave = async (data: TSlackConfigForm) => { - if (!currentWorkspace) { + const handleRemoveIntegration = async ( + integrationType: WorkflowIntegrationPlatform, + integrationId: string + ) => { + if (!currentWorkspace.id) { return; } - await updateProjectSlackConfig({ - workspaceId: currentWorkspace.id, - ...data, - accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), - secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") + await deleteIntegration({ + projectId: currentWorkspace?.id ?? "", + integration: integrationType, + integrationId }); createNotification({ type: "success", - text: "Successfully updated slack integration" + text: `Successfully removed ${integrationType.replace("-", " ").replace(/\b\w/g, (char) => char.toUpperCase())} integration` }); }; - useEffect(() => { - if (slackConfig) { - setValue("slackIntegrationId", slackConfig.slackIntegrationId); - setValue( - "isSecretRequestNotificationEnabled", - slackConfig.isSecretRequestNotificationEnabled - ); - setValue( - "isAccessRequestNotificationEnabled", - slackConfig.isAccessRequestNotificationEnabled - ); - - if (slackChannels) { - setValue( - "secretRequestChannels", - slackConfig.secretRequestChannels - .split(", ") - .filter((channel) => channel in slackChannelIdToName) - ); - setValue( - "accessRequestChannels", - slackConfig.accessRequestChannels - .split(", ") - .filter((channel) => channel in slackChannelIdToName) - ); - } - } - }, [slackConfig, slackChannels]); - - if (isSlackConfigLoading) { - return ; - } - - return !slackIntegrations?.length ? ( - - -
- Create one now -
- -
- ) : ( + return (
-

Slack Integration

+

Workflow Integrations

+ + {(isAllowed) => ( + + )} +

- This integration allows you to send notifications to your Slack workspace in response to - events in your project. + Connect Infisical to other platforms for notification and workflow integrations.

-
-
- - {(isAllowed) => ( - ( - - - - )} - control={control} - name="slackIntegrationId" - /> - )} - -
- {selectedSlackIntegrationId && ( - <> -

Events

- { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Secret Approval Requests

-
-
- ); - }} - /> - {secretRequestNotifState && ( - ( - - - - slackChannelIdToName[entry]) - .join(", ")} - className="text-left" - /> - - - {sortedSlackChannels?.map((slackChannel) => { - const isChecked = value?.includes(slackChannel.id); - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter((el: string) => el !== slackChannel.id) - : [...(value || []), slackChannel.id] - ); - }} - key={`secret-requests-slack-channel-${slackChannel.id}`} - iconPos="right" - icon={isChecked && } - > - {slackChannel.name} - - ); - })} - - - - )} - /> - )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Access Requests

-
-
- ); - }} - /> - {accessRequestNotifState && ( - ( - - - - slackChannelIdToName[entry]) - .join(", ")} - className="text-left" - /> - - - {sortedSlackChannels?.map((slackChannel) => { - const isChecked = value?.includes(slackChannel.id); - return ( - { - evt.preventDefault(); - onChange( - isChecked - ? value?.filter((el: string) => el !== slackChannel.id) - : [...(value || []), slackChannel.id] - ); - }} - key={`access-requests-slack-channel-${slackChannel.id}`} - iconPos="right" - icon={isChecked && } - > - {slackChannel.name} - - ); - })} - - - - )} - /> - )} - - - )} - + + + + + + + + + + + + + + + + +
ProviderAccess Request NotificationsSecret Request NotificationsAccess Request Notifications DestinationSecret Request Notifications DestinationActions
+
+ handlePopUpToggle("addWorkflowIntegration", state)} + /> + handlePopUpToggle("removeIntegration", isOpen)} + deleteKey="confirm" + onDeleteApproved={async () => { + await handleRemoveIntegration( + popUp.removeIntegration.data?.integration, + popUp.removeIntegration.data?.integrationId + ); + handlePopUpClose("removeIntegration"); + }} + /> + handlePopUpClose("editIntegration")} + integration={popUp.editIntegration.data?.integration} + />
); }; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx new file mode 100644 index 000000000..444ad44e2 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/AddWorkflowIntegrationModal.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { BsMicrosoftTeams } from "react-icons/bs"; +import { faSlack } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AnimatePresence, motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useGetWorkspaceWorkflowIntegrationConfig } from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { MicrosoftTeamsIntegrationForm } from "./MicrosoftTeamsIntegrationForm"; +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +}; + +enum WizardSteps { + SelectPlatform = "select-platform", + PlatformInputs = "platform-inputs" +} + +const PLATFORM_LIST = [ + { + icon: , + platform: WorkflowIntegrationPlatform.SLACK, + title: "Slack" + }, + { + icon: , + platform: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + title: "Microsoft Teams" + } +]; + +export const AddWorkflowIntegrationModal = ({ isOpen, onToggle }: Props) => { + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectPlatform); + const [selectedPlatform, setSelectedPlatform] = useState(null); + + const { currentWorkspace } = useWorkspace(); + const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + + const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); + + const microsoftTeamsConfigured = !!microsoftTeamsConfig; + const slackConfigured = !!slackConfig; + + const handleFormReset = (state: boolean = false) => { + onToggle(state); + setWizardStep(WizardSteps.SelectPlatform); + setSelectedPlatform(null); + }; + + return ( + handleFormReset(state)}> + char.toUpperCase()) ?? "workflow"} integration`} + className="my-4" + > + + {wizardStep === WizardSteps.SelectPlatform && ( + +
Select a workflow integration
+
+ {PLATFORM_LIST.map(({ icon, platform, title }) => { + const isConfigured = + (platform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && + microsoftTeamsConfigured) || + (platform === WorkflowIntegrationPlatform.SLACK && slackConfigured); + + return ( +
+
{ + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + }} + onKeyDown={(evt) => { + if (evt.key === "Enter") { + setSelectedPlatform(platform); + setWizardStep(WizardSteps.PlatformInputs); + } + }} + > +
{icon}
+
{title}
+
+ {isConfigured && ( +
+
+
+ Already Configured +
+
+
+ )} +
+ ); + })} +
+
+ )} + + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.SLACK && ( + + handleFormReset(false)} /> + + )} + {wizardStep === WizardSteps.PlatformInputs && + selectedPlatform === WorkflowIntegrationPlatform.MICROSOFT_TEAMS && ( + + handleFormReset(false)} /> + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx new file mode 100644 index 000000000..50f5da832 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/EditWorkflowIntegrationModal.tsx @@ -0,0 +1,29 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +import { SlackIntegrationForm } from "./SlackIntegrationForm"; + +type Props = { + isOpen?: boolean; + onClose: () => void; + + integration: WorkflowIntegrationPlatform; +}; + +export const EditWorkflowIntegrationModal = ({ isOpen, onClose, integration }: Props) => { + const handleFormReset = () => { + onClose(); + }; + + return ( + + char.toUpperCase()) ?? "workflow"} integration`} + > + {integration === WorkflowIntegrationPlatform.SLACK && ( + + )} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx new file mode 100644 index 000000000..e85ea1e27 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsConfigRow.tsx @@ -0,0 +1,153 @@ +import { BsMicrosoftTeams } from "react-icons/bs"; +import { faCheck, faEllipsis, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Spinner, + Td, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context"; +import { + useGetMicrosoftTeamsIntegrationTeams, + useGetWorkspaceWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>, + data?: { + integrationId?: string; + integration: WorkflowIntegrationPlatform; + } + ) => void; +}; + +export const MicrosoftTeamsConfigRow = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + + const { data: microsoftTeamsChannels, isPending: isMicrosoftTeamsChannelsLoading } = + useGetMicrosoftTeamsIntegrationTeams(microsoftTeamsConfig?.integrationId); + const microsoftTeamsChannelIdToName = Object.fromEntries( + microsoftTeamsChannels?.flatMap((team) => + team.channels.map((channel) => [channel.channelId, channel.channelName]) + ) ?? [] + ); + + if (microsoftTeamsConfig?.integration !== WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + return null; + } + + console.log("after"); + + return ( + + +
+ + Microsoft Teams +
+ + + {microsoftTeamsConfig.isAccessRequestNotificationEnabled ? ( + + ) : ( + + )} + + + {microsoftTeamsConfig.isSecretRequestNotificationEnabled ? ( + + ) : ( + + )} + + + {isMicrosoftTeamsChannelsLoading ? ( + + ) : ( + + {microsoftTeamsConfig.accessRequestChannels?.channelIds + ?.map((channel) => microsoftTeamsChannelIdToName[channel]) + .join(", ")} + + )} + + + {isMicrosoftTeamsChannelsLoading ? ( + + ) : ( + + {microsoftTeamsConfig.secretRequestChannels?.channelIds + ?.map((channel) => microsoftTeamsChannelIdToName[channel]) + .join(", ")} + + )} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + integrationId: microsoftTeamsConfig.integrationId, + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + }} + > + Delete + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("editIntegration", { + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + }} + > + Edit + + )} + + +
+ + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx new file mode 100644 index 000000000..40d958527 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/MicrosoftTeamsIntegrationForm.tsx @@ -0,0 +1,540 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + useGetMicrosoftTeamsIntegrations, + useGetMicrosoftTeamsIntegrationTeams, + useGetWorkspaceWorkflowIntegrationConfig, + useUpdateProjectWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +const formSchema = z + .object({ + microsoftTeamsIntegrationId: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + isAccessRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.object({ + teamId: z.string(), + channelIds: z.string().array() + }), + accessRequestChannels: z.object({ + teamId: z.string(), + channelIds: z.string().array() + }) + }) + .superRefine((data, ctx) => { + if (data.isSecretRequestNotificationEnabled) { + if (!data.secretRequestChannels.teamId) { + ctx.addIssue({ + path: ["secretRequestChannels", "teamId"], + code: z.ZodIssueCode.custom, + message: "Team is required" + }); + } + + if (!data.secretRequestChannels.channelIds.length) { + ctx.addIssue({ + path: ["secretRequestChannels", "channelIds"], + code: z.ZodIssueCode.custom, + message: "At least one channel is required" + }); + } + } + + if (data.isAccessRequestNotificationEnabled) { + if (!data.accessRequestChannels.teamId) { + ctx.addIssue({ + path: ["accessRequestChannels", "teamId"], + code: z.ZodIssueCode.custom, + message: "Team is required" + }); + } + + if (!data.accessRequestChannels.channelIds.length) { + ctx.addIssue({ + path: ["accessRequestChannels", "channelIds"], + code: z.ZodIssueCode.custom, + message: "At least one channel is required" + }); + } + } + }); + +type TSlackConfigForm = z.infer; + +type Props = { + onClose: () => void; +}; + +export const MicrosoftTeamsIntegrationForm = ({ onClose }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: microsoftTeamsConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS + }); + const { data: microsoftTeamsIntegrations } = useGetMicrosoftTeamsIntegrations( + currentWorkspace?.orgId + ); + + const { mutateAsync: updateProjectMicrosoftTeamsConfig } = + useUpdateProjectWorkflowIntegrationConfig(); + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isAccessRequestNotificationEnabled: false, + isSecretRequestNotificationEnabled: false, + accessRequestChannels: { + teamId: "", + channelIds: [] + }, + secretRequestChannels: { + teamId: "", + channelIds: [] + } + } + }); + + const handleIntegrationSave = async (data: TSlackConfigForm) => { + try { + if (!currentWorkspace) { + return; + } + + await updateProjectMicrosoftTeamsConfig({ + workspaceId: currentWorkspace.id, + isAccessRequestNotificationEnabled: data.isAccessRequestNotificationEnabled, + isSecretRequestNotificationEnabled: data.isSecretRequestNotificationEnabled, + ...(data.isAccessRequestNotificationEnabled && { + accessRequestChannels: data.accessRequestChannels + }), + ...(data.isSecretRequestNotificationEnabled && { + secretRequestChannels: data.secretRequestChannels + }), + integration: WorkflowIntegrationPlatform.MICROSOFT_TEAMS, + integrationId: data.microsoftTeamsIntegrationId + }); + + createNotification({ + type: "success", + text: "Successfully created microsoft teams integration" + }); + + onClose(); + } catch { + createNotification({ + type: "error", + text: "Failed to create microsoft teams integration" + }); + } + }; + + const selectedAccessRequestTeamId = watch("accessRequestChannels.teamId"); + const selectedSecretRequestTeamId = watch("secretRequestChannels.teamId"); + + console.log("selectedAccessRequestTeamId", selectedAccessRequestTeamId); + console.log("selectedSecretRequestTeamId", selectedSecretRequestTeamId); + + const selectedMicrosoftTeamsIntegrationId = watch("microsoftTeamsIntegrationId"); + + const accessRequestNotificationsEnabled = watch("isAccessRequestNotificationEnabled"); + const secretRequestNotificationsEnabled = watch("isSecretRequestNotificationEnabled"); + + const { + data: microsoftTeamsIntegrationTeams, + isPending: isLoadingMicrosoftTeamsIntegrationTeams + } = useGetMicrosoftTeamsIntegrationTeams(selectedMicrosoftTeamsIntegrationId); + + const sortedMicrosoftTeamsIntegrationTeams = microsoftTeamsIntegrationTeams?.sort((a, b) => + a.teamName.toLowerCase().localeCompare(b.teamName.toLowerCase()) + ); + + const selectableAccessRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams + ?.filter((team) => team.teamId === selectedAccessRequestTeamId) + .map((team) => team.channels) + .flat(); + const selectableSecretRequestChannelIds = sortedMicrosoftTeamsIntegrationTeams + ?.filter((team) => team.teamId === selectedSecretRequestTeamId) + .map((team) => team.channels) + .flat(); + + console.log("selectableAccessRequestChannelIds", selectableAccessRequestChannelIds); + console.log("selectableSecretRequestChannelIds", selectableSecretRequestChannelIds); + + const channelIdToName = [ + ...(selectableAccessRequestChannelIds || []), + ...(selectableSecretRequestChannelIds || []) + ].reduce( + (acc, channel) => { + acc[channel.channelId] = channel.channelName; + return acc; + }, + {} as Record + ); + + useEffect(() => { + if (microsoftTeamsConfig) { + setValue("microsoftTeamsIntegrationId", microsoftTeamsConfig.integrationId); + setValue( + "isSecretRequestNotificationEnabled", + microsoftTeamsConfig.isSecretRequestNotificationEnabled + ); + setValue( + "isAccessRequestNotificationEnabled", + microsoftTeamsConfig.isAccessRequestNotificationEnabled + ); + + if (microsoftTeamsConfig.integration === WorkflowIntegrationPlatform.MICROSOFT_TEAMS) { + if (microsoftTeamsConfig.secretRequestChannels) { + setValue("secretRequestChannels", microsoftTeamsConfig.secretRequestChannels); + setValue("accessRequestChannels", microsoftTeamsConfig.accessRequestChannels); + } + } + } + }, [microsoftTeamsConfig]); + + return ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + /> + )} + +
+ {selectedMicrosoftTeamsIntegrationId && ( + <> +

Configure Events

+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotificationsEnabled && ( + <> + ( + + + + )} + /> + ( + + + + {selectedSecretRequestTeamId ? ( + channelIdToName[entry]) + .join(", ") + } + className="text-left" + /> + ) : ( + + )} + + + {selectableSecretRequestChannelIds?.map((channel) => { + const isChecked = value?.includes(channel.channelId); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== channel.channelId) + : [...(value || []), channel.channelId] + ); + }} + key={`secret-requests-slack-channel-${channel.channelId}`} + iconPos="right" + icon={isChecked && } + > + {channel.channelName} + + ); + })} + + + + )} + /> + + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotificationsEnabled && ( + <> + ( + + + + )} + /> + + ( + + + + {selectedAccessRequestTeamId ? ( + channelIdToName[entry]) + .join(", ") + } + className="text-left" + /> + ) : ( + + )} + + + {selectableAccessRequestChannelIds?.map((channel) => { + const isChecked = value?.includes(channel.channelId); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== channel.channelId) + : [...(value || []), channel.channelId] + ); + }} + key={`access-requests-slack-channel-${channel.channelId}`} + iconPos="right" + icon={isChecked && } + > + {channel.channelName} + + ); + })} + + + + )} + /> + + )} + + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx new file mode 100644 index 000000000..70a707656 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackConfigRow.tsx @@ -0,0 +1,143 @@ +import { BsSlack } from "react-icons/bs"; +import { faCheck, faEllipsis, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Td, + Tr +} from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useWorkspace } from "@app/context"; +import { + useGetSlackIntegrationChannels, + useGetWorkspaceWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeIntegration", "editIntegration"]>, + data?: { + integrationId?: string; + integration: WorkflowIntegrationPlatform; + } + ) => void; +}; + +export const SlackConfigRow = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); + + const { data: slackChannels } = useGetSlackIntegrationChannels(slackConfig?.integrationId); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + + if (slackConfig?.integration !== WorkflowIntegrationPlatform.SLACK) { + return null; + } + + console.log("after slakc"); + + return ( + + +
+ + Slack +
+ + + {slackConfig.isAccessRequestNotificationEnabled ? ( + + ) : ( + + )} + + + {slackConfig.isSecretRequestNotificationEnabled ? ( + + ) : ( + + )} + + + + {slackConfig.accessRequestChannels + .split(", ") + .map((channel) => slackChannelIdToName[channel]) + .join(", ")} + + + + + {slackConfig.secretRequestChannels + .split(", ") + .map((channel) => slackChannelIdToName[channel]) + .join(", ")} + + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("removeIntegration", { + integrationId: slackConfig.integrationId, + integration: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Delete + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + + handlePopUpOpen("editIntegration", { + integration: WorkflowIntegrationPlatform.SLACK + }); + }} + > + Edit + + )} + + +
+ + + ); +}; diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx new file mode 100644 index 000000000..72d4ced85 --- /dev/null +++ b/frontend/src/pages/secret-manager/SettingsPage/components/WorkflowIntegrationSection/components/SlackIntegrationForm.tsx @@ -0,0 +1,356 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheckCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + FormControl, + Input, + Select, + SelectItem, + Switch +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext"; +import { + useGetSlackIntegrationChannels, + useGetWorkflowIntegrations, + useGetWorkspaceWorkflowIntegrationConfig, + useUpdateProjectWorkflowIntegrationConfig +} from "@app/hooks/api"; +import { WorkflowIntegrationPlatform } from "@app/hooks/api/workflowIntegrations/types"; + +const formSchema = z.object({ + slackIntegrationId: z.string(), + isSecretRequestNotificationEnabled: z.boolean(), + secretRequestChannels: z.string().array(), + isAccessRequestNotificationEnabled: z.boolean(), + accessRequestChannels: z.string().array() +}); + +type TSlackConfigForm = z.infer; + +type Props = { + onClose: () => void; +}; + +export const SlackIntegrationForm = ({ onClose }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data: slackConfig } = useGetWorkspaceWorkflowIntegrationConfig({ + workspaceId: currentWorkspace?.id ?? "", + integration: WorkflowIntegrationPlatform.SLACK + }); + const { data: workflowIntegrations } = useGetWorkflowIntegrations(currentWorkspace?.orgId); + const { mutateAsync: updateProjectSlackConfig } = useUpdateProjectWorkflowIntegrationConfig(); + + const slackIntegrations = workflowIntegrations?.filter( + (integration) => integration.integration === WorkflowIntegrationPlatform.SLACK + ); + + const { + control, + watch, + handleSubmit, + setValue, + formState: { isDirty, isSubmitting } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + isAccessRequestNotificationEnabled: false, + accessRequestChannels: [], + isSecretRequestNotificationEnabled: false, + secretRequestChannels: [] + } + }); + + const handleIntegrationSave = async (data: TSlackConfigForm) => { + try { + if (!currentWorkspace) { + return; + } + + await updateProjectSlackConfig({ + ...data, + workspaceId: currentWorkspace.id, + integration: WorkflowIntegrationPlatform.SLACK, + integrationId: data.slackIntegrationId, + accessRequestChannels: data.accessRequestChannels.filter(Boolean).join(", "), + secretRequestChannels: data.secretRequestChannels.filter(Boolean).join(", ") + }); + + createNotification({ + type: "success", + text: "Successfully created slack integration" + }); + + onClose(); + } catch { + createNotification({ + type: "error", + text: "Failed to create slack integration" + }); + } + }; + + const secretRequestNotifState = watch("isSecretRequestNotificationEnabled"); + const selectedSlackIntegrationId = watch("slackIntegrationId"); + const accessRequestNotifState = watch("isAccessRequestNotificationEnabled"); + + const { data: slackChannels } = useGetSlackIntegrationChannels(selectedSlackIntegrationId); + const slackChannelIdToName = Object.fromEntries( + (slackChannels || []).map((channel) => [channel.id, channel.name]) + ); + const sortedSlackChannels = slackChannels?.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + + useEffect(() => { + if (slackConfig) { + setValue("slackIntegrationId", slackConfig.integrationId); + setValue( + "isSecretRequestNotificationEnabled", + slackConfig.isSecretRequestNotificationEnabled + ); + setValue( + "isAccessRequestNotificationEnabled", + slackConfig.isAccessRequestNotificationEnabled + ); + + if (slackConfig.integration === WorkflowIntegrationPlatform.SLACK) { + if (slackChannels) { + setValue( + "secretRequestChannels", + slackConfig.secretRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + setValue( + "accessRequestChannels", + slackConfig.accessRequestChannels + .split(", ") + .filter((channel) => channel in slackChannelIdToName) + ); + } + } + } + }, [slackConfig, slackChannels]); + + return ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + /> + )} + +
+ {selectedSlackIntegrationId && ( + <> +

Configure Events

+ { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Secret Approval Requests

+
+
+ ); + }} + /> + {secretRequestNotifState && ( + ( + + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`secret-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + + + )} + /> + )} + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Access Requests

+
+
+ ); + }} + /> + {accessRequestNotifState && ( + ( + + + + slackChannelIdToName[entry]) + .join(", ")} + className="text-left" + /> + + + {sortedSlackChannels?.map((slackChannel) => { + const isChecked = value?.includes(slackChannel.id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== slackChannel.id) + : [...(value || []), slackChannel.id] + ); + }} + key={`access-requests-slack-channel-${slackChannel.id}`} + iconPos="right" + icon={isChecked && } + > + {slackChannel.name} + + ); + })} + + + + )} + /> + )} + + + )} + + ); +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 000000000..da01a6729 --- /dev/null +++ b/manifest.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.11/MicrosoftTeams.schema.json", + "manifestVersion": "1.11", + "id": "78c44cc5-275f-468d-862d-4191485e5267", + "version": "1.0.0", + "name": { + "short": "Infisical", + "full": "Infisical" + }, + "description": { + "short": "Infisical", + "full": "Infisical" + }, + "icons": { + "outline": "outline.png", + "color": "color.png" + }, + "accentColor": "#FFFFFF", + "developer": { + "name": "Infisical Inc.", + "websiteUrl": "https://infisical.com", + "privacyUrl": "https://infisical.com/privacy", + "termsOfUseUrl": "https://infisical.com/terms" + }, + "webApplicationInfo": { + "id": "78c44cc5-275f-468d-862d-4191485e5267", + "resource": "api://78c44cc5-275f-468d-862d-4191485e5267" + }, + "bots": [ + { + "botId": "78c44cc5-275f-468d-862d-4191485e5267", + "scopes": ["team", "personal"], + "isNotificationOnly": true + } + ], + "validDomains": [ + "*.infisical.com", + "*danielinfisical.onmicrosoft.com" + ] +} \ No newline at end of file