Compare commits
77 Commits
daniel/upd
...
daniel/fix
Author | SHA1 | Date | |
---|---|---|---|
|
a91f64f742 | ||
|
1bc508b286 | ||
|
d3d30eba80 | ||
|
623a99be0e | ||
|
f80023f8f3 | ||
|
98289f56ae | ||
|
c40f195c1d | ||
|
fbfe694fc0 | ||
|
2098bd3be2 | ||
|
ef82c664a6 | ||
|
fcbedfaf1b | ||
|
882f6b22f5 | ||
|
bcd778457d | ||
|
0a1242db75 | ||
|
a078cb6059 | ||
|
095b26c8c9 | ||
|
fcdfcd0219 | ||
|
132de1479d | ||
|
d4a76b3621 | ||
|
331dcd4d79 | ||
|
025f64f068 | ||
|
05d7f94518 | ||
|
b58e32c754 | ||
|
4ace30aecd | ||
|
8b2a866994 | ||
|
b4386af2e0 | ||
|
ec5e6eb7b4 | ||
|
48cb5f6e9b | ||
|
3c63312944 | ||
|
0842901d4f | ||
|
32d6826ade | ||
|
a750f48922 | ||
|
67662686f3 | ||
|
11c96245a7 | ||
|
a63191e11d | ||
|
7a13c155f5 | ||
|
fb6a085bf9 | ||
|
6c533f89d3 | ||
|
5ceb30f43f | ||
|
7728a4793b | ||
|
d3523ed1d6 | ||
|
35a9b2a38d | ||
|
16a9f8c194 | ||
|
9557639bfe | ||
|
1049f95952 | ||
|
e618d5ca5f | ||
|
d659250ce8 | ||
|
87363eabfe | ||
|
d1b9c316d8 | ||
|
b9867c0d06 | ||
|
afa2f383c5 | ||
|
39f7354fec | ||
|
c46c0cb1e8 | ||
|
6905ffba4e | ||
|
64fd423c61 | ||
|
da1a7466d1 | ||
|
d3f3f34129 | ||
|
c8fba7ce4c | ||
|
ae51fbb8f2 | ||
|
62910e93ca | ||
|
9e3c632a1f | ||
|
bb094f60c1 | ||
|
a18f3c2919 | ||
|
a852b15a1e | ||
|
dab8f0b261 | ||
|
4293665130 | ||
|
8afa65c272 | ||
|
4c739fd57f | ||
|
bcc2840020 | ||
|
8b3af92d23 | ||
|
9ca58894f0 | ||
|
d131314de0 | ||
|
9c03144f19 | ||
|
5495ffd78e | ||
|
e4b149a849 | ||
|
269f851cbf | ||
|
7a61995dd4 |
@@ -10,12 +10,15 @@ export const mockQueue = (): TQueueServiceFactory => {
|
||||
queue: async (name, jobData) => {
|
||||
job[name] = jobData;
|
||||
},
|
||||
queuePg: async () => {},
|
||||
initialize: async () => {},
|
||||
shutdown: async () => undefined,
|
||||
stopRepeatableJob: async () => true,
|
||||
start: (name, jobFn) => {
|
||||
queues[name] = jobFn;
|
||||
workers[name] = jobFn;
|
||||
},
|
||||
startPg: async () => {},
|
||||
listen: (name, event) => {
|
||||
events[name] = event;
|
||||
},
|
||||
|
@@ -53,13 +53,13 @@ export default {
|
||||
extension: "ts"
|
||||
});
|
||||
const smtp = mockSmtpServer();
|
||||
const queue = queueServiceFactory(cfg.REDIS_URL);
|
||||
const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI);
|
||||
const keyStore = keyStoreFactory(cfg.REDIS_URL);
|
||||
|
||||
const hsmModule = initializeHsmModule();
|
||||
hsmModule.initialize();
|
||||
|
||||
const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule() });
|
||||
const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule(), redis });
|
||||
|
||||
// @ts-expect-error type
|
||||
globalThis.testServer = server;
|
||||
|
137
backend/package-lock.json
generated
@@ -28,6 +28,7 @@
|
||||
"@fastify/session": "^10.7.0",
|
||||
"@fastify/swagger": "^8.14.0",
|
||||
"@fastify/swagger-ui": "^2.1.0",
|
||||
"@google-cloud/kms": "^4.5.0",
|
||||
"@node-saml/passport-saml": "^4.0.4",
|
||||
"@octokit/auth-app": "^7.1.1",
|
||||
"@octokit/plugin-retry": "^5.0.5",
|
||||
@@ -92,6 +93,7 @@
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"pg-boss": "^10.1.5",
|
||||
"pg-query-stream": "^4.5.3",
|
||||
"picomatch": "^3.0.1",
|
||||
"pino": "^8.16.2",
|
||||
@@ -5598,6 +5600,18 @@
|
||||
"yaml": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/kms": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz",
|
||||
"integrity": "sha512-i2vC0DI7bdfEhQszqASTw0KVvbB7HsO2CwTBod423NawAu7FWi+gVVa7NLfXVNGJaZZayFfci2Hu+om/HmyEjQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-gax": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/paginator": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
|
||||
@@ -12259,14 +12273,6 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"node_modules/buffer-writer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
|
||||
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
|
||||
@@ -15086,6 +15092,44 @@
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/google-gax": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz",
|
||||
"integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.10.9",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@types/long": "^4.0.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"duplexify": "^4.0.0",
|
||||
"google-auth-library": "^9.3.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"object-hash": "^3.0.0",
|
||||
"proto3-json-serializer": "^2.0.2",
|
||||
"protobufjs": "^7.3.2",
|
||||
"retry-request": "^7.0.0",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/google-gax/node_modules/@types/long": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
|
||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/google-gax/node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis": {
|
||||
"version": "137.1.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz",
|
||||
@@ -18185,11 +18229,6 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/packet-reader": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
|
||||
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -18408,15 +18447,13 @@
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz",
|
||||
"integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==",
|
||||
"version": "8.13.1",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
|
||||
"integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
|
||||
"dependencies": {
|
||||
"buffer-writer": "2.0.0",
|
||||
"packet-reader": "1.0.0",
|
||||
"pg-connection-string": "^2.6.2",
|
||||
"pg-pool": "^3.6.1",
|
||||
"pg-protocol": "^1.6.0",
|
||||
"pg-connection-string": "^2.7.0",
|
||||
"pg-pool": "^3.7.0",
|
||||
"pg-protocol": "^1.7.0",
|
||||
"pg-types": "^2.1.0",
|
||||
"pgpass": "1.x"
|
||||
},
|
||||
@@ -18435,6 +18472,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss": {
|
||||
"version": "10.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.1.5.tgz",
|
||||
"integrity": "sha512-H87NL6c7N6nTCSCePh16EaSQVSFevNXWdJuzY6PZz4rw+W/nuMKPfI/vYyXS0AdT1g1Q3S3EgeOYOHcB7ZVToQ==",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.9.0",
|
||||
"pg": "^8.13.0",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
|
||||
@@ -18471,17 +18521,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz",
|
||||
"integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz",
|
||||
"integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz",
|
||||
"integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q=="
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz",
|
||||
"integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ=="
|
||||
},
|
||||
"node_modules/pg-query-stream": {
|
||||
"version": "4.5.3",
|
||||
@@ -18510,9 +18560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg/node_modules/pg-connection-string": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
|
||||
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA=="
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz",
|
||||
"integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
@@ -19223,6 +19273,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/proto3-json-serializer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
|
||||
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"protobufjs": "^7.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.4.0",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
|
||||
@@ -20111,6 +20173,20 @@
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
@@ -22130,7 +22206,6 @@
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
@@ -136,6 +136,7 @@
|
||||
"@fastify/session": "^10.7.0",
|
||||
"@fastify/swagger": "^8.14.0",
|
||||
"@fastify/swagger-ui": "^2.1.0",
|
||||
"@google-cloud/kms": "^4.5.0",
|
||||
"@node-saml/passport-saml": "^4.0.4",
|
||||
"@octokit/auth-app": "^7.1.1",
|
||||
"@octokit/plugin-retry": "^5.0.5",
|
||||
@@ -200,6 +201,7 @@
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"pg": "^8.11.3",
|
||||
"pg-boss": "^10.1.5",
|
||||
"pg-query-stream": "^4.5.3",
|
||||
"picomatch": "^3.0.1",
|
||||
"pino": "^8.16.2",
|
||||
|
@@ -2,6 +2,6 @@ import "@fastify/request-context";
|
||||
|
||||
declare module "@fastify/request-context" {
|
||||
interface RequestContextData {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
}
|
||||
}
|
||||
|
7
backend/src/@types/fastify.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
import "fastify";
|
||||
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
import { TUsers } from "@app/db/schemas";
|
||||
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
|
||||
import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
|
||||
@@ -87,6 +89,10 @@ import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
|
||||
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
|
||||
|
||||
declare module "fastify" {
|
||||
interface Session {
|
||||
callbackPort: string;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
realIp: string;
|
||||
// used for mfa session authentication
|
||||
@@ -115,6 +121,7 @@ declare module "fastify" {
|
||||
}
|
||||
|
||||
interface FastifyInstance {
|
||||
redis: Redis;
|
||||
services: {
|
||||
login: TAuthLoginFactory;
|
||||
password: TAuthPasswordFactory;
|
||||
|
@@ -4,9 +4,15 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import {
|
||||
ExternalKmsAwsSchema,
|
||||
ExternalKmsGcpCredentialSchema,
|
||||
ExternalKmsGcpSchema,
|
||||
ExternalKmsInputSchema,
|
||||
ExternalKmsInputUpdateSchema
|
||||
ExternalKmsInputUpdateSchema,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsProviders,
|
||||
TExternalKmsGcpCredentialSchema
|
||||
} from "@app/ee/services/external-kms/providers/model";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
@@ -44,7 +50,8 @@ const sanitizedExternalSchemaForGetById = KmsKeysSchema.extend({
|
||||
statusDetails: true,
|
||||
provider: true
|
||||
}).extend({
|
||||
providerInput: ExternalKmsAwsSchema
|
||||
// for GCP, we don't return the credential object as it is sensitive data that should not be exposed
|
||||
providerInput: z.union([ExternalKmsAwsSchema, ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })])
|
||||
})
|
||||
});
|
||||
|
||||
@@ -286,4 +293,67 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => {
|
||||
return { externalKms };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/gcp/keys",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.discriminatedUnion("authMethod", [
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential),
|
||||
region: z.string().trim().min(1),
|
||||
credential: ExternalKmsGcpCredentialSchema
|
||||
}),
|
||||
z.object({
|
||||
authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms),
|
||||
region: z.string().trim().min(1),
|
||||
kmsId: z.string().trim().min(1)
|
||||
})
|
||||
]),
|
||||
response: {
|
||||
200: z.object({
|
||||
keys: z.string().array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { region, authMethod } = req.body;
|
||||
let credentialJson: TExternalKmsGcpCredentialSchema | undefined;
|
||||
|
||||
if (authMethod === KmsGcpKeyFetchAuthType.Credential) {
|
||||
credentialJson = req.body.credential;
|
||||
} else if (authMethod === KmsGcpKeyFetchAuthType.Kms) {
|
||||
const externalKms = await server.services.externalKms.findById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.body.kmsId
|
||||
});
|
||||
|
||||
if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) {
|
||||
throw new NotFoundError({ message: "KMS not found or not of type GCP" });
|
||||
}
|
||||
|
||||
credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema;
|
||||
}
|
||||
|
||||
if (!credentialJson) {
|
||||
throw new NotFoundError({
|
||||
message: "Something went wrong while fetching the GCP credential, please check inputs and try again"
|
||||
});
|
||||
}
|
||||
|
||||
const results = await server.services.externalKms.fetchGcpKeys({
|
||||
credential: credentialJson,
|
||||
gcpRegion: region
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -9,7 +9,6 @@
|
||||
import { Authenticator, Strategy } from "@fastify/passport";
|
||||
import fastifySession from "@fastify/session";
|
||||
import RedisStore from "connect-redis";
|
||||
import { Redis } from "ioredis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs";
|
||||
@@ -21,7 +20,6 @@ import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerOidcRouter = async (server: FastifyZodProvider) => {
|
||||
const appCfg = getConfig();
|
||||
const redis = new Redis(appCfg.REDIS_URL);
|
||||
const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" });
|
||||
|
||||
/*
|
||||
@@ -30,7 +28,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => {
|
||||
- Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js
|
||||
*/
|
||||
const redisStore = new RedisStore({
|
||||
client: redis,
|
||||
client: server.redis,
|
||||
prefix: "oidc-session:",
|
||||
ttl: 600 // 10 minutes
|
||||
});
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { RawAxiosRequestHeaders } from "axios";
|
||||
|
||||
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
@@ -20,27 +21,130 @@ type TAuditLogQueueServiceFactoryDep = {
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TAuditLogQueueServiceFactory = ReturnType<typeof auditLogQueueServiceFactory>;
|
||||
export type TAuditLogQueueServiceFactory = Awaited<ReturnType<typeof auditLogQueueServiceFactory>>;
|
||||
|
||||
// keep this timeout 5s it must be fast because else the queue will take time to finish
|
||||
// audit log is a crowded queue thus needs to be fast
|
||||
export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000;
|
||||
export const auditLogQueueServiceFactory = ({
|
||||
|
||||
export const auditLogQueueServiceFactory = async ({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
auditLogStreamDAL
|
||||
}: TAuditLogQueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const pushToLog = async (data: TCreateAuditLogDTO) => {
|
||||
await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, {
|
||||
removeOnFail: {
|
||||
count: 3
|
||||
},
|
||||
removeOnComplete: true
|
||||
});
|
||||
if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) {
|
||||
await queueService.queuePg<QueueName.AuditLog>(QueueJobs.AuditLog, data, {
|
||||
retryLimit: 10,
|
||||
retryBackoff: true
|
||||
});
|
||||
} else {
|
||||
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
|
||||
removeOnFail: {
|
||||
count: 3
|
||||
},
|
||||
removeOnComplete: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (appCfg.SHOULD_INIT_PG_QUEUE) {
|
||||
await queueService.startPg<QueueName.AuditLog>(
|
||||
QueueJobs.AuditLog,
|
||||
async ([job]) => {
|
||||
const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data;
|
||||
let { orgId } = job.data;
|
||||
const MS_IN_DAY = 24 * 60 * 60 * 1000;
|
||||
let project;
|
||||
|
||||
if (!orgId) {
|
||||
// it will never be undefined for both org and project id
|
||||
// TODO(akhilmhdh): use caching here in dal to avoid db calls
|
||||
project = await projectDAL.findById(projectId as string);
|
||||
orgId = project.orgId;
|
||||
}
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (plan.auditLogsRetentionDays === 0) {
|
||||
// skip inserting if audit log retention is 0 meaning its not supported
|
||||
return;
|
||||
}
|
||||
|
||||
// For project actions, set TTL to project-level audit log retention config
|
||||
// This condition ensures that the plan's audit log retention days cannot be bypassed
|
||||
const ttlInDays =
|
||||
project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays
|
||||
? project.auditLogsRetentionDays
|
||||
: plan.auditLogsRetentionDays;
|
||||
|
||||
const ttl = ttlInDays * MS_IN_DAY;
|
||||
|
||||
const auditLog = await auditLogDAL.create({
|
||||
actor: actor.type,
|
||||
actorMetadata: actor.metadata,
|
||||
userAgent,
|
||||
projectId,
|
||||
projectName: project?.name,
|
||||
ipAddress,
|
||||
orgId,
|
||||
eventType: event.type,
|
||||
expiresAt: new Date(Date.now() + ttl),
|
||||
eventMetadata: event.metadata,
|
||||
userAgentType
|
||||
});
|
||||
|
||||
const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : [];
|
||||
await Promise.allSettled(
|
||||
logStreams.map(
|
||||
async ({
|
||||
url,
|
||||
encryptedHeadersTag,
|
||||
encryptedHeadersIV,
|
||||
encryptedHeadersKeyEncoding,
|
||||
encryptedHeadersCiphertext
|
||||
}) => {
|
||||
const streamHeaders =
|
||||
encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag
|
||||
? (JSON.parse(
|
||||
infisicalSymmetricDecrypt({
|
||||
keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding,
|
||||
iv: encryptedHeadersIV,
|
||||
tag: encryptedHeadersTag,
|
||||
ciphertext: encryptedHeadersCiphertext
|
||||
})
|
||||
) as LogStreamHeaders[])
|
||||
: [];
|
||||
|
||||
const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
if (streamHeaders.length)
|
||||
streamHeaders.forEach(({ key, value }) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
return request.post(url, auditLog, {
|
||||
headers,
|
||||
// request timeout
|
||||
timeout: AUDIT_LOG_STREAM_TIMEOUT,
|
||||
// connection timeout
|
||||
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 30,
|
||||
pollingIntervalSeconds: 0.5
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
queueService.start(QueueName.AuditLog, async (job) => {
|
||||
const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data;
|
||||
let { orgId } = job.data;
|
||||
|
@@ -127,7 +127,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin
|
||||
};
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
@@ -211,7 +211,7 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => {
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
const renew = async (_inputs: unknown, entityId: string) => {
|
||||
// No renewal necessary
|
||||
return { entityId };
|
||||
};
|
||||
|
@@ -9,7 +9,7 @@ const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
|
||||
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
@@ -122,7 +122,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & {
|
||||
return users;
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
const renew = async (_inputs: unknown, entityId: string) => {
|
||||
// No renewal necessary
|
||||
return { entityId };
|
||||
};
|
||||
|
@@ -9,7 +9,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = (size = 48) => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
};
|
||||
|
||||
|
@@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
|
||||
import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => {
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
const renew = async (_inputs: unknown, entityId: string) => {
|
||||
// No renewal necessary
|
||||
return { entityId };
|
||||
};
|
||||
|
@@ -8,7 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = (size = 48) => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
};
|
||||
|
||||
|
@@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
|
||||
import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = (size = 48) => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
};
|
||||
|
||||
|
@@ -11,7 +11,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
|
||||
import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
@@ -141,7 +141,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => {
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
const renew = async (_inputs: unknown, entityId: string) => {
|
||||
// No renewal necessary
|
||||
return { entityId };
|
||||
};
|
||||
|
@@ -10,7 +10,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
|
||||
import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
|
@@ -12,7 +12,7 @@ import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models";
|
||||
const noop = () => {};
|
||||
|
||||
const generatePassword = (size = 48) => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
};
|
||||
|
||||
|
@@ -14,7 +14,7 @@ const generatePassword = (provider: SqlProviders) => {
|
||||
// oracle has limit of 48 password length
|
||||
const size = provider === SqlProviders.Oracle ? 30 : 48;
|
||||
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
|
||||
return customAlphabet(charset, 48)(size);
|
||||
};
|
||||
|
||||
|
@@ -20,7 +20,8 @@ import {
|
||||
TUpdateExternalKmsDTO
|
||||
} from "./external-kms-types";
|
||||
import { AwsKmsProviderFactory } from "./providers/aws-kms";
|
||||
import { ExternalKmsAwsSchema, KmsProviders } from "./providers/model";
|
||||
import { GcpKmsProviderFactory } from "./providers/gcp-kms";
|
||||
import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model";
|
||||
|
||||
type TExternalKmsServiceFactoryDep = {
|
||||
externalKmsDAL: TExternalKmsDALFactory;
|
||||
@@ -78,6 +79,13 @@ export const externalKmsServiceFactory = ({
|
||||
await externalKms.validateConnection();
|
||||
}
|
||||
break;
|
||||
case KmsProviders.Gcp:
|
||||
{
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs });
|
||||
await externalKms.validateConnection();
|
||||
sanitizedProviderInput = JSON.stringify(provider.inputs);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({ message: "external kms provided is invalid" });
|
||||
}
|
||||
@@ -88,7 +96,7 @@ export const externalKmsServiceFactory = ({
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedProviderInputs } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(sanitizedProviderInput, "utf8")
|
||||
plainText: Buffer.from(sanitizedProviderInput)
|
||||
});
|
||||
|
||||
const externalKms = await externalKmsDAL.transaction(async (tx) => {
|
||||
@@ -162,7 +170,7 @@ export const externalKmsServiceFactory = ({
|
||||
case KmsProviders.Aws:
|
||||
{
|
||||
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs };
|
||||
const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput });
|
||||
@@ -170,6 +178,17 @@ export const externalKmsServiceFactory = ({
|
||||
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
|
||||
}
|
||||
break;
|
||||
case KmsProviders.Gcp:
|
||||
{
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs };
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput });
|
||||
await externalKms.validateConnection();
|
||||
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({ message: "external kms provided is invalid" });
|
||||
}
|
||||
@@ -178,7 +197,7 @@ export const externalKmsServiceFactory = ({
|
||||
let encryptedProviderInputs: Buffer | undefined;
|
||||
if (sanitizedProviderInput) {
|
||||
const { cipherTextBlob } = orgDataKeyEncryptor({
|
||||
plainText: Buffer.from(sanitizedProviderInput, "utf8")
|
||||
plainText: Buffer.from(sanitizedProviderInput)
|
||||
});
|
||||
encryptedProviderInputs = cipherTextBlob;
|
||||
}
|
||||
@@ -271,10 +290,17 @@ export const externalKmsServiceFactory = ({
|
||||
switch (externalKmsDoc.provider) {
|
||||
case KmsProviders.Aws: {
|
||||
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
|
||||
}
|
||||
case KmsProviders.Gcp: {
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
|
||||
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({ message: "external kms provided is invalid" });
|
||||
}
|
||||
@@ -312,21 +338,34 @@ export const externalKmsServiceFactory = ({
|
||||
switch (externalKmsDoc.provider) {
|
||||
case KmsProviders.Aws: {
|
||||
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
|
||||
}
|
||||
case KmsProviders.Gcp: {
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString())
|
||||
);
|
||||
|
||||
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({ message: "external kms provided is invalid" });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGcpKeys = async ({ credential, gcpRegion }: Pick<TExternalKmsGcpSchema, "credential" | "gcpRegion">) => {
|
||||
const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } });
|
||||
return externalKms.getKeysList();
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
list,
|
||||
findById,
|
||||
findByName
|
||||
findByName,
|
||||
fetchGcpKeys
|
||||
};
|
||||
};
|
||||
|
113
backend/src/ee/services/external-kms/providers/gcp-kms.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { KeyManagementServiceClient } from "@google-cloud/kms";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { ExternalKmsGcpSchema, TExternalKmsGcpClientSchema, TExternalKmsProviderFns } from "./model";
|
||||
|
||||
const getGcpKmsClient = async ({ credential, gcpRegion }: TExternalKmsGcpClientSchema) => {
|
||||
const gcpKmsClient = new KeyManagementServiceClient({
|
||||
credentials: credential
|
||||
});
|
||||
const projectId = credential.project_id;
|
||||
const locationName = gcpKmsClient.locationPath(projectId, gcpRegion);
|
||||
|
||||
return {
|
||||
gcpKmsClient,
|
||||
locationName
|
||||
};
|
||||
};
|
||||
|
||||
type GcpKmsProviderArgs = {
|
||||
inputs: unknown;
|
||||
};
|
||||
type TGcpKmsProviderFactoryReturn = TExternalKmsProviderFns & {
|
||||
getKeysList: () => Promise<{ keys: string[] }>;
|
||||
};
|
||||
|
||||
export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Promise<TGcpKmsProviderFactoryReturn> => {
|
||||
const { credential, gcpRegion, keyName } = await ExternalKmsGcpSchema.parseAsync(inputs);
|
||||
const { gcpKmsClient, locationName } = await getGcpKmsClient({
|
||||
credential,
|
||||
gcpRegion
|
||||
});
|
||||
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
await gcpKmsClient.listKeyRings({
|
||||
parent: locationName
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Cannot connect to GCP KMS"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Used when adding the KMS to fetch the list of keys in specified region
|
||||
const getKeysList = async () => {
|
||||
try {
|
||||
const [keyRings] = await gcpKmsClient.listKeyRings({
|
||||
parent: locationName
|
||||
});
|
||||
|
||||
const validKeyRings = keyRings
|
||||
.filter(
|
||||
(keyRing): keyRing is { name: string } =>
|
||||
keyRing !== null && typeof keyRing === "object" && "name" in keyRing && typeof keyRing.name === "string"
|
||||
)
|
||||
.map((keyRing) => keyRing.name);
|
||||
const keyList: string[] = [];
|
||||
const keyListPromises = validKeyRings.map((keyRingName) =>
|
||||
gcpKmsClient
|
||||
.listCryptoKeys({
|
||||
parent: keyRingName
|
||||
})
|
||||
.then(([cryptoKeys]) =>
|
||||
cryptoKeys
|
||||
.filter(
|
||||
(key): key is { name: string } =>
|
||||
key !== null && typeof key === "object" && "name" in key && typeof key.name === "string"
|
||||
)
|
||||
.map((key) => key.name)
|
||||
)
|
||||
);
|
||||
|
||||
const cryptoKeyLists = await Promise.all(keyListPromises);
|
||||
keyList.push(...cryptoKeyLists.flat());
|
||||
return { keys: keyList };
|
||||
} catch (error) {
|
||||
logger.error(error, "Could not validate GCP KMS connection and credentials");
|
||||
throw new BadRequestError({
|
||||
message: "Could not validate GCP KMS connection and credentials",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const encrypt = async (data: Buffer) => {
|
||||
const encryptedText = await gcpKmsClient.encrypt({
|
||||
name: keyName,
|
||||
plaintext: data
|
||||
});
|
||||
if (!encryptedText[0].ciphertext) throw new Error("encryption failed");
|
||||
return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) };
|
||||
};
|
||||
|
||||
const decrypt = async (encryptedBlob: Buffer) => {
|
||||
const decryptedText = await gcpKmsClient.decrypt({
|
||||
name: keyName,
|
||||
ciphertext: encryptedBlob
|
||||
});
|
||||
if (!decryptedText[0].plaintext) throw new Error("decryption failed");
|
||||
return { data: Buffer.from(decryptedText[0].plaintext) };
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
getKeysList,
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
};
|
@@ -1,13 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export enum KmsProviders {
|
||||
Aws = "aws"
|
||||
Aws = "aws",
|
||||
Gcp = "gcp"
|
||||
}
|
||||
|
||||
export enum KmsAwsCredentialType {
|
||||
AssumeRole = "assume-role",
|
||||
AccessKey = "access-key"
|
||||
}
|
||||
// Google uses snake_case for their enum values and we need to match that
|
||||
export enum KmsGcpCredentialType {
|
||||
ServiceAccount = "service_account"
|
||||
}
|
||||
|
||||
export enum KmsGcpKeyFetchAuthType {
|
||||
Credential = "credential",
|
||||
Kms = "kmsId"
|
||||
}
|
||||
|
||||
export const ExternalKmsAwsSchema = z.object({
|
||||
credential: z
|
||||
@@ -42,14 +52,44 @@ export const ExternalKmsAwsSchema = z.object({
|
||||
});
|
||||
export type TExternalKmsAwsSchema = z.infer<typeof ExternalKmsAwsSchema>;
|
||||
|
||||
export const ExternalKmsGcpCredentialSchema = z.object({
|
||||
type: z.literal(KmsGcpCredentialType.ServiceAccount),
|
||||
project_id: z.string().min(1),
|
||||
private_key_id: z.string().min(1),
|
||||
private_key: z.string().min(1),
|
||||
client_email: z.string().min(1),
|
||||
client_id: z.string().min(1),
|
||||
auth_uri: z.string().min(1),
|
||||
token_uri: z.string().min(1),
|
||||
auth_provider_x509_cert_url: z.string().min(1),
|
||||
client_x509_cert_url: z.string().min(1),
|
||||
universe_domain: z.string().min(1)
|
||||
});
|
||||
|
||||
export type TExternalKmsGcpCredentialSchema = z.infer<typeof ExternalKmsGcpCredentialSchema>;
|
||||
|
||||
export const ExternalKmsGcpSchema = z.object({
|
||||
credential: ExternalKmsGcpCredentialSchema.describe("GCP Service Account JSON credential to connect"),
|
||||
gcpRegion: z.string().trim().describe("GCP region where the KMS key is located"),
|
||||
keyName: z.string().trim().describe("GCP key name")
|
||||
});
|
||||
export type TExternalKmsGcpSchema = z.infer<typeof ExternalKmsGcpSchema>;
|
||||
|
||||
const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({
|
||||
credential: ExternalKmsGcpCredentialSchema
|
||||
});
|
||||
export type TExternalKmsGcpClientSchema = z.infer<typeof ExternalKmsGcpClientSchema>;
|
||||
|
||||
// The root schema of the JSON
|
||||
export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema })
|
||||
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema })
|
||||
]);
|
||||
export type TExternalKmsInputSchema = z.infer<typeof ExternalKmsInputSchema>;
|
||||
|
||||
export const ExternalKmsInputUpdateSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() })
|
||||
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }),
|
||||
z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema.partial() })
|
||||
]);
|
||||
export type TExternalKmsInputUpdateSchema = z.infer<typeof ExternalKmsInputUpdateSchema>;
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
import ms from "ms";
|
||||
|
||||
@@ -62,7 +62,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityId,
|
||||
@@ -139,7 +142,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
|
||||
);
|
||||
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityProjectMembership.identityId,
|
||||
@@ -216,7 +222,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
|
||||
);
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityProjectMembership.identityId,
|
||||
@@ -258,7 +267,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
|
||||
);
|
||||
|
||||
return {
|
||||
...identityPrivilege,
|
||||
@@ -289,7 +301,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
|
||||
);
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@@ -321,7 +336,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
|
||||
);
|
||||
|
||||
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find(
|
||||
{
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
|
||||
import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability";
|
||||
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
|
||||
import ms from "ms";
|
||||
|
||||
@@ -69,7 +69,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityId,
|
||||
@@ -146,7 +150,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
@@ -241,7 +249,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityProjectMembership.identityId,
|
||||
@@ -294,7 +306,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
|
||||
slug,
|
||||
@@ -333,7 +348,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({
|
||||
projectMembershipId: identityProjectMembership.id
|
||||
|
@@ -82,6 +82,10 @@ export type SecretImportSubjectFields = {
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
@@ -121,7 +125,10 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
@@ -213,6 +220,21 @@ const SecretConditionV2Schema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
const IdentityManagementConditionSchema = z
|
||||
.object({
|
||||
identityId: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
|
||||
})
|
||||
.partial()
|
||||
])
|
||||
})
|
||||
.partial();
|
||||
|
||||
const GeneralPermissionSchema = [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
|
||||
@@ -262,12 +284,6 @@ const GeneralPermissionSchema = [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.ServiceTokens).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
@@ -373,6 +389,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
...GeneralPermissionSchema
|
||||
]);
|
||||
|
||||
@@ -417,6 +439,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: IdentityManagementConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
...GeneralPermissionSchema
|
||||
]);
|
||||
|
||||
@@ -697,26 +729,26 @@ export const buildServiceTokenProjectPermission = (
|
||||
[ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretFolders].forEach(
|
||||
(subject) => {
|
||||
if (canWrite) {
|
||||
// TODO: @Akhi
|
||||
// @ts-expect-error type
|
||||
can(ProjectPermissionActions.Edit, subject, {
|
||||
// TODO: @Akhi
|
||||
// @ts-expect-error type
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
});
|
||||
// @ts-expect-error type
|
||||
can(ProjectPermissionActions.Create, subject, {
|
||||
// @ts-expect-error type
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
});
|
||||
// @ts-expect-error type
|
||||
can(ProjectPermissionActions.Delete, subject, {
|
||||
// @ts-expect-error type
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
});
|
||||
}
|
||||
if (canRead) {
|
||||
// @ts-expect-error type
|
||||
can(ProjectPermissionActions.Read, subject, {
|
||||
// @ts-expect-error type
|
||||
secretPath: { $glob: secretPath },
|
||||
environment
|
||||
});
|
||||
|
@@ -178,7 +178,10 @@ const envSchema = z
|
||||
HSM_LIB_PATH: zpStr(z.string().optional()),
|
||||
HSM_PIN: zpStr(z.string().optional()),
|
||||
HSM_KEY_LABEL: zpStr(z.string().optional()),
|
||||
HSM_SLOT: z.coerce.number().optional().default(0)
|
||||
HSM_SLOT: z.coerce.number().optional().default(0),
|
||||
|
||||
USE_PG_QUEUE: zodStrBool.default("false"),
|
||||
SHOULD_INIT_PG_QUEUE: zodStrBool.default("false")
|
||||
})
|
||||
// To ensure that basic encryption is always possible.
|
||||
.refine(
|
||||
|
@@ -89,9 +89,9 @@ const redactedKeys = [
|
||||
|
||||
const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID";
|
||||
|
||||
const extractRequestId = () => {
|
||||
const extractReqId = () => {
|
||||
try {
|
||||
return requestContext.get("requestId") || UNKNOWN_REQUEST_ID;
|
||||
return requestContext.get("reqId") || UNKNOWN_REQUEST_ID;
|
||||
} catch (err) {
|
||||
console.log("failed to get request context", err);
|
||||
return UNKNOWN_REQUEST_ID;
|
||||
@@ -133,22 +133,22 @@ export const initLogger = async () => {
|
||||
const wrapLogger = (originalLogger: Logger): CustomLogger => {
|
||||
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
|
||||
originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => {
|
||||
return originalLogger.child({ requestId: extractRequestId() }).info(obj, msg, ...args);
|
||||
return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
|
||||
originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => {
|
||||
return originalLogger.child({ requestId: extractRequestId() }).error(obj, msg, ...args);
|
||||
return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
|
||||
originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => {
|
||||
return originalLogger.child({ requestId: extractRequestId() }).warn(obj, msg, ...args);
|
||||
return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
|
||||
originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => {
|
||||
return originalLogger.child({ requestId: extractRequestId() }).debug(obj, msg, ...args);
|
||||
return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args);
|
||||
};
|
||||
|
||||
return originalLogger;
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import "./lib/telemetry/instrumentation";
|
||||
|
||||
import dotenv from "dotenv";
|
||||
import { Redis } from "ioredis";
|
||||
import path from "path";
|
||||
|
||||
import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns";
|
||||
@@ -55,13 +56,17 @@ const run = async () => {
|
||||
}
|
||||
|
||||
const smtp = smtpServiceFactory(formatSmtpConfig());
|
||||
const queue = queueServiceFactory(appCfg.REDIS_URL);
|
||||
|
||||
const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI);
|
||||
await queue.initialize();
|
||||
|
||||
const keyStore = keyStoreFactory(appCfg.REDIS_URL);
|
||||
const redis = new Redis(appCfg.REDIS_URL);
|
||||
|
||||
const hsmModule = initializeHsmModule();
|
||||
hsmModule.initialize();
|
||||
|
||||
const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore });
|
||||
const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore, redis });
|
||||
const bootstrap = await bootstrapCheck({ db });
|
||||
|
||||
// eslint-disable-next-line
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq";
|
||||
import Redis from "ioredis";
|
||||
import PgBoss, { WorkOptions } from "pg-boss";
|
||||
|
||||
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
|
||||
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||
@@ -7,6 +8,8 @@ import {
|
||||
TScanFullRepoEventPayload,
|
||||
TScanPushEventPayload
|
||||
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
TFailedIntegrationSyncEmailsPayload,
|
||||
TIntegrationSyncPayload,
|
||||
@@ -184,17 +187,39 @@ export type TQueueJobTypes = {
|
||||
};
|
||||
|
||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||
export const queueServiceFactory = (redisUrl: string) => {
|
||||
export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => {
|
||||
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
||||
const queueContainer = {} as Record<
|
||||
QueueName,
|
||||
Queue<TQueueJobTypes[QueueName]["payload"], void, TQueueJobTypes[QueueName]["name"]>
|
||||
>;
|
||||
|
||||
const pgBoss = new PgBoss({
|
||||
connectionString: dbConnectionUrl,
|
||||
archiveCompletedAfterSeconds: 60,
|
||||
archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried
|
||||
deleteAfterSeconds: 30
|
||||
});
|
||||
|
||||
const queueContainerPg = {} as Record<QueueJobs, boolean>;
|
||||
|
||||
const workerContainer = {} as Record<
|
||||
QueueName,
|
||||
Worker<TQueueJobTypes[QueueName]["payload"], void, TQueueJobTypes[QueueName]["name"]>
|
||||
>;
|
||||
|
||||
const initialize = async () => {
|
||||
const appCfg = getConfig();
|
||||
if (appCfg.SHOULD_INIT_PG_QUEUE) {
|
||||
logger.info("Initializing pg-queue...");
|
||||
await pgBoss.start();
|
||||
|
||||
pgBoss.on("error", (error) => {
|
||||
logger.error(error, "pg-queue error");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const start = <T extends QueueName>(
|
||||
name: T,
|
||||
jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>, token?: string) => Promise<void>,
|
||||
@@ -215,6 +240,27 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const startPg = async <T extends QueueName>(
|
||||
jobName: QueueJobs,
|
||||
jobsFn: (jobs: PgBoss.Job<TQueueJobTypes[T]["payload"]>[]) => Promise<void>,
|
||||
options: WorkOptions & {
|
||||
workerCount: number;
|
||||
}
|
||||
) => {
|
||||
if (queueContainerPg[jobName]) {
|
||||
throw new Error(`${jobName} queue is already initialized`);
|
||||
}
|
||||
|
||||
await pgBoss.createQueue(jobName);
|
||||
queueContainerPg[jobName] = true;
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: options.workerCount }).map(() =>
|
||||
pgBoss.work<TQueueJobTypes[T]["payload"]>(jobName, options, jobsFn)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const listen = <
|
||||
T extends QueueName,
|
||||
U extends keyof WorkerListener<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>
|
||||
@@ -238,6 +284,18 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
await q.add(job, data, opts);
|
||||
};
|
||||
|
||||
const queuePg = async <T extends QueueName>(
|
||||
job: TQueueJobTypes[T]["name"],
|
||||
data: TQueueJobTypes[T]["payload"],
|
||||
opts?: PgBoss.SendOptions & { jobId?: string }
|
||||
) => {
|
||||
await pgBoss.send({
|
||||
name: job,
|
||||
data,
|
||||
options: opts
|
||||
});
|
||||
};
|
||||
|
||||
const stopRepeatableJob = async <T extends QueueName>(
|
||||
name: T,
|
||||
job: TQueueJobTypes[T]["name"],
|
||||
@@ -274,5 +332,17 @@ export const queueServiceFactory = (redisUrl: string) => {
|
||||
await Promise.all(Object.values(workerContainer).map((worker) => worker.close()));
|
||||
};
|
||||
|
||||
return { start, listen, queue, shutdown, stopRepeatableJob, stopRepeatableJobByJobId, clearQueue, stopJobById };
|
||||
return {
|
||||
initialize,
|
||||
start,
|
||||
listen,
|
||||
queue,
|
||||
shutdown,
|
||||
stopRepeatableJob,
|
||||
stopRepeatableJobByJobId,
|
||||
clearQueue,
|
||||
stopJobById,
|
||||
startPg,
|
||||
queuePg
|
||||
};
|
||||
};
|
||||
|
@@ -12,6 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
|
||||
import ratelimiter from "@fastify/rate-limit";
|
||||
import { fastifyRequestContext } from "@fastify/request-context";
|
||||
import fastify from "fastify";
|
||||
import { Redis } from "ioredis";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { HsmModule } from "@app/ee/services/hsm/hsm-types";
|
||||
@@ -41,10 +42,11 @@ type TMain = {
|
||||
queue: TQueueServiceFactory;
|
||||
keyStore: TKeyStoreFactory;
|
||||
hsmModule: HsmModule;
|
||||
redis: Redis;
|
||||
};
|
||||
|
||||
// Run the server!
|
||||
export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore }: TMain) => {
|
||||
export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore, redis }: TMain) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const server = fastify({
|
||||
@@ -60,6 +62,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key
|
||||
server.setValidatorCompiler(validatorCompiler);
|
||||
server.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
server.decorate("redis", redis);
|
||||
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => {
|
||||
try {
|
||||
const strBody = body instanceof Buffer ? body.toString() : body;
|
||||
@@ -109,9 +112,9 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key
|
||||
await server.register(maintenanceMode);
|
||||
|
||||
await server.register(fastifyRequestContext, {
|
||||
defaultStoreValues: (request) => ({
|
||||
requestId: request.id,
|
||||
log: request.log.child({ requestId: request.id })
|
||||
defaultStoreValues: (req) => ({
|
||||
reqId: req.id,
|
||||
log: req.log.child({ reqId: req.id })
|
||||
})
|
||||
});
|
||||
|
||||
|
@@ -27,6 +27,7 @@ enum HttpStatusCodes {
|
||||
NotFound = 404,
|
||||
Unauthorized = 401,
|
||||
Forbidden = 403,
|
||||
UnprocessableContent = 422,
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
InternalServerError = 500,
|
||||
GatewayTimeout = 504,
|
||||
@@ -39,42 +40,42 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
if (error instanceof BadRequestError) {
|
||||
void res
|
||||
.status(HttpStatusCodes.BadRequest)
|
||||
.send({ requestId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name });
|
||||
.send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name });
|
||||
} else if (error instanceof NotFoundError) {
|
||||
void res
|
||||
.status(HttpStatusCodes.NotFound)
|
||||
.send({ requestId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name });
|
||||
.send({ reqId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name });
|
||||
} else if (error instanceof UnauthorizedError) {
|
||||
void res.status(HttpStatusCodes.Unauthorized).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.Unauthorized,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
});
|
||||
} else if (error instanceof DatabaseError || error instanceof InternalServerError) {
|
||||
void res.status(HttpStatusCodes.InternalServerError).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.InternalServerError,
|
||||
message: "Something went wrong",
|
||||
error: error.name
|
||||
});
|
||||
} else if (error instanceof GatewayTimeoutError) {
|
||||
void res.status(HttpStatusCodes.GatewayTimeout).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.GatewayTimeout,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
});
|
||||
} else if (error instanceof ZodError) {
|
||||
void res.status(HttpStatusCodes.Unauthorized).send({
|
||||
requestId: req.id,
|
||||
statusCode: HttpStatusCodes.Unauthorized,
|
||||
void res.status(HttpStatusCodes.UnprocessableContent).send({
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.UnprocessableContent,
|
||||
error: "ValidationFailure",
|
||||
message: error.issues
|
||||
});
|
||||
} else if (error instanceof ForbiddenError) {
|
||||
void res.status(HttpStatusCodes.Forbidden).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.Forbidden,
|
||||
error: "PermissionDenied",
|
||||
message: `You are not allowed to ${error.action} on ${error.subjectType}`,
|
||||
@@ -87,28 +88,28 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
});
|
||||
} else if (error instanceof ForbiddenRequestError) {
|
||||
void res.status(HttpStatusCodes.Forbidden).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.Forbidden,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
});
|
||||
} else if (error instanceof RateLimitError) {
|
||||
void res.status(HttpStatusCodes.TooManyRequests).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.TooManyRequests,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
});
|
||||
} else if (error instanceof ScimRequestError) {
|
||||
void res.status(error.status).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
schemas: error.schemas,
|
||||
status: error.status,
|
||||
detail: error.detail
|
||||
});
|
||||
} else if (error instanceof OidcAuthError) {
|
||||
void res.status(HttpStatusCodes.InternalServerError).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.InternalServerError,
|
||||
message: error.message,
|
||||
error: error.name
|
||||
@@ -127,14 +128,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
}
|
||||
|
||||
void res.status(HttpStatusCodes.Forbidden).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.Forbidden,
|
||||
error: "TokenError",
|
||||
message: errorMessage
|
||||
});
|
||||
} else {
|
||||
void res.status(HttpStatusCodes.InternalServerError).send({
|
||||
requestId: req.id,
|
||||
reqId: req.id,
|
||||
statusCode: HttpStatusCodes.InternalServerError,
|
||||
error: "InternalServerError",
|
||||
message: "Something went wrong"
|
||||
|
@@ -394,13 +394,14 @@ export const registerRoutes = async (
|
||||
permissionService
|
||||
});
|
||||
|
||||
const auditLogQueue = auditLogQueueServiceFactory({
|
||||
const auditLogQueue = await auditLogQueueServiceFactory({
|
||||
auditLogDAL,
|
||||
queueService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
auditLogStreamDAL
|
||||
});
|
||||
|
||||
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
|
||||
const auditLogStreamService = auditLogStreamServiceFactory({
|
||||
licenseService,
|
||||
|
@@ -30,32 +30,39 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({
|
||||
|
||||
export const DefaultResponseErrorsSchema = {
|
||||
400: z.object({
|
||||
requestId: z.string(),
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(400),
|
||||
message: z.string(),
|
||||
error: z.string()
|
||||
}),
|
||||
404: z.object({
|
||||
requestId: z.string(),
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(404),
|
||||
message: z.string(),
|
||||
error: z.string()
|
||||
}),
|
||||
401: z.object({
|
||||
requestId: z.string(),
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(401),
|
||||
message: z.any(),
|
||||
message: z.string(),
|
||||
error: z.string()
|
||||
}),
|
||||
403: z.object({
|
||||
requestId: z.string(),
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(403),
|
||||
message: z.string(),
|
||||
details: z.any().optional(),
|
||||
error: z.string()
|
||||
}),
|
||||
// Zod errors return a message of varying shapes and sizes, so z.any() is used here
|
||||
422: z.object({
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(422),
|
||||
message: z.any(),
|
||||
error: z.string()
|
||||
}),
|
||||
500: z.object({
|
||||
requestId: z.string(),
|
||||
reqId: z.string(),
|
||||
statusCode: z.literal(500),
|
||||
message: z.string(),
|
||||
error: z.string()
|
||||
|
@@ -8,6 +8,7 @@
|
||||
|
||||
import { Authenticator } from "@fastify/passport";
|
||||
import fastifySession from "@fastify/session";
|
||||
import RedisStore from "connect-redis";
|
||||
import { Strategy as GitHubStrategy } from "passport-github";
|
||||
import { Strategy as GitLabStrategy } from "passport-gitlab2";
|
||||
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
|
||||
@@ -23,8 +24,22 @@ import { OrgAuthMethod } from "@app/services/org/org-types";
|
||||
|
||||
export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const passport = new Authenticator({ key: "sso", userProperty: "passportUser" });
|
||||
await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY });
|
||||
const redisStore = new RedisStore({
|
||||
client: server.redis,
|
||||
prefix: "oauth-session:",
|
||||
ttl: 600 // 10 minutes
|
||||
});
|
||||
|
||||
await server.register(fastifySession, {
|
||||
secret: appCfg.COOKIE_SECRET_SIGN_KEY,
|
||||
store: redisStore,
|
||||
cookie: {
|
||||
secure: appCfg.HTTPS_ENABLED,
|
||||
sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server
|
||||
}
|
||||
});
|
||||
await server.register(passport.initialize());
|
||||
await server.register(passport.secureSession());
|
||||
// passport oauth strategy for Google
|
||||
@@ -37,11 +52,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string,
|
||||
clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string,
|
||||
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`,
|
||||
scope: ["profile", " email"]
|
||||
scope: ["profile", " email"],
|
||||
state: true
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
async (req, _accessToken, _refreshToken, profile, cb) => {
|
||||
try {
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const callbackPort = req.session.get("callbackPort");
|
||||
|
||||
const email = profile?.emails?.[0]?.value;
|
||||
if (!email)
|
||||
throw new NotFoundError({
|
||||
@@ -54,7 +73,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
firstName: profile?.name?.givenName || "",
|
||||
lastName: profile?.name?.familyName || "",
|
||||
authMethod: AuthMethod.GOOGLE,
|
||||
callbackPort: req.query.state as string
|
||||
callbackPort
|
||||
});
|
||||
cb(null, { isUserCompleted, providerAuthToken });
|
||||
} catch (error) {
|
||||
@@ -76,10 +95,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string,
|
||||
clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string,
|
||||
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`,
|
||||
scope: ["user:email"]
|
||||
scope: ["user:email"],
|
||||
// akhilmhdh: because the ts type for this is outdated by the maintainer
|
||||
state: true as unknown as string
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
async (req, accessToken, _refreshToken, profile, cb) => {
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const callbackPort = req.session.get("callbackPort");
|
||||
try {
|
||||
const ghEmails = await fetchGithubEmails(accessToken);
|
||||
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
|
||||
@@ -88,7 +111,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
firstName: profile.displayName,
|
||||
lastName: "",
|
||||
authMethod: AuthMethod.GITHUB,
|
||||
callbackPort: req.query.state as string
|
||||
callbackPort
|
||||
});
|
||||
return cb(null, { isUserCompleted, providerAuthToken });
|
||||
} catch (error) {
|
||||
@@ -112,17 +135,20 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
clientID: appCfg.CLIENT_ID_GITLAB_LOGIN,
|
||||
clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN,
|
||||
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`,
|
||||
baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL
|
||||
baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL,
|
||||
state: true
|
||||
},
|
||||
async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => {
|
||||
try {
|
||||
const callbackPort = req.session.get("callbackPort");
|
||||
|
||||
const email = profile.emails[0].value;
|
||||
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({
|
||||
email,
|
||||
firstName: profile.displayName,
|
||||
lastName: "",
|
||||
authMethod: AuthMethod.GITLAB,
|
||||
callbackPort: req.query.state as string
|
||||
callbackPort
|
||||
});
|
||||
|
||||
return cb(null, { isUserCompleted, providerAuthToken });
|
||||
@@ -143,17 +169,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
callback_port: z.string().optional()
|
||||
})
|
||||
},
|
||||
preValidation: (req, res) =>
|
||||
(
|
||||
passport.authenticate("google", {
|
||||
scope: ["profile", "email"],
|
||||
session: false,
|
||||
state: req.query.callback_port,
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any
|
||||
)(req, res),
|
||||
preValidation: [
|
||||
async (req, res) => {
|
||||
const { callback_port: callbackPort } = req.query;
|
||||
// ensure fresh session state per login attempt
|
||||
await req.session.regenerate();
|
||||
if (callbackPort) {
|
||||
req.session.set("callbackPort", callbackPort);
|
||||
}
|
||||
return (
|
||||
passport.authenticate("google", {
|
||||
scope: ["profile", "email"],
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any
|
||||
)(req, res);
|
||||
}
|
||||
],
|
||||
handler: () => {}
|
||||
});
|
||||
|
||||
@@ -166,7 +199,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
}) as never,
|
||||
handler: (req, res) => {
|
||||
handler: async (req, res) => {
|
||||
await req.session.destroy();
|
||||
if (req.passportUser.isUserCompleted) {
|
||||
return res.redirect(
|
||||
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
|
||||
@@ -186,15 +220,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
callback_port: z.string().optional()
|
||||
})
|
||||
},
|
||||
preValidation: (req, res) =>
|
||||
(
|
||||
passport.authenticate("github", {
|
||||
session: false,
|
||||
state: req.query.callback_port,
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
}) as any
|
||||
)(req, res),
|
||||
preValidation: [
|
||||
async (req, res) => {
|
||||
const { callback_port: callbackPort } = req.query;
|
||||
// ensure fresh session state per login attempt
|
||||
await req.session.regenerate();
|
||||
if (callbackPort) {
|
||||
req.session.set("callbackPort", callbackPort);
|
||||
}
|
||||
|
||||
return (
|
||||
passport.authenticate("github", {
|
||||
session: false,
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
}) as any
|
||||
)(req, res);
|
||||
}
|
||||
],
|
||||
handler: () => {}
|
||||
});
|
||||
|
||||
@@ -245,7 +288,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
}) as any,
|
||||
handler: (req, res) => {
|
||||
handler: async (req, res) => {
|
||||
await req.session.destroy();
|
||||
if (req.passportUser.isUserCompleted) {
|
||||
return res.redirect(
|
||||
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
|
||||
@@ -265,16 +309,25 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
callback_port: z.string().optional()
|
||||
})
|
||||
},
|
||||
preValidation: (req, res) =>
|
||||
(
|
||||
passport.authenticate("gitlab", {
|
||||
session: false,
|
||||
state: req.query.callback_port,
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any
|
||||
)(req, res),
|
||||
preValidation: [
|
||||
async (req, res) => {
|
||||
const { callback_port: callbackPort } = req.query;
|
||||
// ensure fresh session state per login attempt
|
||||
await req.session.regenerate();
|
||||
if (callbackPort) {
|
||||
req.session.set("callbackPort", callbackPort);
|
||||
}
|
||||
|
||||
return (
|
||||
passport.authenticate("gitlab", {
|
||||
session: false,
|
||||
authInfo: false
|
||||
// this is due to zod type difference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any
|
||||
)(req, res);
|
||||
}
|
||||
],
|
||||
handler: () => {}
|
||||
});
|
||||
|
||||
@@ -288,7 +341,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
// this is due to zod type difference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
handler: (req, res) => {
|
||||
handler: async (req, res) => {
|
||||
await req.session.destroy();
|
||||
if (req.passportUser.isUserCompleted) {
|
||||
return res.redirect(
|
||||
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import ms from "ms";
|
||||
|
||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||
@@ -61,7 +61,12 @@ export const identityProjectServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Identity, {
|
||||
identityId
|
||||
})
|
||||
);
|
||||
|
||||
const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (existingIdentity)
|
||||
@@ -161,7 +166,10 @@ export const identityProjectServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId });
|
||||
if (!projectIdentity)
|
||||
@@ -253,7 +261,11 @@ export const identityProjectServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityId,
|
||||
@@ -317,7 +329,11 @@ export const identityProjectServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId });
|
||||
if (!identityMembership)
|
||||
|
@@ -4,8 +4,10 @@ import { z } from "zod";
|
||||
|
||||
import { KmsKeysSchema, TKmsRootConfig } from "@app/db/schemas";
|
||||
import { AwsKmsProviderFactory } from "@app/ee/services/external-kms/providers/aws-kms";
|
||||
import { GcpKmsProviderFactory } from "@app/ee/services/external-kms/providers/gcp-kms";
|
||||
import {
|
||||
ExternalKmsAwsSchema,
|
||||
ExternalKmsGcpSchema,
|
||||
KmsProviders,
|
||||
TExternalKmsProviderFns
|
||||
} from "@app/ee/services/external-kms/providers/model";
|
||||
@@ -291,6 +293,16 @@ export const kmsServiceFactory = ({
|
||||
});
|
||||
break;
|
||||
}
|
||||
case KmsProviders.Gcp: {
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
|
||||
);
|
||||
|
||||
externalKms = await GcpKmsProviderFactory({
|
||||
inputs: decryptedProviderInput
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid KMS provider.");
|
||||
}
|
||||
@@ -353,6 +365,16 @@ export const kmsServiceFactory = ({
|
||||
});
|
||||
break;
|
||||
}
|
||||
case KmsProviders.Gcp: {
|
||||
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
|
||||
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
|
||||
);
|
||||
|
||||
externalKms = await GcpKmsProviderFactory({
|
||||
inputs: decryptedProviderInput
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid KMS provider.");
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas";
|
||||
import { ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
@@ -9,7 +9,6 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
|
||||
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
|
||||
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
@@ -370,20 +369,6 @@ export const projectServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Get the role permission for the identity
|
||||
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
|
||||
OrgMembershipRole.Member,
|
||||
organization.id
|
||||
);
|
||||
|
||||
// Identity has to be at least a member in order to create projects
|
||||
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPrivilege)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
});
|
||||
const isCustomRole = Boolean(customRole);
|
||||
|
||||
const identityProjectMembership = await identityProjectDAL.create(
|
||||
{
|
||||
identityId: actorId,
|
||||
@@ -395,8 +380,7 @@ export const projectServiceFactory = ({
|
||||
await identityProjectMembershipRoleDAL.create(
|
||||
{
|
||||
projectMembershipId: identityProjectMembership.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
|
||||
customRoleId: customRole?.id
|
||||
role: ProjectMembershipRole.Admin
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
@@ -932,8 +932,12 @@ export const secretQueueFactory = ({
|
||||
);
|
||||
|
||||
const message =
|
||||
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
|
||||
"Unknown error occurred.";
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
(err instanceof AxiosError
|
||||
? err?.response?.data
|
||||
? JSON.stringify(err?.response?.data)
|
||||
: err?.message
|
||||
: (err as Error)?.message) || "Unknown error occurred.";
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
|
@@ -8,9 +8,9 @@
|
||||
</head>
|
||||
<body>
|
||||
<h2>Join your organization on Infisical</h2>
|
||||
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization — {{organizationName}}</p>
|
||||
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Join now</a>
|
||||
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization named {{organizationName}}</p>
|
||||
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Click to join</a>
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
@@ -6,10 +6,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<h2>Join your team on Infisical</h2>
|
||||
<p>You have been invited to a new Infisical project — {{workspaceName}}</p>
|
||||
<a href="{{callback_url}}">Join now</a>
|
||||
<p>You have been invited to a new Infisical project named {{workspaceName}}</p>
|
||||
<a href="{{callback_url}}">Click to join</a>
|
||||
<h3>What is Infisical?</h3>
|
||||
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets
|
||||
and configs.</p>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
@@ -74,22 +74,22 @@ Next, you will need to follow the steps listed below to add AWS KMS for your org
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to the organization settings and select the 'Encryption' tab.">
|
||||

|
||||

|
||||
</Step>
|
||||
<Step title="Click on the 'Add' button">
|
||||

|
||||

|
||||
Click the 'Add' button to begin adding a new external KMS.
|
||||
</Step>
|
||||
<Step title="Select 'AWS KMS'">
|
||||

|
||||

|
||||
Choose 'AWS KMS' from the list of encryption providers.
|
||||
</Step>
|
||||
<Step title="Provide the inputs for AWS KMS">
|
||||
Selecting AWS as the provider will require you input the following fields.
|
||||
Selecting AWS as the provider will require you input the following fields.
|
||||
|
||||
<ParamField path="Alias" type="string" required>
|
||||
Name for referencing the AWS KMS key within the organization.
|
||||
</ParamField>
|
||||
<ParamField path="Alias" type="string" required>
|
||||
Name for referencing the AWS KMS key within the organization.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Description" type="string">
|
||||
Short description of the AWS KMS key.
|
||||
|
132
docs/documentation/platform/kms-configuration/gcp-kms.mdx
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: "GCP Key Management Service"
|
||||
description: "Learn how to manage encryption using GCP KMS"
|
||||
---
|
||||
|
||||
To enhance the security of your Infisical projects, you can now encrypt your secrets using an external Key Management Service (KMS).
|
||||
When external KMS is configured for your project, all encryption and decryption operations will be handled by the chosen KMS.
|
||||
This guide will walk you through the steps needed to configure external KMS support with Google Cloud KMS.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, you'll first need to set up a GCP Service Account, add a KMS key and set the required permissions.
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a GCP Service Account">
|
||||
1. Navigate to the [Create Service Account](https://console.cloud.google.com/iam-admin/serviceaccounts/create) page in your GCP Console.
|
||||

|
||||
|
||||
2. Give the service account a suitable **name** and **description**. Then click **Create and Continue**.
|
||||
3. Under **Grant this service account access to project**, click **Select a role** and select the
|
||||
**Cloud KMS Viewer** and **Cloud KMS CryptoKey Encrypter/Decrypter*** roles, then click **Continue**.
|
||||

|
||||
3. You can skip the **Grant users access to this service account** options.
|
||||
4. Click Done.
|
||||
5. You should see the service account in the list of service accounts. Click it to view the service account details.
|
||||
6. Select the **Keys** tab, click **Add Key**, select **Create new key**, select **JSON** as the key type, then click **Create**.
|
||||
7. You will be prompted to download a JSON file that we will need later on.
|
||||
<Info>
|
||||
Remember to keep the JSON file in a secure location. It will be used to authenticate your GCP service account.
|
||||
|
||||
Once you have successfully set up GCP KMS with Infisical, you should permanently delete the JSON file.
|
||||
</Info>
|
||||
</Step>
|
||||
|
||||
<Step title="Add a GCP KMS Key">
|
||||
1. Navigate to the [KMS](https://console.cloud.google.com/security/kms) page in your GCP Console.
|
||||
<Info>
|
||||
If you have not used GCP KMS before, you will be redirected to the **Cloud Key Management Service (KMS) API** page.
|
||||
|
||||
Click **Enable** to enable the KMS API, then continue the steps below.
|
||||
|
||||
It may take a few minutes for the API to be enabled and KMS section of the Cloud Console to become viewable.
|
||||
</Info>
|
||||
|
||||
2. In the KMS section, click **Create Key Ring**.
|
||||

|
||||
|
||||
3. Give the key ring a **Name** and select a **Region**, then click **Create**.
|
||||
<Info>
|
||||
We don't currently support multi-region key rings.
|
||||
</Info>
|
||||
|
||||
4. On the "Create Key" page, give the key a **Name** and set the **Protection Level** based on your requirements (or use default *Software*), then click **Continue**.
|
||||
|
||||
5. Under **Key Material**, select **Generated Key**, then click **Continue**.
|
||||
|
||||
6. Under **Purpose**, select **Symmetric encrypt/decrypt**, then click **Continue**.
|
||||
|
||||
7. For **Key Rotation Period**, select **Never (manual rotation)**, then click **Continue** followed by **Create**.
|
||||
|
||||
8. You should see the key in the list of keys. We're now ready to set it up in Infisical.
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Setup GCP KMS in the Organization Settings
|
||||
|
||||
Next, you will need to follow the steps listed below to add GCP KMS for your organization.
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to the organization settings and select the 'Encryption' tab.">
|
||||

|
||||
</Step>
|
||||
<Step title="Click on the 'Add' button">
|
||||

|
||||
Click the 'Add' button to begin adding a new external KMS.
|
||||
</Step>
|
||||
<Step title="Select 'GCP KMS'">
|
||||

|
||||
Choose 'GCP KMS' from the list of encryption providers.
|
||||
</Step>
|
||||
<Step title="Provide the inputs for GCP KMS">
|
||||
|
||||

|
||||
Selecting GCP as the provider will require you input the following fields.
|
||||
|
||||
<ParamField path="Alias" type="string" required>
|
||||
Name for referencing the GCP KMS key within the organization.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Description" type="string">
|
||||
Short description of the GCP KMS key.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="GCP Region" type="dropdown" required>
|
||||
The GCP region where the GCP KMS key ring is located.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Service Account Credential JSON" type="file" required>
|
||||
Upload the JSON file you downloaded earlier when creating the GCP service account.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="GCP Key Name" type="dropdown" required>
|
||||
This field will be populated with the list of GCP KMS keys in the selected region. Select the key you created earlier.
|
||||
</ParamField>
|
||||
|
||||
</Step>
|
||||
<Step title="Click Save">
|
||||
Save your configuration to apply the settings.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
You now have a GCP KMS Key configured at the organization level. You can assign these GCP KMS keys to existing Infisical projects by visiting the 'Project Settings' page.
|
||||
|
||||
## Assign GCP KMS Key to an Existing Project
|
||||
|
||||
To assign the GCP KMS key you added to your organization, follow the steps below.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Project Settings and select to the Encryption Tab">
|
||||

|
||||
</Step>
|
||||
<Step title="Under the Key Management section, select your newly added GCP KMS key from the dropdown">
|
||||

|
||||
Choose the GCP KMS key you configured earlier.
|
||||
</Step>
|
||||
<Step title="Click Save">
|
||||
Once you have selected the KMS of choice, click save.
|
||||
</Step>
|
||||
</Steps>
|
@@ -25,4 +25,4 @@ For existing projects, you can configure the KMS from the Project Settings page.
|
||||
|
||||
## External KMS
|
||||
|
||||
Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption.
|
||||
Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) or [GCP Key Management Service](./gcp-kms) for managing encryption.
|
||||
|
Before Width: | Height: | Size: 348 KiB |
BIN
docs/images/platform/kms/encryption-modal-provider-select.png
Normal file
After Width: | Height: | Size: 590 KiB |
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 694 KiB |
Before Width: | Height: | Size: 482 KiB After Width: | Height: | Size: 482 KiB |
BIN
docs/images/platform/kms/gcp/gcp-add-modal-filled.png
Normal file
After Width: | Height: | Size: 611 KiB |
BIN
docs/images/platform/kms/gcp/keyring-create.png
Normal file
After Width: | Height: | Size: 78 KiB |
BIN
docs/images/platform/kms/gcp/project-settings.png
Normal file
After Width: | Height: | Size: 978 KiB |
BIN
docs/images/platform/kms/gcp/select-gcp-kms-in-project.png
Normal file
After Width: | Height: | Size: 974 KiB |
BIN
docs/images/platform/kms/gcp/service-account-form.png
Normal file
After Width: | Height: | Size: 122 KiB |
BIN
docs/images/platform/kms/gcp/service-account-permissions.png
Normal file
After Width: | Height: | Size: 122 KiB |
@@ -162,6 +162,10 @@ spec:
|
||||
secretName: managed-secret
|
||||
secretNamespace: default
|
||||
creationPolicy: "Orphan" ## Owner | Orphan
|
||||
# template:
|
||||
# includeAllSecrets: true
|
||||
# data:
|
||||
# CUSTOM_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}"
|
||||
# secretType: kubernetes.io/dockerconfigjson
|
||||
```
|
||||
|
||||
@@ -674,6 +678,51 @@ The namespace of the managed Kubernetes secret to be created.
|
||||
<Accordion title="managedSecretReference.secretType">
|
||||
Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets.
|
||||
</Accordion>
|
||||
<Accordion title="managedSecretReference.template">
|
||||
Templates enable you to transform data from Infisical before storing it as a Kubernetes Secret.
|
||||
</Accordion>
|
||||
<Accordion title="managedSecretReference.template.includeAllSecrets">
|
||||
When set to true, this option injects all secrets retrieved from Infisical into your configuration.
|
||||
Secrets defined in the template will override the automatically injected secrets.
|
||||
</Accordion>
|
||||
<Accordion title="managedSecretReference.template.data">
|
||||
Define secret keys and their corresponding templates.
|
||||
Each data value uses a Golang template with access to all secrets retrieved from the specified scope.
|
||||
|
||||
Secrets are structured as follows:
|
||||
```golang
|
||||
type TemplateSecret struct {
|
||||
Value string `json:"value"`
|
||||
SecretPath string `json:"secretPath"`
|
||||
}
|
||||
```
|
||||
|
||||
#### Example template configuration:
|
||||
```golang
|
||||
managedSecretReference:
|
||||
secretName: managed-secret
|
||||
secretNamespace: default
|
||||
template:
|
||||
includeAllSecrets: true
|
||||
data:
|
||||
NEW_KEY: "{{ .KEY1.SecretPath }} {{ .KEY1.Value }}"
|
||||
```
|
||||
|
||||
When you run the following command:
|
||||
```bash
|
||||
kubectl get secret managed-secret -o jsonpath='{.data}'
|
||||
```
|
||||
|
||||
You'll receive Kubernetes secrets output that includes the NEW_KEY:
|
||||
```bash
|
||||
{... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="}
|
||||
```
|
||||
|
||||
When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be:
|
||||
```bash
|
||||
{"NEW_KEY":"LyBoZWxsbw=="}
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="managedSecretReference.creationPolicy">
|
||||
Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator.
|
||||
This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically.
|
||||
|
@@ -32,7 +32,10 @@
|
||||
"thumbsRating": true
|
||||
},
|
||||
"api": {
|
||||
"baseUrl": ["https://app.infisical.com", "http://localhost:8080"]
|
||||
"baseUrl": [
|
||||
"https://app.infisical.com",
|
||||
"http://localhost:8080"
|
||||
]
|
||||
},
|
||||
"topbarLinks": [
|
||||
{
|
||||
@@ -73,7 +76,9 @@
|
||||
"documentation/getting-started/introduction",
|
||||
{
|
||||
"group": "Quickstart",
|
||||
"pages": ["documentation/guides/local-development"]
|
||||
"pages": [
|
||||
"documentation/guides/local-development"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Guides",
|
||||
@@ -127,7 +132,8 @@
|
||||
"pages": [
|
||||
"documentation/platform/kms-configuration/overview",
|
||||
"documentation/platform/kms-configuration/aws-kms",
|
||||
"documentation/platform/kms-configuration/aws-hsm"
|
||||
"documentation/platform/kms-configuration/aws-hsm",
|
||||
"documentation/platform/kms-configuration/gcp-kms"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -461,11 +467,15 @@
|
||||
},
|
||||
{
|
||||
"group": "Build Tool Integrations",
|
||||
"pages": ["integrations/build-tools/gradle"]
|
||||
"pages": [
|
||||
"integrations/build-tools/gradle"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "",
|
||||
"pages": ["sdks/overview"]
|
||||
"pages": [
|
||||
"sdks/overview"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "SDK's",
|
||||
@@ -485,7 +495,9 @@
|
||||
"api-reference/overview/authentication",
|
||||
{
|
||||
"group": "Examples",
|
||||
"pages": ["api-reference/overview/examples/integration"]
|
||||
"pages": [
|
||||
"api-reference/overview/examples/integration"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -760,11 +772,15 @@
|
||||
},
|
||||
{
|
||||
"group": "Service Tokens",
|
||||
"pages": ["api-reference/endpoints/service-tokens/get"]
|
||||
"pages": [
|
||||
"api-reference/endpoints/service-tokens/get"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Audit Logs",
|
||||
"pages": ["api-reference/endpoints/audit-logs/export-audit-log"]
|
||||
"pages": [
|
||||
"api-reference/endpoints/audit-logs/export-audit-log"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -863,7 +879,9 @@
|
||||
},
|
||||
{
|
||||
"group": "",
|
||||
"pages": ["changelog/overview"]
|
||||
"pages": [
|
||||
"changelog/overview"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Contributing",
|
||||
@@ -887,7 +905,9 @@
|
||||
},
|
||||
{
|
||||
"group": "Contributing to SDK",
|
||||
"pages": ["contributing/sdk/developing"]
|
||||
"pages": [
|
||||
"contributing/sdk/developing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -911,13 +931,22 @@
|
||||
{
|
||||
"title": "PRODUCT",
|
||||
"links": [
|
||||
{ "label": "Secret Management", "url": "https://infisical.com/" },
|
||||
{ "label": "Secret Scanning", "url": "https://infisical.com/radar" },
|
||||
{
|
||||
"label": "Secret Management",
|
||||
"url": "https://infisical.com/"
|
||||
},
|
||||
{
|
||||
"label": "Secret Scanning",
|
||||
"url": "https://infisical.com/radar"
|
||||
},
|
||||
{
|
||||
"label": "Share Secrets",
|
||||
"url": "https://app.infisical.com/share-secret"
|
||||
},
|
||||
{ "label": "Pricing", "url": "https://infisical.com/pricing" },
|
||||
{
|
||||
"label": "Pricing",
|
||||
"url": "https://infisical.com/pricing"
|
||||
},
|
||||
{
|
||||
"label": "Security",
|
||||
"url": "https://infisical.com/docs/internals/security"
|
||||
@@ -1061,4 +1090,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
2
frontend/package-lock.json
generated
@@ -89,7 +89,7 @@
|
||||
"react-mailchimp-subscribe": "^2.1.3",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-select": "^5.8.3",
|
||||
"react-select": "^5.8.1",
|
||||
"react-table": "^7.8.0",
|
||||
"react-toastify": "^9.1.3",
|
||||
"sanitize-html": "^2.12.1",
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 23 KiB |
@@ -1,18 +1,60 @@
|
||||
import { ReactNode } from "react";
|
||||
import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify";
|
||||
import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { CopyButton } from "../v2/CopyButton";
|
||||
|
||||
export type TNotification = {
|
||||
title?: string;
|
||||
text: ReactNode;
|
||||
children?: ReactNode;
|
||||
callToAction?: ReactNode;
|
||||
copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[];
|
||||
};
|
||||
|
||||
export const NotificationContent = ({ title, text, children }: TNotification) => {
|
||||
export const NotificationContent = ({
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
callToAction,
|
||||
copyActions
|
||||
}: TNotification) => {
|
||||
return (
|
||||
<div className="msg-container">
|
||||
{title && <div className="text-md mb-1 font-medium">{title}</div>}
|
||||
<div className={title ? "text-sm text-neutral-400" : "text-md"}>{text}</div>
|
||||
{children && <div className="mt-2">{children}</div>}
|
||||
{(callToAction || copyActions) && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-2 flex h-7 w-full flex-row items-end gap-2",
|
||||
callToAction ? "justify-between" : "justify-end"
|
||||
)}
|
||||
>
|
||||
{callToAction}
|
||||
|
||||
{copyActions && (
|
||||
<div className="flex h-7 flex-row items-center gap-2">
|
||||
{copyActions.map((action) => (
|
||||
<div className="flex flex-row items-center gap-2" key={`copy-${action.name}`}>
|
||||
{action.label && (
|
||||
<span className="ml-2 text-xs text-mineshaft-400">{action.label}</span>
|
||||
)}
|
||||
<CopyButton
|
||||
value={action.value}
|
||||
name={action.name}
|
||||
size="xs"
|
||||
variant="plain"
|
||||
color="text-mineshaft-400"
|
||||
icon={action.icon ?? faCopy}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
55
frontend/src/components/v2/CopyButton/CopyButton.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
export type CopyButtonProps = {
|
||||
value: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
variant?: "solid" | "outline" | "plain" | "star" | "outline_bg";
|
||||
color?: string;
|
||||
name?: string;
|
||||
icon?: IconDefinition;
|
||||
};
|
||||
|
||||
export const CopyButton = ({
|
||||
value,
|
||||
size = "sm",
|
||||
variant = "solid",
|
||||
color,
|
||||
name,
|
||||
icon = faCopy
|
||||
}: CopyButtonProps) => {
|
||||
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
|
||||
initialState: name ? `Copy ${name}` : "Copy to clipboard"
|
||||
});
|
||||
|
||||
async function handleCopyText() {
|
||||
setCopyText("Copied");
|
||||
navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tooltip content={copyText} size={size === "xs" || size === "sm" ? "sm" : "md"}>
|
||||
<IconButton
|
||||
ariaLabel={copyText}
|
||||
variant={variant}
|
||||
className={twMerge("group relative", color)}
|
||||
size={size}
|
||||
onClick={() => {
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopying ? faCheck : icon} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CopyButton.displayName = "CopyButton";
|
2
frontend/src/components/v2/CopyButton/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { CopyButtonProps } from "./CopyButton";
|
||||
export { CopyButton } from "./CopyButton";
|
@@ -40,32 +40,39 @@ export const FilterableSelect = <T,>({
|
||||
...props.components
|
||||
}}
|
||||
classNames={{
|
||||
container: () => "w-full font-inter",
|
||||
control: ({ isFocused }) =>
|
||||
container: ({ isDisabled }) =>
|
||||
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
|
||||
control: ({ isFocused, isDisabled }) =>
|
||||
twMerge(
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
|
||||
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
|
||||
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
|
||||
`border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
|
||||
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
|
||||
} `
|
||||
),
|
||||
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
|
||||
input: () => "pl-1 py-0.5",
|
||||
placeholder: () =>
|
||||
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
|
||||
input: () => "pl-1",
|
||||
valueContainer: () =>
|
||||
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`,
|
||||
`px-1 max-h-[8.2rem] ${
|
||||
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
|
||||
} gap-1`,
|
||||
singleValue: () => "leading-7 ml-1",
|
||||
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
|
||||
multiValueLabel: () => "leading-6 text-sm",
|
||||
multiValueRemove: () => "hover:text-red text-bunker-400",
|
||||
indicatorsContainer: () => "p-1 gap-1",
|
||||
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
|
||||
indicatorSeparator: () => "bg-bunker-400",
|
||||
dropdownIndicator: () => "text-bunker-200 p-1",
|
||||
menuList: () => "flex flex-col gap-1",
|
||||
menu: () =>
|
||||
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
"my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
|
||||
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
|
||||
option: ({ isFocused, isSelected }) =>
|
||||
twMerge(
|
||||
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
|
||||
isSelected && "text-mineshaft-200",
|
||||
"hover:cursor-pointer mb-1 rounded text-xs px-3 py-2"
|
||||
"hover:cursor-pointer rounded text-xs px-3 py-2"
|
||||
),
|
||||
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
|
||||
}}
|
||||
|
@@ -54,7 +54,7 @@ export const Pagination = ({
|
||||
)}
|
||||
>
|
||||
{startAdornment}
|
||||
<div className="ml-auto mr-6 flex items-center space-x-2">
|
||||
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
|
||||
<div className="text-xs">
|
||||
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
||||
</div>
|
||||
|
@@ -36,10 +36,12 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => {
|
||||
export const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
|
||||
return (
|
||||
<components.Option isSelected={isSelected} {...props}>
|
||||
{children}
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<p className="truncate">{children}</p>
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||
)}
|
||||
</div>
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
@@ -13,6 +13,7 @@ export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
isDisabled?: boolean;
|
||||
center?: boolean;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export const Tooltip = ({
|
||||
@@ -26,6 +27,7 @@ export const Tooltip = ({
|
||||
asChild = true,
|
||||
isDisabled,
|
||||
position = "top",
|
||||
size = "md",
|
||||
...props
|
||||
}: TooltipProps) =>
|
||||
// just render children if tooltip content is empty
|
||||
@@ -43,7 +45,7 @@ export const Tooltip = ({
|
||||
sideOffset={5}
|
||||
{...props}
|
||||
className={twMerge(
|
||||
`z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md
|
||||
`z-50 max-w-[15rem] select-none border border-mineshaft-600 bg-mineshaft-800 font-light text-bunker-200 shadow-md
|
||||
data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade
|
||||
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
|
||||
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade
|
||||
@@ -51,6 +53,8 @@ export const Tooltip = ({
|
||||
`,
|
||||
isDisabled && "!hidden",
|
||||
center && "text-center",
|
||||
size === "sm" && "rounded-sm py-1 px-2 text-xs",
|
||||
size === "md" && "rounded-md py-2 px-4 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
@@ -33,6 +33,10 @@ export enum PermissionConditionOperators {
|
||||
$GLOB = "$glob"
|
||||
}
|
||||
|
||||
export type IdentityManagementSubjectFields = {
|
||||
identityId: string;
|
||||
};
|
||||
|
||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||
[PermissionConditionOperators.$EQ]: "equal to",
|
||||
[PermissionConditionOperators.$IN]: "contains",
|
||||
@@ -151,7 +155,13 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
| ProjectPermissionSub.Identity
|
||||
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
|
||||
)
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
|
||||
|
12
frontend/src/helpers/members.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { TWorkspaceUser } from "@app/hooks/api/users/types";
|
||||
|
||||
export const getMemberLabel = (member: TWorkspaceUser) => {
|
||||
const {
|
||||
inviteEmail,
|
||||
user: { firstName, lastName, username, email }
|
||||
} = member;
|
||||
|
||||
return firstName || lastName
|
||||
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
|
||||
: username || email || inviteEmail;
|
||||
};
|
@@ -1,4 +1,4 @@
|
||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
|
||||
|
||||
enum OrgMembershipRole {
|
||||
Admin = "admin",
|
||||
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
|
||||
|
||||
export const isCustomProjectRole = (slug: string) =>
|
||||
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
|
||||
|
||||
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
|
||||
isCustomOrgRole(roleIdOrSlug)
|
||||
? roles.find((r) => r.id === roleIdOrSlug)
|
||||
: roles.find((r) => r.slug === roleIdOrSlug);
|
||||
|
@@ -177,11 +177,21 @@ export const useGetProjectSecretsOverview = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -270,11 +280,21 @@ export const useGetProjectSecretsDetails = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -355,11 +375,21 @@ export const useGetProjectSecretsQuickSearch = (
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secrets deep search",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
useAddExternalKms,
|
||||
useExternalKmsFetchGcpKeys,
|
||||
useLoadProjectKmsBackup,
|
||||
useRemoveExternalKms,
|
||||
useUpdateExternalKms,
|
||||
|
@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { kmsKeys } from "./queries";
|
||||
import { AddExternalKmsType, KmsType } from "./types";
|
||||
import {
|
||||
AddExternalKmsType,
|
||||
ExternalKmsGcpSchemaType,
|
||||
KmsGcpKeyFetchAuthType,
|
||||
KmsType,
|
||||
UpdateExternalKmsType
|
||||
} from "./types";
|
||||
|
||||
export const useAddExternalKms = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -33,7 +39,7 @@ export const useUpdateExternalKms = (orgId: string) => {
|
||||
provider
|
||||
}: {
|
||||
kmsId: string;
|
||||
} & AddExternalKmsType) => {
|
||||
} & UpdateExternalKmsType) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, {
|
||||
name,
|
||||
description,
|
||||
@@ -96,3 +102,44 @@ export const useLoadProjectKmsBackup = (projectId: string) => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useExternalKmsFetchGcpKeys = (orgId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
gcpRegion,
|
||||
...rest
|
||||
}: Pick<ExternalKmsGcpSchemaType, "gcpRegion"> &
|
||||
(
|
||||
| (Pick<ExternalKmsGcpSchemaType, KmsGcpKeyFetchAuthType.Credential> & {
|
||||
[KmsGcpKeyFetchAuthType.Kms]?: never;
|
||||
})
|
||||
| {
|
||||
[KmsGcpKeyFetchAuthType.Kms]: string;
|
||||
[KmsGcpKeyFetchAuthType.Credential]?: never;
|
||||
}
|
||||
)): Promise<{ keys: string[] }> => {
|
||||
const {
|
||||
[KmsGcpKeyFetchAuthType.Credential]: credential,
|
||||
[KmsGcpKeyFetchAuthType.Kms]: kmsId
|
||||
} = rest;
|
||||
|
||||
if ((credential && kmsId) || (!credential && !kmsId)) {
|
||||
throw new Error(
|
||||
`Either '${KmsGcpKeyFetchAuthType.Credential}' or '${KmsGcpKeyFetchAuthType.Kms}' must be provided, but not both.`
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", {
|
||||
authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms,
|
||||
region: gcpRegion,
|
||||
...rest
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(kmsKeys.getExternalKmsList(orgId));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -35,7 +35,8 @@ export enum KmsType {
|
||||
}
|
||||
|
||||
export enum ExternalKmsProvider {
|
||||
AWS = "aws"
|
||||
Aws = "aws",
|
||||
Gcp = "gcp"
|
||||
}
|
||||
|
||||
export const INTERNAL_KMS_KEY_ID = "internal";
|
||||
@@ -44,6 +45,10 @@ export enum KmsAwsCredentialType {
|
||||
AssumeRole = "assume-role",
|
||||
AccessKey = "access-key"
|
||||
}
|
||||
// Google uses snake_case for their enum values and we need to match that
|
||||
export enum KmsGcpCredentialType {
|
||||
ServiceAccount = "service_account"
|
||||
}
|
||||
|
||||
export const ExternalKmsAwsSchema = z.object({
|
||||
credential: z
|
||||
@@ -83,8 +88,34 @@ export const ExternalKmsAwsSchema = z.object({
|
||||
)
|
||||
});
|
||||
|
||||
export const ExternalKmsGcpCredentialSchema = z.object({
|
||||
type: z.literal(KmsGcpCredentialType.ServiceAccount),
|
||||
project_id: z.string().min(1),
|
||||
private_key_id: z.string().min(1),
|
||||
private_key: z.string().min(1),
|
||||
client_email: z.string().min(1),
|
||||
client_id: z.string().min(1),
|
||||
auth_uri: z.string().min(1),
|
||||
token_uri: z.string().min(1),
|
||||
auth_provider_x509_cert_url: z.string().min(1),
|
||||
client_x509_cert_url: z.string().min(1),
|
||||
universe_domain: z.string().min(1)
|
||||
});
|
||||
|
||||
export type ExternalKmsGcpCredentialSchemaType = z.infer<typeof ExternalKmsGcpCredentialSchema>;
|
||||
|
||||
export const ExternalKmsGcpSchema = z.object({
|
||||
credential: ExternalKmsGcpCredentialSchema.describe(
|
||||
"GCP Service Account JSON credential to connect"
|
||||
),
|
||||
gcpRegion: z.string().min(1).trim().describe("GCP region where the KMS key is located"),
|
||||
keyName: z.string().min(1).trim().describe("GCP key name")
|
||||
});
|
||||
export type ExternalKmsGcpSchemaType = z.infer<typeof ExternalKmsGcpSchema>;
|
||||
|
||||
export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ExternalKmsProvider.AWS), inputs: ExternalKmsAwsSchema })
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema })
|
||||
]);
|
||||
|
||||
export const AddExternalKmsSchema = z.object({
|
||||
@@ -100,3 +131,71 @@ export const AddExternalKmsSchema = z.object({
|
||||
});
|
||||
|
||||
export type AddExternalKmsType = z.infer<typeof AddExternalKmsSchema>;
|
||||
|
||||
// we need separate schema for update because the credential field is not required on GCP
|
||||
export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
|
||||
z.object({
|
||||
type: z.literal(ExternalKmsProvider.Gcp),
|
||||
inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })
|
||||
})
|
||||
]);
|
||||
|
||||
export const UpdateExternalKmsSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((v) => slugify(v) === v, {
|
||||
message: "Alias must be a valid slug"
|
||||
}),
|
||||
description: z.string().trim().optional(),
|
||||
provider: ExternalKmsUpdateInputSchema
|
||||
});
|
||||
|
||||
export type UpdateExternalKmsType = z.infer<typeof UpdateExternalKmsSchema>;
|
||||
|
||||
const GCP_CREDENTIAL_MAX_FILE_SIZE = 8 * 1024; // 8KB
|
||||
const GCP_CREDENTIAL_ACCEPTED_FILE_TYPES = ["application/json"];
|
||||
|
||||
const AddExternalKmsGcpFormSchemaStandardInputs = z.object({
|
||||
keyObject: z
|
||||
.object({ label: z.string().trim(), value: z.string().trim() })
|
||||
.describe("GCP key name"),
|
||||
gcpRegion: z.object({ label: z.string().trim(), value: z.string().trim() }).describe("GCP Region")
|
||||
});
|
||||
|
||||
export const AddExternalKmsGcpFormSchema = z.discriminatedUnion("formType", [
|
||||
z
|
||||
.object({
|
||||
formType: z.literal("newGcpKms"),
|
||||
// `FileList` is a browser-only (window-specific) type, so we need to handle it differently on the server to avoid SSR errors
|
||||
credentialFile:
|
||||
typeof window === "undefined"
|
||||
? z.any()
|
||||
: z
|
||||
.instanceof(FileList)
|
||||
.refine((files) => files?.length === 1, "Image is required.")
|
||||
.refine(
|
||||
(files) => files?.[0]?.size <= GCP_CREDENTIAL_MAX_FILE_SIZE,
|
||||
"Max file size is 8KB."
|
||||
)
|
||||
.refine(
|
||||
(files) => GCP_CREDENTIAL_ACCEPTED_FILE_TYPES.includes(files?.[0]?.type),
|
||||
"Only .json files are accepted."
|
||||
)
|
||||
})
|
||||
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
|
||||
.merge(AddExternalKmsSchema.pick({ name: true, description: true })),
|
||||
z
|
||||
.object({ formType: z.literal("updateGcpKms") })
|
||||
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
|
||||
.merge(AddExternalKmsSchema.pick({ name: true, description: true }))
|
||||
]);
|
||||
|
||||
export type AddExternalKmsGcpFormSchemaType = z.infer<typeof AddExternalKmsGcpFormSchema>;
|
||||
|
||||
export enum KmsGcpKeyFetchAuthType {
|
||||
Credential = "credential",
|
||||
Kms = "kmsId"
|
||||
}
|
||||
|
@@ -117,11 +117,21 @@ export const useGetProjectSecrets = ({
|
||||
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
createNotification({
|
||||
title: "Error fetching secrets",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -148,15 +158,24 @@ export const useGetProjectSecretsAllEnv = ({
|
||||
enabled: Boolean(workspaceId && environment),
|
||||
onError: (error: unknown) => {
|
||||
if (axios.isAxiosError(error) && !isErrorHandled) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
|
||||
const { message, requestId } = error.response?.data as {
|
||||
message: string;
|
||||
requestId: string;
|
||||
};
|
||||
if (message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
|
||||
createNotification({
|
||||
title: "Error fetching secrets",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
text: message,
|
||||
copyActions: [
|
||||
{
|
||||
value: requestId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${requestId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
setIsErrorHandled.on();
|
||||
}
|
||||
},
|
||||
|
@@ -51,26 +51,26 @@ export enum ApiErrorTypes {
|
||||
|
||||
export type TApiErrors =
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.ValidationError;
|
||||
message: ZodIssue[];
|
||||
statusCode: 401;
|
||||
statusCode: 422;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.UnauthorizedError;
|
||||
message: string;
|
||||
statusCode: 401;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
error: ApiErrorTypes.ForbiddenError;
|
||||
message: string;
|
||||
details: PureAbility["rules"];
|
||||
statusCode: 403;
|
||||
}
|
||||
| {
|
||||
requestId: string;
|
||||
reqId: string;
|
||||
statusCode: 400;
|
||||
message: string;
|
||||
error: ApiErrorTypes.BadRequestError;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { components, MenuProps, OptionProps } from "react-select";
|
||||
import { faStar } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faEye, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -93,7 +93,7 @@ const ProjectOption = ({
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
{isSelected && (
|
||||
<FontAwesomeIcon className="mr-2 text-mineshaft-300" icon={faEye} size="sm" />
|
||||
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
|
||||
)}
|
||||
<p className="truncate">{children}</p>
|
||||
{data.isFavorite ? (
|
||||
|
@@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2";
|
||||
|
||||
@@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`;
|
||||
window.location.assign(link);
|
||||
};
|
||||
|
@@ -18,6 +18,7 @@ import {
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
enum AuthMethod {
|
||||
APP = "APP",
|
||||
@@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() {
|
||||
if (selectedAuthMethod === AuthMethod.APP) {
|
||||
router.push("/integrations/select-integration-auth?integrationSlug=github");
|
||||
} else {
|
||||
if (!githubIntegration?.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(
|
||||
"githubactions",
|
||||
"cicd",
|
||||
"connecting-with-github-oauth"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
|
@@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { useGetCloudIntegrations } from "@app/hooks/api";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2";
|
||||
|
||||
@@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() {
|
||||
|
||||
if (!integrationOption) return;
|
||||
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseURL =
|
||||
(gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim();
|
||||
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
useGetOrgIntegrationAuths
|
||||
} from "@app/hooks/api";
|
||||
import { IntegrationAuth } from "@app/hooks/api/types";
|
||||
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
|
||||
|
||||
export default function SelectIntegrationAuthPage() {
|
||||
const router = useRouter();
|
||||
@@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() {
|
||||
localStorage.setItem("latestCSRFToken", state);
|
||||
|
||||
if (integrationSlug === "github") {
|
||||
if (!currentIntegration?.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification("githubactions", "cicd");
|
||||
return;
|
||||
}
|
||||
|
||||
// for now we only handle Github apps
|
||||
window.location.assign(
|
||||
`https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}`
|
||||
|
@@ -876,7 +876,7 @@ const OrganizationPage = () => {
|
||||
<Pagination
|
||||
className={
|
||||
projectsViewMode === ProjectsViewMode.GRID
|
||||
? "col-span-full border-transparent bg-transparent"
|
||||
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
|
||||
: "rounded-b-md border border-mineshaft-600"
|
||||
}
|
||||
perPage={perPage}
|
||||
|
@@ -32,13 +32,8 @@ export const queryClient = new QueryClient({
|
||||
{
|
||||
title: "Validation Error",
|
||||
type: "error",
|
||||
text: (
|
||||
<div>
|
||||
<p>Please check the input and try again.</p>
|
||||
<p className="mt-2 text-xs">Request ID: {serverResponse.requestId}</p>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
text: "Please check the input and try again.",
|
||||
callToAction: (
|
||||
<Modal>
|
||||
<ModalTrigger>
|
||||
<Button variant="outline_bg" size="xs">
|
||||
@@ -66,7 +61,14 @@ export const queryClient = new QueryClient({
|
||||
</TableContainer>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
),
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{ closeOnClick: false }
|
||||
);
|
||||
@@ -77,9 +79,8 @@ export const queryClient = new QueryClient({
|
||||
{
|
||||
title: "Forbidden Access",
|
||||
type: "error",
|
||||
|
||||
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`,
|
||||
children: serverResponse?.details?.length ? (
|
||||
text: `${serverResponse.message}.`,
|
||||
callToAction: serverResponse?.details?.length ? (
|
||||
<Modal>
|
||||
<ModalTrigger>
|
||||
<Button variant="outline_bg" size="xs">
|
||||
@@ -92,7 +93,7 @@ export const queryClient = new QueryClient({
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{serverResponse.details?.map((el, index) => {
|
||||
const hasConditions = Object.keys(el.conditions || {}).length;
|
||||
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
|
||||
return (
|
||||
<div
|
||||
key={`Forbidden-error-details-${index + 1}`}
|
||||
@@ -165,7 +166,14 @@ export const queryClient = new QueryClient({
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
) : undefined
|
||||
) : undefined,
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
},
|
||||
{ closeOnClick: false }
|
||||
);
|
||||
@@ -174,7 +182,14 @@ export const queryClient = new QueryClient({
|
||||
createNotification({
|
||||
title: "Bad Request",
|
||||
type: "error",
|
||||
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`
|
||||
text: `${serverResponse.message}.`,
|
||||
copyActions: [
|
||||
{
|
||||
value: serverResponse.reqId,
|
||||
name: "Request ID",
|
||||
label: `Request ID: ${serverResponse.reqId}`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import {
|
||||
@@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) =
|
||||
return { encryptedKey: ciphertext, nonce };
|
||||
};
|
||||
|
||||
export const createIntegrationMissingEnvVarsNotification = (
|
||||
slug: string,
|
||||
type: "cloud" | "cicd" = "cloud",
|
||||
hashtag?: string
|
||||
) =>
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: (
|
||||
<a
|
||||
href={`https://infisical.com/docs/integrations/${type}/${slug}${
|
||||
hashtag ? `#${hashtag}` : ""
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Click here to view docs
|
||||
</a>
|
||||
),
|
||||
title: "Missing Environment Variables"
|
||||
});
|
||||
|
||||
export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => {
|
||||
try {
|
||||
// generate CSRF token for OAuth2 code-token exchange integrations
|
||||
@@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`;
|
||||
break;
|
||||
case "azure-key-vault":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "azure-app-configuration":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`;
|
||||
break;
|
||||
case "aws-parameter-store":
|
||||
@@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/aws-secret-manager/authorize`;
|
||||
break;
|
||||
case "heroku":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
|
||||
break;
|
||||
case "vercel":
|
||||
if (!integrationOption.clientSlug) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
|
||||
break;
|
||||
case "netlify":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
|
||||
return;
|
||||
}
|
||||
link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
|
||||
break;
|
||||
case "github":
|
||||
@@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
|
||||
link = `${window.location.origin}/integrations/cloudflare-workers/authorize`;
|
||||
break;
|
||||
case "bitbucket":
|
||||
if (!integrationOption.clientId) {
|
||||
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
|
||||
return;
|
||||
}
|
||||
link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
|
||||
break;
|
||||
case "codefresh":
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import {
|
||||
@@ -28,11 +30,17 @@ type Props = {
|
||||
}>;
|
||||
};
|
||||
|
||||
enum IntegrationView {
|
||||
List = "list",
|
||||
New = "new"
|
||||
}
|
||||
|
||||
export const IntegrationsPage = withProjectPermission(
|
||||
({ frameworkIntegrations, infrastructureIntegrations }: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
|
||||
|
||||
const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
|
||||
useGetCloudIntegrations();
|
||||
@@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission(
|
||||
const {
|
||||
data: integrations,
|
||||
isLoading: isIntegrationLoading,
|
||||
isFetching: isIntegrationFetching
|
||||
isFetching: isIntegrationFetching,
|
||||
isFetched: isIntegrationsFetched
|
||||
} = useGetWorkspaceIntegrations(workspaceId);
|
||||
|
||||
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
|
||||
@@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission(
|
||||
isIntegrationsEmpty
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
|
||||
}, [isIntegrationsFetched]);
|
||||
|
||||
const handleProviderIntegration = async (provider: string) => {
|
||||
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
|
||||
if (!selectedCloudIntegration) return;
|
||||
@@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission(
|
||||
}
|
||||
};
|
||||
|
||||
if (isIntegrationLoading || isCloudIntegrationsLoading)
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ContentLoader text={["Loading integrations..."]} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-7xl pb-12 text-white">
|
||||
<IntegrationsSection
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<CloudIntegrationSection
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
|
||||
<div className="relative">
|
||||
{view === IntegrationView.List ? (
|
||||
<motion.div
|
||||
key="view-integrations"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<IntegrationsSection
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
onAddIntegration={() => setView(IntegrationView.New)}
|
||||
isLoading={isIntegrationLoading}
|
||||
integrations={integrations}
|
||||
environments={environments}
|
||||
onIntegrationDelete={handleIntegrationDelete}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="add-integration"
|
||||
transition={{ duration: 0.3 }}
|
||||
initial={{ opacity: 0, translateX: 30 }}
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: 30 }}
|
||||
className="w-full"
|
||||
>
|
||||
<CloudIntegrationSection
|
||||
onViewActiveIntegrations={
|
||||
integrations?.length ? () => setView(IntegrationView.List) : undefined
|
||||
}
|
||||
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrationAuths={integrationAuths}
|
||||
onIntegrationStart={handleProviderIntegrationStart}
|
||||
onIntegrationRevoke={handleIntegrationAuthRevoke}
|
||||
/>
|
||||
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
|
||||
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
|
||||
{
|
||||
action: ProjectPermissionActions.Read,
|
||||
subject: ProjectPermissionSub.Integrations
|
||||
}
|
||||
);
|
||||
|
@@ -1,11 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faChevronLeft,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
EmptyState,
|
||||
Input,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
@@ -22,6 +35,7 @@ type Props = {
|
||||
onIntegrationStart: (slug: string) => void;
|
||||
// cb: handle popUpClose child->parent communication pattern
|
||||
onIntegrationRevoke: (slug: string, cb: () => void) => void;
|
||||
onViewActiveIntegrations?: () => void;
|
||||
};
|
||||
|
||||
type TRevokeIntegrationPopUp = { provider: string };
|
||||
@@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegrations = [],
|
||||
integrationAuths = {},
|
||||
onIntegrationStart,
|
||||
onIntegrationRevoke
|
||||
onIntegrationRevoke,
|
||||
onViewActiveIntegrations
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
@@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({
|
||||
return sortedIntegrations;
|
||||
}, [cloudIntegrations, currentWorkspace?.environments]);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) =>
|
||||
cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim())
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5">
|
||||
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({
|
||||
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
|
||||
{onViewActiveIntegrations && (
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onViewActiveIntegrations}
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
>
|
||||
Back to Integrations
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
|
||||
</div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search cloud integrations..."
|
||||
containerClassName="flex-1 h-min text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{isLoading &&
|
||||
Array.from({ length: 12 }).map((_, index) => (
|
||||
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
|
||||
))}
|
||||
{!isLoading &&
|
||||
sortedCloudIntegrations?.map((cloudIntegration) => (
|
||||
|
||||
{!isLoading && filteredIntegrations.length ? (
|
||||
filteredIntegrations.map((cloudIntegration) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
@@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({
|
||||
cloudIntegration.isAvailable
|
||||
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
|
||||
: "opacity-50"
|
||||
} flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
|
||||
onClick={() => {
|
||||
if (!cloudIntegration.isAvailable) return;
|
||||
if (
|
||||
@@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({
|
||||
>
|
||||
<img
|
||||
src={`/images/integrations/${cloudIntegration.image}`}
|
||||
height={70}
|
||||
width={70}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{cloudIntegration.name}
|
||||
</div>
|
||||
{cloudIntegration.isAvailable &&
|
||||
@@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
|
||||
title="No cloud integrations match search..."
|
||||
icon={faSearch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEmpty && (
|
||||
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">
|
||||
|
@@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
|
||||
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="mx-6 mt-4 grid grid-flow-dense gap-3"
|
||||
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
|
||||
>
|
||||
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
|
||||
{sortedFrameworks.map((framework) => (
|
||||
<a
|
||||
key={`framework-integration-${framework.slug}`}
|
||||
href={framework.docsLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 ${
|
||||
framework?.name?.split(" ").length > 1 ? "px-1 text-sm" : "px-2 text-xl"
|
||||
} w-full max-w-xs text-center`}
|
||||
>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={framework?.name ? 60 : 90}
|
||||
width={framework?.name ? 60 : 90}
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && framework?.image && <div className="h-2" />}
|
||||
{framework?.name && framework.name}
|
||||
</div>
|
||||
{framework?.image && (
|
||||
<img
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={60}
|
||||
width={60}
|
||||
className="mt-auto"
|
||||
alt="integration logo"
|
||||
/>
|
||||
)}
|
||||
{framework?.name && (
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
{framework.name}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
<a
|
||||
@@ -58,13 +53,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/cli/commands/run"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-2 text-white/90" icon={faKeyboard} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
CLI
|
||||
</div>
|
||||
</a>
|
||||
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
|
||||
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
|
||||
>
|
||||
<div
|
||||
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
|
||||
>
|
||||
<FontAwesomeIcon className="text-5xl mb-1 text-white/90" icon={faComputer} />
|
||||
<div className="h-2" />
|
||||
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
|
||||
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
|
||||
SDKs
|
||||
</div>
|
||||
</a>
|
||||
|
@@ -1,291 +0,0 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowRight,
|
||||
faCalendarCheck,
|
||||
faEllipsis,
|
||||
faRefresh,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { integrationSlugNameMapping } from "public/data/frequentConstants";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
};
|
||||
|
||||
export const ConfiguredIntegrationItem = ({
|
||||
integration,
|
||||
environments,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
key={`integration-${integration?.id.toString()}`}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{environments.find((e) => e.id === integration.envId)?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Secret Path" />
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.secretPath}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex h-full items-center">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel
|
||||
tooltipText={
|
||||
integration.integration === "github" ? (
|
||||
<div className="text-xs">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "Syncing to all private repositories in the organization"
|
||||
: "Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
label="Integration"
|
||||
/>
|
||||
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integrationSlugNameMapping[integration.integration]}
|
||||
</div>
|
||||
</div>
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Space" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<div className="flex flex-row">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Org" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.owner || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Project" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetService || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Env" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.targetEnvironment || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
|
||||
{(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
|
||||
`${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") &&
|
||||
`${integration.owner}/${integration.app}`) ||
|
||||
integration.app}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div className="ml-4 flex flex-col">
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Environment" />
|
||||
<div className="min-w-[8rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-2 flex flex-col">
|
||||
<FormLabel label="Workspace" />
|
||||
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Group" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Organization" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.owner}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Category" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration.targetService}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") && (
|
||||
<div className="ml-2">
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
|
||||
{integration?.metadata?.secretSuffix || "-"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-[1.5rem] flex cursor-default space-x-3">
|
||||
{integration.isSynced != null && integration.lastUsed != null && (
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<Tooltip
|
||||
center
|
||||
className="max-w-xs whitespace-normal break-words"
|
||||
content={
|
||||
<div className="flex max-h-[10rem] flex-col overflow-auto ">
|
||||
<div className="flex self-start">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
|
||||
<div className="text-sm">Last successful sync</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">
|
||||
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
{!integration.isSynced && (
|
||||
<>
|
||||
<div className="mt-2 flex self-start">
|
||||
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
|
||||
<div className="text-sm">Fail reason</div>
|
||||
</div>
|
||||
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex h-full items-center space-x-2">
|
||||
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
|
||||
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-x-1.5">
|
||||
<Tooltip className="text-center" content="Manually sync integration secrets">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<Tooltip content="View details">
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
colorSchema="primary"
|
||||
variant="star"
|
||||
className="max-w-[2.5rem] border-none bg-mineshaft-500"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,13 +1,16 @@
|
||||
import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem";
|
||||
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { IntegrationsTable } from "./components";
|
||||
|
||||
type Props = {
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
isLoading?: boolean;
|
||||
onIntegrationDelete: (
|
||||
integrationId: string,
|
||||
@@ -15,6 +18,7 @@ type Props = {
|
||||
cb: () => void
|
||||
) => Promise<void>;
|
||||
workspaceId: string;
|
||||
onAddIntegration: () => void;
|
||||
};
|
||||
|
||||
export const IntegrationsSection = ({
|
||||
@@ -22,58 +26,47 @@ export const IntegrationsSection = ({
|
||||
environments = [],
|
||||
isLoading,
|
||||
onIntegrationDelete,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
onAddIntegration,
|
||||
cloudIntegrations = []
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteConfirmation",
|
||||
"deleteSecretsConfirmation"
|
||||
] as const);
|
||||
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Current Integrations</h1>
|
||||
<div className="mx-6 mb-8">
|
||||
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
|
||||
<h1 className="text-3xl font-semibold">Integrations</h1>
|
||||
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="p-6 pt-0">
|
||||
<Skeleton className="h-28" />
|
||||
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={onAddIntegration}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !integrations.length && (
|
||||
<div className="mx-6">
|
||||
<EmptyState
|
||||
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
|
||||
title="No integrations found. Click on one of the below providers to sync secrets."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
|
||||
{integrations?.map((integration) => (
|
||||
<ConfiguredIntegrationItem
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
integration={integration}
|
||||
environments={environments}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<IntegrationsTable
|
||||
cloudIntegrations={cloudIntegrations}
|
||||
integrations={integrations}
|
||||
isLoading={isLoading}
|
||||
workspaceId={workspaceId}
|
||||
environments={environments}
|
||||
onDeleteIntegration={(integration) => {
|
||||
setShouldDeleteSecrets.off();
|
||||
handlePopUpOpen("deleteConfirmation", integration);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteConfirmation.isOpen}
|
||||
title={`Are you sure want to remove ${
|
||||
|
@@ -0,0 +1,138 @@
|
||||
import { FormLabel } from "@app/components/v2";
|
||||
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
type Props = {
|
||||
integration: TIntegration;
|
||||
};
|
||||
|
||||
const FIELD_CLASSNAME =
|
||||
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
|
||||
|
||||
export const getIntegrationDestination = (integration: TIntegration) =>
|
||||
(integration.integration === "hashicorp-vault" &&
|
||||
`${integration.app} - path: ${integration.path}`) ||
|
||||
(integration.scope === "github-org" && `${integration.owner}`) ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) ||
|
||||
(integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||
|
||||
integration.app ||
|
||||
"-";
|
||||
|
||||
export const IntegrationDetails = ({ integration }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{integration.integration === "octopus-deploy" && (
|
||||
<div>
|
||||
<FormLabel label="Space" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "qovery" && (
|
||||
<>
|
||||
<div>
|
||||
<FormLabel label="Org" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Project" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel label="Env" />
|
||||
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!(
|
||||
integration.integration === "aws-secret-manager" &&
|
||||
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
|
||||
) && (
|
||||
<div>
|
||||
<FormLabel
|
||||
label={
|
||||
(integration.integration === "qovery" && integration?.scope) ||
|
||||
(integration.integration === "circleci" && "Project") ||
|
||||
(integration.integration === "bitbucket" && "Repository") ||
|
||||
(integration.integration === "octopus-deploy" && "Project") ||
|
||||
(integration.integration === "aws-secret-manager" && "Secret") ||
|
||||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
|
||||
(integration?.integration === "terraform-cloud" && "Project") ||
|
||||
(integration?.scope === "github-org" && "Organization") ||
|
||||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
|
||||
"Repository") ||
|
||||
"App"
|
||||
}
|
||||
/>
|
||||
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "vercel" ||
|
||||
integration.integration === "netlify" ||
|
||||
integration.integration === "railway" ||
|
||||
integration.integration === "gitlab" ||
|
||||
integration.integration === "teamcity" ||
|
||||
(integration.integration === "github" && integration.scope === "github-env")) && (
|
||||
<div>
|
||||
<FormLabel label="Target Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "bitbucket" && (
|
||||
<>
|
||||
{integration.targetServiceId && (
|
||||
<div>
|
||||
<FormLabel label="Environment" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetService || integration.targetServiceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<FormLabel label="Workspace" />
|
||||
<div className={FIELD_CLASSNAME}>
|
||||
{integration.targetEnvironment || integration.targetEnvironmentId}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{integration.integration === "checkly" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Group" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "circleci" && integration.owner && (
|
||||
<div>
|
||||
<FormLabel label="Organization" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "terraform-cloud" && integration.targetService && (
|
||||
<div>
|
||||
<FormLabel label="Category" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
|
||||
</div>
|
||||
)}
|
||||
{(integration.integration === "checkly" || integration.integration === "github") &&
|
||||
integration?.metadata?.secretSuffix && (
|
||||
<div>
|
||||
<FormLabel label="Secret Suffix" />
|
||||
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
|
||||
</div>
|
||||
)}
|
||||
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
|
||||
<div className="mt-2 text-xs text-mineshaft-200">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{integration.metadata?.githubVisibility === "selected"
|
||||
? "* Syncing to selected repositories in the organization. "
|
||||
: integration.metadata?.githubVisibility === "private"
|
||||
? "* Syncing to all private repositories in the organization"
|
||||
: "* Syncing to all public and private repositories in the organization"}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1,185 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faCalendarCheck,
|
||||
faCheck,
|
||||
faInfoCircle,
|
||||
faRefresh,
|
||||
faTrash,
|
||||
faWarning,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { TCloudIntegration } from "@app/hooks/api/integrations/types";
|
||||
import { TIntegration } from "@app/hooks/api/types";
|
||||
|
||||
import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails";
|
||||
|
||||
type IProps = {
|
||||
integration: TIntegration;
|
||||
environment?: { name: string; slug: string; id: string };
|
||||
onRemoveIntegration: VoidFunction;
|
||||
onManualSyncIntegration: VoidFunction;
|
||||
cloudIntegration: TCloudIntegration;
|
||||
};
|
||||
|
||||
export const IntegrationRow = ({
|
||||
integration,
|
||||
environment,
|
||||
onRemoveIntegration,
|
||||
onManualSyncIntegration,
|
||||
cloudIntegration
|
||||
}: IProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { id, secretPath, syncMessage, isSynced } = integration;
|
||||
|
||||
const failureMessage = useMemo(() => {
|
||||
if (isSynced === false) {
|
||||
if (syncMessage)
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(syncMessage), null, 2);
|
||||
} catch (e) {
|
||||
return syncMessage;
|
||||
}
|
||||
|
||||
return "An Unknown Error Occurred.";
|
||||
}
|
||||
return null;
|
||||
}, [isSynced, syncMessage]);
|
||||
|
||||
return (
|
||||
<Tr
|
||||
onClick={() => router.push(`/integrations/details/${integration.id}`)}
|
||||
className={twMerge(
|
||||
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
|
||||
isSynced === false && "bg-red/5 hover:bg-red/10"
|
||||
)}
|
||||
key={`integration-${id}`}
|
||||
>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegration?.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegration?.image}`}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td className="!min-w-[8rem] max-w-0">
|
||||
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
|
||||
<p className="truncate">{secretPath}</p>
|
||||
</Tooltip>{" "}
|
||||
</Td>
|
||||
<Td>{environment?.name ?? "-"}</Td>
|
||||
<Td className="!min-w-[5rem] max-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate">{getIntegrationDestination(integration)}</p>
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="min-w-[20rem] max-w-lg"
|
||||
content={<IntegrationDetails integration={integration} />}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{" "}
|
||||
{typeof integration.isSynced !== "boolean" ? (
|
||||
<Badge variant="primary" key={integration.id}>
|
||||
Pending Sync
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
position="left"
|
||||
className="max-w-sm"
|
||||
content={
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{integration.lastUsed && (
|
||||
<div>
|
||||
<div
|
||||
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faCalendarCheck}
|
||||
className="ml-1 pt-0.5 pr-1.5 text-sm"
|
||||
/>
|
||||
<div className="text-xs">Last Synced</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">
|
||||
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{failureMessage && (
|
||||
<div>
|
||||
<div className="mb-2 flex self-start text-red">
|
||||
<FontAwesomeIcon icon={faXmark} className="ml-1 pt-0.5 pr-1.5 text-sm" />
|
||||
<div className="text-xs">Failure Reason</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="w-min whitespace-nowrap">
|
||||
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
|
||||
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex gap-2 whitespace-nowrap">
|
||||
<Tooltip className="max-w-sm text-center" content="Manually Sync">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onManualSyncIntegration();
|
||||
}}
|
||||
ariaLabel="sync"
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faRefresh} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Integrations}
|
||||
>
|
||||
{(isAllowed: boolean) => (
|
||||
<Tooltip content="Remove Integration">
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveIntegration();
|
||||
}}
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="danger"
|
||||
variant="plain"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className="px-1" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
@@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faCheck,
|
||||
faClock,
|
||||
faFilter,
|
||||
faMagnifyingGlass,
|
||||
faPlug,
|
||||
faSearch,
|
||||
faWarning
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
|
||||
import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types";
|
||||
|
||||
import { getIntegrationDestination } from "./IntegrationDetails";
|
||||
import { IntegrationRow } from "./IntegrationRow";
|
||||
|
||||
type Props = {
|
||||
integrations?: TIntegration[];
|
||||
cloudIntegrations?: TCloudIntegration[];
|
||||
workspaceId: string;
|
||||
isLoading?: boolean;
|
||||
environments: Array<{ name: string; slug: string; id: string }>;
|
||||
onDeleteIntegration: (integration: TIntegration) => void;
|
||||
};
|
||||
|
||||
enum IntegrationsOrderBy {
|
||||
App = "app",
|
||||
Status = "status",
|
||||
SecretPath = "secretPath",
|
||||
Environment = "environment",
|
||||
Destination = "destination"
|
||||
}
|
||||
|
||||
enum IntegrationStatus {
|
||||
Synced = "synced",
|
||||
NotSynced = "not-synced",
|
||||
PendingSync = "pending-sync"
|
||||
}
|
||||
|
||||
type IntegrationFilters = {
|
||||
environmentIds: string[];
|
||||
integrations: string[];
|
||||
status: IntegrationStatus[];
|
||||
};
|
||||
|
||||
const STATUS_ICON_MAP = {
|
||||
[IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" },
|
||||
[IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" },
|
||||
[IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" }
|
||||
};
|
||||
|
||||
export const IntegrationsTable = ({
|
||||
integrations = [],
|
||||
cloudIntegrations = [],
|
||||
workspaceId,
|
||||
environments,
|
||||
onDeleteIntegration,
|
||||
isLoading
|
||||
}: Props) => {
|
||||
const { mutate: syncIntegration } = useSyncIntegration();
|
||||
|
||||
const initialFilters = useMemo(
|
||||
() => ({
|
||||
environmentIds: environments.map((env) => env.id),
|
||||
integrations: [...new Set(integrations.map(({ integration }) => integration))],
|
||||
status: Object.values(IntegrationStatus)
|
||||
}),
|
||||
[environments, integrations]
|
||||
);
|
||||
|
||||
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
|
||||
|
||||
const cloudIntegrationMap = useMemo(() => {
|
||||
return new Map(
|
||||
cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration])
|
||||
);
|
||||
}, [cloudIntegrations]);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection,
|
||||
orderBy,
|
||||
setOrderDirection,
|
||||
setOrderBy
|
||||
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
|
||||
|
||||
useEffect(() => {
|
||||
if (integrations?.some((integration) => integration.isSynced === false))
|
||||
setOrderBy(IntegrationsOrderBy.Status);
|
||||
}, []);
|
||||
|
||||
const environmentMap = new Map(environments.map((env) => [env.id, env]));
|
||||
|
||||
const filteredIntegrations = useMemo(
|
||||
() =>
|
||||
integrations
|
||||
.filter((integration) => {
|
||||
const { secretPath, envId, isSynced } = integration;
|
||||
|
||||
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
|
||||
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
|
||||
return false;
|
||||
if (
|
||||
!filters.status.includes(IntegrationStatus.PendingSync) &&
|
||||
typeof isSynced !== "boolean"
|
||||
)
|
||||
return false;
|
||||
|
||||
if (!filters.integrations.includes(integration.integration)) return false;
|
||||
|
||||
if (!filters.environmentIds.includes(envId)) return false;
|
||||
|
||||
return (
|
||||
integration.integration
|
||||
.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) ||
|
||||
getIntegrationDestination(integration)
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase()) ||
|
||||
environmentMap
|
||||
.get(envId)
|
||||
?.name.replace("-", " ")
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase())
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const [integrationOne, integrationTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
switch (orderBy) {
|
||||
case IntegrationsOrderBy.SecretPath:
|
||||
return integrationOne.secretPath
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.secretPath.toLowerCase());
|
||||
case IntegrationsOrderBy.Environment:
|
||||
return (environmentMap.get(integrationOne.envId)?.name ?? "-")
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
(environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase()
|
||||
);
|
||||
case IntegrationsOrderBy.Destination:
|
||||
return getIntegrationDestination(integrationOne)
|
||||
.toLowerCase()
|
||||
.localeCompare(getIntegrationDestination(integrationTwo).toLowerCase());
|
||||
case IntegrationsOrderBy.Status:
|
||||
if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end
|
||||
if (typeof integrationTwo.isSynced !== "boolean") return -1;
|
||||
|
||||
return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced);
|
||||
case IntegrationsOrderBy.App:
|
||||
default:
|
||||
return integrationOne.integration
|
||||
.toLowerCase()
|
||||
.localeCompare(integrationTwo.integration.toLowerCase());
|
||||
}
|
||||
}),
|
||||
[integrations, orderDirection, search, orderBy, filters]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredIntegrations.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const handleSort = (column: IntegrationsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
toggleOrderDirection();
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
const getClassName = (col: IntegrationsOrderBy) =>
|
||||
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||
|
||||
const getColSortIcon = (col: IntegrationsOrderBy) =>
|
||||
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||
|
||||
const isTableFiltered =
|
||||
filters.integrations.length !== initialFilters.integrations.length ||
|
||||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
|
||||
filters.status.length !== initialFilters.status.length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search integrations..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="Environments"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||
isTableFiltered && "border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<Tooltip content="Filter Integrations" className="mb-2">
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
{Object.values(IntegrationStatus).map((status) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status: prev.status.includes(status)
|
||||
? prev.status.filter((s) => s !== status)
|
||||
: [...prev.status, status]
|
||||
}));
|
||||
}}
|
||||
key={status}
|
||||
icon={
|
||||
filters.status.includes(status) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon
|
||||
icon={STATUS_ICON_MAP[status].icon}
|
||||
className={STATUS_ICON_MAP[status].className}
|
||||
/>
|
||||
<span className="capitalize">{status.replace("-", " ")}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Integration</DropdownMenuLabel>
|
||||
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
integrations: prev.integrations.includes(integration)
|
||||
? prev.integrations.filter((i) => i !== integration)
|
||||
: [...prev.integrations, integration]
|
||||
}));
|
||||
}}
|
||||
key={integration}
|
||||
icon={
|
||||
filters.integrations.includes(integration) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
|
||||
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuLabel>Environment</DropdownMenuLabel>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
environmentIds: prev.environmentIds.includes(env.id)
|
||||
? prev.environmentIds.filter((i) => i !== env.id)
|
||||
: [...prev.environmentIds, env.id]
|
||||
}));
|
||||
}}
|
||||
key={env.id}
|
||||
icon={
|
||||
filters.environmentIds.includes(env.id) && (
|
||||
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
|
||||
)
|
||||
}
|
||||
iconPos="right"
|
||||
>
|
||||
<span className="capitalize">{env.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-[25%]">
|
||||
<div className="flex items-center">
|
||||
Integration
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.App)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.App)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Path
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.SecretPath)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Source Environment
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Environment)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Destination
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Destination)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-1/5">
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={getClassName(IntegrationsOrderBy.Status)}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(IntegrationsOrderBy.Status)}
|
||||
>
|
||||
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
|
||||
<IntegrationRow
|
||||
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
|
||||
key={`integration-${integration.id}`}
|
||||
onManualSyncIntegration={() => {
|
||||
syncIntegration({
|
||||
workspaceId,
|
||||
id: integration.id,
|
||||
lastUsed: integration.lastUsed as string
|
||||
});
|
||||
}}
|
||||
onRemoveIntegration={() => onDeleteIntegration(integration)}
|
||||
integration={integration}
|
||||
environment={environmentMap.get(integration.envId)}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredIntegrations.length) && (
|
||||
<Pagination
|
||||
count={filteredIntegrations.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredIntegrations?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
integrations.length
|
||||
? "No integrations match search..."
|
||||
: "This project has no integrations configured"
|
||||
}
|
||||
icon={integrations.length ? faSearch : faPlug}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -0,0 +1 @@
|
||||
export * from "./IntegrationsTable";
|
@@ -6,14 +6,14 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
|
||||
.string()
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(36, "Slug must be 36 characters or fewer"),
|
||||
role: z.string()
|
||||
role: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
|
||||
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset({
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
role: group?.customRole?.slug ?? group.role
|
||||
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
slug: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.group?.data, roles]);
|
||||
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
} else {
|
||||
await createMutateAsync({
|
||||
name,
|
||||
slug,
|
||||
organizationId: currentOrg.id,
|
||||
role: role || undefined
|
||||
role: role.slug || undefined
|
||||
});
|
||||
}
|
||||
handlePopUpToggle("group", false);
|
||||
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.group?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`org-group-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
options={roles}
|
||||
placeholder="Select role..."
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -9,27 +9,24 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import {
|
||||
// IdentityAuthMethod,
|
||||
useAddIdentityUniversalAuth
|
||||
} from "@app/hooks/api/identities";
|
||||
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
name: z.string().min(1, "Required"),
|
||||
role: z.object({ slug: z.string(), name: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
if (identity) {
|
||||
reset({
|
||||
name: identity.name,
|
||||
role: identity?.customRole?.slug ?? identity.role,
|
||||
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
|
||||
metadata: identity.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
|
||||
});
|
||||
}
|
||||
}, [popUp?.identity?.data, roles]);
|
||||
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
await updateMutateAsync({
|
||||
identityId: identity.identityId,
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
const { id: createdId } = await createMutateAsync({
|
||||
name,
|
||||
role: role || undefined,
|
||||
role: role.slug || undefined,
|
||||
organizationId: orgId,
|
||||
metadata
|
||||
});
|
||||
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -15,7 +15,7 @@ import {
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { findOrgMembershipRole } from "@app/helpers/roles";
|
||||
import {
|
||||
useAddUsersToOrg,
|
||||
useFetchServerStatus,
|
||||
@@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({
|
||||
)
|
||||
.default([]),
|
||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
|
||||
organizationRole: z.object({ name: z.string(), slug: z.string() })
|
||||
});
|
||||
|
||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||
@@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({
|
||||
useEffect(() => {
|
||||
if (organizationRoles) {
|
||||
reset({
|
||||
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
|
||||
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
|
||||
: currentOrg?.defaultMembershipRole
|
||||
organizationRole: findOrgMembershipRole(
|
||||
organizationRoles,
|
||||
currentOrg?.defaultMembershipRole!
|
||||
)
|
||||
});
|
||||
}
|
||||
}, [organizationRoles]);
|
||||
|
||||
const onAddMembers = async ({
|
||||
emails,
|
||||
organizationRoleSlug,
|
||||
organizationRole,
|
||||
projects: selectedProjects,
|
||||
projectRoleSlug
|
||||
}: TAddMemberForm) => {
|
||||
@@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({
|
||||
const { data } = await addUsersMutateAsync({
|
||||
organizationId: currentOrg?.id,
|
||||
inviteeEmails: emails.split(",").map((email) => email.trim()),
|
||||
organizationRoleSlug,
|
||||
organizationRoleSlug: organizationRole.slug,
|
||||
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
|
||||
});
|
||||
|
||||
@@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="organizationRoleSlug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
name="organizationRole"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
tooltipText="Select which organization role you want to assign to the user."
|
||||
label="Assign organization role"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<div>
|
||||
<Select
|
||||
className="w-full"
|
||||
{...field}
|
||||
onValueChange={(val) => field.onChange(val)}
|
||||
>
|
||||
{organizationRoles?.map((role) => (
|
||||
<SelectItem key={role.id} value={role.slug}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={organizationRoles}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -296,7 +296,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
status,
|
||||
isActive
|
||||
}) => {
|
||||
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
|
||||
const name =
|
||||
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
|
||||
const email = u?.email || inviteEmail;
|
||||
const username = u?.username ?? inviteEmail ?? "-";
|
||||
return (
|
||||
|
@@ -123,7 +123,7 @@ export const UserPage = withPermission(
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName}`
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
{userId !== membership.user.id && (
|
||||
@@ -148,7 +148,8 @@ export const UserPage = withPermission(
|
||||
onClick={() =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role
|
||||
role: membership.role,
|
||||
roleId: membership.roleId
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
|
@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
handlePopUpOpen("orgMembership", {
|
||||
membershipId: membership.id,
|
||||
role: membership.role,
|
||||
roleId: membership.roleId,
|
||||
metadata: membership.metadata
|
||||
});
|
||||
}}
|
||||
@@ -117,7 +118,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{membership.user.firstName || membership.user.lastName
|
||||
? `${membership.user.firstName} ${membership.user.lastName}`
|
||||
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -8,21 +9,21 @@ import { z } from "zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import { useOrganization, useSubscription } from "@app/context";
|
||||
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
|
||||
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z.object({
|
||||
role: z.string(),
|
||||
role: z.object({ name: z.string(), slug: z.string() }),
|
||||
metadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
const { data: roles = [] } = useGetOrgRoles(orgId);
|
||||
|
||||
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
|
||||
|
||||
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
const popUpData = popUp?.orgMembership?.data as {
|
||||
membershipId: string;
|
||||
role: string;
|
||||
roleId?: string;
|
||||
metadata: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
|
||||
if (popUpData) {
|
||||
reset({
|
||||
role: popUpData.role,
|
||||
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
|
||||
metadata: popUpData.metadata
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
role: roles[0].slug
|
||||
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
|
||||
});
|
||||
}
|
||||
}, [popUp?.orgMembership?.data, roles]);
|
||||
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
await updateOrgMembership({
|
||||
organizationId: orgId,
|
||||
membershipId: popUpData.membershipId,
|
||||
role,
|
||||
role: role.slug,
|
||||
metadata
|
||||
});
|
||||
|
||||
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title="Update Membership">
|
||||
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="role"
|
||||
defaultValue=""
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Update Organization Role"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(e);
|
||||
<FilterableSelect
|
||||
placeholder="Select role..."
|
||||
options={roles}
|
||||
onChange={(newValue) => {
|
||||
const role = newValue as SingleValue<(typeof roles)[number]>;
|
||||
|
||||
if (!role) return;
|
||||
|
||||
const isCustomRole = isCustomOrgRole(role.slug);
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(e);
|
||||
onChange(role);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{(roles || []).map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={`st-role-${slug}`}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
value={value}
|
||||
getOptionValue={(option) => option.slug}
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
@@ -1,6 +1,27 @@
|
||||
import { faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faMagnifyingGlass,
|
||||
faSearch,
|
||||
faUser
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import {
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Table,
|
||||
TableContainer,
|
||||
TBody,
|
||||
Th,
|
||||
THead,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgUser } from "@app/hooks/api/types";
|
||||
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
@@ -12,31 +33,106 @@ type Props = {
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
enum UserGroupsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
|
||||
const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username);
|
||||
const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships(
|
||||
orgMembership.user.username
|
||||
);
|
||||
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
setPage,
|
||||
page,
|
||||
perPage,
|
||||
setPerPage,
|
||||
offset,
|
||||
orderDirection,
|
||||
toggleOrderDirection
|
||||
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
|
||||
|
||||
const filteredGroupMemberships = useMemo(
|
||||
() =>
|
||||
groupMemberships
|
||||
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const [membershipOne, membershipTwo] =
|
||||
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||
|
||||
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
|
||||
}),
|
||||
[groupMemberships, orderDirection, search]
|
||||
);
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount: filteredGroupMemberships.length,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{groups?.map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && !groups?.length && (
|
||||
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
|
||||
)}
|
||||
</TableContainer>
|
||||
<div>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search groups..."
|
||||
/>
|
||||
<TableContainer className="mt-4">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-full">
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={toggleOrderDirection}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
|
||||
<UserGroupsRow
|
||||
key={`user-group-${group.id}`}
|
||||
group={group}
|
||||
handlePopUpOpen={handlePopUpOpen}
|
||||
/>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
{Boolean(filteredGroupMemberships.length) && (
|
||||
<Pagination
|
||||
count={filteredGroupMemberships.length}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={setPage}
|
||||
onChangePerPage={setPerPage}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !filteredGroupMemberships?.length && (
|
||||
<EmptyState
|
||||
title={
|
||||
groupMemberships.length
|
||||
? "No groups match search..."
|
||||
: "This user has not been assigned to any groups"
|
||||
}
|
||||
icon={groupMemberships.length ? faSearch : faUser}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
@@ -94,7 +95,9 @@ export const IdentityDetailsPage = withProjectPermission(
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={ProjectPermissionSub.Identity}
|
||||
a={subject(ProjectPermissionSub.Identity, {
|
||||
identityId: identityMembershipDetails?.identity?.id
|
||||
})}
|
||||
renderTooltip
|
||||
allowedLabel="Remove from project"
|
||||
>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCaretDown,
|
||||
faChevronLeft,
|
||||
@@ -17,12 +18,13 @@ import { TtlFormLabel } from "@app/components/features";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
@@ -35,7 +37,6 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateIdentityProjectAdditionalPrivilege,
|
||||
useGetIdentityProjectPrivilegeDetails,
|
||||
@@ -43,10 +44,10 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
|
||||
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
|
||||
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
|
||||
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
isConditionalSubjects,
|
||||
PROJECT_PERMISSION_OBJECT,
|
||||
projectRoleFormSchema,
|
||||
rolePermission2Form
|
||||
@@ -88,7 +89,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
}: Props) => {
|
||||
const isCreate = !privilegeId;
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
|
||||
const projectId = currentWorkspace?.id || "";
|
||||
const { data: privilegeDetails, isLoading } = useGetIdentityProjectPrivilegeDetails({
|
||||
identityId,
|
||||
@@ -98,7 +98,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
const { permission } = useProjectPermission();
|
||||
const isIdentityEditDisabled = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
ProjectPermissionSub.Identity
|
||||
subject(ProjectPermissionSub.Identity, { identityId })
|
||||
);
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
@@ -194,6 +194,30 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
}
|
||||
}
|
||||
|
||||
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
|
||||
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
|
||||
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[...rootPolicyValue, ...[]],
|
||||
{ shouldDirty: true, shouldTouch: true }
|
||||
);
|
||||
} else {
|
||||
form.setValue(
|
||||
`permissions.${selectedSubject}`,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
|
||||
[{}],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
@@ -232,24 +256,39 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Modal
|
||||
isOpen={popUp.createPolicy.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="h-10 rounded-l-none"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
New Policy
|
||||
New policy
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
|
||||
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
|
||||
{Object.keys(PROJECT_PERMISSION_OBJECT)
|
||||
.sort((a, b) =>
|
||||
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
PROJECT_PERMISSION_OBJECT[
|
||||
b as keyof typeof PROJECT_PERMISSION_OBJECT
|
||||
].title.toLowerCase()
|
||||
)
|
||||
)
|
||||
.map((permissionSubject) => (
|
||||
<DropdownMenuItem
|
||||
key={`permission-create-${permissionSubject}`}
|
||||
className="py-3"
|
||||
onClick={() => onNewPolicy(permissionSubject as ProjectPermissionSub)}
|
||||
>
|
||||
{PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,17 +415,19 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
|
||||
<div className="p-4">
|
||||
<div className="mb-2 text-lg">Policies</div>
|
||||
{(isCreate || !isLoading) && <PermissionEmptyState />}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={subject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[subject].title}
|
||||
key={`project-permission-${subject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(subject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
))}
|
||||
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
|
||||
(permissionSubject) => (
|
||||
<GeneralPermissionPolicies
|
||||
subject={permissionSubject}
|
||||
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
|
||||
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
|
||||
key={`project-permission-${permissionSubject}`}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{renderConditionalComponents(permissionSubject, isDisabled)}
|
||||
</GeneralPermissionPolicies>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</FormProvider>
|
||||
</form>
|
||||
|