From 0d9ce700004ee6cf836aecd56d17380fc52a5d33 Mon Sep 17 00:00:00 2001 From: Reza Rahemtola Date: Sun, 3 Sep 2023 23:28:19 +0200 Subject: [PATCH 01/40] docs: Fixing multiple typos --- docs/changelog/overview.mdx | 6 +++--- docs/cli/token.mdx | 4 ++-- docs/contributing/pull-requests.mdx | 4 ++-- docs/documentation/platform/project.mdx | 6 +++--- docs/self-hosting/configuration/envars.mdx | 4 ++-- .../deployment-options/digital-ocean-marketplace.mdx | 10 +++++----- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index db2958b32..4961ef274 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -34,7 +34,7 @@ The changelog below reflects new product developments and updates on a monthly b - Released the [Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform#5-run-terraform). - Updated the usage and billing page. Added the free trial for the professional tier. -- Added native intergations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). +- Added native integrations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). - Completed a penetration test with a `very good` result. - Added support for multi-line secrets. @@ -70,7 +70,7 @@ The changelog below reflects new product developments and updates on a monthly b ## Feb 2023 - Upgraded private key encryption/decryption mechanism to use Argon2id and 256-bit protected keys. -- Added preliminary emai-based 2FA capability. +- Added preliminary email-based 2FA capability. - Added suspicious login alerting if user logs in via new device or IP address. - Added documentation for PM2 integration. - Added secret backups support for the CLI; it now fetches and caches secrets locally to be used in the event of future failed fetch. @@ -120,7 +120,7 @@ The changelog below reflects new product developments and updates on a monthly b ## Sep 2022 - Added capability to change user roles in projects. -- Added capabilty to delete projects. +- Added capability to delete projects. - Added Stripe. - Added default environments (development, staging, production) for new users with example key-pairs. - Added loading indicators. diff --git a/docs/cli/token.mdx b/docs/cli/token.mdx index c6cc6dcdd..b5a8dc6a9 100644 --- a/docs/cli/token.mdx +++ b/docs/cli/token.mdx @@ -1,11 +1,11 @@ --- title: "Infisical Token" -description: "How to use Infical service token within the CLI." +description: "How to use Infisical service token within the CLI." --- Prerequisite: [Infisical Token and How to Generate One](/documentation/platform/token). -It's possible to use the CLI to sync environment varialbes without manually entering login credentials by using a service token in the prerequisite link above. +It's possible to use the CLI to sync environment variables without manually entering login credentials by using a service token in the prerequisite link above. ## Feeding Infisical Token to the CLI diff --git a/docs/contributing/pull-requests.mdx b/docs/contributing/pull-requests.mdx index a06d9f164..15b95926c 100644 --- a/docs/contributing/pull-requests.mdx +++ b/docs/contributing/pull-requests.mdx @@ -29,7 +29,7 @@ Feel free to add a short video or screenshots of what your PR achieves. ## Getting your PR reviewed -Once your PR is reviewed, one or two relevent members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. +Once your PR is reviewed, one or two relevant members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. - Vlad: Frontend, Web UI - Tony: Backend, SDKs, Security @@ -44,4 +44,4 @@ Once everything is good, the team member(s) will approve the PR to be merged int reviewing PRs once they are fully complete and well-tested. In the past, we've often had to review low-quality PRs up to 10 times which severely restricts our capacity to address other issues, PRs, and initiatives in the pipeline. As such, we ask of the community to submit higher-quality PRs that, for example, don't break existing code; in return we'll prioritize the first 3 reviews for PRs. - \ No newline at end of file + diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 065835fb3..38a677ab0 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -27,10 +27,10 @@ In most cases, environment variables belong to specific environments: developmen ### Personal overrides -Every environment variable value can be overriden with a custom value. +Every environment variable value can be overridden with a custom value. -- An overriden value can only be read and accesssed by the user that overrode the original shared value. -- A (default) shared value can be read and accesssed by other users in a project. +- An overridden value can only be read and accessed by the user that overrode the original shared value. +- A (default) shared value can be read and accessed by other users in a project. You can turn overrides on/off by toggling the override/branch icon: diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 0f50bf639..d03f57128 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -5,7 +5,7 @@ description: "Configure your environment variables when self-hosting Infisical." ## Backend environment variables -Depending on your choosen self hosted deployment method, you may need to configured at least the required environment variable listed below. +Depending on your chosen self hosted deployment method, you may need to configured at least the required environment variable listed below. Other environment variables are listed below to increase the functionality of your self hosted instance based on your use case. @@ -159,7 +159,7 @@ Other environment variables are listed below to increase the functionality of yo #### JWT - JWT token lifetime expressed in seconds or a string describing a time span + JWT token lifetime expressed in seconds or a string describing a time span diff --git a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx b/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx index 8049cd3e5..f1e739f08 100644 --- a/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx +++ b/docs/self-hosting/deployment-options/digital-ocean-marketplace.mdx @@ -3,10 +3,10 @@ title: "Digital Ocean" description: "Learn to install Infisical on Digital Ocean" --- -Infisical can be deployed on a Kubernetes cluster with a single click through our Digital Ocean marketplace application. -The initiation of the installation process triggers the creation of a Kubernetes cluster, followed by the installation of Infisical onto that cluster. +Infisical can be deployed on a Kubernetes cluster with a single click through our Digital Ocean marketplace application. +The initiation of the installation process triggers the creation of a Kubernetes cluster, followed by the installation of Infisical onto that cluster. -This automated deploymnet method uses the same proccess under the hood as the manual [Kubernetes installation guide](./kubernetes-helm). +This automated deployment method uses the same process under the hood as the manual [Kubernetes installation guide](./kubernetes-helm). ### Initiate the installation @@ -23,5 +23,5 @@ Within this section, you'll find the newly created load balancer for Infisical. ### Adjusting configurations If you need to either upgrade or downgrade Infisical, or modify environment variables to alter its functionality, refer to our [Kubernetes installation](./kubernetes-helm) page for detailed instructions. -Because Digital Ocean deploys the same Helm application as described in our [Kubernetes installation](./kubernetes-helm) guide, you can utilize that guide to implement the required changes. -It's important to note that any modifications requires familiarly with Helm package manager. \ No newline at end of file +Because Digital Ocean deploys the same Helm application as described in our [Kubernetes installation](./kubernetes-helm) guide, you can utilize that guide to implement the required changes. +It's important to note that any modifications requires familiarly with Helm package manager. From 43caccad9fa12e4b1e3623bd614a2ad0eab7bb3d Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 7 Sep 2023 17:35:26 -0700 Subject: [PATCH 02/40] removed intercom --- frontend/src/layouts/AppLayout/AppLayout.tsx | 11 ----------- frontend/src/pages/_app.tsx | 10 +++------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1391fe298..525524b5a 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -130,17 +130,6 @@ export const AppLayout = ({ children }: LayoutProps) => { const { t } = useTranslation(); - useEffect(() => { - const handleRouteChange = () => { - window.Intercom("update"); - }; - - router.events.on("routeChangeComplete", handleRouteChange); - - return () => { - router.events.off("routeChangeComplete", handleRouteChange); - }; - }, []); const logout = useLogoutUser(); const logOutUser = async () => { diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 332557732..56fb8c9f7 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -12,7 +12,6 @@ import { QueryClientProvider } from "@tanstack/react-query"; import NProgress from "nprogress"; import NotificationProvider from "@app/components/context/Notifications/NotificationProvider"; -import { IntercomProvider } from "@app/components/utilities/intercom/intercomProvider"; import Telemetry from "@app/components/utilities/telemetry/Telemetry"; import { TooltipProvider } from "@app/components/v2"; import { publicPaths } from "@app/const"; @@ -47,7 +46,6 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => const telemetry = new Telemetry().getInstance(); const handleRouteChange = () => { - // (window).Intercom('update'); if (typeof window !== "undefined") { telemetry.capture("$pageview"); } @@ -101,11 +99,9 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => - - - - - + + + From 6bca7dcc58e8080bda176d4b56b42a19843369f6 Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Thu, 7 Sep 2023 22:33:57 -0400 Subject: [PATCH 03/40] update k8 self host image versions examples --- .../deployment-options/kubernetes-helm.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index 83f3c8f6e..37383fa82 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -43,14 +43,14 @@ frontend: replicaCount: 2 image: repository: infisical/frontend - tag: "v0.26.0" # <--- frontend version + tag: "v0.34.2" # <--- frontend version pullPolicy: Always backend: replicaCount: 2 image: repository: infisical/backend - tag: "v0.26.0" # <--- backend version + tag: "v0.34.2" # <--- backend version pullPolicy: Always ``` @@ -111,14 +111,14 @@ frontend: replicaCount: 2 image: repository: infisical/frontend - tag: "v0.26.0" # <--- frontend version + tag: "v0.34.2" # <--- frontend version pullPolicy: Always backend: replicaCount: 2 image: repository: infisical/backend - tag: "v0.26.0" # <--- backend version + tag: "v0.34.2" # <--- backend version pullPolicy: Always backendEnvironmentVariables: @@ -144,7 +144,7 @@ ingress: replicaCount: 4 image: repository: infisical/frontend - tag: "v0.1.3" + tag: "v0.34.2" # <--- frontend version pullPolicy: IfNotPresent kubeSecretRef: null service: @@ -160,7 +160,7 @@ ingress: replicaCount: 4 image: repository: infisical/backend - tag: "v0.1.3" + tag: "v0.34.2" # <--- backend version pullPolicy: IfNotPresent kubeSecretRef: null service: From 1bf9041ac934d73e74d90afea314875745093fa2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 14 Aug 2023 20:44:55 +0530 Subject: [PATCH 04/40] feat(rbac): implemented backend base apis for rbac permission system --- backend/package-lock.json | 114 ++++++++++- backend/package.json | 5 +- backend/src/controllers/v1/index.ts | 2 + backend/src/controllers/v1/roleController.ts | 159 +++++++++++++++ .../controllers/v2/organizationsController.ts | 189 ++++++++++-------- backend/src/helpers/membership.ts | 38 ++-- backend/src/helpers/membershipOrg.ts | 128 ++++++------ backend/src/helpers/validation.ts | 17 ++ backend/src/index.ts | 2 + backend/src/models/membership.ts | 91 +++++---- backend/src/models/membershipOrg.ts | 78 ++++---- backend/src/models/role.ts | 53 +++++ backend/src/routes/v1/index.ts | 4 +- backend/src/routes/v1/role.ts | 20 ++ backend/src/routes/v2/organizations.ts | 128 ++++++------ backend/src/services/RoleService.ts | 74 +++++++ backend/src/validation/index.ts | 3 +- backend/src/validation/role.ts | 52 +++++ backend/src/variables/organization.ts | 3 +- 19 files changed, 833 insertions(+), 327 deletions(-) create mode 100644 backend/src/controllers/v1/roleController.ts create mode 100644 backend/src/helpers/validation.ts create mode 100644 backend/src/models/role.ts create mode 100644 backend/src/routes/v1/role.ts create mode 100644 backend/src/services/RoleService.ts create mode 100644 backend/src/validation/role.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 1423cf03e..3a65f61d6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,8 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-secrets-manager": "^3.319.0", + "@casl/ability": "^6.5.0", + "@casl/mongoose": "^7.2.1", "@godaddy/terminus": "^4.12.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^19.0.5", @@ -59,7 +61,8 @@ "typescript": "^4.9.3", "utility-types": "^3.10.0", "winston": "^3.8.2", - "winston-loki": "^6.0.7" + "winston-loki": "^6.0.6", + "zod": "^3.21.4" }, "devDependencies": { "@jest/globals": "^29.3.1", @@ -3339,6 +3342,26 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, + "node_modules/@casl/mongoose": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz", + "integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==", + "peerDependencies": { + "@casl/ability": "^6.3.2", + "mongoose": "^6.0.13 || ^7.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -6174,6 +6197,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -16626,6 +16680,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -19324,6 +19386,20 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, + "@casl/mongoose": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@casl/mongoose/-/mongoose-7.2.1.tgz", + "integrity": "sha512-pojgSWYKNIwFM6wWDNct1YD0+8nIxhe2jp5jBbK8JGU60dEs2o0Yw3mCo2y7nBwbvRC2oEots/BlLMVb1Wdo8A==", + "requires": {} + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -21575,6 +21651,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@xmldom/xmldom": { "version": "0.8.10", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", @@ -29273,6 +29380,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" } } } diff --git a/backend/package.json b/backend/package.json index a70ea9974..21f7af45a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,8 @@ { "dependencies": { "@aws-sdk/client-secrets-manager": "^3.319.0", + "@casl/ability": "^6.5.0", + "@casl/mongoose": "^7.2.1", "@godaddy/terminus": "^4.12.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^19.0.5", @@ -50,7 +52,8 @@ "typescript": "^4.9.3", "utility-types": "^3.10.0", "winston": "^3.8.2", - "winston-loki": "^6.0.7" + "winston-loki": "^6.0.6", + "zod": "^3.21.4" }, "name": "infisical-api", "version": "1.0.0", diff --git a/backend/src/controllers/v1/index.ts b/backend/src/controllers/v1/index.ts index 422aaa5ec..f3212d32d 100644 --- a/backend/src/controllers/v1/index.ts +++ b/backend/src/controllers/v1/index.ts @@ -16,6 +16,7 @@ import * as workspaceController from "./workspaceController"; import * as secretScanningController from "./secretScanningController"; import * as webhookController from "./webhookController"; import * as secretImportController from "./secretImportController"; +import * as roleController from "./roleController"; export { authController, @@ -35,5 +36,6 @@ export { workspaceController, secretScanningController, webhookController, + roleController, secretImportController }; diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts new file mode 100644 index 000000000..9402a5091 --- /dev/null +++ b/backend/src/controllers/v1/roleController.ts @@ -0,0 +1,159 @@ +import { Request, Response } from "express"; +import { BadRequestError } from "../../utils/errors"; +import Role from "../../models/role"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + adminPermissions, + getUserOrgPermissions, + memberPermissions +} from "../../services/RoleService"; +import { validateRequest } from "../../helpers/validation"; +import { + CreateRoleSchema, + DeleteRoleSchema, + GetRoleSchema, + UpdateRoleSchema +} from "../../validation"; + +export const createRole = async (req: Request, res: Response) => { + const { + body: { workspaceId, name, description, slug, permissions, orgId } + } = await validateRequest(CreateRoleSchema, req); + + const orgPermission = await getUserOrgPermissions(req.user.id, orgId); + if (orgPermission.cannot(GeneralPermissionActions.Create, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the permission." }); + } + + const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule + + const existingRole = await Role.findOne({ organization: orgId, workspace: workspaceId, slug }); + if (existingRole) { + throw BadRequestError({ message: "Role already exist" }); + } + + const role = new Role({ + organization: orgId, + workspace: workspaceId, + isOrgRole, + name, + slug, + permissions, + description + }); + await role.save(); + + res.status(200).json({ + message: "Successfully created role", + data: { + role + } + }); +}; + +export const updateRole = async (req: Request, res: Response) => { + const { + params: { id }, + body: { name, description, slug, permissions, workspaceId, orgId } + } = await validateRequest(UpdateRoleSchema, req); + const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule + + const orgPermission = await getUserOrgPermissions(req.user.id, orgId); + if (orgPermission.cannot(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the permission." }); + } + + if (slug) { + const existingRole = await Role.findOne({ + organization: orgId, + slug, + isOrgRole, + workspace: workspaceId + }); + if (existingRole && existingRole.id !== id) { + throw BadRequestError({ message: "Role already exist" }); + } + } + + const role = await Role.findByIdAndUpdate( + id, + { name, description, slug, permissions }, + { returnDocument: "after" } + ); + + if (!role) { + throw BadRequestError({ message: "Role not found" }); + } + res.status(200).json({ + message: "Successfully updated role", + data: { + role + } + }); +}; + +export const deleteRole = async (req: Request, res: Response) => { + const { + params: { id } + } = await validateRequest(DeleteRoleSchema, req); + + const role = await Role.findById(id); + if (!role) { + throw BadRequestError({ message: "Role not found" }); + } + + const orgPermission = await getUserOrgPermissions(req.user.id, role.organization.toString()); + if (orgPermission.cannot(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the permission." }); + } + await Role.findByIdAndDelete(role.id); + + res.status(200).json({ + message: "Successfully deleted role", + data: { + role + } + }); +}; + +export const getRoles = async (req: Request, res: Response) => { + const { + query: { workspaceId, orgId } + } = await validateRequest(GetRoleSchema, req); + const isOrgRole = !workspaceId; + + const orgPermission = await getUserOrgPermissions(req.user.id, orgId); + if (orgPermission.cannot(GeneralPermissionActions.Read, OrgPermissionSubjects.Role)) { + throw BadRequestError({ message: "User doesn't have the permission." }); + } + + const roles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId }); + + res.status(200).json({ + message: "Successfully fetched role list", + data: { + roles: [ + { + name: "Owner", + slug: "owner", + description: "Complete administration access over the organization.", + permissions: adminPermissions.rules + }, + { + name: "Admin", + slug: "admin", + description: "Complete administration access over the organization", + permissions: adminPermissions.rules + }, + { + name: "Member", + slug: "member", + description: "Non-administrative role in an organization", + permissions: memberPermissions.rules + }, + ...roles + ] + } + }); +}; diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 301cac9d0..3f86ea396 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -1,21 +1,19 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { - Membership, - MembershipOrg, - ServiceAccount, - Workspace, -} from "../../models"; +import { Membership, MembershipOrg, ServiceAccount, Workspace } from "../../models"; import { deleteMembershipOrg } from "../../helpers/membershipOrg"; import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; +import Role from "../../models/role"; +import { BadRequestError } from "../../utils/errors"; +import { CUSTOM } from "../../variables"; /** * Return memberships for organization with id [organizationId] - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationMemberships = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return organization memberships' #swagger.description = 'Return organization memberships' @@ -48,24 +46,24 @@ export const getOrganizationMemberships = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { organizationId } = req.params; - const memberships = await MembershipOrg.find({ - organization: organizationId, - }).populate("user", "+publicKey"); - - return res.status(200).send({ - memberships, - }); -} + const memberships = await MembershipOrg.find({ + organization: organizationId + }).populate("user", "+publicKey"); + + return res.status(200).send({ + memberships + }); +}; /** * Update role of membership with id [membershipId] to role [role] - * @param req - * @param res + * @param req + * @param res */ export const updateOrganizationMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Update organization membership' #swagger.description = 'Update organization membership' @@ -118,31 +116,46 @@ export const updateOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; - const { role } = req.body; - - const membership = await MembershipOrg.findByIdAndUpdate( - membershipId, - { - role, - }, { - new: true, - } - ); - - return res.status(200).send({ - membership, + const { membershipId } = req.params; + const { role } = req.body; + + const isCustomRole = !["admin", "member", "owner"].includes(role); + if (isCustomRole) { + const orgRole = await Role.findOne({ slug: role, isOrgRole: true }); + if (!orgRole) throw BadRequestError({ message: "Role not found" }); + + const membership = await MembershipOrg.findByIdAndUpdate(membershipId, { + role: CUSTOM, + customRole: orgRole }); -} + return res.status(200).send({ + membership + }); + } + + const membership = await MembershipOrg.findByIdAndUpdate( + membershipId, + { + role + }, + { + new: true + } + ); + + return res.status(200).send({ + membership + }); +}; /** * Delete organization membership with id [membershipId] - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationMembership = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Delete organization membership' #swagger.description = 'Delete organization membership' @@ -178,30 +191,30 @@ export const deleteOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; - - // delete organization membership - const membership = await deleteMembershipOrg({ - membershipOrgId: membershipId, - }); + const { membershipId } = req.params; - await updateSubscriptionOrgQuantity({ - organizationId: membership.organization.toString(), - }); + // delete organization membership + const membership = await deleteMembershipOrg({ + membershipOrgId: membershipId + }); - return res.status(200).send({ - membership, - }); -} + await updateSubscriptionOrgQuantity({ + organizationId: membership.organization.toString() + }); + + return res.status(200).send({ + membership + }); +}; /** * Return workspaces for organization with id [organizationId] that user has * access to - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationWorkspaces = async (req: Request, res: Response) => { - /* + /* #swagger.summary = 'Return projects in organization that user is part of' #swagger.description = 'Return projects in organization that user is part of' @@ -234,45 +247,45 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { organizationId } = req.params; - const workspacesSet = new Set( - ( - await Workspace.find( - { - organization: organizationId, - }, - "_id" - ) - ).map((w) => w._id.toString()) - ); + const workspacesSet = new Set( + ( + await Workspace.find( + { + organization: organizationId + }, + "_id" + ) + ).map((w) => w._id.toString()) + ); - const workspaces = ( - await Membership.find({ - user: req.user._id, - }).populate("workspace") - ) + const workspaces = ( + await Membership.find({ + user: req.user._id + }).populate("workspace") + ) .filter((m) => workspacesSet.has(m.workspace._id.toString())) .map((m) => m.workspace); -return res.status(200).send({ - workspaces, - }); -} + return res.status(200).send({ + workspaces + }); +}; /** * Return service accounts for organization with id [organizationId] - * @param req - * @param res + * @param req + * @param res */ export const getOrganizationServiceAccounts = async (req: Request, res: Response) => { - const { organizationId } = req.params; - - const serviceAccounts = await ServiceAccount.find({ - organization: new Types.ObjectId(organizationId), - }); - - return res.status(200).send({ - serviceAccounts, - }); -} + const { organizationId } = req.params; + + const serviceAccounts = await ServiceAccount.find({ + organization: new Types.ObjectId(organizationId) + }); + + return res.status(200).send({ + serviceAccounts + }); +}; diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index d2fcf5b17..3fd7fa3d8 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -11,29 +11,29 @@ import { BadRequestError, MembershipNotFoundError } from "../utils/errors"; * @returns {Membership} membership - membership of user with id [userId] for workspace with id [workspaceId] */ export const validateMembership = async ({ - userId, - workspaceId, - acceptedRoles, + userId, + workspaceId, + acceptedRoles }: { userId: Types.ObjectId | string; workspaceId: Types.ObjectId | string; - acceptedRoles?: Array<"admin" | "member">; + acceptedRoles?: Array<"admin" | "member" | "custom">; }) => { const membership = await Membership.findOne({ user: userId, - workspace: workspaceId, + workspace: workspaceId }).populate("workspace"); if (!membership) { throw MembershipNotFoundError({ - message: "Failed to find workspace membership", + message: "Failed to find workspace membership" }); } if (acceptedRoles) { if (!acceptedRoles.includes(membership.role)) { throw BadRequestError({ - message: "Failed authorization for membership role", + message: "Failed authorization for membership role" }); } } @@ -47,7 +47,7 @@ export const validateMembership = async ({ * @return {Object} membership - membership */ export const findMembership = async (queryObj: any) => { - const membership = await Membership.findOne(queryObj); + const membership = await Membership.findOne(queryObj); return membership; }; @@ -60,9 +60,9 @@ export const findMembership = async (queryObj: any) => { * @param {String[]} obj.roles - roles of users. */ export const addMemberships = async ({ - userIds, - workspaceId, - roles, + userIds, + workspaceId, + roles }: { userIds: string[]; workspaceId: string; @@ -74,15 +74,15 @@ export const addMemberships = async ({ filter: { user: userId, workspace: workspaceId, - role: roles[idx], + role: roles[idx] }, update: { user: userId, workspace: workspaceId, - role: roles[idx], + role: roles[idx] }, - upsert: true, - }, + upsert: true + } }; }); await Membership.bulkWrite(operations as any); @@ -94,8 +94,8 @@ export const addMemberships = async ({ * @param {String} obj.membershipId - id of membership to delete */ export const deleteMembership = async ({ membershipId }: { membershipId: string }) => { - const deletedMembership = await Membership.findOneAndDelete({ - _id: membershipId, + const deletedMembership = await Membership.findOneAndDelete({ + _id: membershipId }); // delete keys associated with the membership @@ -103,9 +103,9 @@ export const deleteMembership = async ({ membershipId }: { membershipId: string // case: membership had a registered user await Key.deleteMany({ receiver: deletedMembership.user, - workspace: deletedMembership.workspace, + workspace: deletedMembership.workspace }); } - return deletedMembership; + return deletedMembership; }; diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index 3ed5be088..46f5fbf56 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -1,14 +1,6 @@ import { Types } from "mongoose"; -import { - Key, - Membership, - MembershipOrg, - Workspace, -} from "../models"; -import { - MembershipOrgNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { Key, Membership, MembershipOrg, Workspace } from "../models"; +import { MembershipOrgNotFoundError, UnauthorizedRequestError } from "../utils/errors"; /** * Validate that user with id [userId] is a member of organization with id [organizationId] @@ -19,39 +11,43 @@ import { * @param {String[]} obj.acceptedRoles */ export const validateMembershipOrg = async ({ - userId, - organizationId, - acceptedRoles, - acceptedStatuses, + userId, + organizationId, + acceptedRoles, + acceptedStatuses }: { - userId: Types.ObjectId; - organizationId: Types.ObjectId; - acceptedRoles?: Array<"owner" | "admin" | "member">; - acceptedStatuses?: Array<"invited" | "accepted">; + userId: Types.ObjectId; + organizationId: Types.ObjectId; + acceptedRoles?: Array<"owner" | "admin" | "member" | "custom">; + acceptedStatuses?: Array<"invited" | "accepted">; }) => { - const membershipOrg = await MembershipOrg.findOne({ - user: userId, - organization: organizationId, - }); - - if (!membershipOrg) { - throw MembershipOrgNotFoundError({ message: "Failed to find organization membership" }); - } - - if (acceptedRoles) { - if (!acceptedRoles.includes(membershipOrg.role)) { - throw UnauthorizedRequestError({ message: "Failed to validate organization membership role" }); - } - } - - if (acceptedStatuses) { - if (!acceptedStatuses.includes(membershipOrg.status)) { - throw UnauthorizedRequestError({ message: "Failed to validate organization membership status" }); - } - } - - return membershipOrg; -} + const membershipOrg = await MembershipOrg.findOne({ + user: userId, + organization: organizationId + }); + + if (!membershipOrg) { + throw MembershipOrgNotFoundError({ message: "Failed to find organization membership" }); + } + + if (acceptedRoles) { + if (!acceptedRoles.includes(membershipOrg.role)) { + throw UnauthorizedRequestError({ + message: "Failed to validate organization membership role" + }); + } + } + + if (acceptedStatuses) { + if (!acceptedStatuses.includes(membershipOrg.status)) { + throw UnauthorizedRequestError({ + message: "Failed to validate organization membership status" + }); + } + } + + return membershipOrg; +}; /** * Return organization membership matching criteria specified in @@ -60,8 +56,8 @@ export const validateMembershipOrg = async ({ * @return {Object} membershipOrg - membership */ export const findMembershipOrg = (queryObj: any) => { - const membershipOrg = MembershipOrg.findOne(queryObj); - return membershipOrg; + const membershipOrg = MembershipOrg.findOne(queryObj); + return membershipOrg; }; /** @@ -73,15 +69,15 @@ export const findMembershipOrg = (queryObj: any) => { * @param {String[]} obj.roles - roles of users. */ export const addMembershipsOrg = async ({ - userIds, - organizationId, - roles, - statuses, + userIds, + organizationId, + roles, + statuses }: { - userIds: string[]; - organizationId: string; - roles: string[]; - statuses: string[]; + userIds: string[]; + organizationId: string; + roles: string[]; + statuses: string[]; }) => { const operations = userIds.map((userId, idx) => { return { @@ -90,16 +86,16 @@ export const addMembershipsOrg = async ({ user: userId, organization: organizationId, role: roles[idx], - status: statuses[idx], + status: statuses[idx] }, update: { user: userId, organization: organizationId, role: roles[idx], - status: statuses[idx], + status: statuses[idx] }, - upsert: true, - }, + upsert: true + } }; }); @@ -111,13 +107,9 @@ export const addMembershipsOrg = async ({ * @param {Object} obj * @param {String} obj.membershipOrgId - id of organization membership to delete */ -export const deleteMembershipOrg = async ({ - membershipOrgId, -}: { - membershipOrgId: string; -}) => { +export const deleteMembershipOrg = async ({ membershipOrgId }: { membershipOrgId: string }) => { const deletedMembershipOrg = await MembershipOrg.findOneAndDelete({ - _id: membershipOrgId, + _id: membershipOrgId }); if (!deletedMembershipOrg) throw new Error("Failed to delete organization membership"); @@ -128,24 +120,24 @@ export const deleteMembershipOrg = async ({ const workspaces = ( await Workspace.find({ - organization: deletedMembershipOrg.organization, + organization: deletedMembershipOrg.organization }) ).map((w) => w._id.toString()); await Membership.deleteMany({ user: deletedMembershipOrg.user, workspace: { - $in: workspaces, - }, + $in: workspaces + } }); await Key.deleteMany({ receiver: deletedMembershipOrg.user, workspace: { - $in: workspaces, - }, + $in: workspaces + } }); } - return deletedMembershipOrg; -}; \ No newline at end of file + return deletedMembershipOrg; +}; diff --git a/backend/src/helpers/validation.ts b/backend/src/helpers/validation.ts new file mode 100644 index 000000000..f552eb69b --- /dev/null +++ b/backend/src/helpers/validation.ts @@ -0,0 +1,17 @@ +import type { Request } from "express"; +import { AnyZodObject, ZodError, z } from "zod"; +import { BadRequestError } from "../utils/errors"; + +export async function validateRequest( + schema: T, + req: Request +): Promise> { + try { + return schema.parseAsync(req); + } catch (error) { + if (error instanceof ZodError) { + throw BadRequestError({ message: error.message }); + } + return BadRequestError({ message: JSON.stringify(error) }); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 098da2aad..7a5723b04 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,7 @@ import { membership as v1MembershipRouter, organization as v1OrganizationRouter, password as v1PasswordRouter, + role as v1RoleRouter, secretImport as v1SecretImportRouter, secret as v1SecretRouter, secretsFolder as v1SecretsFolder, @@ -173,6 +174,7 @@ const main = async () => { app.use("/api/v1/secret-scanning", v1SecretScanningRouter); app.use("/api/v1/webhooks", v1WebhooksRouter); app.use("/api/v1/secret-imports", v1SecretImportRouter); + app.use("/api/v1/roles", v1RoleRouter); // v2 routes (improvements) app.use("/api/v2/signup", v2SignupRouter); diff --git a/backend/src/models/membership.ts b/backend/src/models/membership.ts index 6c32ff64f..0032e7e64 100644 --- a/backend/src/models/membership.ts +++ b/backend/src/models/membership.ts @@ -1,55 +1,60 @@ import { Schema, Types, model } from "mongoose"; -import { ADMIN, MEMBER } from "../variables"; +import { ADMIN, CUSTOM, MEMBER } from "../variables"; export interface IMembershipPermission { - environmentSlug: string, - ability: string + environmentSlug: string; + ability: string; } export interface IMembership { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail?: string; - workspace: Types.ObjectId; - role: "admin" | "member"; - deniedPermissions: IMembershipPermission[] + _id: Types.ObjectId; + user: Types.ObjectId; + inviteEmail?: string; + workspace: Types.ObjectId; + role: "admin" | "member" | "custom"; + customRole: Types.ObjectId; + deniedPermissions: IMembershipPermission[]; } const membershipSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - workspace: { - type: Schema.Types.ObjectId, - ref: "Workspace", - required: true, - }, - deniedPermissions: { - type: [ - { - environmentSlug: String, - ability: { - type: String, - enum: ["read", "write"], - }, - }, - ], - default: [], - }, - role: { - type: String, - enum: [ADMIN, MEMBER], - required: true, - }, - }, - { - timestamps: true, - } + { + user: { + type: Schema.Types.ObjectId, + ref: "User" + }, + inviteEmail: { + type: String + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace", + required: true + }, + deniedPermissions: { + type: [ + { + environmentSlug: String, + ability: { + type: String, + enum: ["read", "write"] + } + } + ], + default: [] + }, + role: { + type: String, + enum: [ADMIN, MEMBER, CUSTOM], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } ); export const Membership = model("Membership", membershipSchema); \ No newline at end of file diff --git a/backend/src/models/membershipOrg.ts b/backend/src/models/membershipOrg.ts index b45f9cfe8..cefd5a131 100644 --- a/backend/src/models/membershipOrg.ts +++ b/backend/src/models/membershipOrg.ts @@ -1,45 +1,49 @@ import { Document, Schema, Types, model } from "mongoose"; -import { ACCEPTED, ADMIN, INVITED, MEMBER, OWNER } from "../variables"; +import { ACCEPTED, ADMIN, CUSTOM, INVITED, MEMBER, OWNER } from "../variables"; export interface IMembershipOrg extends Document { - _id: Types.ObjectId; - user: Types.ObjectId; - inviteEmail: string; - organization: Types.ObjectId; - role: "owner" | "admin" | "member"; - status: "invited" | "accepted"; + _id: Types.ObjectId; + user: Types.ObjectId; + inviteEmail: string; + organization: Types.ObjectId; + role: "owner" | "admin" | "member" | "custom"; + customRole: Types.ObjectId; + status: "invited" | "accepted"; } const membershipOrgSchema = new Schema( - { - user: { - type: Schema.Types.ObjectId, - ref: "User", - }, - inviteEmail: { - type: String, - }, - organization: { - type: Schema.Types.ObjectId, - ref: "Organization", - }, - role: { - type: String, - enum: [OWNER, ADMIN, MEMBER], - required: true, - }, - status: { - type: String, - enum: [INVITED, ACCEPTED], - required: true, - }, - }, - { - timestamps: true, - } + { + user: { + type: Schema.Types.ObjectId, + ref: "User" + }, + inviteEmail: { + type: String + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization" + }, + role: { + type: String, + enum: [OWNER, ADMIN, MEMBER, CUSTOM], + required: true + }, + status: { + type: String, + enum: [INVITED, ACCEPTED], + required: true + }, + customRole: { + type: Schema.Types.ObjectId, + ref: "Role" + } + }, + { + timestamps: true + } ); -export const MembershipOrg = model( - "MembershipOrg", - membershipOrgSchema -); \ No newline at end of file +const MembershipOrg = model("MembershipOrg", membershipOrgSchema); + +export default MembershipOrg; diff --git a/backend/src/models/role.ts b/backend/src/models/role.ts new file mode 100644 index 000000000..4fe6cfc39 --- /dev/null +++ b/backend/src/models/role.ts @@ -0,0 +1,53 @@ +import { Schema, Types, model } from "mongoose"; + +export interface IRole { + _id: Types.ObjectId; + name: string; + description: string; + slug: string; + permissions: Array; + workspace: Types.ObjectId; + organization: Types.ObjectId; + isOrgRole: boolean; +} + +const roleSchema = new Schema( + { + name: { + type: String, + required: true + }, + organization: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true + }, + workspace: { + type: Schema.Types.ObjectId, + ref: "Workspace" + }, + isOrgRole: { + type: Boolean, + required: true, + select: false + }, + description: { + type: String + }, + slug: { + type: String, + required: true + }, + permissions: { + type: Array, + required: true + } + }, + { + timestamps: true + } +); + +const Role = model("Role", roleSchema); + +export default Role; diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts index 08298a1c6..5edab6a18 100644 --- a/backend/src/routes/v1/index.ts +++ b/backend/src/routes/v1/index.ts @@ -17,6 +17,7 @@ import integrationAuth from "./integrationAuth"; import secretsFolder from "./secretsFolder"; import webhooks from "./webhook"; import secretImport from "./secretImport"; +import role from "./role"; export { signup, @@ -37,5 +38,6 @@ export { integrationAuth, secretsFolder, webhooks, - secretImport + secretImport, + role }; diff --git a/backend/src/routes/v1/role.ts b/backend/src/routes/v1/role.ts new file mode 100644 index 000000000..a590091b8 --- /dev/null +++ b/backend/src/routes/v1/role.ts @@ -0,0 +1,20 @@ +import express from "express"; +import { roleController } from "../../controllers/v1"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; + +const router = express.Router(); + +router.post("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.createRole); + +router.patch("/:id", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.updateRole); + +router.delete( + "/:id", + requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), + roleController.deleteRole +); + +router.get("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.getRoles); + +export default router; diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index 55f8606df..35e3cf9dd 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -1,88 +1,82 @@ import express from "express"; const router = express.Router(); import { - requireAuth, - requireMembershipOrgAuth, - requireOrganizationAuth, - validateRequest, + requireAuth, + requireMembershipOrgAuth, + requireOrganizationAuth, + validateRequest } from "../../middleware"; import { body, param } from "express-validator"; -import { - ACCEPTED, - ADMIN, - AuthMode, - MEMBER, - OWNER -} from "../../variables"; +import { ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER } from "../../variables"; import { organizationsController } from "../../controllers/v2"; // TODO: /POST to create membership router.get( - "/:organizationId/memberships", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationMemberships + "/:organizationId/memberships", + param("organizationId").exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationMemberships ); router.patch( - "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - body("role").exists().isString().trim().isIn([OWNER, ADMIN, MEMBER]), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.updateOrganizationMembership + "/:organizationId/memberships/:membershipId", + param("organizationId").exists().trim(), + param("membershipId").exists().trim(), + body("role").exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireMembershipOrgAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.updateOrganizationMembership ); router.delete( - "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.deleteOrganizationMembership + "/:organizationId/memberships/:membershipId", + param("organizationId").exists().trim(), + param("membershipId").exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireMembershipOrgAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.deleteOrganizationMembership ); router.get( - "/:organizationId/workspaces", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationWorkspaces + "/:organizationId/workspaces", + param("organizationId").exists().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + organizationsController.getOrganizationWorkspaces ); router.get( // TODO endpoint: deprecate service accounts @@ -99,4 +93,4 @@ router.get( // TODO endpoint: deprecate service accounts organizationsController.getOrganizationServiceAccounts ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts new file mode 100644 index 000000000..228827c38 --- /dev/null +++ b/backend/src/services/RoleService.ts @@ -0,0 +1,74 @@ +import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; +import { MembershipOrg } from "../models"; +import { IRole } from "../models/role"; +import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; + +export enum GeneralPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum OrgPermissionSubjects { + Workspace = "workspace", + Role = "role" +} + +export type OrgPermissionSet = + | [GeneralPermissionActions, OrgPermissionSubjects.Workspace] + | [GeneralPermissionActions, OrgPermissionSubjects.Role]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + // ws permissions + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Workspace); + // role permission + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role); + + return build(); +}; + +export const adminPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + // ws permissions + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); + + return build(); +}; + +export const memberPermissions = buildMemberPermission(); + +export const getUserOrgPermissions = async (userId: string, orgId: string) => { + // TODO(akhilmhdh): speed this up by pulling from cache later + const orgMembership = await MembershipOrg.findOne({ user: userId, organization: orgId }) + .populate<{ customRole: IRole & { permissions: RawRuleOf>[] } }>( + "customRole" + ) + .exec(); + + if (!orgMembership || (orgMembership.role === "custom" && !orgMembership.customRole)) { + throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); + } + + if (orgMembership.role === "admin" || orgMembership.role === "owner") return adminPermissions; + + if (orgMembership.role === "member") return memberPermissions; + + if (orgMembership.role === "custom") { + const permission = createMongoAbility(orgMembership.customRole.permissions); + return permission; + } + + throw BadRequestError({ message: "User role not found" }); +}; diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts index 4cc25450f..c3ed7696c 100644 --- a/backend/src/validation/index.ts +++ b/backend/src/validation/index.ts @@ -1,4 +1,5 @@ export * from "./user"; +export * from "./role"; export * from "./workspace"; export * from "./bot"; export * from "./integration"; @@ -8,4 +9,4 @@ export * from "./membershipOrg"; export * from "./organization"; export * from "./secrets"; export * from "./serviceAccount"; -export * from "./serviceTokenData"; \ No newline at end of file +export * from "./serviceTokenData"; diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts new file mode 100644 index 000000000..3ed732046 --- /dev/null +++ b/backend/src/validation/role.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const CreateRoleSchema = z.object({ + body: z.object({ + slug: z.string(), + name: z.string(), + description: z.string().optional(), + workspaceId: z.string().optional(), + orgId: z.string(), + permissions: z + .object({ + subject: z.string(), + action: z.string(), + condition: z.record(z.union([z.string(), z.number()])) + }) + .array() + }) +}); + +export const UpdateRoleSchema = z.object({ + params: z.object({ + id: z.string() + }), + body: z.object({ + slug: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + workspaceId: z.string().optional(), + orgId: z.string(), + permissions: z + .object({ + subject: z.string(), + action: z.string(), + condition: z.record(z.union([z.string(), z.number()])) + }) + .array() + .optional() + }) +}); + +export const DeleteRoleSchema = z.object({ + params: z.object({ + id: z.string() + }) +}); + +export const GetRoleSchema = z.object({ + query: z.object({ + workspaceId: z.string().optional(), + orgId: z.string() + }) +}); diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 4f5620236..a1f9498e7 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -2,9 +2,10 @@ export const OWNER = "owner"; export const ADMIN = "admin"; export const MEMBER = "member"; +export const CUSTOM = "custom"; // membership statuses export const INVITED = "invited"; // -- organization -export const ACCEPTED = "accepted"; \ No newline at end of file +export const ACCEPTED = "accepted"; From 1167b1bc60703f13ac8001b3c8250b8f56db8ab8 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 14 Aug 2023 20:46:06 +0530 Subject: [PATCH 05/40] feat(rbac): ui components and hooks for rbac --- frontend/package-lock.json | 771 ++++++++++++++++-- frontend/package.json | 4 +- .../src/components/v2/Accordion/Accordion.tsx | 77 ++ .../src/components/v2/Accordion/index.tsx | 1 + .../components/v2/IconButton/IconButton.tsx | 3 +- frontend/src/components/v2/Table/Table.tsx | 4 +- frontend/src/components/v2/Tabs/Tabs.tsx | 178 +--- frontend/src/components/v2/Tabs/index.tsx | 6 +- frontend/src/components/v2/index.tsx | 2 + frontend/src/hooks/api/index.tsx | 1 + frontend/src/hooks/api/roles/index.tsx | 2 + frontend/src/hooks/api/roles/mutation.tsx | 42 + frontend/src/hooks/api/roles/queries.tsx | 27 + frontend/src/hooks/api/roles/types.ts | 43 + frontend/src/hooks/api/users/types.ts | 9 +- 15 files changed, 974 insertions(+), 196 deletions(-) create mode 100644 frontend/src/components/v2/Accordion/Accordion.tsx create mode 100644 frontend/src/components/v2/Accordion/index.tsx create mode 100644 frontend/src/hooks/api/roles/index.tsx create mode 100644 frontend/src/hooks/api/roles/mutation.tsx create mode 100644 frontend/src/hooks/api/roles/queries.tsx create mode 100644 frontend/src/hooks/api/roles/types.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 990b03377..11d27b3d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,8 @@ "packages": { "": { "dependencies": { + "@casl/ability": "^6.5.0", + "@casl/react": "^3.1.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", @@ -19,7 +21,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", - "@radix-ui/react-accordion": "^1.1.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", @@ -2458,6 +2460,26 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, + "node_modules/@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "peerDependencies": { + "@casl/ability": "^3.0.0 || ^4.0.0 || ^5.1.0 || ^6.0.0", + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -4235,24 +4257,230 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.1.tgz", - "integrity": "sha512-TQtyyRubYe8DD6DYCovNLTjd2D+TFrNCpr99T5M3cYUbR7BsRxWsxfInjbQ1nHsdy2uPTcnJS5npyXPVfP0piw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collapsible": "1.0.2", - "@radix-ui/react-collection": "1.0.2", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" }, "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-alert-dialog": { @@ -4307,23 +4535,210 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.2.tgz", - "integrity": "sha512-QNiDT6Au8jUU0K1WV+HEd4loH7C5CKQjeXxskwqyiyAkyCmW7qlQM5vSSJCIoQC+OVPyhgafSmGudRP8Qm1/gA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", "dependencies": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" }, "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-collection": { @@ -8457,6 +8872,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -24852,6 +25298,20 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "requires": { + "@ucast/mongo2js": "^1.3.0" + } + }, + "@casl/react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@casl/react/-/react-3.1.0.tgz", + "integrity": "sha512-p4Xmex1Slxz/G0cBtZik+xyOkeOynBUe0UrMFTai6aYkYOb4NyUy3w+9rtnedjcuKijiow2HKJQjnSurLxdc/g==", + "requires": {} + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -26029,20 +26489,118 @@ } }, "@radix-ui/react-accordion": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.1.tgz", - "integrity": "sha512-TQtyyRubYe8DD6DYCovNLTjd2D+TFrNCpr99T5M3cYUbR7BsRxWsxfInjbQ1nHsdy2uPTcnJS5npyXPVfP0piw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", "requires": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collapsible": "1.0.2", - "@radix-ui/react-collection": "1.0.2", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + } } }, "@radix-ui/react-alert-dialog": { @@ -26085,19 +26643,107 @@ } }, "@radix-ui/react-collapsible": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.2.tgz", - "integrity": "sha512-QNiDT6Au8jUU0K1WV+HEd4loH7C5CKQjeXxskwqyiyAkyCmW7qlQM5vSSJCIoQC+OVPyhgafSmGudRP8Qm1/gA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", "requires": { "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.0", - "@radix-ui/react-use-layout-effect": "1.0.0" + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + } } }, "@radix-ui/react-collection": { @@ -29108,6 +29754,37 @@ "eslint-visitor-keys": "^3.3.0" } }, + "@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "requires": { + "@ucast/core": "^1.0.0" + } + }, + "@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "requires": { + "@ucast/core": "^1.4.1" + } + }, + "@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "requires": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index fab8b7033..ad83a25f3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,8 @@ "build-storybook": "storybook build" }, "dependencies": { + "@casl/ability": "^6.5.0", + "@casl/react": "^3.1.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", @@ -27,7 +29,7 @@ "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", - "@radix-ui/react-accordion": "^1.1.0", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.2", "@radix-ui/react-checkbox": "^1.0.1", "@radix-ui/react-dialog": "^1.0.2", diff --git a/frontend/src/components/v2/Accordion/Accordion.tsx b/frontend/src/components/v2/Accordion/Accordion.tsx new file mode 100644 index 000000000..20ec5bacd --- /dev/null +++ b/frontend/src/components/v2/Accordion/Accordion.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from "react"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { twMerge } from "tailwind-merge"; + +export const AccordionItem = forwardRef( + ({ children, className, ...props }, forwardedRef) => ( + + {children} + + ) +); +AccordionItem.displayName = "AccordionItem"; + +export const AccordionTrigger = forwardRef< + HTMLButtonElement, + AccordionPrimitive.AccordionTriggerProps +>(({ children, className, ...props }, forwardedRef) => ( + + + {children} + + + +)); + +AccordionTrigger.displayName = "AccordionTrigger"; + +export const AccordionContent = forwardRef< + HTMLDivElement, + AccordionPrimitive.AccordionContentProps +>(({ children, className, ...props }, forwardedRef) => ( + +
{children}
+
+)); + +AccordionContent.displayName = "AccordionContent"; + +// ref: https://www.radix-ui.com/primitives/docs/components/accordion#root +export const Accordion = ({ + children, + ...props +}: AccordionPrimitive.AccordionSingleProps | AccordionPrimitive.AccordionMultipleProps) => ( + + {children} + +); diff --git a/frontend/src/components/v2/Accordion/index.tsx b/frontend/src/components/v2/Accordion/index.tsx new file mode 100644 index 000000000..831904d54 --- /dev/null +++ b/frontend/src/components/v2/Accordion/index.tsx @@ -0,0 +1 @@ +export { Accordion, AccordionContent, AccordionItem,AccordionTrigger } from "./Accordion"; diff --git a/frontend/src/components/v2/IconButton/IconButton.tsx b/frontend/src/components/v2/IconButton/IconButton.tsx index 56e15c9af..2f6a2837a 100644 --- a/frontend/src/components/v2/IconButton/IconButton.tsx +++ b/frontend/src/components/v2/IconButton/IconButton.tsx @@ -57,7 +57,8 @@ const iconButtonVariants = cva( { colorSchema: "primary", variant: "outline_bg", - className: "bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200 hover:text-bunker-100 duration-100" + className: + "bg-mineshaft-700 border border-mineshaft-600 hover:bg-primary/[0.15] hover:border-primary/60 text-bunker-200 hover:text-bunker-100 duration-100" }, { colorSchema: "danger", diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index eaebda330..82cfffd21 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -79,7 +79,7 @@ export const Tr = ({ }: TrProps): JSX.Element => ( ; export const Td = ({ children, className, ...props }: TdProps): JSX.Element => ( - + {children} ); diff --git a/frontend/src/components/v2/Tabs/Tabs.tsx b/frontend/src/components/v2/Tabs/Tabs.tsx index cc7e755f6..e0315195c 100644 --- a/frontend/src/components/v2/Tabs/Tabs.tsx +++ b/frontend/src/components/v2/Tabs/Tabs.tsx @@ -1,142 +1,46 @@ -import { useState } from "react"; -import { faArrowUpRightFromSquare, faCheck, faClipboard } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import * as Tabs from "@radix-ui/react-tabs"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { twMerge } from "tailwind-merge"; +export type TabsProps = TabsPrimitive.TabsProps; -export type TabsProps = Tabs.TabsProps & {}; +export const Tabs = ({ className, children, ...props }: TabsProps) => ( + + {children} + +); -function copyToClipboard(id: string, setState: (value: boolean) => void) { - // Get the text field - const copyText = document.getElementById(id) as HTMLInputElement; +export type TabListProps = TabsPrimitive.TabsListProps; - // Select the text field - copyText.select(); - copyText.setSelectionRange(0, 99999); // For mobile devices - - // Copy the text inside the text field - navigator.clipboard.writeText(copyText.value); - - setState(true); - setTimeout(() => setState(false), 2000); - // Alert the copied text - // alert("Copied the text: " + copyText.value); -} - -const CodeItem = ({isCopied, setIsCopied, textExplanation, code, id}: {isCopied: boolean, setIsCopied: (value: boolean) => void, textExplanation: string, code: string, id: string}) => { - return <> -

- {textExplanation} -

-
- - -
- -} - -export const TabsObject = () => { - const [downloadCodeCopied, setDownloadCodeCopied] = useState(false); - const [downloadCode2Copied, setDownloadCode2Copied] = useState(false); - const [loginCodeCopied, setLoginCodeCopied] = useState(false); - const [initCodeCopied, setInitCodeCopied] = useState(false); - const [runCodeCopied, setRunCodeCopied] = useState(false); - - return ( + - - - MacOS - - - Windows - - {/* - Arch Linux - */} - - Other Platforms - - - - - - - -

You can find example of start commands for different frameworks - here - .

-
- - -
- - -
- - - -

You can find example of start commands for different frameworks - here - .

-
-
-}; \ No newline at end of file + {children} + +); + +export type TabProps = TabsPrimitive.TabsTriggerProps; + +export const Tab = ({ className, children, ...props }: TabProps) => ( + + {children} + +); + +export type TabPanelProps = TabsPrimitive.TabsContentProps; + +export const TabPanel = ({ className, children, ...props }: TabPanelProps) => ( + + {children} + +); diff --git a/frontend/src/components/v2/Tabs/index.tsx b/frontend/src/components/v2/Tabs/index.tsx index 0f76dfecc..4e2246aaf 100644 --- a/frontend/src/components/v2/Tabs/index.tsx +++ b/frontend/src/components/v2/Tabs/index.tsx @@ -1,4 +1,2 @@ -export type { - TabsProps -} from "./Tabs"; -export { TabsObject } from "./Tabs"; +export type { TabListProps,TabPanelProps, TabProps, TabsProps } from "./Tabs"; +export { Tab, TabList, TabPanel, Tabs } from "./Tabs"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 8d23594dc..eb49f0d88 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -1,3 +1,4 @@ +export * from "./Accordion"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; @@ -21,6 +22,7 @@ export * from "./Skeleton"; export * from "./Spinner"; export * from "./Switch"; export * from "./Table"; +export * from "./Tabs"; export * from "./Tag"; export * from "./TextArea"; export * from "./Tooltip"; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 4e5cb66f4..f3a7ab4ca 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,6 +6,7 @@ export * from "./integrationAuth"; export * from "./integrations"; export * from "./keys"; export * from "./organization"; +export * from "./roles"; export * from "./secretFolders"; export * from "./secretImports"; export * from "./secrets"; diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx new file mode 100644 index 000000000..e675be785 --- /dev/null +++ b/frontend/src/hooks/api/roles/index.tsx @@ -0,0 +1,2 @@ +export { useCreateRole, useDeleteRole,useUpdateRole } from "./mutation"; +export { useGetRoles } from "./queries"; diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx new file mode 100644 index 000000000..f9e5336a5 --- /dev/null +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { roleQueryKeys } from "./queries"; +import { TCreateRoleDTO, TDeleteRoleDTO, TUpdateRoleDTO } from "./types"; + +export const useCreateRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (dto: TCreateRoleDTO) => apiRequest.post("/api/v1/roles", dto), + onSuccess: (_, { orgId, workspaceId }) => { + queryClient.invalidateQueries(roleQueryKeys.getRoles({ orgId, workspaceId })); + } + }); +}; + +export const useUpdateRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, ...dto }: TUpdateRoleDTO) => apiRequest.patch(`/api/v1/roles/${id}`, dto), + onSuccess: (_, { orgId, workspaceId }) => { + queryClient.invalidateQueries(roleQueryKeys.getRoles({ orgId, workspaceId })); + } + }); +}; + +export const useDeleteRole = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ orgId, id }: TDeleteRoleDTO) => + apiRequest.delete(`/api/v1/roles/${id}`, { + data: { orgId } + }), + onSuccess: (_, { orgId, workspaceId }) => { + queryClient.invalidateQueries(roleQueryKeys.getRoles({ orgId, workspaceId })); + } + }); +}; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx new file mode 100644 index 000000000..e0f3246f8 --- /dev/null +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TGetRolesDTO, TRole } from "./types"; + +export const roleQueryKeys = { + getRoles: ({ orgId, workspaceId }: TGetRolesDTO) => ["roles", { orgId, workspaceId }] as const +}; + +const getRoles = async ({ orgId, workspaceId }: TGetRolesDTO) => { + const { data } = await apiRequest.get<{ data: { roles: TRole[] } }>("/api/v1/roles", { + params: { + workspaceId, + orgId + } + }); + + return data.data.roles; +}; + +export const useGetRoles = ({ orgId, workspaceId }: TGetRolesDTO) => + useQuery({ + queryKey: roleQueryKeys.getRoles({ orgId, workspaceId }), + queryFn: () => getRoles({ orgId, workspaceId }), + enabled: Boolean(orgId) + }); diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts new file mode 100644 index 000000000..11830086a --- /dev/null +++ b/frontend/src/hooks/api/roles/types.ts @@ -0,0 +1,43 @@ +export type TGetRolesDTO = { + orgId: string; + workspaceId?: string; +}; + +export type TRole = { + _id: string; + organization: string; + workspace: string; + name: string; + description: string; + slug: string; + permissions: TPermission[]; + createdAt: string; + updatedAt: string; +}; + +export type TPermission = { + condition?: Record; + action: "read" | "edit" | "create" | "delete"; + subject: string; +}; + +export type TCreateRoleDTO = { + orgId: string; + workspaceId?: string; + name: string; + description?: string; + slug: string; + permissions: TPermission[]; +}; + +export type TUpdateRoleDTO = { + orgId: string; + id: string; + workspaceId?: string; +} & Partial>; + +export type TDeleteRoleDTO = { + orgId: string; + id: string; + workspaceId?: string; +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 8e0d19646..5c3890ee6 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -42,9 +42,10 @@ export type OrgUser = { }; inviteEmail: string; organization: string; - role: "owner" | "admin" | "member"; + role: "owner" | "admin" | "member" | "custom"; status: "invited" | "accepted" | "verified" | "completed"; deniedPermissions: any[]; + customRole: string; }; export type AddUserToWsDTO = { @@ -76,7 +77,7 @@ export type AddUserToOrgDTO = { export type CreateAPIKeyRes = { apiKey: string; apiKeyData: APIKeyData; -} +}; export type RenameUserDTO = { newName: string; @@ -89,7 +90,7 @@ export type APIKeyData = { lastUsed: string; createdAt: string; expiresAt: string; -} +}; export type TokenVersion = { _id: string; @@ -99,4 +100,4 @@ export type TokenVersion = { lastUsed: string; createdAt: string; updatedAt: string; -} \ No newline at end of file +}; From c5f76b1e6fa291646793a65d2c8f4fab830e7ee9 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 14 Aug 2023 20:46:46 +0530 Subject: [PATCH 06/40] feat(rbac): base ui for org rbac management --- .../src/pages/org/[id]/overview/index.tsx | 211 ++++++++- .../src/views/Org/MembersPage/MembersPage.tsx | 252 ++--------- .../OrgIncidentContactsTable.tsx | 200 --------- .../OrgIncidentContactsTable/index.tsx | 1 - .../OrgMembersTable/OrgMembersTable.tsx | 422 ++++++++++++------ .../OrgNameChangeSection.tsx | 69 --- .../components/OrgNameChangeSection/index.tsx | 1 - .../OrgRoleModifySection.tsx | 167 +++++++ .../OrgRoleModifySection.utils.ts | 93 ++++ .../OrgRoleWorkspacePermission.tsx | 205 +++++++++ .../OrgRoleModifySection/index.tsx | 1 + .../OrgRoleTabSection/OrgRoleTabSection.tsx | 46 ++ .../OrgRoleTabSection/OrgRoleTable.tsx | 140 ++++++ .../components/OrgRoleTabSection/index.tsx | 1 + .../OrgServiceAccountsTable.tsx | 367 --------------- .../OrgServiceAccountsTable/index.tsx | 1 - .../Org/MembersPage/components/index.tsx | 5 - 17 files changed, 1182 insertions(+), 1000 deletions(-) delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx delete mode 100644 frontend/src/views/Org/MembersPage/components/index.tsx diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 3f9355863..8a8d832cf 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -1,3 +1,5 @@ +// REFACTOR(akhilmhdh): This file needs to be split into multiple components too complex + import crypto from "crypto"; import { useEffect, useState } from "react"; @@ -11,8 +13,11 @@ import { faSlack } from "@fortawesome/free-brands-svg-icons"; import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; import { faArrowRight, + faArrowUpRightFromSquare, + faCheck, faCheckCircle, faExclamationCircle, + faClipboard, faHandPeace, faMagnifyingGlass, faNetworkWired, @@ -22,6 +27,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { yupResolver } from "@hookform/resolvers/yup"; +import * as Tabs from "@radix-ui/react-tabs"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; @@ -36,7 +42,6 @@ import { Skeleton, UpgradePlanModal } from "@app/components/v2"; -import { TabsObject } from "@app/components/v2/Tabs"; import { useSubscription, useUser, useWorkspace } from "@app/context"; import { fetchOrgUsers, useAddUserToWs, useCreateWorkspace, useRegisterUserAction,useUploadWsKey } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; @@ -63,6 +68,210 @@ type ItemProps = { link?: string; }; +function copyToClipboard(id: string, setState: (value: boolean) => void) { + // Get the text field + const copyText = document.getElementById(id) as HTMLInputElement; + + // Select the text field + copyText.select(); + copyText.setSelectionRange(0, 99999); // For mobile devices + + // Copy the text inside the text field + navigator.clipboard.writeText(copyText.value); + + setState(true); + setTimeout(() => setState(false), 2000); + // Alert the copied text + // alert("Copied the text: " + copyText.value); +} + +const CodeItem = ({ + isCopied, + setIsCopied, + textExplanation, + code, + id +}: { + isCopied: boolean; + setIsCopied: (value: boolean) => void; + textExplanation: string; + code: string; + id: string; +}) => { + return ( + <> +

{textExplanation}

+
+ + +
+ + ); +}; + +const TabsObject = () => { + const [downloadCodeCopied, setDownloadCodeCopied] = useState(false); + const [downloadCode2Copied, setDownloadCode2Copied] = useState(false); + const [loginCodeCopied, setLoginCodeCopied] = useState(false); + const [initCodeCopied, setInitCodeCopied] = useState(false); + const [runCodeCopied, setRunCodeCopied] = useState(false); + + return ( + + + + MacOS + + + Windows + + {/* + Arch Linux + */} + + Other Platforms + + + + + + + +

+ You can find example of start commands for different frameworks{" "} + + here + + .{" "} +

+
+ + +
+ + +
+ + + +

+ You can find example of start commands for different frameworks{" "} + + here + + .{" "} +

+
+
+ ); +}; + const LearningItem = ({ text, subText, diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index 0d6b8e7f2..065825b48 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -1,216 +1,28 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { useRouter } from "next/router"; +import { motion } from "framer-motion"; -import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - decryptAssymmetric, - encryptAssymmetric -} from "@app/components/utilities/cryptography/crypto"; -import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; -import { - useAddIncidentContact, - useAddUserToOrg, - useDeleteIncidentContact, - useDeleteOrgMembership, - useGetOrgIncidentContact, - useGetOrgUsers, - useGetUserWorkspaceMemberships, - useGetUserWsKey, - useRenameOrg, - useUpdateOrgUserRole, - useUploadWsKey -} from "@app/hooks/api"; +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetRoles } from "@app/hooks/api"; -import { - OrgIncidentContactsTable, - OrgMembersTable, - OrgNameChangeSection, - OrgServiceAccountsTable -} from "./components"; +import { OrgMembersTable } from "./components/OrgMembersTable"; +import { OrgRoleTabSection } from "./components/OrgRoleTabSection"; + +enum TabSections { + Member = "members", + Roles = "roles" +} export const MembersPage = () => { - const host = window.location.origin; - const router = useRouter(); - const { action } = router.query; - const { t } = useTranslation(); const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); - const { user } = useUser(); - const { subscription } = useSubscription(); - const { createNotification } = useNotificationContext(); const orgId = currentOrg?._id || ""; - const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId); - const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = - useGetUserWorkspaceMemberships(orgId); - const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || ""); - const { data: incidentContact, isLoading: IsIncidentContactLoading } = - useGetOrgIncidentContact(orgId); - - const renameOrg = useRenameOrg(); - const removeUserOrgMembership = useDeleteOrgMembership(); - const addUserToOrg = useAddUserToOrg(); - const updateOrgUserRole = useUpdateOrgUserRole(); - const uploadWsKey = useUploadWsKey(); - const addIncidentContact = useAddIncidentContact(); - const removeIncidentContact = useDeleteIncidentContact(); - - const [completeInviteLink, setcompleteInviteLink] = useState(""); - - const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false; - - const onRenameOrg = async (name: string) => { - if (!currentOrg?._id) return; - - try { - await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); - createNotification({ - text: "Successfully renamed organization", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to rename organization", - type: "error" - }); - } - }; - - const onRemoveUserOrgMembership = async (membershipId: string) => { - if (!currentOrg?._id) return; - - try { - await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId }); - createNotification({ - text: "Successfully removed user from org", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove user from the organization", - type: "error" - }); - } - }; - const onAddUserToOrg = async (email: string) => { - if (!currentOrg?._id) return; - - try { - const { data } = await addUserToOrg.mutateAsync({ - organizationId: currentOrg?._id, - inviteeEmail: email - }); - setcompleteInviteLink(data?.completeInviteLink); - - // only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLink) { - createNotification({ - text: "Successfully invited user to the organization.", - type: "success" - }); - } - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to invite user to org", - type: "error" - }); - } - }; - - const onUpdateOrgUserRole = async (membershipId: string, role: string) => { - if (!currentOrg?._id) return; - - try { - await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role }); - createNotification({ - text: "Successfully updated user role", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update user role", - type: "error" - }); - } - }; - - const onGrantUserAccess = async (userId: string, publicKey: string) => { - try { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - if (!PRIVATE_KEY || !wsKey) return; - - // assymmetrically decrypt symmetric key with local private key - const key = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey.mutateAsync({ - userId, - nonce, - encryptedKey: ciphertext, - workspaceId: currentWorkspace?._id || "" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to grant access to user", - type: "error" - }); - } - }; - - const onAddIncidentContact = async (email: string) => { - if (!currentOrg?._id) return; - - try { - await addIncidentContact.mutateAsync({ orgId, email }); - createNotification({ - text: "Successfully added incident contact", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to add incident contact", - type: "error" - }); - } - }; - - const onRemoveIncidentContact = async (email: string) => { - if (!currentOrg?._id) return; - - try { - await removeIncidentContact.mutateAsync({ orgId, email }); - createNotification({ - text: "Successfully removed incident contact", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove incident contact", - type: "error" - }); - } - }; + const { data: roles } = useGetRoles({ + orgId + }); return (
@@ -218,20 +30,28 @@ export const MembersPage = () => {

{t("section.members.org-members")}

- + + + Members + {process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( + Roles + )} + + + + + + + + + +
); diff --git a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx deleted file mode 100644 index cfafae765..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { - faContactBook, - faMagnifyingGlass, - faPlus, - faTrash -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { - Button, - DeleteActionModal, - EmailServiceSetupModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalContent, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { usePopUp } from "@app/hooks"; -import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; -import { IncidentContact } from "@app/hooks/api/types"; - -type Props = { - isLoading?: boolean; - contacts?: IncidentContact[]; - onRemoveContact: (email: string) => Promise; - onAddContact: (email: string) => Promise; -}; - -const addContactFormSchema = yup.object({ - email: yup.string().email().required().label("Email").trim() -}); - -type TAddContactForm = yup.InferType; - -export const OrgIncidentContactsTable = ({ - contacts = [], - onAddContact, - onRemoveContact, - isLoading -}: Props) => { - const [searchContact, setSearchContact] = useState(""); - const { data: serverDetails } = useFetchServerStatus(); - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "addContact", - "removeContact", - "setUpEmail" - ] as const); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addContactFormSchema) }); - - const onAddIncidentContact = ({ email }: TAddContactForm) => { - onAddContact(email); - handlePopUpClose("addContact"); - reset(); - }; - - const onRemoveIncidentContact = async () => { - const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; - await onRemoveContact(incidentContactEmail); - handlePopUpClose("removeContact"); - }; - - const filteredContacts = contacts.filter(({ email }) => - email.toLocaleLowerCase().includes(searchContact) - ); - - return ( -
-
-
- setSearchContact(e.target.value)} - leftIcon={} - placeholder="Search incident contact by email..." - /> -
-
- -
-
-
- - - - - - - - - {isLoading && } - {filteredContacts?.map(({ email }) => ( - - - - - ))} - -
Email -
{email} - handlePopUpOpen("removeContact", { email })} - > - - -
- {filteredContacts?.length === 0 && !isLoading && ( - - )} -
-
- { - handlePopUpToggle("addContact", isOpen); - reset(); - }} - > - -
- ( - - - - )} - /> -
- - -
- -
-
- handlePopUpToggle("removeContact", isOpen)} - onDeleteApproved={onRemoveIncidentContact} - /> - handlePopUpToggle("setUpEmail", isOpen)} - /> -
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx deleted file mode 100644 index b1df7cd68..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgIncidentContactsTable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index 2955f5b51..e13a2ca0a 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useRouter } from "next/router"; import { @@ -14,6 +14,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + decryptAssymmetric, + encryptAssymmetric +} from "@app/components/utilities/cryptography/crypto"; import { Button, DeleteActionModal, @@ -37,26 +41,23 @@ import { Tr, UpgradePlanModal } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; +import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; -import { useGetSSOConfig } from "@app/hooks/api"; +import { + useAddUserToOrg, + useDeleteOrgMembership, + useGetOrgUsers, + useGetSSOConfig, + useGetUserWorkspaceMemberships, + useGetUserWsKey, + useUpdateOrgUserRole, + useUploadWsKey +} from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; -import { OrgUser, Workspace } from "@app/hooks/api/types"; type Props = { - members?: OrgUser[]; - workspaceMemberships?: Record; - orgName: string; - isLoading?: boolean; - isMoreUserNotAllowed: boolean; - onRemoveMember: (userId: string) => Promise; - onInviteMember: (email: string) => Promise; - onRoleChange: (membershipId: string, role: string) => Promise; - onGrantAccess: (userId: string, publicKey: string) => Promise; - // the current user id to block remove org button - userId: string; - completeInviteLink: string | undefined; - setCompleteInviteLink: Dispatch>; + roles?: TRole[]; }; const addMemberFormSchema = yup.object({ @@ -65,27 +66,21 @@ const addMemberFormSchema = yup.object({ type TAddMemberForm = yup.InferType; -export const OrgMembersTable = ({ - members = [], - workspaceMemberships = {}, - orgName, - isMoreUserNotAllowed, - onRemoveMember, - onInviteMember, - onGrantAccess, - onRoleChange, - userId, - isLoading, - completeInviteLink, - setCompleteInviteLink -}: Props) => { +export const OrgMembersTable = ({ roles = [] }: Props) => { const router = useRouter(); const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); - const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(currentOrg?._id ?? ""); + const { workspaces, currentWorkspace } = useWorkspace(); + const { user } = useUser(); + const userId = user?._id || ""; + const orgId = currentOrg?._id || ""; + const workspaceId = currentWorkspace?._id || ""; + + const { data: ssoConfig, isLoading: isLoadingSSOConfig } = useGetSSOConfig(orgId); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const { data: serverDetails } = useFetchServerStatus(); - const { workspaces } = useWorkspace(); + const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addMember", @@ -93,6 +88,23 @@ export const OrgMembersTable = ({ "upgradePlan", "setUpEmail" ] as const); + const { subscription } = useSubscription(); + + const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); + const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = + useGetUserWorkspaceMemberships(orgId); + const { data: wsKey } = useGetUserWsKey(workspaceId); + + const removeUserOrgMembership = useDeleteOrgMembership(); + const addUserToOrg = useAddUserToOrg(); + const updateOrgUserRole = useUpdateOrgUserRole(); + const uploadWsKey = useUploadWsKey(); + + const [completeInviteLink, setCompleteInviteLink] = useState(""); + + const isMoreUsersNotAllowed = subscription?.memberLimit + ? subscription.membersUsed >= subscription.memberLimit + : false; useEffect(() => { if (router.query.action === "invite") { @@ -108,32 +120,100 @@ export const OrgMembersTable = ({ } = useForm({ resolver: yupResolver(addMemberFormSchema) }); const onAddMember = async ({ email }: TAddMemberForm) => { - await onInviteMember(email); + if (!currentOrg?._id) return; + + try { + const { data } = await addUserToOrg.mutateAsync({ + organizationId: currentOrg?._id, + inviteeEmail: email + }); + setCompleteInviteLink(data?.completeInviteLink); + // only show this notification when email is configured. + // A [completeInviteLink] will not be sent if smtp is configured + if (!data.completeInviteLink) { + createNotification({ + text: "Successfully invited user to the organization.", + type: "success" + }); + } + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to invite user to org", + type: "error" + }); + } if (serverDetails?.emailConfigured) { handlePopUpClose("addMember"); } - reset(); }; + const onAddUserToOrg = async (email: string) => { + if (!currentOrg?._id) return; + + try { + const { data } = await addUserToOrg.mutateAsync({ + organizationId: currentOrg?._id, + inviteeEmail: email + }); + setCompleteInviteLink(data?.completeInviteLink); + + // only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured + if (!data.completeInviteLink) { + createNotification({ + text: "Successfully invited user to the organization.", + type: "success" + }); + } + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to invite user to org", + type: "error" + }); + } + }; + const onRemoveOrgMemberApproved = async () => { - const orgMembershipId = (popUp?.removeMember?.data as { id: string })?.id; - await onRemoveMember(orgMembershipId); + const membershipId = (popUp?.removeMember?.data as { id: string })?.id; + if (!currentOrg?._id) return; + + try { + await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId }); + createNotification({ + text: "Successfully removed user from org", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove user from the organization", + type: "error" + }); + } handlePopUpClose("removeMember"); }; const isIamOwner = useMemo( - () => members.find(({ user }) => userId === user?._id)?.role === "owner", + () => members?.find(({ user: u }) => userId === u?._id)?.role === "owner", [userId, members] ); + const findRoleFromId = useCallback( + (roleId: string) => { + return roles.find(({ _id: id }) => id === roleId); + }, + [roles] + ); + const filterdUser = useMemo( () => - members.filter( - ({ user, inviteEmail }) => - user?.firstName?.toLowerCase().includes(searchMemberFilter) || - user?.lastName?.toLowerCase().includes(searchMemberFilter) || - user?.email?.toLowerCase().includes(searchMemberFilter) || + members?.filter( + ({ user: u, inviteEmail }) => + u?.firstName?.toLowerCase().includes(searchMemberFilter) || + u?.lastName?.toLowerCase().includes(searchMemberFilter) || + u?.email?.toLowerCase().includes(searchMemberFilter) || inviteEmail?.includes(searchMemberFilter) ), [members, searchMemberFilter] @@ -147,11 +227,65 @@ export const OrgMembersTable = ({ return () => clearTimeout(timer); }, [isInviteLinkCopied]); + const onRoleChange = async (membershipId: string, role: string) => { + if (!currentOrg?._id) return; + + try { + await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role }); + createNotification({ + text: "Successfully updated user role", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to update user role", + type: "error" + }); + } + }; + + const onGrantAccess = async (grantedUserId: string, publicKey: string) => { + try { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + if (!PRIVATE_KEY || !wsKey) return; + + // assymmetrically decrypt symmetric key with local private key + const key = decryptAssymmetric({ + ciphertext: wsKey.encryptedKey, + nonce: wsKey.nonce, + publicKey: wsKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: key, + publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadWsKey.mutateAsync({ + userId: grantedUserId, + nonce, + encryptedKey: ciphertext, + workspaceId: currentWorkspace?._id || "" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to grant access to user", + type: "error" + }); + } + }; + const copyTokenToClipboard = () => { navigator.clipboard.writeText(completeInviteLink as string); setInviteLinkCopied.on(); }; + const isLoading = isMembersLoading || IsWsMembershipLoading; + return (
@@ -175,7 +309,7 @@ export const OrgMembersTable = ({ return; } - if (isMoreUserNotAllowed) { + if (isMoreUsersNotAllowed) { handlePopUpOpen("upgradePlan"); } else { handlePopUpOpen("addMember"); @@ -200,106 +334,116 @@ export const OrgMembersTable = ({ {isLoading && } {!isLoading && - filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => { - const name = user ? `${user.firstName} ${user.lastName}` : "-"; - const email = user?.email || inviteEmail; - const userWs = workspaceMemberships?.[user?._id]; + filterdUser?.map( + ({ user: u, inviteEmail, role, customRole, _id: orgMembershipId, status }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + const userWs = workspaceMemberships?.[u?._id]; - return ( - - {name} - {email} - - {status === "accepted" && ( - - )} - {(status === "invited" || status === "verified") && - serverDetails?.emailConfigured && ( - + )} + {status === "completed" && ( + )} - {status === "completed" && ( - - )} - - - {userWs ? ( - userWs?.map(({ name: wsName, _id }) => ( - - {wsName} - - )) - ) : ( -
- {(status === "invited" || status === "verified") && - serverDetails?.emailConfigured ? ( - - This user hasn't accepted the invite yet + + + {userWs ? ( + userWs?.map(({ name: wsName, _id }) => ( + + {wsName} - ) : ( - - This user isn't part of any projects yet - - )} - {router.query.id !== "undefined" && - !( - (status === "invited" || status === "verified") && - serverDetails?.emailConfigured - ) && ( - + )) + ) : ( +
+ {(status === "invited" || status === "verified") && + serverDetails?.emailConfigured ? ( + + This user hasn't accepted the invite yet + + ) : ( + + This user isn't part of any projects yet + )} -
- )} - - - {userId !== user?._id && ( - handlePopUpOpen("removeMember", { id: orgMembershipId })} - > - - - )} - - - ); - })} + {router.query.id !== "undefined" && + !( + (status === "invited" || status === "verified") && + serverDetails?.emailConfigured + ) && ( + + )} +
+ )} + + + {userId !== u?._id && ( + + handlePopUpOpen("removeMember", { id: orgMembershipId }) + } + > + + + )} + + + ); + } + )} {!isLoading && filterdUser?.length === 0 && ( @@ -315,7 +459,7 @@ export const OrgMembersTable = ({ }} > {!completeInviteLink && ( diff --git a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx deleted file mode 100644 index 525b52471..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useTranslation } from "react-i18next"; -import { faCheck } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { Button, FormControl, Input } from "@app/components/v2"; - -type Props = { - orgName?: string; - onOrgNameChange: (name: string) => Promise; -}; - -const formSchema = yup.object({ - name: yup.string().required().label("Project Name") -}); - -type FormData = yup.InferType; - -export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => { - const { - handleSubmit, - control, - reset, - formState: { isDirty, isSubmitting } - } = useForm({ resolver: yupResolver(formSchema) }); - const { t } = useTranslation(); - - useEffect(() => { - reset({ name: orgName }); - }, [orgName]); - - const onFormSubmit = async ({ name }: FormData) => { - await onOrgNameChange(name); - }; - - return ( -
-
-

{t("common.display-name")}

-
- ( - - - - )} - control={control} - name="name" - /> -
- -
-
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx deleted file mode 100644 index 4d86fcddb..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgNameChangeSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgNameChangeSection } from "./OrgNameChangeSection"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx new file mode 100644 index 000000000..d761706b4 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { faArrowLeft, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { yupResolver } from "@hookform/resolvers/yup"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useCreateRole, useUpdateRole } from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; + +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + TFormSchema +} from "./OrgRoleModifySection.utils"; +import { OrgRoleWorkspacePermission } from "./OrgRoleWorkspacePermission"; + +type Props = { + role?: TRole; + onGoBack: VoidFunction; +}; + +export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { + const [searchPermission, setSearchPermission] = useState(""); + + const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || ""); + const isNewRole = !role?.slug; + + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + const { + handleSubmit, + register, + formState: { isSubmitting, isDirty, errors }, + setValue, + control + } = useForm({ + defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + resolver: yupResolver(formSchema) + }); + + const { mutateAsync: createRole } = useCreateRole(); + const { mutateAsync: updateRole } = useUpdateRole(); + + const handleRoleUpdate = async (el: TFormSchema) => { + if (!role?._id) return; + + try { + await updateRole({ + orgId, + id: role?._id, + ...el, + permissions: formRolePermission2API(el.permissions) + }); + createNotification({ type: "success", text: "Successfully updated role" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update role" }); + } + }; + + const handleFormSubmit = async (el: TFormSchema) => { + if (!isNewRole) { + await handleRoleUpdate(el); + return; + } + + try { + await createRole({ + orgId, + ...el, + permissions: formRolePermission2API(el.permissions) + }); + createNotification({ type: "success", text: "Created new role" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to create role" }); + } + }; + + return ( +
+
+
+

+ {isNewRole ? "New" : "Edit"} Role +

+ +
+

+ Roles are used to grant access to particular resources in your organization +

+
+ + + + + + + + + +
+
+

Add Permission

+
+
+ setSearchPermission(e.target.value)} + leftIcon={} + placeholder="Search permissions..." + /> +
+
+
+ +
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts new file mode 100644 index 000000000..3a2936697 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -0,0 +1,93 @@ +/* eslint-disable no-param-reassign */ +import * as yup from "yup"; + +import { TPermission } from "@app/hooks/api/roles/types"; + +const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const; + +export const formSchema = yup.object({ + name: yup.string().required().label("Name"), + description: yup.string(), + slug: yup.string().required().label("Slug"), + permissions: yup.object({ + workspace: yup.lazy((val) => + yup.object( + Object.fromEntries( + Object.entries(val || {}).map(([k]) => [ + k, + yup.object({ + read: yup.bool(), + edit: yup.bool(), + delete: yup.bool(), + create: yup.bool() + }) + ]) + ) + ) + ) + }) +}); + +export type TFormSchema = yup.InferType; + +const api2FormWorkspace = ( + formVal: TFormSchema["permissions"]["workspace"], + permission: TPermission +) => { + if (permission.subject !== "workspace") return; + const isCustomRule = Boolean(permission?.condition?.id); + if (isCustomRule && !formVal?.custom) { + formVal.custom = { read: true, edit: true, delete: true, create: true }; + } + + const workspaceId = permission?.condition?.id || "all"; + if (!formVal?.[workspaceId]) + formVal[workspaceId] = { read: false, edit: false, create: false, delete: false }; + formVal[workspaceId][permission.action] = true; +}; + +// convert role permission to form compatiable data structure +export const rolePermission2Form = (permissions: TPermission[] = []) => { + const formVal: TFormSchema["permissions"] = { + workspace: {} + }; + + permissions.forEach((permission) => { + api2FormWorkspace(formVal?.workspace, permission); + }); + + return formVal; +}; + +const form2ApiWorkspace = ( + permissions: TPermission[], + workspace: TFormSchema["permissions"]["workspace"] +) => { + const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]); + + PERMISSION_ACTIONS.forEach((action) => { + if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" }); + }); + + if (!isFullAccess) { + Object.keys(workspace) + .filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter + .forEach((workspaceId) => { + const actions = Object.keys(workspace[workspaceId]) as ["read", "edit", "create", "delete"]; + actions.forEach((action) => { + // if not full access for an action + if (!workspace?.all?.[action] && workspace[workspaceId][action]) { + permissions.push({ action, subject: "workspace", condition: { id: workspaceId } }); + } + }); + }); + } +}; + +export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { + const permissions: TPermission[] = []; + // easy deep copy + if (formVal?.workspace) + form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace))); + return permissions; +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx new file mode 100644 index 000000000..deb1bed20 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx @@ -0,0 +1,205 @@ +import { useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faClipboardList } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; + +import { + Checkbox, + Select, + SelectItem, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum WorkspacePermission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { + const { workspaces } = useWorkspace(); + + const customWorkspaceRule = useWatch({ + control, + name: "permissions.workspace.custom" + }); + const isCustom = Boolean(customWorkspaceRule); + const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" }); + + const selectedWsTopVal = useMemo(() => { + const { read, delete: del, edit, create } = allWorkspaceRule || {}; + if (read && del && edit && create) return WorkspacePermission.FullAccess; + if (read) return WorkspacePermission.ReadOnly; + return WorkspacePermission.NoAccess; + }, [allWorkspaceRule]); + + const handleTopLevelPermissionChange = (val: WorkspacePermission) => { + switch (val) { + case WorkspacePermission.NoAccess: + setValue("permissions.workspace", {}, { shouldDirty: true }); + break; + case WorkspacePermission.FullAccess: + setValue( + "permissions.workspace", + { all: { read: true, edit: true, create: true, delete: true } }, + { shouldDirty: true } + ); + break; + case WorkspacePermission.ReadOnly: + setValue( + "permissions.workspace", + { all: { read: true, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.workspace", + { custom: { read: false, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Projects
+
User project access control
+
+
+ +
+
+ + + + + + + + + + + + + {isCustom && + workspaces?.map(({ name, _id: id }) => ( + + + + + + + + ))} + +
+ ReadCreateEditDelete
{name} + ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx new file mode 100644 index 000000000..86de1647a --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/index.tsx @@ -0,0 +1 @@ +export { OrgRoleModifySection } from "./OrgRoleModifySection"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx new file mode 100644 index 000000000..631266168 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTabSection.tsx @@ -0,0 +1,46 @@ +import { motion } from "framer-motion"; + +import { usePopUp } from "@app/hooks"; + +import { TRole } from "~/hooks/api/roles/types"; + +import { OrgRoleModifySection } from "./OrgRoleModifySection"; +import { OrgRoleTable } from "./OrgRoleTable"; + +type Props = { + roles?: TRole[]; + isRolesLoading?: boolean; +}; + +export const OrgRoleTabSection = ({ roles = [], isRolesLoading }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); + + return popUp.editRole.isOpen ? ( + + handlePopUpClose("editRole")} + /> + + ) : ( + + handlePopUpOpen("editRole", role)} + /> + + ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx new file mode 100644 index 000000000..2bc09cb89 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { + Button, + DeleteActionModal, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useDeleteRole } from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; + +import { useNotificationContext } from "~/components/context/Notifications/NotificationProvider"; +import { usePopUp } from "~/hooks/usePopUp"; + +type Props = { + isRolesLoading?: boolean; + roles?: TRole[]; + onSelectRole: (role?: TRole) => void; +}; + +export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props) => { + const [searchRoles, setSearchRoles] = useState(""); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); + + const { mutateAsync: deleteRole } = useDeleteRole(); + + const handleRoleDelete = async () => { + const { _id: id } = popUp?.deleteRole?.data as TRole; + try { + await deleteRole({ + orgId, + id + }); + createNotification({ type: "success", text: "Successfully removed the role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to create role" }); + } + }; + + return ( +
+
+
+ setSearchRoles(e.target.value)} + leftIcon={} + placeholder="Search roles..." + /> +
+ +
+
+ + + + + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { _id: id, name, createdAt, slug } = role; + const isNonMutatable = ["owner", "admin", "member"].includes(slug); + + return ( + + + + + + + ); + })} + +
NameSlugCreated At +
{name}{slug} + {createdAt ? format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa") : "-"} + +
+ + onSelectRole(role)} + variant="plain" + > + + + + + handlePopUpOpen("deleteRole", role)} + variant="plain" + isDisabled={isNonMutatable} + > + + + +
+
+
+
+ handlePopUpClose("deleteRole")} + onDeleteApproved={handleRoleDelete} + /> +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx new file mode 100644 index 000000000..a78c88bcb --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/index.tsx @@ -0,0 +1 @@ +export { OrgRoleTabSection } from "./OrgRoleTabSection"; diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx deleted file mode 100644 index bd9f601c2..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { useRouter } from "next/router"; -import { - faCheck, - faCopy, - faMagnifyingGlass, - faPencil, - faPlus, - faServer, - faTrash -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; -import * as yup from "yup"; - -import { generateKeyPair } from "@app/components/utilities/cryptography/crypto"; -import { - Button, - DeleteActionModal, - EmptyState, - FormControl, - IconButton, - Input, - Modal, - ModalContent, - Select, - SelectItem, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; -import { usePopUp, useToggle } from "@app/hooks"; -import { - useCreateServiceAccount, - useDeleteServiceAccount, - useGetServiceAccounts -} from "@app/hooks/api"; - -const serviceAccountExpiration = [ - { label: "1 Day", value: 86400 }, - { label: "7 Days", value: 604800 }, - { label: "1 Month", value: 2592000 }, - { label: "6 months", value: 15552000 }, - { label: "12 months", value: 31104000 }, - { label: "Never", value: -1 } -]; - -const addServiceAccountFormSchema = yup.object({ - name: yup.string().required().label("Name").trim(), - expiresIn: yup.string().required().label("Service Account Expiration") -}); - -type TAddServiceAccountForm = yup.InferType; - -export const OrgServiceAccountsTable = () => { - const router = useRouter(); - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); - - const orgId = currentOrg?._id || ""; - const [step, setStep] = useState(0); - const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); - const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false); - const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); - const [accessKey, setAccessKey] = useState(""); - const [publicKey, setPublicKey] = useState(""); - const [privateKey, setPrivateKey] = useState(""); - const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(""); - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "addServiceAccount", - "removeServiceAccount" - ] as const); - - const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = - useGetServiceAccounts(orgId); - - const createServiceAccount = useCreateServiceAccount(); - const removeServiceAccount = useDeleteServiceAccount(); - - useEffect(() => { - let timer: NodeJS.Timeout; - if (isAccessKeyCopied) { - timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); - } - - if (isPublicKeyCopied) { - timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000); - } - - if (isPrivateKeyCopied) { - timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); - } - - return () => clearTimeout(timer); - }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) }); - - const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => { - if (!currentOrg?._id) return; - - const keyPair = generateKeyPair(); - setPublicKey(keyPair.publicKey); - setPrivateKey(keyPair.privateKey); - - const serviceAccountDetails = await createServiceAccount.mutateAsync({ - name, - organizationId: currentOrg?._id, - publicKey: keyPair.publicKey, - expiresIn: Number(expiresIn) - }); - - setAccessKey(serviceAccountDetails.serviceAccountAccessKey); - - setStep(1); - reset(); - }; - - const onRemoveServiceAccount = async () => { - const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; - await removeServiceAccount.mutateAsync(serviceAccountId); - handlePopUpClose("removeServiceAccount"); - }; - - const filteredServiceAccounts = useMemo( - () => - serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)), - [serviceAccounts, searchServiceAccountFilter] - ); - - const renderStep = (stepToRender: number) => { - switch (stepToRender) { - case 0: - return ( -
- ( - - - - )} - /> - { - return ( - - - - ); - }} - /> -
- - -
- - ); - case 1: - return ( - <> -

Access Key

-
-

{accessKey}

- { - navigator.clipboard.writeText(accessKey); - setIsAccessKeyCopied.on(); - }} - > - - - Copy - - -
-

Public Key

-
-

{publicKey}

- { - navigator.clipboard.writeText(publicKey); - setIsPublicKeyCopied.on(); - }} - > - - - Copy - - -
-

Private Key

-
-

{privateKey}

- { - navigator.clipboard.writeText(privateKey); - setIsPrivateKeyCopied.on(); - }} - > - - - Copy - - -
- - ); - default: - return
; - } - }; - - return ( -
-
-
- setSearchServiceAccountFilter(e.target.value)} - leftIcon={} - placeholder="Search service accounts..." - /> -
- -
- - - - - - - - {isServiceAccountsLoading && ( - - )} - {!isServiceAccountsLoading && - filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => { - return ( - - - - - - ); - })} - -
NameValid Until -
{name}{new Date(expiresAt).toUTCString()} -
- { - if (currentWorkspace?._id) { - router.push( - `/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}` - ); - } - }} - className="mr-2" - > - - - - handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId }) - } - > - - -
-
- {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( - - )} -
- { - handlePopUpToggle("addServiceAccount", isOpen); - reset(); - }} - > - - {renderStep(step)} - - - handlePopUpToggle("removeServiceAccount", isOpen)} - onDeleteApproved={onRemoveServiceAccount} - /> -
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx deleted file mode 100644 index 8e900f6fa..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgServiceAccountsTable/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/index.tsx b/frontend/src/views/Org/MembersPage/components/index.tsx deleted file mode 100644 index 796bb6aa3..000000000 --- a/frontend/src/views/Org/MembersPage/components/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable"; -export { OrgMembersTable } from "./OrgMembersTable"; -export { OrgNameChangeSection } from "./OrgNameChangeSection"; -export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable"; - From 96437fd1b7c639bc9e561d05412968b367eb83be Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 15 Aug 2023 16:55:48 +0530 Subject: [PATCH 07/40] feat(rbac): added rest of permissions in ui and backend api for org level --- backend/src/controllers/v1/roleController.ts | 3 + backend/src/services/RoleService.ts | 53 +++++- backend/src/validation/role.ts | 4 +- frontend/package-lock.json | 16 +- frontend/package.json | 3 +- .../BillingPermission.tsx | 147 +++++++++++++++++ .../IncidentContactPermission.tsx | 151 ++++++++++++++++++ .../OrgRoleModifySection/MemberPermission.tsx | 146 +++++++++++++++++ .../OrgRoleModifySection.tsx | 52 +++++- .../OrgRoleModifySection.utils.ts | 91 +++++++---- .../OrgRoleModifySection/RolePermission.tsx | 150 +++++++++++++++++ .../ServiceAccountPermission.tsx | 150 +++++++++++++++++ .../SettingsPermission.tsx | 150 +++++++++++++++++ .../OrgRoleModifySection/SsoPermission.tsx | 150 +++++++++++++++++ ...Permission.tsx => WorkspacePermission.tsx} | 43 ++--- .../OrgRoleTabSection/OrgRoleTable.tsx | 2 +- 16 files changed, 1253 insertions(+), 58 deletions(-) create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx create mode 100644 frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx rename frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/{OrgRoleWorkspacePermission.tsx => WorkspacePermission.tsx} (84%) diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index 9402a5091..026139ce9 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -135,18 +135,21 @@ export const getRoles = async (req: Request, res: Response) => { data: { roles: [ { + _id: "owner", name: "Owner", slug: "owner", description: "Complete administration access over the organization.", permissions: adminPermissions.rules }, { + _id: "admin", name: "Admin", slug: "admin", description: "Complete administration access over the organization", permissions: adminPermissions.rules }, { + _id: "member", name: "Member", slug: "member", description: "Non-administrative role in an organization", diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 228827c38..7274c437f 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -12,12 +12,24 @@ export enum GeneralPermissionActions { export enum OrgPermissionSubjects { Workspace = "workspace", - Role = "role" + Role = "role", + Member = "member", + Settings = "settings", + ServiceAccount = "service-account", + IncidentAccount = "incident-contact", + Sso = "sso", + Billing = "billing" } export type OrgPermissionSet = | [GeneralPermissionActions, OrgPermissionSubjects.Workspace] - | [GeneralPermissionActions, OrgPermissionSubjects.Role]; + | [GeneralPermissionActions, OrgPermissionSubjects.Role] + | [GeneralPermissionActions, OrgPermissionSubjects.Member] + | [GeneralPermissionActions, OrgPermissionSubjects.Settings] + | [GeneralPermissionActions, OrgPermissionSubjects.ServiceAccount] + | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [GeneralPermissionActions, OrgPermissionSubjects.Sso] + | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -32,6 +44,36 @@ const buildAdminPermission = () => { can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role); can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Member); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Settings); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.ServiceAccount); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Sso); + + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Billing); + return build(); }; @@ -40,9 +82,14 @@ export const adminPermissions = buildAdminPermission(); const buildMemberPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); - // ws permissions can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); return build(); }; diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts index 3ed732046..2e6623d57 100644 --- a/backend/src/validation/role.ts +++ b/backend/src/validation/role.ts @@ -11,7 +11,7 @@ export const CreateRoleSchema = z.object({ .object({ subject: z.string(), action: z.string(), - condition: z.record(z.union([z.string(), z.number()])) + condition: z.record(z.union([z.string(), z.number()])).optional() }) .array() }) @@ -31,7 +31,7 @@ export const UpdateRoleSchema = z.object({ .object({ subject: z.string(), action: z.string(), - condition: z.record(z.union([z.string(), z.number()])) + condition: z.record(z.union([z.string(), z.number()])).optional() }) .array() .optional() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11d27b3d5..262051bcf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -90,7 +90,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", @@ -23634,6 +23635,14 @@ "engines": { "node": ">=10" } + }, + "node_modules/zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } }, "dependencies": { @@ -40759,6 +40768,11 @@ "property-expr": "^2.0.4", "toposort": "^2.0.2" } + }, + "zod": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz", + "integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==" } } } diff --git a/frontend/package.json b/frontend/package.json index ad83a25f3..1e73e4c7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -98,7 +98,8 @@ "uuid": "^8.3.2", "uuidv4": "^6.2.13", "yaml": "^2.2.2", - "yup": "^0.32.11" + "yup": "^0.32.11", + "zod": "^3.22.0" }, "devDependencies": { "@storybook/addon-essentials": "^7.0.23", diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx new file mode 100644 index 000000000..6fbeac9fb --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx @@ -0,0 +1,147 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faMoneyBill } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const BillingPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.billing" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + switch (val) { + case Permission.NoAccess: + setValue( + "permissions.billing", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + "permissions.billing", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.billing", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.billing", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Billing
+
Billing management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx new file mode 100644 index 000000000..2d92f5100 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx @@ -0,0 +1,151 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faContactCard } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const IncidentContactPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.incident-contact" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.incident-contact", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.incident-contact", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Incident Contact
+
Incident Contacts management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx new file mode 100644 index 000000000..f924e52db --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx @@ -0,0 +1,146 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Invite" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const MemberPermission = ({ isNonEditable, setValue, control }: Props) => { + const memberRule = useWatch({ + control, + name: "permissions.member" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(memberRule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += memberRule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && memberRule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [memberRule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setValue( + "permissions.member", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + "permissions.member", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.member", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.member", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Members
+
Project member management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index d761706b4..9143b1f10 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; import { faArrowLeft, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { yupResolver } from "@hookform/resolvers/yup"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { Button, FormControl, Input } from "@app/components/v2"; @@ -10,13 +10,20 @@ import { useOrganization } from "@app/context"; import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; +import { BillingPermission } from "./BillingPermission"; +import { IncidentContactPermission } from "./IncidentContactPermission"; +import { MemberPermission } from "./MemberPermission"; import { formRolePermission2API, formSchema, rolePermission2Form, TFormSchema } from "./OrgRoleModifySection.utils"; -import { OrgRoleWorkspacePermission } from "./OrgRoleWorkspacePermission"; +import { RolePermission } from "./RolePermission"; +import { ServiceAccountPermission } from "./ServiceAccountPermission"; +import { SettingsPermission } from "./SettingsPermission"; +import { SsoPermission } from "./SsoPermission"; +import { WorkspacePermission } from "./WorkspacePermission"; type Props = { role?: TRole; @@ -40,7 +47,7 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { control } = useForm({ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, - resolver: yupResolver(formSchema) + resolver: zodResolver(formSchema) }); const { mutateAsync: createRole } = useCreateRole(); @@ -142,7 +149,44 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
- +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ - yup.object( - Object.fromEntries( - Object.entries(val || {}).map(([k]) => [ - k, - yup.object({ - read: yup.bool(), - edit: yup.bool(), - delete: yup.bool(), - create: yup.bool() - }) - ]) - ) - ) - ) +const generalPermissionSchema = z.object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() +}); + +export const formSchema = z.object({ + name: z.string(), + description: z.string().optional(), + slug: z.string(), + permissions: z.object({ + workspace: z.record(generalPermissionSchema), + member: generalPermissionSchema, + role: generalPermissionSchema, + settings: generalPermissionSchema, + "service-account": generalPermissionSchema, + "incident-contact": generalPermissionSchema, + sso: generalPermissionSchema, + billing: generalPermissionSchema }) }); -export type TFormSchema = yup.InferType; +export type TFormSchema = z.infer; const api2FormWorkspace = ( formVal: TFormSchema["permissions"]["workspace"], @@ -36,24 +36,43 @@ const api2FormWorkspace = ( ) => { if (permission.subject !== "workspace") return; const isCustomRule = Boolean(permission?.condition?.id); + // full access if (isCustomRule && !formVal?.custom) { formVal.custom = { read: true, edit: true, delete: true, create: true }; } const workspaceId = permission?.condition?.id || "all"; - if (!formVal?.[workspaceId]) + // initalize + if (!formVal?.[workspaceId]) { formVal[workspaceId] = { read: false, edit: false, create: false, delete: false }; + } formVal[workspaceId][permission.action] = true; }; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TPermission[] = []) => { const formVal: TFormSchema["permissions"] = { - workspace: {} + workspace: {}, + billing: {}, + settings: {}, + role: {}, + sso: {}, + member: {}, + "service-account": {}, + "incident-contact": {} }; permissions.forEach((permission) => { - api2FormWorkspace(formVal?.workspace, permission); + switch (permission.subject) { + case "workspace": + api2FormWorkspace(formVal?.workspace, permission); + break; + default: + // everything else follows same pattern + // formVal[settings][read | write] = true + formVal[permission.subject as keyof TFormSchema["permissions"]][permission.action] = true; + break; + } }); return formVal; @@ -64,7 +83,7 @@ const form2ApiWorkspace = ( workspace: TFormSchema["permissions"]["workspace"] ) => { const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]); - + // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" }); }); @@ -86,8 +105,24 @@ const form2ApiWorkspace = ( export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { const permissions: TPermission[] = []; - // easy deep copy - if (formVal?.workspace) + if (formVal?.workspace) { + // easy deep copy form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace))); + } + // other than workspace everything else follows same + // if in future there is a different follow the above on how workspace is done + const { workspace, ...rules } = formVal; + (Object.keys(rules) as Array).forEach((rule) => { + // all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] + // quite annoying i know + const actions = Object.keys(rules[rule]) as Array< + keyof z.infer + >; + actions.forEach((action) => { + if (rules[rule][action]) { + permissions.push({ action, subject: rule }); + } + }); + }); return permissions; }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx new file mode 100644 index 000000000..342d87732 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faUserCog } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { + const roleRule = useWatch({ + control, + name: "permissions.role" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(roleRule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += roleRule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && roleRule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [roleRule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.role", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.role", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.role", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.role", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Role
+
Project role management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx new file mode 100644 index 000000000..37e52dccf --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/ServiceAccountPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faLaptopCode } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.service-account" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.service-account", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.service-account", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Service Accounts
+
Service Account management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx new file mode 100644 index 000000000..0bfc821e7 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faCog } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.settings" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.settings", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.settings", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Settings
+
Settings management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx new file mode 100644 index 000000000..25ae2438b --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx @@ -0,0 +1,150 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faSignIn } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./OrgRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.sso" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + let score = 0; + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + actions.forEach((key) => (score += rule[key] ? 1 : 0)); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setIsCustom.off(); + setValue( + "permissions.sso", + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setIsCustom.on(); + setValue( + "permissions.sso", + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
SSO
+
SSO management control
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx similarity index 84% rename from frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx rename to frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx index deb1bed20..daac5a523 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleWorkspacePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx @@ -3,6 +3,7 @@ import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form" import { faClipboardList } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { Checkbox, @@ -26,14 +27,14 @@ type Props = { control: Control; }; -enum WorkspacePermission { +enum Permission { NoAccess = "no-access", ReadOnly = "read-only", FullAccess = "full-acess", Custom = "custom" } -export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { +export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { const { workspaces } = useWorkspace(); const customWorkspaceRule = useWatch({ @@ -43,26 +44,26 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: const isCustom = Boolean(customWorkspaceRule); const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" }); - const selectedWsTopVal = useMemo(() => { + const selectedPermissionCategory = useMemo(() => { const { read, delete: del, edit, create } = allWorkspaceRule || {}; - if (read && del && edit && create) return WorkspacePermission.FullAccess; - if (read) return WorkspacePermission.ReadOnly; - return WorkspacePermission.NoAccess; + if (read && del && edit && create) return Permission.FullAccess; + if (read) return Permission.ReadOnly; + return Permission.NoAccess; }, [allWorkspaceRule]); - const handleTopLevelPermissionChange = (val: WorkspacePermission) => { + const handlePermissionChange = (val: Permission) => { switch (val) { - case WorkspacePermission.NoAccess: + case Permission.NoAccess: setValue("permissions.workspace", {}, { shouldDirty: true }); break; - case WorkspacePermission.FullAccess: + case Permission.FullAccess: setValue( "permissions.workspace", { all: { read: true, edit: true, create: true, delete: true } }, { shouldDirty: true } ); break; - case WorkspacePermission.ReadOnly: + case Permission.ReadOnly: setValue( "permissions.workspace", { all: { read: true, edit: false, create: false, delete: false } }, @@ -80,7 +81,13 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }: }; return ( -
+
@@ -91,15 +98,15 @@ export const OrgRoleWorkspacePermission = ({ isNonEditable, setValue, control }:
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx index 2bc09cb89..1fbe3a957 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -88,7 +88,7 @@ export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props const isNonMutatable = ["owner", "admin", "member"].includes(slug); return ( - + {name} {slug} From e342e884992c0d49b005831f0b6511ef41b74725 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 16 Aug 2023 16:56:02 +0530 Subject: [PATCH 08/40] feat(rbac): added new zod validation and permission check for all org level api --- .../controllers/v1/membershipOrgController.ts | 78 ++- .../controllers/v1/organizationController.ts | 322 ++++++---- backend/src/controllers/v1/roleController.ts | 16 +- .../v1/secretScanningController.ts | 140 +++-- .../src/controllers/v1/workspaceController.ts | 98 +-- .../controllers/v2/organizationsController.ts | 49 +- .../controllers/v1/organizationsController.ts | 559 +++++++++++++----- .../src/ee/controllers/v1/ssoController.ts | 403 +++++++------ backend/src/ee/routes/v1/organizations.ts | 266 +++------ backend/src/ee/routes/v1/secretScanning.ts | 56 +- backend/src/ee/routes/v1/sso.ts | 169 ++---- backend/src/routes/v1/inviteOrg.ts | 25 +- backend/src/routes/v1/membership.ts | 70 +-- backend/src/routes/v1/membershipOrg.ts | 29 +- backend/src/routes/v1/organization.ts | 197 ++---- backend/src/routes/v2/organizations.ts | 63 +- backend/src/services/RoleService.ts | 54 +- backend/src/validation/membershipOrg.ts | 94 +-- backend/src/validation/organization.ts | 174 +++++- backend/src/validation/secretScanning.ts | 25 + backend/src/validation/sso.ts | 28 + backend/src/validation/workspace.ts | 217 ++++--- frontend/src/hooks/api/roles/types.ts | 12 +- .../OrgRoleModifySection.tsx | 10 +- .../OrgRoleModifySection.utils.ts | 84 +-- ...ssion.tsx => SecretScanningPermission.tsx} | 26 +- .../WorkspacePermission.tsx | 199 ++----- 27 files changed, 1870 insertions(+), 1593 deletions(-) create mode 100644 backend/src/validation/secretScanning.ts create mode 100644 backend/src/validation/sso.ts rename frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/{ServiceAccountPermission.tsx => SecretScanningPermission.tsx} (84%) diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 0c319fe62..9bc7e243b 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -8,14 +8,8 @@ import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; import { sendMail } from "../../helpers/nodemailer"; import { TokenService } from "../../services"; import { EELicenseService } from "../../ee/services"; -import { - ACCEPTED, - ADMIN, - INVITED, - MEMBER, - OWNER, - TOKEN_EMAIL_ORG_INVITATION -} from "../../variables"; +import { ACCEPTED, INVITED, MEMBER, TOKEN_EMAIL_ORG_INVITATION } from "../../variables"; +import * as reqValidator from "../../validation/membershipOrg"; import { getJwtSignupLifetime, getJwtSignupSecret, @@ -23,6 +17,13 @@ import { getSmtpConfigured } from "../../config"; import { validateUserEmail } from "../../validation"; +import { validateRequest } from "../../helpers/validation"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Delete organization membership with id [membershipOrgId] from organization @@ -31,7 +32,9 @@ import { validateUserEmail } from "../../validation"; * @returns */ export const deleteMembershipOrg = async (req: Request, _res: Response) => { - const { membershipOrgId } = req.params; + const { + params: { membershipOrgId } + } = await validateRequest(reqValidator.DelOrgMembershipv1, req); // check if organization membership to delete exists const membershipOrgToDelete = await MembershipOrg.findOne({ @@ -42,21 +45,14 @@ export const deleteMembershipOrg = async (req: Request, _res: Response) => { throw new Error("Failed to delete organization membership that doesn't exist"); } - // check if user is a member and admin of the organization - // whose membership we wish to delete - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: membershipOrgToDelete.organization - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); - } - - if (membershipOrg.role !== OWNER && membershipOrg.role !== ADMIN) { - // user is not an admin member of the organization - throw new Error("Insufficient role for deleting organization membership"); - } + const { permission, membership: membershipOrg } = await getUserOrgPermissions( + req.user._id, + membershipOrgToDelete.organization.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Delete, + OrgPermissionSubjects.Member + ); // delete organization membership await deleteMemberFromOrg({ @@ -96,22 +92,20 @@ export const changeMembershipOrgRole = async (req: Request, res: Response) => { */ export const inviteUserToOrganization = async (req: Request, res: Response) => { let inviteeMembershipOrg, completeInviteLink; - const { organizationId, inviteeEmail } = req.body; + const { + body: { inviteeEmail, organizationId } + } = await validateRequest(reqValidator.InviteUserToOrgv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Member + ); + const host = req.headers.host; const siteUrl = `${req.protocol}://${host}`; - - // validate membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: new Types.ObjectId(organizationId) - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); - } - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - + const ssoConfig = await SSOConfig.findOne({ organization: new Types.ObjectId(organizationId) }); @@ -119,9 +113,8 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { if (ssoConfig && ssoConfig.isActive) { // case: SAML SSO is enabled for the organization return res.status(400).send({ - message: - "Failed to invite member due to SAML SSO configured for organization" - }); + message: "Failed to invite member due to SAML SSO configured for organization" + }); } if (plan.memberLimit !== null) { @@ -231,7 +224,10 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { */ export const verifyUserToOrganization = async (req: Request, res: Response) => { let user; - const { email, organizationId, code } = req.body; + + const { + body: { organizationId, email, code } + } = await validateRequest(reqValidator.VerifyUserToOrgv1, req); user = await User.findOne({ email }).select("+publicKey"); diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index f738891a2..f34d6eec5 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -1,28 +1,38 @@ import { Request, Response } from "express"; import { - IncidentContactOrg, - Membership, - MembershipOrg, - Organization, - Workspace, + IncidentContactOrg, + Membership, + MembershipOrg, + Organization, + Workspace } from "../../models"; import { createOrganization as create } from "../../helpers/organization"; import { addMembershipsOrg } from "../../helpers/membershipOrg"; import { ACCEPTED, OWNER } from "../../variables"; import { getLicenseServerUrl, getSiteURL } from "../../config"; import { licenseServerKeyRequest } from "../../config/request"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/organization"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + WorkspacePermissionActions, + getUserOrgPermissions +} from "../../services/RoleService"; +import { OrganizationNotFoundError } from "../../utils/errors"; +import { ForbiddenError } from "@casl/ability"; export const getOrganizations = async (req: Request, res: Response) => { const organizations = ( await MembershipOrg.find({ user: req.user._id, - status: ACCEPTED, + status: ACCEPTED }).populate("organization") ).map((m) => m.organization); - return res.status(200).send({ - organizations, - }); + return res.status(200).send({ + organizations + }); }; /** @@ -33,28 +43,26 @@ export const getOrganizations = async (req: Request, res: Response) => { * @returns */ export const createOrganization = async (req: Request, res: Response) => { - const { organizationName } = req.body; - - if (organizationName.length < 1) { - throw new Error("Organization names must be at least 1-character long"); - } + const { + body: { organizationName } + } = await validateRequest(reqValidator.CreateOrgv1, req); // create organization and add user as member const organization = await create({ email: req.user.email, - name: organizationName, + name: organizationName }); await addMembershipsOrg({ userIds: [req.user._id.toString()], organizationId: organization._id.toString(), roles: [OWNER], - statuses: [ACCEPTED], + statuses: [ACCEPTED] }); - return res.status(200).send({ - organization, - }); + return res.status(200).send({ + organization + }); }; /** @@ -64,10 +72,23 @@ export const createOrganization = async (req: Request, res: Response) => { * @returns */ export const getOrganization = async (req: Request, res: Response) => { - const organization = req.organization - return res.status(200).send({ - organization, - }); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgv1, req); + + // ensure user has membership + await getUserOrgPermissions(req.user._id, organizationId); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + return res.status(200).send({ + organization + }); }; /** @@ -77,15 +98,23 @@ export const getOrganization = async (req: Request, res: Response) => { * @returns */ export const getOrganizationMembers = async (req: Request, res: Response) => { - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Member + ); const users = await MembershipOrg.find({ - organization: organizationId, + organization: organizationId }).populate("user", "+publicKey"); - return res.status(200).send({ - users, - }); + return res.status(200).send({ + users + }); }; /** @@ -94,17 +123,22 @@ export const getOrganizationMembers = async (req: Request, res: Response) => { * @param res * @returns */ -export const getOrganizationWorkspaces = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationWorkspaces = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgWorkspacesv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + WorkspacePermissionActions.Read, + OrgPermissionSubjects.Workspace + ); const workspacesSet = new Set( ( await Workspace.find( { - organization: organizationId, + organization: organizationId }, "_id" ) @@ -113,15 +147,15 @@ export const getOrganizationWorkspaces = async ( const workspaces = ( await Membership.find({ - user: req.user._id, + user: req.user._id }).populate("workspace") ) .filter((m) => workspacesSet.has(m.workspace._id.toString())) .map((m) => m.workspace); - return res.status(200).send({ - workspaces, - }); + return res.status(200).send({ + workspaces + }); }; /** @@ -131,25 +165,33 @@ export const getOrganizationWorkspaces = async ( * @returns */ export const changeOrganizationName = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const { name } = req.body; + const { + params: { organizationId }, + body: { name } + } = await validateRequest(reqValidator.ChangeOrgNamev1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.Settings + ); const organization = await Organization.findOneAndUpdate( { - _id: organizationId, + _id: organizationId }, { - name, + name }, { - new: true, + new: true } ); - return res.status(200).send({ - message: "Successfully changed organization name", - organization, - }); + return res.status(200).send({ + message: "Successfully changed organization name", + organization + }); }; /** @@ -158,19 +200,24 @@ export const changeOrganizationName = async (req: Request, res: Response) => { * @param res * @returns */ -export const getOrganizationIncidentContacts = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationIncidentContacts = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgIncidentContactv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactsOrg = await IncidentContactOrg.find({ - organization: organizationId, + organization: organizationId }); - return res.status(200).send({ - incidentContactsOrg, - }); + return res.status(200).send({ + incidentContactsOrg + }); }; /** @@ -179,12 +226,17 @@ export const getOrganizationIncidentContacts = async ( * @param res * @returns */ -export const addOrganizationIncidentContact = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; - const { email } = req.body; +export const addOrganizationIncidentContact = async (req: Request, res: Response) => { + const { + params: { organizationId }, + body: { email } + } = await validateRequest(reqValidator.CreateOrgIncideContact, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactOrg = await IncidentContactOrg.findOneAndUpdate( { email, organization: organizationId }, @@ -192,9 +244,9 @@ export const addOrganizationIncidentContact = async ( { upsert: true, new: true } ); - return res.status(200).send({ - incidentContactOrg, - }); + return res.status(200).send({ + incidentContactOrg + }); }; /** @@ -203,22 +255,27 @@ export const addOrganizationIncidentContact = async ( * @param res * @returns */ -export const deleteOrganizationIncidentContact = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; - const { email } = req.body; +export const deleteOrganizationIncidentContact = async (req: Request, res: Response) => { + const { + params: { organizationId }, + body: { email } + } = await validateRequest(reqValidator.DelOrgIncideContact, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Delete, + OrgPermissionSubjects.IncidentAccount + ); const incidentContactOrg = await IncidentContactOrg.findOneAndDelete({ email, - organization: organizationId, + organization: organizationId }); - return res.status(200).send({ - message: "Successfully deleted organization incident contact", - incidentContactOrg, - }); + return res.status(200).send({ + message: "Successfully deleted organization incident contact", + incidentContactOrg + }); }; /** @@ -228,19 +285,41 @@ export const deleteOrganizationIncidentContact = async ( * @param res * @returns */ -export const createOrganizationPortalSession = async ( - req: Request, - res: Response -) => { - const { data: { pmtMethods } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, +export const createOrganizationPortalSession = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Billing ); - + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { pmtMethods } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods` + ); + if (pmtMethods.length < 1) { // case: organization has no payment method on file - // -> redirect to add payment method portal - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, + // -> redirect to add payment method portal + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods`, { success_url: (await getSiteURL()) + "/dashboard", cancel_url: (await getSiteURL()) + "/dashboard" @@ -250,8 +329,12 @@ export const createOrganizationPortalSession = async ( } else { // case: organization has payment method on file // -> redirect to billing portal - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/billing-portal`, + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/billing-portal`, { return_url: (await getSiteURL()) + "/dashboard" } @@ -266,36 +349,43 @@ export const createOrganizationPortalSession = async ( * @param res * @returns */ -export const getOrganizationMembersAndTheirWorkspaces = async ( - req: Request, - res: Response -) => { - const { organizationId } = req.params; +export const getOrganizationMembersAndTheirWorkspaces = async (req: Request, res: Response) => { + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv1, req); - const workspacesSet = ( - await Workspace.find( - { - organization: organizationId, - }, - "_id" - ) - ).map((w) => w._id.toString()); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Member + ); + ForbiddenError.from(permission).throwUnlessCan( + WorkspacePermissionActions.Read, + OrgPermissionSubjects.Workspace + ); - const memberships = ( - await Membership.find({ - workspace: { $in: workspacesSet }, - }).populate("workspace") - ); - const userToWorkspaceIds: any = {}; + const workspacesSet = ( + await Workspace.find( + { + organization: organizationId + }, + "_id" + ) + ).map((w) => w._id.toString()); - memberships.forEach(membership => { - const user = membership.user.toString(); - if (userToWorkspaceIds[user]) { - userToWorkspaceIds[user].push(membership.workspace); - } else { - userToWorkspaceIds[user] = [membership.workspace]; - } - }); + const memberships = await Membership.find({ + workspace: { $in: workspacesSet } + }).populate("workspace"); + const userToWorkspaceIds: any = {}; - return res.json(userToWorkspaceIds); + memberships.forEach((membership) => { + const user = membership.user.toString(); + if (userToWorkspaceIds[user]) { + userToWorkspaceIds[user].push(membership.workspace); + } else { + userToWorkspaceIds[user] = [membership.workspace]; + } + }); + + return res.json(userToWorkspaceIds); }; diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index 026139ce9..844caa5fc 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -21,8 +21,8 @@ export const createRole = async (req: Request, res: Response) => { body: { workspaceId, name, description, slug, permissions, orgId } } = await validateRequest(CreateRoleSchema, req); - const orgPermission = await getUserOrgPermissions(req.user.id, orgId); - if (orgPermission.cannot(GeneralPermissionActions.Create, OrgPermissionSubjects.Role)) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(GeneralPermissionActions.Create, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } @@ -59,8 +59,8 @@ export const updateRole = async (req: Request, res: Response) => { } = await validateRequest(UpdateRoleSchema, req); const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule - const orgPermission = await getUserOrgPermissions(req.user.id, orgId); - if (orgPermission.cannot(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role)) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } @@ -103,8 +103,8 @@ export const deleteRole = async (req: Request, res: Response) => { throw BadRequestError({ message: "Role not found" }); } - const orgPermission = await getUserOrgPermissions(req.user.id, role.organization.toString()); - if (orgPermission.cannot(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role)) { + const { permission } = await getUserOrgPermissions(req.user.id, role.organization.toString()); + if (permission.cannot(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } await Role.findByIdAndDelete(role.id); @@ -123,8 +123,8 @@ export const getRoles = async (req: Request, res: Response) => { } = await validateRequest(GetRoleSchema, req); const isOrgRole = !workspaceId; - const orgPermission = await getUserOrgPermissions(req.user.id, orgId); - if (orgPermission.cannot(GeneralPermissionActions.Read, OrgPermissionSubjects.Role)) { + const { permission } = await getUserOrgPermissions(req.user.id, orgId); + if (permission.cannot(GeneralPermissionActions.Read, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } diff --git a/backend/src/controllers/v1/secretScanningController.ts b/backend/src/controllers/v1/secretScanningController.ts index ee55a4fc5..bb0403b3b 100644 --- a/backend/src/controllers/v1/secretScanningController.ts +++ b/backend/src/controllers/v1/secretScanningController.ts @@ -2,20 +2,45 @@ import { Request, Response } from "express"; import GitAppInstallationSession from "../../ee/models/gitAppInstallationSession"; import crypto from "crypto"; import { Types } from "mongoose"; -import { UnauthorizedRequestError } from "../../utils/errors"; +import { OrganizationNotFoundError, UnauthorizedRequestError } from "../../utils/errors"; import GitAppOrganizationInstallation from "../../ee/models/gitAppOrganizationInstallation"; -import { MembershipOrg } from "../../models"; import { scanGithubFullRepoForSecretLeaks } from "../../queues/secret-scanning/githubScanFullRepository" import { getSecretScanningGitAppId, getSecretScanningPrivateKey } from "../../config"; import GitRisks, { STATUS_RESOLVED_FALSE_POSITIVE, STATUS_RESOLVED_NOT_REVOKED, STATUS_RESOLVED_REVOKED } from "../../ee/models/gitRisks"; import { ProbotOctokit } from "probot"; +import { Organization } from "../../models"; +import { validateRequest } from "../../helpers/validation"; +import * as reqValidator from "../../validation/secretScanning"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; export const createInstallationSession = async (req: Request, res: Response) => { const sessionId = crypto.randomBytes(16).toString("hex"); + const { + params: { organizationId } + } = await validateRequest(reqValidator.CreateInstalLSessionv1, req); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.SecretScanning + ); + await GitAppInstallationSession.findByIdAndUpdate( - req.organization, + organization, { - organization: new Types.ObjectId(req.organization), + organization: organization.id, sessionId: sessionId, user: new Types.ObjectId(req.user._id) }, @@ -24,31 +49,43 @@ export const createInstallationSession = async (req: Request, res: Response) => res.send({ sessionId: sessionId - }) -} + }); +}; export const linkInstallationToOrganization = async (req: Request, res: Response) => { - const { installationId, sessionId } = req.body + const { + body: { sessionId, installationId } + } = await validateRequest(reqValidator.LinkInstallationToOrgv1, req); - const installationSession = await GitAppInstallationSession.findOneAndDelete({ sessionId: sessionId }) + const installationSession = await GitAppInstallationSession.findOneAndDelete({ + sessionId: sessionId + }); if (!installationSession) { - throw UnauthorizedRequestError() + throw UnauthorizedRequestError(); } - const userMembership = await MembershipOrg.find({ user: req.user._id, organization: installationSession.organization }) - if (!userMembership) { - throw UnauthorizedRequestError() - } + const { permission } = await getUserOrgPermissions( + req.user._id, + installationSession.organization.toString() + ); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.SecretScanning + ); - const installationLink = await GitAppOrganizationInstallation.findOneAndUpdate({ - organizationId: installationSession.organization, - }, { - installationId: installationId, - organizationId: installationSession.organization, - user: installationSession.user - }, { - upsert: true - }).lean() + const installationLink = await GitAppOrganizationInstallation.findOneAndUpdate( + { + organizationId: installationSession.organization + }, + { + installationId: installationId, + organizationId: installationSession.organization, + user: installationSession.user + }, + { + upsert: true + } + ).lean(); const octokit = new ProbotOctokit({ auth: { @@ -66,41 +103,68 @@ export const linkInstallationToOrganization = async (req: Request, res: Response } export const getCurrentOrganizationInstallationStatus = async (req: Request, res: Response) => { - const { organizationId } = req.params + const { organizationId } = req.params; try { - const appInstallation = await GitAppOrganizationInstallation.findOne({ organizationId: organizationId }).lean() + const appInstallation = await GitAppOrganizationInstallation.findOne({ + organizationId: organizationId + }).lean(); if (!appInstallation) { res.json({ appInstallationComplete: false - }) + }); } res.json({ appInstallationComplete: true - }) + }); } catch { res.json({ appInstallationComplete: false - }) + }); } -} +}; export const getRisksForOrganization = async (req: Request, res: Response) => { - const { organizationId } = req.params - const risks = await GitRisks.find({ organization: organizationId }).sort({ createdAt: -1 }).lean() + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgRisksv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.SecretScanning + ); + + const risks = await GitRisks.find({ organization: organizationId }) + .sort({ createdAt: -1 }) + .lean(); res.json({ risks: risks - }) -} + }); +}; export const updateRisksStatus = async (req: Request, res: Response) => { - const { riskId } = req.params - const { status } = req.body - const isRiskResolved = status == STATUS_RESOLVED_FALSE_POSITIVE || status == STATUS_RESOLVED_REVOKED || status == STATUS_RESOLVED_NOT_REVOKED ? true : false + const { + params: { organizationId, riskId }, + body: { status } + } = await validateRequest(reqValidator.UpdateRiskStatusv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.SecretScanning + ); + + const isRiskResolved = + status == STATUS_RESOLVED_FALSE_POSITIVE || + status == STATUS_RESOLVED_REVOKED || + status == STATUS_RESOLVED_NOT_REVOKED + ? true + : false; const risk = await GitRisks.findByIdAndUpdate(riskId, { status: status, isResolved: isRiskResolved - }).lean() + }).lean(); - res.json(risk) -} \ No newline at end of file + res.json(risk); +}; diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 1425c142c..a655e9690 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -5,17 +5,21 @@ import { Integration, IntegrationAuth, Membership, - MembershipOrg, + Organization, ServiceToken, - Workspace, + Workspace } from "../../models"; -import { - createWorkspace as create, - deleteWorkspace as deleteWork, -} from "../../helpers/workspace"; +import { createWorkspace as create, deleteWorkspace as deleteWork } from "../../helpers/workspace"; import { EELicenseService } from "../../ee/services"; import { addMemberships } from "../../helpers/membership"; import { ADMIN } from "../../variables"; +import { OrganizationNotFoundError } from "../../utils/errors"; +import { + OrgPermissionSubjects, + WorkspacePermissionActions, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Return public keys of members of workspace with id [workspaceId] @@ -28,17 +32,17 @@ export const getWorkspacePublicKeys = async (req: Request, res: Response) => { const publicKeys = ( await Membership.find({ - workspace: workspaceId, + workspace: workspaceId }).populate<{ user: IUser }>("user", "publicKey") ).map((member) => { return { publicKey: member.user.publicKey, - userId: member.user._id, + userId: member.user._id }; }); return res.status(200).send({ - publicKeys, + publicKeys }); }; @@ -52,11 +56,11 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { const { workspaceId } = req.params; const users = await Membership.find({ - workspace: workspaceId, + workspace: workspaceId }).populate("user", "+publicKey"); return res.status(200).send({ - users, + users }); }; @@ -69,12 +73,12 @@ export const getWorkspaceMemberships = async (req: Request, res: Response) => { export const getWorkspaces = async (req: Request, res: Response) => { const workspaces = ( await Membership.find({ - user: req.user._id, + user: req.user._id }).populate("workspace") ).map((m) => m.workspace); return res.status(200).send({ - workspaces, + workspaces }); }; @@ -88,11 +92,11 @@ export const getWorkspace = async (req: Request, res: Response) => { const { workspaceId } = req.params; const workspace = await Workspace.findOne({ - _id: workspaceId, + _id: workspaceId }); return res.status(200).send({ - workspace, + workspace }); }; @@ -106,24 +110,28 @@ export const getWorkspace = async (req: Request, res: Response) => { export const createWorkspace = async (req: Request, res: Response) => { const { workspaceName, organizationId } = req.body; - // validate organization membership - const membershipOrg = await MembershipOrg.findOne({ - user: req.user._id, - organization: new Types.ObjectId(organizationId), - }); - - if (!membershipOrg) { - throw new Error("Failed to validate organization membership"); + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); } + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + WorkspacePermissionActions.Create, + OrgPermissionSubjects.Workspace + ); + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - + if (plan.workspaceLimit !== null) { // case: limit imposed on number of workspaces allowed if (plan.workspacesUsed >= plan.workspaceLimit) { // case: number of workspaces used exceeds the number of workspaces allowed return res.status(400).send({ - message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces.", + message: + "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." }); } } @@ -135,17 +143,17 @@ export const createWorkspace = async (req: Request, res: Response) => { // create workspace and add user as member const workspace = await create({ name: workspaceName, - organizationId: new Types.ObjectId(organizationId), + organizationId: new Types.ObjectId(organizationId) }); await addMemberships({ userIds: [req.user._id], workspaceId: workspace._id.toString(), - roles: [ADMIN], + roles: [ADMIN] }); return res.status(200).send({ - workspace, + workspace }); }; @@ -160,11 +168,11 @@ export const deleteWorkspace = async (req: Request, res: Response) => { // delete workspace await deleteWork({ - id: workspaceId, + id: workspaceId }); return res.status(200).send({ - message: "Successfully deleted workspace", + message: "Successfully deleted workspace" }); }; @@ -180,19 +188,19 @@ export const changeWorkspaceName = async (req: Request, res: Response) => { const workspace = await Workspace.findOneAndUpdate( { - _id: workspaceId, + _id: workspaceId }, { - name, + name }, { - new: true, + new: true } ); return res.status(200).send({ message: "Successfully changed workspace name", - workspace, + workspace }); }; @@ -206,11 +214,11 @@ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { const { workspaceId } = req.params; const integrations = await Integration.find({ - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - integrations, + integrations }); }; @@ -220,18 +228,15 @@ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { * @param res * @returns */ -export const getWorkspaceIntegrationAuthorizations = async ( - req: Request, - res: Response -) => { +export const getWorkspaceIntegrationAuthorizations = async (req: Request, res: Response) => { const { workspaceId } = req.params; const authorizations = await IntegrationAuth.find({ - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - authorizations, + authorizations }); }; @@ -241,18 +246,15 @@ export const getWorkspaceIntegrationAuthorizations = async ( * @param res * @returns */ -export const getWorkspaceServiceTokens = async ( - req: Request, - res: Response -) => { +export const getWorkspaceServiceTokens = async (req: Request, res: Response) => { const { workspaceId } = req.params; // ?? FIX. const serviceTokens = await ServiceToken.find({ user: req.user._id, - workspace: workspaceId, + workspace: workspaceId }); return res.status(200).send({ - serviceTokens, + serviceTokens }); }; diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 3f86ea396..d74ac27ba 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -6,6 +6,15 @@ import { updateSubscriptionOrgQuantity } from "../../helpers/organization"; import Role from "../../models/role"; import { BadRequestError } from "../../utils/errors"; import { CUSTOM } from "../../variables"; +import * as reqValidator from "../../validation/organization"; +import { validateRequest } from "../../helpers/validation"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + WorkspacePermissionActions, + getUserOrgPermissions +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Return memberships for organization with id [organizationId] @@ -46,7 +55,15 @@ export const getOrganizationMemberships = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgMembersv2, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Member + ); const memberships = await MembershipOrg.find({ organization: organizationId @@ -116,8 +133,15 @@ export const updateOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; - const { role } = req.body; + const { + params: { organizationId, membershipId }, + body: { role } + } = await validateRequest(reqValidator.UpdateOrgMemberv2, req); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.Member + ); const isCustomRole = !["admin", "member", "owner"].includes(role); if (isCustomRole) { @@ -191,7 +215,14 @@ export const deleteOrganizationMembership = async (req: Request, res: Response) } } */ - const { membershipId } = req.params; + const { + params: { organizationId, membershipId } + } = await validateRequest(reqValidator.DeleteOrgMemberv2, req); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Delete, + OrgPermissionSubjects.Member + ); // delete organization membership const membership = await deleteMembershipOrg({ @@ -247,7 +278,15 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => } } */ - const { organizationId } = req.params; + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgWorkspacesv2, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + WorkspacePermissionActions.Read, + OrgPermissionSubjects.Workspace + ); const workspacesSet = new Set( ( diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts index b641de985..6bc8b2533 100644 --- a/backend/src/ee/controllers/v1/organizationsController.ts +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -3,228 +3,503 @@ import { Request, Response } from "express"; import { getLicenseServerUrl } from "../../../config"; import { licenseServerKeyRequest } from "../../../config/request"; import { EELicenseService } from "../../services"; +import { validateRequest } from "../../../helpers/validation"; +import * as reqValidator from "../../../validation/organization"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; +import { Organization } from "../../../models"; +import { OrganizationNotFoundError } from "../../../utils/errors"; export const getOrganizationPlansTable = async (req: Request, res: Response) => { - const billingCycle = req.query.billingCycle as string; - - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` - ); + const { + query: { billingCycle }, + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlansTablev1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` + ); + + return res.status(200).send(data); +}; /** * Return the organization current plan's feature set */ export const getOrganizationPlan = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const workspaceId = req.query.workspaceId as string; + const { + query: { workspaceId }, + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanv1, req); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId), new Types.ObjectId(workspaceId)); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); - return res.status(200).send({ - plan, - }); -} + const plan = await EELicenseService.getPlan( + new Types.ObjectId(organizationId), + new Types.ObjectId(workspaceId) + ); + + return res.status(200).send({ + plan + }); +}; /** * Return checkout url for pro trial - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const startOrganizationTrial = async (req: Request, res: Response) => { - const { organizationId } = req.params; - const { success_url } = req.body; + const { + params: { organizationId }, + body: { success_url } + } = await validateRequest(reqValidator.StartOrgTrailv1, req); - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/session/trial`, - { - success_url - } - ); - - EELicenseService.delPlan(new Types.ObjectId(organizationId)); - - return res.status(200).send({ - url + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Billing + ); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" }); -} + } + + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/session/trial`, + { + success_url + } + ); + + EELicenseService.delPlan(new Types.ObjectId(organizationId)); + + return res.status(200).send({ + url + }); +}; /** * Return the organization's current plan's billing info - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationPlanBillingInfo = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/billing` - ); - - return res.status(200).send(data); -} + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanBillingInfov1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/cloud-plan/billing` + ); + + return res.status(200).send(data); +}; /** * Return the organization's current plan's feature table - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationPlanTable = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan/table` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPlanTablev1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/cloud-plan/table` + ); + + return res.status(200).send(data); +}; export const getOrganizationBillingDetails = async (req: Request, res: Response) => { - const { data } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgBillingDetailsv1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details` + ); + + return res.status(200).send(data); +}; export const updateOrganizationBillingDetails = async (req: Request, res: Response) => { - const { - name, - email - } = req.body; + const { + params: { organizationId }, + body: { name, email } + } = await validateRequest(reqValidator.UpdateOrgBillingDetailsv1, req); - const { data } = await licenseServerKeyRequest.patch( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details`, - { - ...(name ? { name } : {}), - ...(email ? { email } : {}) - } - ); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.Billing + ); - return res.status(200).send(data); -} + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.patch( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details`, + { + ...(name ? { name } : {}), + ...(email ? { email } : {}) + } + ); + + return res.status(200).send(data); +}; /** * Return the organization's payment methods on file */ export const getOrganizationPmtMethods = async (req: Request, res: Response) => { - const { data: { pmtMethods } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgPmtMethodsv1, req); - return res.status(200).send(pmtMethods); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { pmtMethods } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods` + ); + + return res.status(200).send(pmtMethods); +}; /** * Return URL to add payment method for organization */ export const addOrganizationPmtMethod = async (req: Request, res: Response) => { - const { - success_url, - cancel_url, - } = req.body; - - const { data: { url } } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, - { - success_url, - cancel_url, - } - ); - - return res.status(200).send({ - url, - }); -} + const { + params: { organizationId }, + body: { success_url, cancel_url } + } = await validateRequest(reqValidator.CreateOrgPmtMethodv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { url } + } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods`, + { + success_url, + cancel_url + } + ); + + return res.status(200).send({ + url + }); +}; /** * Delete payment method with id [pmtMethodId] for organization - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationPmtMethod = async (req: Request, res: Response) => { - const { pmtMethodId } = req.params; + const { + params: { organizationId, pmtMethodId } + } = await validateRequest(reqValidator.DelOrgPmtMethodv1, req); - const { data } = await licenseServerKeyRequest.delete( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods/${pmtMethodId}`, - ); - - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Delete, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/payment-methods/${pmtMethodId}` + ); + + return res.status(200).send(data); +}; /** * Return the organization's tax ids on file */ export const getOrganizationTaxIds = async (req: Request, res: Response) => { - const { data: { tax_ids } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgTaxIdsv1, req); - return res.status(200).send(tax_ids); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { tax_ids } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids` + ); + + return res.status(200).send(tax_ids); +}; /** * Add tax id to organization */ export const addOrganizationTaxId = async (req: Request, res: Response) => { - const { - type, - value - } = req.body; + const { + params: { organizationId }, + body: { type, value } + } = await validateRequest(reqValidator.CreateOrgTaxId, req); - const { data } = await licenseServerKeyRequest.post( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids`, - { - type, - value - } - ); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Billing + ); - return res.status(200).send(data); -} + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids`, + { + type, + value + } + ); + + return res.status(200).send(data); +}; /** * Delete tax id with id [taxId] from organization tax ids on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const deleteOrganizationTaxId = async (req: Request, res: Response) => { - const { taxId } = req.params; + const { + params: { organizationId, taxId } + } = await validateRequest(reqValidator.DelOrgTaxIdv1, req); - const { data } = await licenseServerKeyRequest.delete( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/tax-ids/${taxId}`, - ); - - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Delete, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { data } = await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/billing-details/tax-ids/${taxId}` + ); + + return res.status(200).send(data); +}; /** * Return organization's invoices on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationInvoices = async (req: Request, res: Response) => { - const { data: { invoices } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/invoices` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgInvoicesv1, req); - return res.status(200).send(invoices); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { invoices } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/invoices` + ); + + return res.status(200).send(invoices); +}; /** * Return organization's licenses on file - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getOrganizationLicenses = async (req: Request, res: Response) => { - const { data: { licenses } } = await licenseServerKeyRequest.get( - `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/licenses` - ); + const { + params: { organizationId } + } = await validateRequest(reqValidator.GetOrgLicencesv1, req); - return res.status(200).send(licenses); -} \ No newline at end of file + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Billing + ); + + const organization = await Organization.findById(organizationId); + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization" + }); + } + + const { + data: { licenses } + } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${ + organization.customerId + }/licenses` + ); + + return res.status(200).send(licenses); +}; diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index d75cf25d2..3e83711d6 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -2,239 +2,258 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; import { BotOrgService } from "../../../services"; import { SSOConfig } from "../../models"; -import { - AuthMethod, - MembershipOrg, - User -} from "../../../models"; +import { AuthMethod, MembershipOrg, User } from "../../../models"; import { getSSOConfigHelper } from "../../helpers/organizations"; import { client } from "../../../config"; import { ResourceNotFoundError } from "../../../utils/errors"; import { getSiteURL } from "../../../config"; import { EELicenseService } from "../../services"; +import * as reqValidator from "../../../validation/sso"; +import { validateRequest } from "../../../helpers/validation"; +import { + GeneralPermissionActions, + OrgPermissionSubjects, + getUserOrgPermissions +} from "../../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Redirect user to appropriate SSO endpoint after successful authentication * to finish inputting their master key for logging in or signing up - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const redirectSSO = async (req: Request, res: Response) => { - if (req.isUserCompleted) { - return res.redirect(`${await getSiteURL()}/login/sso?token=${encodeURIComponent(req.providerAuthToken)}`); - } - - return res.redirect(`${await getSiteURL()}/signup/sso?token=${encodeURIComponent(req.providerAuthToken)}`); -} + if (req.isUserCompleted) { + return res.redirect( + `${await getSiteURL()}/login/sso?token=${encodeURIComponent(req.providerAuthToken)}` + ); + } + + return res.redirect( + `${await getSiteURL()}/signup/sso?token=${encodeURIComponent(req.providerAuthToken)}` + ); +}; /** * Return organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const getSSOConfig = async (req: Request, res: Response) => { - const organizationId = req.query.organizationId as string; - - const data = await getSSOConfigHelper({ - organizationId: new Types.ObjectId(organizationId) - }); + const { + query: { organizationId } + } = await validateRequest(reqValidator.GetSsoConfigv1, req); - return res.status(200).send(data); -} + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Read, + OrgPermissionSubjects.Sso + ); + + const data = await getSSOConfigHelper({ + organizationId: new Types.ObjectId(organizationId) + }); + + return res.status(200).send(data); +}; /** * Update organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const updateSSOConfig = async (req: Request, res: Response) => { + const { + body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } + } = await validateRequest(reqValidator.UpdateSsoConfigv1, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Edit, + OrgPermissionSubjects.Sso + ); + + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); + + if (!plan.samlSSO) + return res.status(400).send({ + message: + "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + }); + + interface PatchUpdate { + authProvider?: string; + isActive?: boolean; + encryptedEntryPoint?: string; + entryPointIV?: string; + entryPointTag?: string; + encryptedIssuer?: string; + issuerIV?: string; + issuerTag?: string; + encryptedCert?: string; + certIV?: string; + certTag?: string; + } + + const update: PatchUpdate = {}; + + if (authProvider) { + update.authProvider = authProvider; + } + + if (isActive !== undefined) { + update.isActive = isActive; + } + + const key = await BotOrgService.getSymmetricKey(new Types.ObjectId(organizationId)); + + if (entryPoint) { const { - organizationId, - authProvider, - isActive, - entryPoint, - issuer, - cert, - } = req.body; + ciphertext: encryptedEntryPoint, + iv: entryPointIV, + tag: entryPointTag + } = client.encryptSymmetric(entryPoint, key); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - - if (!plan.samlSSO) return res.status(400).send({ - message: "Failed to update SAML SSO configuration due to plan restriction. Upgrade plan to update SSO configuration." + update.encryptedEntryPoint = encryptedEntryPoint; + update.entryPointIV = entryPointIV; + update.entryPointTag = entryPointTag; + } + + if (issuer) { + const { + ciphertext: encryptedIssuer, + iv: issuerIV, + tag: issuerTag + } = client.encryptSymmetric(issuer, key); + + update.encryptedIssuer = encryptedIssuer; + update.issuerIV = issuerIV; + update.issuerTag = issuerTag; + } + + if (cert) { + const { + ciphertext: encryptedCert, + iv: certIV, + tag: certTag + } = client.encryptSymmetric(cert, key); + + update.encryptedCert = encryptedCert; + update.certIV = certIV; + update.certTag = certTag; + } + + const ssoConfig = await SSOConfig.findOneAndUpdate( + { + organization: new Types.ObjectId(organizationId) + }, + update, + { + new: true + } + ); + + if (!ssoConfig) + throw ResourceNotFoundError({ + message: "Failed to find SSO config to update" }); - - interface PatchUpdate { - authProvider?: string; - isActive?: boolean; - encryptedEntryPoint?: string; - entryPointIV?: string; - entryPointTag?: string; - encryptedIssuer?: string; - issuerIV?: string; - issuerTag?: string; - encryptedCert?: string; - certIV?: string; - certTag?: string; - } - - const update: PatchUpdate = {}; - - if (authProvider) { - update.authProvider = authProvider; - } - - if (isActive !== undefined) { - update.isActive = isActive; - } - - const key = await BotOrgService.getSymmetricKey( - new Types.ObjectId(organizationId) - ); - - if (entryPoint) { - const { - ciphertext: encryptedEntryPoint, - iv: entryPointIV, - tag: entryPointTag - } = client.encryptSymmetric(entryPoint, key); - - update.encryptedEntryPoint = encryptedEntryPoint; - update.entryPointIV = entryPointIV; - update.entryPointTag = entryPointTag; - } - if (issuer) { - const { - ciphertext: encryptedIssuer, - iv: issuerIV, - tag: issuerTag - } = client.encryptSymmetric(issuer, key); - - update.encryptedIssuer = encryptedIssuer; - update.issuerIV = issuerIV; - update.issuerTag = issuerTag; - } + if (update.isActive !== undefined) { + const membershipOrgs = await MembershipOrg.find({ + organization: new Types.ObjectId(organizationId) + }).select("user"); - if (cert) { - const { - ciphertext: encryptedCert, - iv: certIV, - tag: certTag - } = client.encryptSymmetric(cert, key); - - update.encryptedCert = encryptedCert; - update.certIV = certIV; - update.certTag = certTag; - } - - const ssoConfig = await SSOConfig.findOneAndUpdate( + if (update.isActive) { + await User.updateMany( { - organization: new Types.ObjectId(organizationId) + _id: { + $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) + } }, - update, { - new: true + authMethods: [ssoConfig.authProvider] } - ); - - if (!ssoConfig) throw ResourceNotFoundError({ - message: "Failed to find SSO config to update" - }); - - if (update.isActive !== undefined) { - const membershipOrgs = await MembershipOrg.find({ - organization: new Types.ObjectId(organizationId) - }).select("user"); - - if (update.isActive) { - await User.updateMany( - { - _id: { - $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) - } - }, - { - authMethods: [ssoConfig.authProvider], - } - ); - } else { - await User.updateMany( - { - _id: { - $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) - } - }, - { - authMethods: [AuthMethod.EMAIL], - } - ); + ); + } else { + await User.updateMany( + { + _id: { + $in: membershipOrgs.map((membershipOrg) => membershipOrg.user) + } + }, + { + authMethods: [AuthMethod.EMAIL] } + ); } - - return res.status(200).send(ssoConfig); -} + } + + return res.status(200).send(ssoConfig); +}; /** * Create organization SAML SSO configuration - * @param req - * @param res - * @returns + * @param req + * @param res + * @returns */ export const createSSOConfig = async (req: Request, res: Response) => { - const { - organizationId, - authProvider, - isActive, - entryPoint, - issuer, - cert - } = req.body; + const { + body: { organizationId, authProvider, isActive, entryPoint, issuer, cert } + } = await validateRequest(reqValidator.CreateSsoConfigv1, req); - const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); - - if (!plan.samlSSO) return res.status(400).send({ - message: "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to add SSO configuration." + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + ForbiddenError.from(permission).throwUnlessCan( + GeneralPermissionActions.Create, + OrgPermissionSubjects.Sso + ); + + const plan = await EELicenseService.getPlan(new Types.ObjectId(organizationId)); + + if (!plan.samlSSO) + return res.status(400).send({ + message: + "Failed to create SAML SSO configuration due to plan restriction. Upgrade plan to add SSO configuration." }); - - const key = await BotOrgService.getSymmetricKey( - new Types.ObjectId(organizationId) - ); - const { - ciphertext: encryptedEntryPoint, - iv: entryPointIV, - tag: entryPointTag - } = client.encryptSymmetric(entryPoint, key); + const key = await BotOrgService.getSymmetricKey(new Types.ObjectId(organizationId)); - const { - ciphertext: encryptedIssuer, - iv: issuerIV, - tag: issuerTag - } = client.encryptSymmetric(issuer, key); + const { + ciphertext: encryptedEntryPoint, + iv: entryPointIV, + tag: entryPointTag + } = client.encryptSymmetric(entryPoint, key); - const { - ciphertext: encryptedCert, - iv: certIV, - tag: certTag - } = client.encryptSymmetric(cert, key); - - const ssoConfig = await new SSOConfig({ - organization: new Types.ObjectId(organizationId), - authProvider, - isActive, - encryptedEntryPoint, - entryPointIV, - entryPointTag, - encryptedIssuer, - issuerIV, - issuerTag, - encryptedCert, - certIV, - certTag - }).save(); + const { + ciphertext: encryptedIssuer, + iv: issuerIV, + tag: issuerTag + } = client.encryptSymmetric(issuer, key); - return res.status(200).send(ssoConfig); -} \ No newline at end of file + const { + ciphertext: encryptedCert, + iv: certIV, + tag: certTag + } = client.encryptSymmetric(cert, key); + + const ssoConfig = await new SSOConfig({ + organization: new Types.ObjectId(organizationId), + authProvider, + isActive, + encryptedEntryPoint, + entryPointIV, + entryPointTag, + encryptedIssuer, + issuerIV, + issuerTag, + encryptedCert, + certIV, + certTag + }).save(); + + return res.status(200).send(ssoConfig); +}; diff --git a/backend/src/ee/routes/v1/organizations.ts b/backend/src/ee/routes/v1/organizations.ts index 6506d3afa..bbe5019b4 100644 --- a/backend/src/ee/routes/v1/organizations.ts +++ b/backend/src/ee/routes/v1/organizations.ts @@ -1,237 +1,127 @@ import express from "express"; const router = express.Router(); -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, param, query } from "express-validator"; +import { requireAuth } from "../../../middleware"; import { organizationsController } from "../../controllers/v1"; -import { - ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER -} from "../../../variables"; +import { AuthMode } from "../../../variables"; router.get( - "/:organizationId/plans/table", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("billingCycle").exists().isString().isIn(["monthly", "yearly"]), - validateRequest, - organizationsController.getOrganizationPlansTable + "/:organizationId/plans/table", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlansTable ); router.get( - "/:organizationId/plan", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlan + "/:organizationId/plan", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlan ); router.post( - "/:organizationId/session/trial", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("success_url").exists().trim(), - validateRequest, - organizationsController.startOrganizationTrial + "/:organizationId/session/trial", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.startOrganizationTrial ); router.get( - "/:organizationId/plan/billing", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlanBillingInfo + "/:organizationId/plan/billing", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlanBillingInfo ); router.get( - "/:organizationId/plan/table", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - query("workspaceId").optional().isString(), - validateRequest, - organizationsController.getOrganizationPlanTable + "/:organizationId/plan/table", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPlanTable ); router.get( - "/:organizationId/billing-details", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationBillingDetails + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationBillingDetails ); router.patch( - "/:organizationId/billing-details", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").optional().isString().trim(), - body("name").optional().isString().trim(), - validateRequest, - organizationsController.updateOrganizationBillingDetails + "/:organizationId/billing-details", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.updateOrganizationBillingDetails ); router.get( - "/:organizationId/billing-details/payment-methods", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationPmtMethods + "/:organizationId/billing-details/payment-methods", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationPmtMethods ); router.post( - "/:organizationId/billing-details/payment-methods", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("success_url").exists().isString(), - body("cancel_url").exists().isString(), - validateRequest, - organizationsController.addOrganizationPmtMethod + "/:organizationId/billing-details/payment-methods", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.addOrganizationPmtMethod ); router.delete( - "/:organizationId/billing-details/payment-methods/:pmtMethodId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - param("pmtMethodId").exists().trim(), - validateRequest, - organizationsController.deleteOrganizationPmtMethod + "/:organizationId/billing-details/payment-methods/:pmtMethodId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.deleteOrganizationPmtMethod ); router.get( - "/:organizationId/billing-details/tax-ids", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationTaxIds + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationTaxIds ); router.post( - "/:organizationId/billing-details/tax-ids", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("type").exists().isString(), - body("value").exists().isString(), - validateRequest, - organizationsController.addOrganizationTaxId + "/:organizationId/billing-details/tax-ids", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.addOrganizationTaxId ); router.delete( - "/:organizationId/billing-details/tax-ids/:taxId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - param("taxId").exists().trim(), - validateRequest, - organizationsController.deleteOrganizationTaxId + "/:organizationId/billing-details/tax-ids/:taxId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.deleteOrganizationTaxId ); router.get( - "/:organizationId/invoices", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationInvoices + "/:organizationId/invoices", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationInvoices ); router.get( - "/:organizationId/licenses", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationsController.getOrganizationLicenses + "/:organizationId/licenses", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationsController.getOrganizationLicenses ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/secretScanning.ts b/backend/src/ee/routes/v1/secretScanning.ts index 1cc3adc46..0afdf0545 100644 --- a/backend/src/ee/routes/v1/secretScanning.ts +++ b/backend/src/ee/routes/v1/secretScanning.ts @@ -1,81 +1,53 @@ import express from "express"; const router = express.Router(); +import { requireAuth } from "../../../middleware"; import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, param } from "express-validator"; -import { createInstallationSession, getCurrentOrganizationInstallationStatus, getRisksForOrganization, linkInstallationToOrganization, updateRisksStatus } from "../../../controllers/v1/secretScanningController"; -import { ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER } from "../../../variables"; + createInstallationSession, + getCurrentOrganizationInstallationStatus, + getRisksForOrganization, + linkInstallationToOrganization, + updateRisksStatus +} from "../../../controllers/v1/secretScanningController"; +import { AuthMode } from "../../../variables"; router.post( "/create-installation-session/organization/:organizationId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, createInstallationSession ); router.post( "/link-installation", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - body("installationId").exists().trim(), - body("sessionId").exists().trim(), - validateRequest, linkInstallationToOrganization ); router.get( "/installation-status/organization/:organizationId", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, getCurrentOrganizationInstallationStatus ); router.get( "/organization/:organizationId/risks", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, getRisksForOrganization ); router.post( "/organization/:organizationId/risks/:riskId/status", requireAuth({ - acceptedAuthModes: [AuthMode.JWT], + acceptedAuthModes: [AuthMode.JWT] }), - param("organizationId").exists().trim(), - param("riskId").exists().trim(), - body("status").exists(), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - validateRequest, updateRisksStatus ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/ee/routes/v1/sso.ts b/backend/src/ee/routes/v1/sso.ts index 005f84a0e..564073aec 100644 --- a/backend/src/ee/routes/v1/sso.ts +++ b/backend/src/ee/routes/v1/sso.ts @@ -1,146 +1,95 @@ import express from "express"; const router = express.Router(); import passport from "passport"; -import { - AuthProvider -} from "../../models"; -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../../middleware"; -import { body, query } from "express-validator"; +import { requireAuth } from "../../../middleware"; import { ssoController } from "../../controllers/v1"; import { authLimiter } from "../../../helpers/rateLimiter"; -import { - ACCEPTED, - ADMIN, - AuthMode, - OWNER -} from "../../../variables"; +import { AuthMode } from "../../../variables"; -router.get( - "/redirect/google", - authLimiter, - (req, res, next) => { - passport.authenticate("google", { - scope: ["profile", "email"], - session: false, - ...(req.query.callback_port ? { - state: req.query.callback_port as string - } : {}) - })(req, res, next); - } -); +router.get("/redirect/google", authLimiter, (req, res, next) => { + passport.authenticate("google", { + scope: ["profile", "email"], + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); router.get( "/google", - passport.authenticate("google", { - failureRedirect: "/login/provider/error", - session: false + passport.authenticate("google", { + failureRedirect: "/login/provider/error", + session: false }), ssoController.redirectSSO ); -router.get( - "/redirect/github", - authLimiter, - (req, res, next) => { - passport.authenticate("github", { - session: false, - ...(req.query.callback_port ? { - state: req.query.callback_port as string - } : {}) - })(req, res, next); - } -); +router.get("/redirect/github", authLimiter, (req, res, next) => { + passport.authenticate("github", { + session: false, + ...(req.query.callback_port + ? { + state: req.query.callback_port as string + } + : {}) + })(req, res, next); +}); router.get( "/github", authLimiter, - passport.authenticate("github", { - failureRedirect: "/login/provider/error", - session: false + passport.authenticate("github", { + failureRedirect: "/login/provider/error", + session: false }), ssoController.redirectSSO ); -router.get( - "/redirect/saml2/:ssoIdentifier", - authLimiter, - (req, res, next) => { - const options = { - failureRedirect: "/", - additionalParams: { - RelayState: req.query.callback_port ?? "" - }, - }; - passport.authenticate("saml", options)(req, res, next); - } -); +router.get("/redirect/saml2/:ssoIdentifier", authLimiter, (req, res, next) => { + const options = { + failureRedirect: "/", + additionalParams: { + RelayState: req.query.callback_port ?? "" + } + }; + passport.authenticate("saml", options)(req, res, next); +}); -router.post("/saml2/:ssoIdentifier", - passport.authenticate("saml", { - failureRedirect: "/login/provider/error", - failureFlash: true, +router.post( + "/saml2/:ssoIdentifier", + passport.authenticate("saml", { + failureRedirect: "/login/provider/error", + failureFlash: true, session: false }), ssoController.redirectSSO ); router.get( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "query" - }), - query("organizationId").exists().trim(), - validateRequest, - ssoController.getSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.getSSOConfig ); router.post( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "body" - }), - body("organizationId").exists().trim(), - body("authProvider").exists().isString().isIn([AuthProvider.OKTA_SAML]), - body("isActive").exists().isBoolean(), - body("entryPoint").exists().isString(), - body("issuer").exists().isString(), - body("cert").exists().isString(), - validateRequest, - ssoController.createSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.createSSOConfig ); router.patch( - "/config", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - locationOrganizationId: "body" - }), - body("organizationId").exists().trim(), - body("authProvider").optional().isString(), - body("isActive").optional().isBoolean(), - body("entryPoint").optional().isString(), - body("issuer").optional().isString(), - body("cert").optional().isString(), - validateRequest, - ssoController.updateSSOConfig + "/config", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + ssoController.updateSSOConfig ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v1/inviteOrg.ts b/backend/src/routes/v1/inviteOrg.ts index f79ce61d1..0fa4ffbfe 100644 --- a/backend/src/routes/v1/inviteOrg.ts +++ b/backend/src/routes/v1/inviteOrg.ts @@ -8,23 +8,20 @@ import { AuthMode } from "../../variables"; // TODO endpoint: consider moving these endpoints to be under /organization to be more RESTful router.post( - "/signup", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("inviteeEmail").exists().trim().notEmpty().isEmail(), - body("organizationId").exists().trim().notEmpty(), - validateRequest, - membershipOrgController.inviteUserToOrganization + "/signup", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipOrgController.inviteUserToOrganization ); router.post( - "/verify", - body("email").exists().trim().notEmpty(), - body("organizationId").exists().trim().notEmpty(), - body("code").exists().trim().notEmpty(), - validateRequest, - membershipOrgController.verifyUserToOrganization + "/verify", + body("email").exists().trim().notEmpty(), + body("organizationId").exists().trim().notEmpty(), + body("code").exists().trim().notEmpty(), + validateRequest, + membershipOrgController.verifyUserToOrganization ); export default router; diff --git a/backend/src/routes/v1/membership.ts b/backend/src/routes/v1/membership.ts index cf38c7cbc..54783e835 100644 --- a/backend/src/routes/v1/membership.ts +++ b/backend/src/routes/v1/membership.ts @@ -9,45 +9,49 @@ import { AuthMode } from "../../variables"; // note: ALL DEPRECIATED (moved to api/v2/workspace/:workspaceId/memberships/:membershipId) // TODO endpoint: consider moving these endpoints to be under /workspace to be more RESTful -router.get( // TODO endpoint: deprecate - used for old CLI (deprecate) - "/:workspaceId/connect", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("workspaceId").exists().trim(), - validateRequest, - membershipController.validateMembership +router.get( + // TODO endpoint: deprecate - used for old CLI (deprecate) + "/:workspaceId/connect", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + param("workspaceId").exists().trim(), + validateRequest, + membershipController.validateMembership ); -router.delete( // TODO endpoint: check dashboard - "/:membershipId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipId").exists().trim(), - validateRequest, - membershipController.deleteMembership +router.delete( + // TODO endpoint: check dashboard + "/:membershipId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + param("membershipId").exists().trim(), + validateRequest, + membershipController.deleteMembership ); -router.post( // TODO endpoint: check dashboard - "/:membershipId/change-role", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("role").exists().trim(), - validateRequest, - membershipController.changeMembershipRole +router.post( + // TODO endpoint: check dashboard + "/:membershipId/change-role", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + body("role").exists().trim(), + validateRequest, + membershipController.changeMembershipRole ); -router.post( // TODO endpoint: check dashboard - "/:membershipId/deny-permissions", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipId").isMongoId().exists().trim(), - body("permissions").isArray().exists(), - validateRequest, - EEMembershipControllers.denyMembershipPermissions +router.post( + // TODO endpoint: check dashboard + "/:membershipId/deny-permissions", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + param("membershipId").isMongoId().exists().trim(), + body("permissions").isArray().exists(), + validateRequest, + EEMembershipControllers.denyMembershipPermissions ); export default router; diff --git a/backend/src/routes/v1/membershipOrg.ts b/backend/src/routes/v1/membershipOrg.ts index 34899072b..1c7c47f88 100644 --- a/backend/src/routes/v1/membershipOrg.ts +++ b/backend/src/routes/v1/membershipOrg.ts @@ -5,24 +5,23 @@ import { requireAuth, validateRequest } from "../../middleware"; import { membershipOrgController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; -router.post( // TODO endpoint: check dashboard - "/membershipOrg/:membershipOrgId/change-role", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipOrgId"), - validateRequest, - membershipOrgController.changeMembershipOrgRole +router.post( + // TODO endpoint: check dashboard + "/membershipOrg/:membershipOrgId/change-role", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + param("membershipOrgId"), + validateRequest, + membershipOrgController.changeMembershipOrgRole ); router.delete( - "/:membershipOrgId", // TODO endpoint: check dashboard - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - param("membershipOrgId").exists().trim(), - validateRequest, - membershipOrgController.deleteMembershipOrg + "/:membershipOrgId", // TODO endpoint: check dashboard + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + membershipOrgController.deleteMembershipOrg ); export default router; diff --git a/backend/src/routes/v1/organization.ts b/backend/src/routes/v1/organization.ts index 78672093d..011d5fed6 100644 --- a/backend/src/routes/v1/organization.ts +++ b/backend/src/routes/v1/organization.ts @@ -1,166 +1,99 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { - requireAuth, - requireOrganizationAuth, - validateRequest, -} from "../../middleware"; -import { - ACCEPTED, - ADMIN, - AuthMode, - MEMBER, - OWNER -} from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { organizationController } from "../../controllers/v1"; -router.get( // TODO endpoint: deprecate (moved to api/v2/users/me/organizations) - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - organizationController.getOrganizations +router.get( + // TODO endpoint: deprecate (moved to api/v2/users/me/organizations) + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizations ); -router.post( // not used on frontend - "/", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - body("organizationName").exists().trim().notEmpty(), - validateRequest, - organizationController.createOrganization +router.post( + // not used on frontend + "/", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.createOrganization ); router.get( - "/:organizationId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganization + "/:organizationId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganization ); -router.get( // TODO endpoint: deprecate (moved to api/v2/organizations/:organizationId/memberships) - "/:organizationId/users", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationMembers +router.get( + // TODO endpoint: deprecate (moved to api/v2/organizations/:organizationId/memberships) + "/:organizationId/users", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationMembers ); -router.get( // TODO endpoint: move to /v2/users/me/organizations/:organizationId/workspaces - "/:organizationId/my-workspaces", // deprecated (moved to api/v2/organizations/:organizationId/workspaces) - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationWorkspaces +router.get( + // TODO endpoint: move to /v2/users/me/organizations/:organizationId/workspaces + "/:organizationId/my-workspaces", // deprecated (moved to api/v2/organizations/:organizationId/workspaces) + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationWorkspaces ); router.patch( - "/:organizationId/name", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("name").exists().trim().notEmpty(), - validateRequest, - organizationController.changeOrganizationName + "/:organizationId/name", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.changeOrganizationName ); router.get( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationIncidentContacts + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationIncidentContacts ); router.post( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").exists().trim().notEmpty(), - validateRequest, - organizationController.addOrganizationIncidentContact + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.addOrganizationIncidentContact ); router.delete( - "/:organizationId/incidentContactOrg", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - body("email").exists().trim().notEmpty(), - validateRequest, - organizationController.deleteOrganizationIncidentContact + "/:organizationId/incidentContactOrg", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.deleteOrganizationIncidentContact ); router.post( - "/:organizationId/customer-portal-session", // TODO endpoint: move to EE - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.createOrganizationPortalSession + "/:organizationId/customer-portal-session", // TODO endpoint: move to EE + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.createOrganizationPortalSession ); router.get( - "/:organizationId/workspace-memberships", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED], - }), - param("organizationId").exists().trim(), - validateRequest, - organizationController.getOrganizationMembersAndTheirWorkspaces + "/:organizationId/workspace-memberships", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + organizationController.getOrganizationMembersAndTheirWorkspaces ); - export default router; diff --git a/backend/src/routes/v2/organizations.ts b/backend/src/routes/v2/organizations.ts index 35e3cf9dd..a12288886 100644 --- a/backend/src/routes/v2/organizations.ts +++ b/backend/src/routes/v2/organizations.ts @@ -2,95 +2,56 @@ import express from "express"; const router = express.Router(); import { requireAuth, - requireMembershipOrgAuth, - requireOrganizationAuth, - validateRequest + requireOrganizationAuth } from "../../middleware"; -import { body, param } from "express-validator"; -import { ACCEPTED, ADMIN, AuthMode, MEMBER, OWNER } from "../../variables"; +import { ACCEPTED, ADMIN, AuthMode, OWNER } from "../../variables"; import { organizationsController } from "../../controllers/v2"; // TODO: /POST to create membership router.get( "/:organizationId/memberships", - param("organizationId").exists().trim(), - validateRequest, requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN, MEMBER], - acceptedStatuses: [ACCEPTED] - }), organizationsController.getOrganizationMemberships ); router.patch( "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - body("role").exists().isString().trim(), - validateRequest, requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), organizationsController.updateOrganizationMembership ); router.delete( "/:organizationId/memberships/:membershipId", - param("organizationId").exists().trim(), - param("membershipId").exists().trim(), - validateRequest, requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - requireMembershipOrgAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), organizationsController.deleteOrganizationMembership ); router.get( "/:organizationId/workspaces", - param("organizationId").exists().trim(), - validateRequest, requireAuth({ acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), + organizationsController.getOrganizationWorkspaces +); + +router.get( + // TODO endpoint: deprecate service accounts + "/:organizationId/service-accounts", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), - organizationsController.getOrganizationWorkspaces -); - -router.get( // TODO endpoint: deprecate service accounts - "/:organizationId/service-accounts", - param("organizationId").exists().trim(), - validateRequest, - requireAuth({ - acceptedAuthModes: [AuthMode.JWT] - }), - requireOrganizationAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED], - }), - organizationsController.getOrganizationServiceAccounts + organizationsController.getOrganizationServiceAccounts ); export default router; diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 7274c437f..178ab7c90 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@ca import { MembershipOrg } from "../models"; import { IRole } from "../models/role"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; +import { ACCEPTED } from "../variables"; export enum GeneralPermissionActions { Read = "read", @@ -10,34 +11,37 @@ export enum GeneralPermissionActions { Delete = "delete" } +export enum WorkspacePermissionActions { + Read = "read", + Create = "create" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", Member = "member", Settings = "settings", - ServiceAccount = "service-account", IncidentAccount = "incident-contact", Sso = "sso", - Billing = "billing" + Billing = "billing", + SecretScanning = "secret-scanning" } export type OrgPermissionSet = - | [GeneralPermissionActions, OrgPermissionSubjects.Workspace] + | [WorkspacePermissionActions, OrgPermissionSubjects.Workspace] | [GeneralPermissionActions, OrgPermissionSubjects.Role] | [GeneralPermissionActions, OrgPermissionSubjects.Member] | [GeneralPermissionActions, OrgPermissionSubjects.Settings] - | [GeneralPermissionActions, OrgPermissionSubjects.ServiceAccount] | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] | [GeneralPermissionActions, OrgPermissionSubjects.Sso] + | [GeneralPermissionActions, OrgPermissionSubjects.SecretScanning] | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); // ws permissions - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Workspace); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Workspace); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Workspace); + can(WorkspacePermissionActions.Read, OrgPermissionSubjects.Workspace); + can(WorkspacePermissionActions.Create, OrgPermissionSubjects.Workspace); // role permission can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); can(GeneralPermissionActions.Create, OrgPermissionSubjects.Role); @@ -49,16 +53,16 @@ const buildAdminPermission = () => { can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Member); can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Member); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(GeneralPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(GeneralPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(GeneralPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); can(GeneralPermissionActions.Create, OrgPermissionSubjects.Settings); can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Settings); can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.ServiceAccount); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.ServiceAccount); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.ServiceAccount); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(GeneralPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); can(GeneralPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); @@ -82,14 +86,15 @@ export const adminPermissions = buildAdminPermission(); const buildMemberPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(WorkspacePermissionActions.Read, OrgPermissionSubjects.Workspace); + can(WorkspacePermissionActions.Create, OrgPermissionSubjects.Workspace); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.ServiceAccount); + can(GeneralPermissionActions.Read, OrgPermissionSubjects.SecretScanning); return build(); }; @@ -98,23 +103,28 @@ export const memberPermissions = buildMemberPermission(); export const getUserOrgPermissions = async (userId: string, orgId: string) => { // TODO(akhilmhdh): speed this up by pulling from cache later - const orgMembership = await MembershipOrg.findOne({ user: userId, organization: orgId }) + const membership = await MembershipOrg.findOne({ + user: userId, + organization: orgId, + status: ACCEPTED + }) .populate<{ customRole: IRole & { permissions: RawRuleOf>[] } }>( "customRole" ) .exec(); - if (!orgMembership || (orgMembership.role === "custom" && !orgMembership.customRole)) { + if (!membership || (membership.role === "custom" && !membership.customRole)) { throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); } - if (orgMembership.role === "admin" || orgMembership.role === "owner") return adminPermissions; + if (membership.role === "admin" || membership.role === "owner") + return { permission: adminPermissions, membership }; - if (orgMembership.role === "member") return memberPermissions; + if (membership.role === "member") return { permission: memberPermissions, membership }; - if (orgMembership.role === "custom") { - const permission = createMongoAbility(orgMembership.customRole.permissions); - return permission; + if (membership.role === "custom") { + const permission = createMongoAbility(membership.customRole.permissions); + return { permission, membership }; } throw BadRequestError({ message: "User role not found" }); diff --git a/backend/src/validation/membershipOrg.ts b/backend/src/validation/membershipOrg.ts index b0ada6a61..02f9cac08 100644 --- a/backend/src/validation/membershipOrg.ts +++ b/backend/src/validation/membershipOrg.ts @@ -1,16 +1,10 @@ import { Types } from "mongoose"; -import { - MembershipOrg, -} from "../models"; -import { - validateMembershipOrg, -} from "../helpers/membershipOrg"; -import { - MembershipOrgNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { MembershipOrg } from "../models"; +import { validateMembershipOrg } from "../helpers/membershipOrg"; +import { MembershipOrgNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; +import { z } from "zod"; /** * Validate authenticated clients for organization membership with id [membershipOrgId] based @@ -22,35 +16,57 @@ import { ActorType } from "../ee/models"; * @param {MembershipOrg} - validated organization membership */ export const validateClientForMembershipOrg = async ({ - authData, - membershipOrgId, - acceptedRoles, - acceptedStatuses, + authData, + membershipOrgId, + acceptedRoles, + acceptedStatuses }: { - authData: AuthData; - membershipOrgId: Types.ObjectId; - acceptedRoles: Array<"owner" | "admin" | "member">; - acceptedStatuses: Array<"invited" | "accepted">; + authData: AuthData; + membershipOrgId: Types.ObjectId; + acceptedRoles: Array<"owner" | "admin" | "member">; + acceptedStatuses: Array<"invited" | "accepted">; }) => { - const membershipOrg = await MembershipOrg.findById(membershipOrgId); + const membershipOrg = await MembershipOrg.findById(membershipOrgId); - if (!membershipOrg) throw MembershipOrgNotFoundError({ - message: "Failed to find organization membership ", - }); - - switch (authData.actor.type) { - case ActorType.USER: - await validateMembershipOrg({ - userId: authData.authPayload._id, - organizationId: membershipOrg.organization, - acceptedRoles, - acceptedStatuses, - }); - - return membershipOrg; - case ActorType.SERVICE: - throw UnauthorizedRequestError({ - message: "Failed service account client authorization for organization membership", - }); - } -} \ No newline at end of file + if (!membershipOrg) + throw MembershipOrgNotFoundError({ + message: "Failed to find organization membership " + }); + + switch (authData.actor.type) { + case ActorType.USER: + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + case ActorType.SERVICE: + throw UnauthorizedRequestError({ + message: "Failed service account client authorization for organization membership" + }); + } +}; + +export const DelOrgMembershipv1 = z.object({ + params: z.object({ + membershipOrgId: z.string().trim() + }) +}); + +export const InviteUserToOrgv1 = z.object({ + body: z.object({ + inviteeEmail: z.string().trim().email(), + organizationId: z.string().trim() + }) +}); + +export const VerifyUserToOrgv1 = z.object({ + body: z.object({ + email: z.string().trim().email(), + organizationId: z.string().trim(), + code: z.string().trim() + }) +}); diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts index 4ca9811c5..ba4fef5b4 100644 --- a/backend/src/validation/organization.ts +++ b/backend/src/validation/organization.ts @@ -1,12 +1,7 @@ import { Types } from "mongoose"; -import { - IUser, - Organization, -} from "../models"; -import { - OrganizationNotFoundError, - UnauthorizedRequestError, -} from "../utils/errors"; +import { z } from "zod"; +import { IUser, Organization } from "../models"; +import { OrganizationNotFoundError, UnauthorizedRequestError } from "../utils/errors"; import { validateUserClientForOrganization } from "./user"; import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; @@ -21,7 +16,7 @@ export const validateClientForOrganization = async ({ authData, organizationId, acceptedRoles, - acceptedStatuses, + acceptedStatuses }: { authData: AuthData; organizationId: Types.ObjectId; @@ -32,10 +27,10 @@ export const validateClientForOrganization = async ({ if (!organization) { throw OrganizationNotFoundError({ - message: "Failed to find organization", + message: "Failed to find organization" }); } - + let membershipOrg; switch (authData.actor.type) { case ActorType.USER: @@ -43,13 +38,162 @@ export const validateClientForOrganization = async ({ user: authData.authPayload as IUser, organization, acceptedRoles, - acceptedStatuses, + acceptedStatuses }); - return { organization, membershipOrg }; + return { organization, membershipOrg }; case ActorType.SERVICE: throw UnauthorizedRequestError({ - message: "Failed service token authorization for organization", + message: "Failed service token authorization for organization" }); } -}; \ No newline at end of file +}; + +export const GetOrgPlansTablev1 = z.object({ + query: z.object({ billingCycle: z.enum(["monthly", "yearly"]) }), + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgPlanv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const StartOrgTrailv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ success_url: z.string().trim() }) +}); + +export const GetOrgPlanBillingInfov1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const GetOrgPlanTablev1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + query: z.object({ workspaceId: z.string().trim().optional() }) +}); + +export const GetOrgBillingDetailsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateOrgBillingDetailsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + email: z.string().trim().email().optional(), + name: z.string().trim().optional() + }) +}); + +export const GetOrgPmtMethodsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgPmtMethodv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + success_url: z.string().trim(), + cancel_url: z.string().trim() + }) +}); + +export const DelOrgPmtMethodv1 = z.object({ + params: z.object({ + organizationId: z.string().trim(), + pmtMethodId: z.string().trim() + }) +}); + +export const GetOrgTaxIdsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgTaxId = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ + type: z.string().trim(), + value: z.string().trim() + }) +}); + +export const DelOrgTaxIdv1 = z.object({ + params: z.object({ + organizationId: z.string().trim(), + taxId: z.string().trim() + }) +}); + +export const GetOrgInvoicesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgLicencesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgv1 = z.object({ + body: z.object({ + organizationName: z.string().trim() + }) +}); + +export const GetOrgv1 = z.object({ + params: z.object({ + organizationId: z.string().trim() + }) +}); + +export const GetOrgMembersv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgWorkspacesv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const ChangeOrgNamev1 = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ name: z.string().trim() }) +}); + +export const GetOrgIncidentContactv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateOrgIncideContact = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ email: z.string().email().trim() }) +}); + +export const DelOrgIncideContact = z.object({ + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ email: z.string().email().trim() }) +}); + +export const CreateOrgPortalSessionv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgMembersAndWsv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgMembersv2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateOrgMemberv2 = z.object({ + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + body: z.object({ + role: z.string().trim() + }) +}); + +export const DeleteOrgMemberv2 = z.object({ + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }) +}); + +export const GetOrgWorkspacesv2 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); diff --git a/backend/src/validation/secretScanning.ts b/backend/src/validation/secretScanning.ts new file mode 100644 index 000000000..e16c67c5c --- /dev/null +++ b/backend/src/validation/secretScanning.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const CreateInstalLSessionv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const LinkInstallationToOrgv1 = z.object({ + body: z.object({ + installationId: z.number(), + sessionId: z.string().trim() + }) +}); + +export const GetOrgInstallStatusv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const GetOrgRisksv1 = z.object({ + params: z.object({ organizationId: z.string().trim() }) +}); + +export const UpdateRiskStatusv1 = z.object({ + params: z.object({ organizationId: z.string().trim(), riskId: z.string().trim() }), + body: z.object({ status: z.string().trim() }) +}); diff --git a/backend/src/validation/sso.ts b/backend/src/validation/sso.ts new file mode 100644 index 000000000..275ae611e --- /dev/null +++ b/backend/src/validation/sso.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; +import { AuthProvider } from "../ee/models"; + +export const GetSsoConfigv1 = z.object({ + query: z.object({ organizationId: z.string().trim() }) +}); + +export const CreateSsoConfigv1 = z.object({ + body: z.object({ + organizationId: z.string().trim(), + authProvider: z.nativeEnum(AuthProvider), + isActive: z.boolean(), + entryPoint: z.string().trim(), + issuer: z.string().trim(), + cert: z.string().trim() + }) +}); + +export const UpdateSsoConfigv1 = z.object({ + body: z.object({ + organizationId: z.string().trim(), + authProvider: z.nativeEnum(AuthProvider).optional(), + isActive: z.boolean().optional(), + entryPoint: z.string().trim().optional(), + issuer: z.string().trim().optional(), + cert: z.string().trim().optional() + }) +}); diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts index 3be8d0dad..c329d0a16 100644 --- a/backend/src/validation/workspace.ts +++ b/backend/src/validation/workspace.ts @@ -1,25 +1,14 @@ import net from "net"; import { Types } from "mongoose"; -import { - IServiceTokenData, - IUser, - SecretBlindIndexData, - Workspace, -} from "../models"; -import { - ActorType, - TrustedIP -} from "../ee/models"; +import { IServiceTokenData, IUser, SecretBlindIndexData, Workspace } from "../models"; +import { ActorType, TrustedIP } from "../ee/models"; import { validateUserClientForWorkspace } from "./user"; import { validateServiceTokenDataClientForWorkspace } from "./serviceTokenData"; -import { - BadRequestError, - UnauthorizedRequestError, - WorkspaceNotFoundError, -} from "../utils/errors"; +import { BadRequestError, UnauthorizedRequestError, WorkspaceNotFoundError } from "../utils/errors"; import { BotService } from "../services"; import { AuthData } from "../interfaces/middleware"; import { extractIPDetails } from "../utils/ip"; +import { z } from "zod"; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -32,106 +21,110 @@ import { extractIPDetails } from "../utils/ip"; * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint */ export const validateClientForWorkspace = async ({ - authData, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, - requireBlindIndicesEnabled, - requireE2EEOff, - checkIPAllowlist + authData, + workspaceId, + environment, + acceptedRoles, + requiredPermissions, + requireBlindIndicesEnabled, + requireE2EEOff, + checkIPAllowlist }: { - authData: AuthData; - workspaceId: Types.ObjectId; - environment?: string; - acceptedRoles: Array<"admin" | "member">; - requiredPermissions?: string[]; - requireBlindIndicesEnabled: boolean; - requireE2EEOff: boolean; - checkIPAllowlist: boolean; + authData: AuthData; + workspaceId: Types.ObjectId; + environment?: string; + acceptedRoles: Array<"admin" | "member">; + requiredPermissions?: string[]; + requireBlindIndicesEnabled: boolean; + requireE2EEOff: boolean; + checkIPAllowlist: boolean; }) => { - const workspace = await Workspace.findById(workspaceId); + const workspace = await Workspace.findById(workspaceId); - if (!workspace) throw WorkspaceNotFoundError({ - message: "Failed to find workspace", - }); + if (!workspace) + throw WorkspaceNotFoundError({ + message: "Failed to find workspace" + }); - if (requireBlindIndicesEnabled) { - // case: blind indices are not enabled for secrets in this workspace - // (i.e. workspace was created before blind indices were introduced - // and no admin has enabled it) - - const secretBlindIndexData = await SecretBlindIndexData.exists({ - workspace: new Types.ObjectId(workspaceId), - }); - - if (!secretBlindIndexData) throw UnauthorizedRequestError({ - message: "Failed workspace authorization due to blind indices not being enabled", - }); - } - - if (requireE2EEOff) { - const isWorkspaceE2EE = await BotService.getIsWorkspaceE2EE(workspaceId); - - if (isWorkspaceE2EE) throw BadRequestError({ - message: "Failed workspace authorization due to end-to-end encryption not being disabled", - }); - } - - let membership; - switch (authData.actor.type) { - case ActorType.USER: - membership = await validateUserClientForWorkspace({ - user: authData.authPayload as IUser, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, - }); - - return ({ membership, workspace }); - case ActorType.SERVICE: - if (checkIPAllowlist) { - const trustedIps = await TrustedIP.find({ - workspace: workspaceId - }); - - if (trustedIps.length > 0) { - // case: check the IP address of the inbound request against trusted IPs + if (requireBlindIndicesEnabled) { + // case: blind indices are not enabled for secrets in this workspace + // (i.e. workspace was created before blind indices were introduced + // and no admin has enabled it) - const blockList = new net.BlockList(); - - for (const trustedIp of trustedIps) { - if (trustedIp.prefix !== undefined) { - blockList.addSubnet( - trustedIp.ipAddress, - trustedIp.prefix, - trustedIp.type - ); - } else { - blockList.addAddress( - trustedIp.ipAddress, - trustedIp.type - ); - } - } - - const { type } = extractIPDetails(authData.ipAddress); - const check = blockList.check(authData.ipAddress, type); - - if (!check) throw UnauthorizedRequestError({ - message: "Failed workspace authorization" - }); - } - } + const secretBlindIndexData = await SecretBlindIndexData.exists({ + workspace: new Types.ObjectId(workspaceId) + }); - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload as IServiceTokenData, - workspaceId, - environment, - requiredPermissions, - }); - - return {}; - } -} + if (!secretBlindIndexData) + throw UnauthorizedRequestError({ + message: "Failed workspace authorization due to blind indices not being enabled" + }); + } + + if (requireE2EEOff) { + const isWorkspaceE2EE = await BotService.getIsWorkspaceE2EE(workspaceId); + + if (isWorkspaceE2EE) + throw BadRequestError({ + message: "Failed workspace authorization due to end-to-end encryption not being disabled" + }); + } + + let membership; + switch (authData.actor.type) { + case ActorType.USER: + membership = await validateUserClientForWorkspace({ + user: authData.authPayload as IUser, + workspaceId, + environment, + acceptedRoles, + requiredPermissions + }); + + return { membership, workspace }; + case ActorType.SERVICE: + if (checkIPAllowlist) { + const trustedIps = await TrustedIP.find({ + workspace: workspaceId + }); + + if (trustedIps.length > 0) { + // case: check the IP address of the inbound request against trusted IPs + + const blockList = new net.BlockList(); + + for (const trustedIp of trustedIps) { + if (trustedIp.prefix !== undefined) { + blockList.addSubnet(trustedIp.ipAddress, trustedIp.prefix, trustedIp.type); + } else { + blockList.addAddress(trustedIp.ipAddress, trustedIp.type); + } + } + + const { type } = extractIPDetails(authData.ipAddress); + const check = blockList.check(authData.ipAddress, type); + + if (!check) + throw UnauthorizedRequestError({ + message: "Failed workspace authorization" + }); + } + } + + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload as IServiceTokenData, + workspaceId, + environment, + requiredPermissions + }); + + return {}; + } +}; + +export const CreateWorkspacev1 = z.object({ + body: z.object({ + workspaceName: z.string().trim(), + organizationId: z.string().trim() + }) +}); diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 11830086a..4096225ff 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -15,10 +15,18 @@ export type TRole = { updatedAt: string; }; -export type TPermission = { +export type TPermission = TWorkspacePermission | TGeneralPermission; + +type TGeneralPermission = { condition?: Record; action: "read" | "edit" | "create" | "delete"; - subject: string; + subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings"; +}; + +type TWorkspacePermission = { + condition?: Record; + action: "read" | "create"; + subject: "workspace"; }; export type TCreateRoleDTO = { diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 9143b1f10..3fb3884ed 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -20,7 +20,7 @@ import { TFormSchema } from "./OrgRoleModifySection.utils"; import { RolePermission } from "./RolePermission"; -import { ServiceAccountPermission } from "./ServiceAccountPermission"; +import { SecretScannigPermission } from "./SecretScanningPermission"; import { SettingsPermission } from "./SettingsPermission"; import { SsoPermission } from "./SsoPermission"; import { WorkspacePermission } from "./WorkspacePermission"; @@ -183,15 +183,15 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { />
- -
-
-
+
+ +
-
-
- {isWorkspaceLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {orgWorkspaces - .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) - .map((workspace) => ( -
-
{workspace.name}
-
- {workspace.environments?.length || 0} environments -
- + )} + +
+
+ {isWorkspaceLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ ))} + {orgWorkspaces + .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) + .map((workspace) => ( +
+
{workspace.name}
+
+ {workspace.environments?.length || 0} environments +
+ +
+ ))} +
+ {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions. +
+
+ )} +
+ {new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000 && ( +
+

Onboarding Guide

+
+ + {orgWorkspaces.length !== 0 && ( + <> + + + + )} +
+ +
+
+ {orgWorkspaces.length !== 0 && ( +
+
+
+ + {false && ( +
+ +
+ )} +
+
Inject secrets locally
+
+ Replace .env files with a more secure and efficient alternative. +
+
+
+
+ About 2 min +
+
+ + {false &&
} +
+ )} + {orgWorkspaces.length !== 0 && ( + + )} +
+ )} +
+

Explore More

+
+ {features.map((feature) => ( +
+
{feature.name}
+
+ {feature.description} +
+
+
+ Setup time: 20 min +
+ + Learn more{" "} -
- + +
))} -
- {isWorkspaceEmpty && ( -
- -
- You are not part of any projects in this organization yet. When you are, they will - appear here. -
-
- Create a new project, or ask other organization members to give you necessary - permissions. -
- )} -
- {new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000 && ( -
-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
- -
-
- {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. -
-
-
-
- About 2 min -
-
- - {false &&
} -
- )} - {orgWorkspaces.length !== 0 && ( - - )}
- )} -
-

Explore More

-
{ + handlePopUpToggle("addNewWs", isModalOpen); + reset(); + }} > - {features.map((feature) => ( -
-
{feature.name}
-
- {feature.description} -
-
-
Setup time: 20 min
- - Learn more{" "} - - -
-
- ))} -
-
- { - handlePopUpToggle("addNewWs", isModalOpen); - reset(); - }} - > - -
- ( - - - - )} - /> -
+ + ( - ( + - Add all members of my organization to this project - + + )} /> -
-
- - -
- -
-
- handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." - /> - {/* */} -
- ); -} +
+ ( + + Add all members of my organization to this project + + )} + /> +
+
+ + +
+ + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You have exceeded the number of projects allowed on the free plan." + /> + {/* */} +
+ ); + }, + { + action: OrgWorkspacePermissionActions.Read, + subject: OrgPermissionSubjects.Workspace + } +); -Organization.requireAuth = true; +Object.assign(OrganizationPage, { requireAuth: true }); + +export default OrganizationPage; diff --git a/frontend/src/pages/org/[id]/secret-scanning/index.tsx b/frontend/src/pages/org/[id]/secret-scanning/index.tsx index e0f98243a..626333e47 100644 --- a/frontend/src/pages/org/[id]/secret-scanning/index.tsx +++ b/frontend/src/pages/org/[id]/secret-scanning/index.tsx @@ -1,89 +1,133 @@ import { useEffect, useState } from "react"; import Head from "next/head"; -import { useRouter } from "next/router" +import { useRouter } from "next/router"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; import { SecretScanningLogsTable } from "@app/views/SecretScanning/components"; import createNewIntegrationSession from "../../../api/secret-scanning/createSecretScanningSession"; import getInstallationStatus from "../../../api/secret-scanning/getInstallationStatus"; import linkGitAppInstallationWithOrganization from "../../../api/secret-scanning/linkGitAppInstallationWithOrganization"; -export default function SecretScanning() { - const router = useRouter() - const queryParams = router.query - const [integrationEnabled, setIntegrationStatus] = useState(false) +const SecretScanning = withPermission( + () => { + const router = useRouter(); + const queryParams = router.query; + const [integrationEnabled, setIntegrationStatus] = useState(false); - useEffect(()=>{ - const linkInstallation = async () => { - if (typeof queryParams.state === "string" && typeof queryParams.installation_id === "string"){ - try { - const isLinked = await linkGitAppInstallationWithOrganization(queryParams.installation_id as string, queryParams.state as string) - if (isLinked){ - router.reload() + useEffect(() => { + const linkInstallation = async () => { + if ( + typeof queryParams.state === "string" && + typeof queryParams.installation_id === "string" + ) { + try { + const isLinked = await linkGitAppInstallationWithOrganization( + queryParams.installation_id as string, + queryParams.state as string + ); + if (isLinked) { + router.reload(); + } + + console.log("installation verification complete"); + } catch (e) { + console.log("app installation is stale, start new session", e); } - - console.log("installation verification complete") - }catch (e){ - console.log("app installation is stale, start new session", e) } - } - } + }; - const fetchInstallationStatus = async () => { - const status = await getInstallationStatus(String(localStorage.getItem("orgData.id"))) - setIntegrationStatus(status) - } + const fetchInstallationStatus = async () => { + const status = await getInstallationStatus(String(localStorage.getItem("orgData.id"))); + setIntegrationStatus(status); + }; - fetchInstallationStatus() - linkInstallation() - },[queryParams.state, queryParams.installation_id]) + fetchInstallationStatus(); + linkInstallation(); + }, [queryParams.state, queryParams.installation_id]); - const generateNewIntegrationSession = async () => { - const session = await createNewIntegrationSession(String(localStorage.getItem("orgData.id"))) - router.push(`https://github.com/apps/infisical-radar/installations/new?state=${session.sessionId}`) - } + const generateNewIntegrationSession = async () => { + const session = await createNewIntegrationSession(String(localStorage.getItem("orgData.id"))); + router.push( + `https://github.com/apps/infisical-radar/installations/new?state=${session.sessionId}` + ); + }; - return ( -
- - Secret scanning - - - -
-
-
Secret Scanning
-
Automatically monitor your GitHub activity and prevent secret leaks
-
-
-
Secret Scanning Status: {integrationEnabled ?

Enabled

:

Not enabled

}
-
{integrationEnabled ?

Your GitHub organization is connected to Infisical, and is being continuously monitored for secret leaks.

:

Connect your GitHub organization to Infisical.

}
+ return ( +
+ + Secret scanning + + + +
+
+
Secret Scanning
+
+ Automatically monitor your GitHub activity and prevent secret leaks
- {integrationEnabled ? ( -
-
-
-
+
+
+
+ Secret Scanning Status:{" "} + {integrationEnabled ? ( +

Enabled

+ ) : ( +

Not enabled

+ )} +
+
+ {integrationEnabled ? ( +

+ Your GitHub organization is connected to Infisical, and is being continuously + monitored for secret leaks. +

+ ) : ( +

+ Connect your GitHub organization to Infisical. +

+ )} +
- ) : ( -
- -
- )} + {integrationEnabled ? ( +
+
+
+
+
+ ) : ( +
+ + {(isAllowed) => ( + + )} + +
+ )} +
+
-
-
- ); -} + ); + }, + { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.SecretScanning } +); -SecretScanning.requireAuth = true; +Object.assign(SecretScanning, { requireAuth: true }); + +export default SecretScanning; diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index 065825b48..d2c213dc4 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; import { useGetRoles } from "@app/hooks/api"; import { OrgMembersTable } from "./components/OrgMembersTable"; @@ -14,45 +15,48 @@ enum TabSections { Roles = "roles" } -export const MembersPage = () => { - const { t } = useTranslation(); - const { currentOrg } = useOrganization(); +export const MembersPage = withPermission( + () => { + const { t } = useTranslation(); + const { currentOrg } = useOrganization(); - const orgId = currentOrg?._id || ""; + const orgId = currentOrg?._id || ""; - const { data: roles } = useGetRoles({ - orgId - }); + const { data: roles } = useGetRoles({ + orgId + }); - return ( -
-
-

- {t("section.members.org-members")} -

- - - Members - {process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( - Roles - )} - - - - - - - - - - + return ( +
+
+

+ {t("section.members.org-members")} +

+ + + Members + {process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( + Roles + )} + + + + + + + + + + +
-
- ); -}; + ); + }, + { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.Member } +); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index e13a2ca0a..2cdb0036b 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -14,6 +14,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; import { decryptAssymmetric, encryptAssymmetric @@ -41,7 +42,14 @@ import { Tr, UpgradePlanModal } from "@app/components/v2"; -import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; +import { + OrgGeneralPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription, + useUser, + useWorkspace +} from "@app/context"; import { usePopUp, useToggle } from "@app/hooks"; import { useAddUserToOrg, @@ -297,27 +305,32 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { placeholder="Search members..." />
- + if (isMoreUsersNotAllowed) { + handlePopUpOpen("upgradePlan"); + } else { + handlePopUpOpen("addMember"); + } + }} + > + Add Member + + )} +
@@ -345,48 +358,59 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { {name} {email} - {status === "accepted" && ( - - )} - {(status === "invited" || status === "verified") && - serverDetails?.emailConfigured && ( - + + {(isAllowed) => ( + <> + {status === "accepted" && ( + + )} + {(status === "invited" || status === "verified") && + serverDetails?.emailConfigured && ( + + )} + {status === "completed" && ( + + )} + )} - {status === "completed" && ( - - )} + {userWs ? ( @@ -428,16 +452,23 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { {userId !== u?._id && ( - - handlePopUpOpen("removeMember", { id: orgMembershipId }) - } + - - + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { id: orgMembershipId }) + } + > + + + )} + )} diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx index 6fbeac9fb..5d77d63ec 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/BillingPermission.tsx @@ -38,10 +38,9 @@ export const BillingPermission = ({ isNonEditable, setValue, control }: Props) = const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -52,11 +51,14 @@ export const BillingPermission = ({ isNonEditable, setValue, control }: Props) = }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { - val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: setValue( diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx index 2d92f5100..14fffb761 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/IncidentContactPermission.tsx @@ -38,10 +38,9 @@ export const IncidentContactPermission = ({ isNonEditable, setValue, control }: const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -52,14 +51,16 @@ export const IncidentContactPermission = ({ isNonEditable, setValue, control }: }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { - val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: - setIsCustom.off(); setValue( "permissions.incident-contact", { read: false, edit: false, create: false, delete: false }, @@ -67,7 +68,6 @@ export const IncidentContactPermission = ({ isNonEditable, setValue, control }: ); break; case Permission.FullAccess: - setIsCustom.off(); setValue( "permissions.incident-contact", { read: true, edit: true, create: true, delete: true }, @@ -75,7 +75,6 @@ export const IncidentContactPermission = ({ isNonEditable, setValue, control }: ); break; case Permission.ReadOnly: - setIsCustom.off(); setValue( "permissions.incident-contact", { read: true, edit: false, create: false, delete: false }, @@ -83,7 +82,6 @@ export const IncidentContactPermission = ({ isNonEditable, setValue, control }: ); break; default: - setIsCustom.on(); setValue( "permissions.incident-contact", { read: false, edit: false, create: false, delete: false }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx index f924e52db..de758b480 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx @@ -31,31 +31,34 @@ const PERMISSIONS = [ ] as const; export const MemberPermission = ({ isNonEditable, setValue, control }: Props) => { - const memberRule = useWatch({ + const rule = useWatch({ control, name: "permissions.member" }); const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; - const actions = Object.keys(memberRule || {}) as Array; + const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += memberRule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; if (score === totalActions) return Permission.FullAccess; - if (score === 1 && memberRule.read) return Permission.ReadOnly; + if (score === 1 && rule.read) return Permission.ReadOnly; return Permission.Custom; - }, [memberRule, isCustom]); + }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: setValue( diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx index 342d87732..c7700b20e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/RolePermission.tsx @@ -31,34 +31,36 @@ const PERMISSIONS = [ ] as const; export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { - const roleRule = useWatch({ + const rule = useWatch({ control, name: "permissions.role" }); const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; - const actions = Object.keys(roleRule || {}) as Array; + const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += roleRule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; if (score === totalActions) return Permission.FullAccess; - if (score === 1 && roleRule.read) return Permission.ReadOnly; + if (score === 1 && rule.read) return Permission.ReadOnly; return Permission.Custom; - }, [roleRule, isCustom]); + }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: - setIsCustom.off(); setValue( "permissions.role", { read: false, edit: false, create: false, delete: false }, @@ -66,7 +68,6 @@ export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { ); break; case Permission.FullAccess: - setIsCustom.off(); setValue( "permissions.role", { read: true, edit: true, create: true, delete: true }, @@ -74,7 +75,6 @@ export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { ); break; case Permission.ReadOnly: - setIsCustom.off(); setValue( "permissions.role", { read: true, edit: false, create: false, delete: false }, @@ -82,7 +82,6 @@ export const RolePermission = ({ isNonEditable, setValue, control }: Props) => { ); break; default: - setIsCustom.on(); setValue( "permissions.role", { read: false, edit: false, create: false, delete: false }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SecretScanningPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SecretScanningPermission.tsx index 779ea51b7..37b59b6bf 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SecretScanningPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SecretScanningPermission.tsx @@ -38,10 +38,9 @@ export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Pr const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -52,13 +51,16 @@ export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Pr }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: - setIsCustom.off(); setValue( "permissions.secret-scanning", { read: false, edit: false, create: false, delete: false }, @@ -66,7 +68,6 @@ export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Pr ); break; case Permission.FullAccess: - setIsCustom.off(); setValue( "permissions.secret-scanning", { read: true, edit: true, create: true, delete: true }, @@ -74,7 +75,6 @@ export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Pr ); break; case Permission.ReadOnly: - setIsCustom.off(); setValue( "permissions.secret-scanning", { read: true, edit: false, create: false, delete: false }, @@ -82,7 +82,6 @@ export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Pr ); break; default: - setIsCustom.on(); setValue( "permissions.secret-scanning", { read: false, edit: false, create: false, delete: false }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx index 0bfc821e7..c574901f8 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SettingsPermission.tsx @@ -38,10 +38,9 @@ export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -52,13 +51,16 @@ export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: - setIsCustom.off(); setValue( "permissions.settings", { read: false, edit: false, create: false, delete: false }, @@ -66,7 +68,6 @@ export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) ); break; case Permission.FullAccess: - setIsCustom.off(); setValue( "permissions.settings", { read: true, edit: true, create: true, delete: true }, @@ -74,7 +75,6 @@ export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) ); break; case Permission.ReadOnly: - setIsCustom.off(); setValue( "permissions.settings", { read: true, edit: false, create: false, delete: false }, @@ -82,7 +82,6 @@ export const SettingsPermission = ({ isNonEditable, setValue, control }: Props) ); break; default: - setIsCustom.on(); setValue( "permissions.settings", { read: false, edit: false, create: false, delete: false }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx index 25ae2438b..3d6493fe4 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SsoPermission.tsx @@ -38,10 +38,9 @@ export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -52,13 +51,16 @@ export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: - setIsCustom.off(); setValue( "permissions.sso", { read: false, edit: false, create: false, delete: false }, @@ -66,7 +68,6 @@ export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { ); break; case Permission.FullAccess: - setIsCustom.off(); setValue( "permissions.sso", { read: true, edit: true, create: true, delete: true }, @@ -74,7 +75,6 @@ export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { ); break; case Permission.ReadOnly: - setIsCustom.off(); setValue( "permissions.sso", { read: true, edit: false, create: false, delete: false }, @@ -82,7 +82,6 @@ export const SsoPermission = ({ isNonEditable, setValue, control }: Props) => { ); break; default: - setIsCustom.on(); setValue( "permissions.sso", { read: false, edit: false, create: false, delete: false }, diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx index b8fcf4b61..8e1abc87f 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx @@ -36,10 +36,9 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) const [isCustom, setIsCustom] = useToggle(); const selectedPermissionCategory = useMemo(() => { - let score = 0; const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - actions.forEach((key) => (score += rule[key] ? 1 : 0)); + const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; @@ -50,11 +49,14 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) }, [rule, isCustom]); useEffect(() => { - selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { - val === Permission.Custom ? setIsCustom.on() : setIsCustom.off(); + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + switch (val) { case Permission.NoAccess: setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true }); diff --git a/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx b/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx index 54b66725d..e434f6536 100644 --- a/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx +++ b/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx @@ -1,28 +1,47 @@ import { useEffect, useState } from "react"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; import updateRiskStatus, { RiskStatus } from "@app/pages/api/secret-scanning/updateRiskStatus"; -export const RiskStatusSelection = ({riskId, currentSelection}: {riskId: any, currentSelection: any }) => { - const [selectedRiskStatus, setSelectedRiskStatus] = useState(currentSelection); - useEffect(()=>{ - if (currentSelection !== selectedRiskStatus){ - const updateSelection = async () =>{ - await updateRiskStatus(String(localStorage.getItem("orgData.id")), riskId, selectedRiskStatus) - } - updateSelection() - } - },[selectedRiskStatus]) +export const RiskStatusSelection = ({ + riskId, + currentSelection +}: { + riskId: any; + currentSelection: any; +}) => { + const [selectedRiskStatus, setSelectedRiskStatus] = useState(currentSelection); + useEffect(() => { + if (currentSelection !== selectedRiskStatus) { + const updateSelection = async () => { + await updateRiskStatus( + String(localStorage.getItem("orgData.id")), + riskId, + selectedRiskStatus + ); + }; + updateSelection(); + } + }, [selectedRiskStatus]); - return ( - - ); -} \ No newline at end of file + return ( + + {(isAllowed) => ( + + )} + + ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx index 9dd751368..fe7b03ac2 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingCloudTab/PreviewSection.tsx @@ -1,125 +1,148 @@ +import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; -import { useOrganization,useSubscription } from "@app/context"; -import { - useCreateCustomerPortalSession, - useGetOrgPlanBillingInfo, - useGetOrgTrialUrl +import { + OrgGeneralPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; +import { + useCreateCustomerPortalSession, + useGetOrgPlanBillingInfo, + useGetOrgTrialUrl } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { ManagePlansModal } from "./ManagePlansModal"; export const PreviewSection = () => { - const { currentOrg } = useOrganization(); - const { subscription } = useSubscription(); - const { data, isLoading } = useGetOrgPlanBillingInfo(currentOrg?._id ?? ""); - const getOrgTrialUrl = useGetOrgTrialUrl(); - const createCustomerPortalSession = useCreateCustomerPortalSession(); - - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ - "managePlan" - ] as const); - - const formatAmount = (amount: number) => { - const formattedTotal = (Math.floor(amount) / 100).toLocaleString("en-US", { - style: "currency", - currency: "USD", - }); - - return formattedTotal; - } - - const formatDate = (date: number) => { - const createdDate = new Date(date * 1000); - const day: number = createdDate.getDate(); - const month: number = createdDate.getMonth() + 1; - const year: number = createdDate.getFullYear(); - const formattedDate: string = `${day}/${month}/${year}`; - - return formattedDate; - } + const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); + const { data, isLoading } = useGetOrgPlanBillingInfo(currentOrg?._id ?? ""); + const getOrgTrialUrl = useGetOrgTrialUrl(); + const createCustomerPortalSession = useCreateCustomerPortalSession(); - function formatPlanSlug(slug: string) { - return slug - .replace(/(\b[a-z])/g, match => match.toUpperCase()) - .replace(/-/g, " "); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["managePlan"] as const); + + const formatAmount = (amount: number) => { + const formattedTotal = (Math.floor(amount) / 100).toLocaleString("en-US", { + style: "currency", + currency: "USD" + }); + + return formattedTotal; + }; + + const formatDate = (date: number) => { + const createdDate = new Date(date * 1000); + const day: number = createdDate.getDate(); + const month: number = createdDate.getMonth() + 1; + const year: number = createdDate.getFullYear(); + const formattedDate: string = `${day}/${month}/${year}`; + + return formattedDate; + }; + + function formatPlanSlug(slug: string) { + return slug.replace(/(\b[a-z])/g, (match) => match.toUpperCase()).replace(/-/g, " "); + } + + const handleUpgradeBtnClick = async () => { + try { + if (!subscription || !currentOrg) return; + + if (!subscription.has_used_trial) { + // direct user to start pro trial + const url = await getOrgTrialUrl.mutateAsync({ + orgId: currentOrg._id, + success_url: window.location.href + }); + + window.location.href = url; + } else { + // open compare plans modal + handlePopUpOpen("managePlan"); + } + } catch (err) { + console.error(err); } - - const handleUpgradeBtnClick = async () => { - try { - if (!subscription || !currentOrg) return; - - if (!subscription.has_used_trial) { - // direct user to start pro trial - const url = await getOrgTrialUrl.mutateAsync({ - orgId: currentOrg._id, - success_url: window.location.href - }); - - window.location.href = url; - } else { - // open compare plans modal - handlePopUpOpen("managePlan"); - } - } catch (err) { - console.error(err); - } - } - - return ( -
- {subscription && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && ( -
-
-

Become Infisical

-

Unlimited members, projects, RBAC, smart alerts, and so much more

-
- -
- )} - {!isLoading && subscription && data && ( -
-
-

Current plan

-

- {`${formatPlanSlug(subscription.slug)} ${subscription.status === "trialing" ? "(Trial)" : ""}`} -

- -
-
-

Price

-

- {subscription.status === "trialing" ? "$0.00 / month" : `${formatAmount(data.amount)} / ${data.interval}`} -

-
-
-

Subscription renews on

-

- {formatDate(data.currentPeriodEnd)} -

-
-
- )} - + }; + + return ( +
+ {subscription && + subscription?.slug !== "enterprise" && + subscription?.slug !== "pro" && + subscription?.slug !== "pro-annual" && ( +
+
+

Become Infisical

+

+ Unlimited members, projects, RBAC, smart alerts, and so much more +

+
+ + {(isAllowed) => ( + + )} + +
+ )} + {!isLoading && subscription && data && ( +
+
+

Current plan

+

+ {`${formatPlanSlug(subscription.slug)} ${ + subscription.status === "trialing" ? "(Trial)" : "" + }`} +

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

Price

+

+ {subscription.status === "trialing" + ? "$0.00 / month" + : `${formatAmount(data.amount)} / ${data.interval}`} +

+
+
+

Subscription renews on

+

+ {formatDate(data.currentPeriodEnd)} +

+
- ); -} \ No newline at end of file + )} + +
+ ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx index 0f27757fa..a11d053c6 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/CompanyNameSection.tsx @@ -1,98 +1,92 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - FormControl, - Input -} from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { - useGetOrgBillingDetails, - useUpdateOrgBillingDetails -} from "@app/hooks/api"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api"; -const schema = yup.object({ +const schema = yup + .object({ name: yup.string().required("Company name is required") -}).required(); + }) + .required(); export const CompanyNameSection = () => { - const { createNotification } = useNotificationContext(); - const { currentOrg } = useOrganization(); - const { reset, control, handleSubmit } = useForm({ - defaultValues: { - name: "" - }, - resolver: yupResolver(schema) - }); - const { data } = useGetOrgBillingDetails(currentOrg?._id ?? ""); - const { mutateAsync, isLoading } = useUpdateOrgBillingDetails(); - - useEffect(() => { - if (data) { - reset({ - name: data?.name ?? "" - }); - } - }, [data]); - - const onFormSubmit = async ({ name }: { name: string }) => { - try { - if (!currentOrg?._id) return; - if (name === "") return; - await mutateAsync({ - name, - organizationId: currentOrg._id - }); - - createNotification({ - text: "Successfully updated business name", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update business name", - type: "error" - }); - } - } + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { reset, control, handleSubmit } = useForm({ + defaultValues: { + name: "" + }, + resolver: yupResolver(schema) + }); + const { data } = useGetOrgBillingDetails(currentOrg?._id ?? ""); + const { mutateAsync, isLoading } = useUpdateOrgBillingDetails(); - return ( -
-

- Business name -

-
- ( - - - - )} - control={control} - name="name" - /> -
- -
- ); -} \ No newline at end of file + useEffect(() => { + if (data) { + reset({ + name: data?.name ?? "" + }); + } + }, [data]); + + const onFormSubmit = async ({ name }: { name: string }) => { + try { + if (!currentOrg?._id) return; + if (name === "") return; + await mutateAsync({ + name, + organizationId: currentOrg._id + }); + + createNotification({ + text: "Successfully updated business name", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update business name", + type: "error" + }); + } + }; + + return ( +
+

Business name

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx index 441cb9cb4..10bc836f3 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/InvoiceEmailSection.tsx @@ -1,97 +1,93 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - FormControl, - Input} from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { - useGetOrgBillingDetails, - useUpdateOrgBillingDetails -} from "@app/hooks/api"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useGetOrgBillingDetails, useUpdateOrgBillingDetails } from "@app/hooks/api"; -const schema = yup.object({ +const schema = yup + .object({ email: yup.string().required("Email is required") -}).required(); + }) + .required(); export const InvoiceEmailSection = () => { - const { createNotification } = useNotificationContext(); - const { currentOrg } = useOrganization(); - const { reset, control, handleSubmit } = useForm({ - defaultValues: { - email: "" - }, - resolver: yupResolver(schema) - }); - const { data } = useGetOrgBillingDetails(currentOrg?._id ?? ""); - const { mutateAsync, isLoading } = useUpdateOrgBillingDetails(); + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { reset, control, handleSubmit } = useForm({ + defaultValues: { + email: "" + }, + resolver: yupResolver(schema) + }); + const { data } = useGetOrgBillingDetails(currentOrg?._id ?? ""); + const { mutateAsync, isLoading } = useUpdateOrgBillingDetails(); - useEffect(() => { - if (data) { - reset({ - email: data?.email ?? "" - }); - } - }, [data]); - - const onFormSubmit = async ({ email }: { email: string }) => { - try { - if (!currentOrg?._id) return; - if (email === "") return; - - await mutateAsync({ - email, - organizationId: currentOrg._id - }); - - createNotification({ - text: "Successfully updated invoice email recipient", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update invoice email recipient", - type: "error" - }); - } + useEffect(() => { + if (data) { + reset({ + email: data?.email ?? "" + }); } + }, [data]); - return ( -
-

- Invoice email recipient -

-
- ( - - - - )} - control={control} - name="email" - /> -
- -
- ); -} \ No newline at end of file + const onFormSubmit = async ({ email }: { email: string }) => { + try { + if (!currentOrg?._id) return; + if (email === "") return; + + await mutateAsync({ + email, + organizationId: currentOrg._id + }); + + createNotification({ + text: "Successfully updated invoice email recipient", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update invoice email recipient", + type: "error" + }); + } + }; + + return ( +
+

Invoice email recipient

+
+ ( + + + + )} + control={control} + name="email" + /> +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx index a124813ed..011006dbe 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsSection.tsx @@ -1,46 +1,47 @@ - import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Button -} from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useAddOrgPmtMethod } from "@app/hooks/api"; import { PmtMethodsTable } from "./PmtMethodsTable"; export const PmtMethodsSection = () => { - const { currentOrg } = useOrganization(); - const { mutateAsync, isLoading } = useAddOrgPmtMethod(); - - const handleAddPmtMethodBtnClick = async () => { - if (!currentOrg?._id) return; - const url = await mutateAsync({ - organizationId: currentOrg._id, - success_url: window.location.href, - cancel_url: window.location.href - }); - - window.location.href = url; - } - - return ( -
-
-

- Payment methods -

- -
- -
- ); -} \ No newline at end of file + const { currentOrg } = useOrganization(); + const { mutateAsync, isLoading } = useAddOrgPmtMethod(); + + const handleAddPmtMethodBtnClick = async () => { + if (!currentOrg?._id) return; + const url = await mutateAsync({ + organizationId: currentOrg._id, + success_url: window.location.href, + cancel_url: window.location.href + }); + + window.location.href = url; + }; + + return ( +
+
+

Payment methods

+ + {(isAllowed) => ( + + )} + +
+ +
+ ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx index 17f4fd21c..6818dbb62 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/PmtMethodsTable.tsx @@ -1,6 +1,7 @@ import { faCreditCard, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { OrgPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useDeleteOrgPmtMethod, useGetOrgPmtMethods } from "@app/hooks/api"; export const PmtMethodsTable = () => { @@ -52,17 +53,25 @@ export const PmtMethodsTable = () => { {last4} {`${exp_month}/${exp_year}`} - { - await handleDeletePmtMethodBtnClick(_id); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" + - - + {(isAllowed) => ( + { + await handleDeletePmtMethodBtnClick(_id); + }} + size="lg" + isDisabled={!isAllowed} + colorSchema="danger" + variant="plain" + ariaLabel="update" + > + + + )} + ))} diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx index 198e02f62..5e3884613 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDSection.tsx @@ -1,38 +1,42 @@ - import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks/usePopUp"; import { TaxIDModal } from "./TaxIDModal"; import { TaxIDTable } from "./TaxIDTable"; export const TaxIDSection = () => { - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "addTaxID" - ] as const); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addTaxID" + ] as const); - return ( -
-
-

- Tax ID -

- -
- - -
- ); -} \ No newline at end of file + return ( +
+
+

Tax ID

+ + {(isAllowed) => ( + + )} + +
+ + +
+ ); +}; diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDTable.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDTable.tsx index 2779bb19d..9980e4689 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDTable.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingDetailsTab/TaxIDTable.tsx @@ -1,6 +1,7 @@ import { faFileInvoice, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { OrgPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useDeleteOrgTaxId, useGetOrgTaxIds } from "@app/hooks/api"; const taxIDTypeLabelMap: { [key: string]: string } = { @@ -101,17 +102,25 @@ export const TaxIDTable = () => { {taxIDTypeLabelMap[type]} {value} - { - await handleDeleteTaxIdBtnClick(_id); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" + - - + {(isAllowed) => ( + { + await handleDeleteTaxIdBtnClick(_id); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx b/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx index d30fa6ffa..bd37adf5d 100644 --- a/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx +++ b/frontend/src/views/Settings/BillingSettingsPage/components/BillingTabGroup/BillingTabGroup.tsx @@ -1,5 +1,8 @@ -import { Fragment } from "react" -import { Tab } from "@headlessui/react" +import { Fragment } from "react"; +import { Tab } from "@headlessui/react"; + +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; import { BillingCloudTab } from "../BillingCloudTab"; import { BillingDetailsTab } from "../BillingDetailsTab"; @@ -7,43 +10,48 @@ import { BillingReceiptsTab } from "../BillingReceiptsTab"; import { BillingSelfHostedTab } from "../BillingSelfHostedTab"; const tabs = [ - { name: "Infisical Cloud", key: "tab-infisical-cloud" }, - { name: "Infisical Self-Hosted", key: "tab-infisical-self-hosted" }, - { name: "Receipts", key: "tab-receipts" }, - { name: "Billing details", key: "tab-billing-details" } + { name: "Infisical Cloud", key: "tab-infisical-cloud" }, + { name: "Infisical Self-Hosted", key: "tab-infisical-self-hosted" }, + { name: "Receipts", key: "tab-receipts" }, + { name: "Billing details", key: "tab-billing-details" } ]; -export const BillingTabGroup = () => { +export const BillingTabGroup = withPermission( + () => { return ( - - - {tabs.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - - - - - - - - - - - - - + + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + + + + + + ); -} \ No newline at end of file + }, + { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.Billing } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx index 9a3672237..b7cf27c07 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgAuthTab.tsx @@ -1,9 +1,15 @@ +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; + import { OrgSSOSection } from "./OrgSSOSection"; -export const OrgAuthTab = () => { +export const OrgAuthTab = withPermission( + () => { return ( -
- -
+
+ +
); -} \ No newline at end of file + }, + { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.Sso } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx index 7b4e65cb7..274d0e511 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgAuthTab/OrgSSOSection.tsx @@ -2,136 +2,150 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, Switch, UpgradePlanModal } from "@app/components/v2"; -import { useOrganization, useSubscription } from "@app/context"; -import { - useCreateSSOConfig, - useGetSSOConfig, - useUpdateSSOConfig -} from "@app/hooks/api"; +import { + OrgGeneralPermissionActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; +import { useCreateSSOConfig, useGetSSOConfig, useUpdateSSOConfig } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { SSOModal } from "./SSOModal"; const ssoAuthProviderMap: { [key: string]: string } = { - "okta-saml": "Okta SAML", - "azure-saml": "Azure SAML", - "jumpcloud-saml": "JumpCloud SAML" -} + "okta-saml": "Okta SAML", + "azure-saml": "Azure SAML", + "jumpcloud-saml": "JumpCloud SAML" +}; export const OrgSSOSection = (): JSX.Element => { - const { currentOrg } = useOrganization(); - const { subscription } = useSubscription(); - const { createNotification } = useNotificationContext(); - const { data, isLoading } = useGetSSOConfig(currentOrg?._id ?? ""); - const { mutateAsync } = useUpdateSSOConfig(); - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "upgradePlan", - "addSSO" - ] as const); - - const { mutateAsync: createMutateAsync } = useCreateSSOConfig(); - - const handleSamlSSOToggle = async (value: boolean) => { - try { - if (!currentOrg?._id) return; + const { currentOrg } = useOrganization(); + const { subscription } = useSubscription(); + const { createNotification } = useNotificationContext(); + const { data, isLoading } = useGetSSOConfig(currentOrg?._id ?? ""); + const { mutateAsync } = useUpdateSSOConfig(); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "upgradePlan", + "addSSO" + ] as const); - await mutateAsync({ - organizationId: currentOrg?._id, - isActive: value - }); + const { mutateAsync: createMutateAsync } = useCreateSSOConfig(); - createNotification({ - text: `Successfully ${value ? "enabled" : "disabled"} SAML SSO`, - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: `Failed to ${value ? "enable" : "disable"} SAML SSO`, - type: "error" - }); - } + const handleSamlSSOToggle = async (value: boolean) => { + try { + if (!currentOrg?._id) return; + + await mutateAsync({ + organizationId: currentOrg?._id, + isActive: value + }); + + createNotification({ + text: `Successfully ${value ? "enabled" : "disabled"} SAML SSO`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${value ? "enable" : "disable"} SAML SSO`, + type: "error" + }); } - - const addSSOBtnClick = async () => { - try { - if (subscription?.samlSSO && currentOrg) { - if (!data) { - // case: SAML SSO is not configured - // -> initialize empty SAML SSO configuration - await createMutateAsync({ - organizationId: currentOrg._id, - authProvider: "okta-saml", - isActive: false, - entryPoint: "", - issuer: "", - cert: "" - }); - } + }; - handlePopUpOpen("addSSO"); - } else { - handlePopUpOpen("upgradePlan"); - } - } catch (err) { - console.error(err); + const addSSOBtnClick = async () => { + try { + if (subscription?.samlSSO && currentOrg) { + if (!data) { + // case: SAML SSO is not configured + // -> initialize empty SAML SSO configuration + await createMutateAsync({ + organizationId: currentOrg._id, + authProvider: "okta-saml", + isActive: false, + entryPoint: "", + issuer: "", + cert: "" + }); } + + handlePopUpOpen("addSSO"); + } else { + handlePopUpOpen("upgradePlan"); + } + } catch (err) { + console.error(err); } - - return ( -
-
-

- SAML SSO Configuration -

- {!isLoading && ( - - )} -
- {data && ( -
- handleSamlSSOToggle(value)} - isChecked={data ? data.isActive : false} - > - Enable SAML SSO - -
+ }; + + return ( +
+
+

SAML SSO Configuration

+ {!isLoading && ( + + {(isAllowed) => ( + )} -
-

SSO identifier

-

{(data && data._id !== "") ? data._id : "-"}

-
-
-

Type

-

{(data && data.authProvider !== "") ? ssoAuthProviderMap[data.authProvider] : "-"}

-
-
-

Entrypoint

-

{(data && data.entryPoint !== "") ? data.entryPoint : "-"}

-
-
-

Issuer

-

{(data && data.issuer !== "") ? data.issuer : "-"}

-
- - handlePopUpToggle("upgradePlan", isOpen)} - text="You can use SAML SSO if you switch to Infisical's Pro plan." - /> +
+ )} +
+ {data && ( +
+ + {(isAllowed) => ( + handleSamlSSOToggle(value)} + isChecked={data ? data.isActive : false} + isDisabled={!isAllowed} + > + Enable SAML SSO + + )} +
- ); -}; \ No newline at end of file + )} +
+

SSO identifier

+

{data && data._id !== "" ? data._id : "-"}

+
+
+

Type

+

+ {data && data.authProvider !== "" ? ssoAuthProviderMap[data.authProvider] : "-"} +

+
+
+

Entrypoint

+

+ {data && data.entryPoint !== "" ? data.entryPoint : "-"} +

+
+
+

Issuer

+

{data && data.issuer !== "" ? data.issuer : "-"}

+
+ + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use SAML SSO if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgGeneralTab/OrgGeneralTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgGeneralTab/OrgGeneralTab.tsx index 5c1fb2738..3c9c016a8 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgGeneralTab/OrgGeneralTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgGeneralTab/OrgGeneralTab.tsx @@ -3,13 +3,11 @@ import { OrgNameChangeSection } from "../OrgNameChangeSection"; import { OrgServiceAccountsTable } from "../OrgServiceAccountsTable"; export const OrgGeneralTab = () => { - return ( -
- -
- -
- -
- ); -} \ No newline at end of file + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx index 238c5d190..e28bbc384 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/AddOrgIncidentContactModal.tsx @@ -3,17 +3,9 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - FormControl, - Input, - Modal, - ModalContent -} from "@app/components/v2"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { - useAddIncidentContact -} from "@app/hooks/api"; +import { useAddIncidentContact } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -24,97 +16,90 @@ const addContactFormSchema = yup.object({ type TAddContactForm = yup.InferType; type Props = { - popUp: UsePopUpState<["addContact"]>; - handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void; + popUp: UsePopUpState<["addContact"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void; }; export const AddOrgIncidentContactModal = ({ - popUp, - handlePopUpClose, - handlePopUpToggle + popUp, + handlePopUpClose, + handlePopUpToggle }: Props) => { - const { createNotification } = useNotificationContext(); - const { currentOrg } = useOrganization(); - const { data: serverDetails } = useFetchServerStatus() - const { - control, - handleSubmit, - reset - } = useForm({ resolver: yupResolver(addContactFormSchema) }); + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const { data: serverDetails } = useFetchServerStatus(); + const { control, handleSubmit, reset } = useForm({ + resolver: yupResolver(addContactFormSchema) + }); - const { mutateAsync, isLoading } = useAddIncidentContact(); - - const onFormSubmit = async ({ email }: TAddContactForm) => { - try { - if (!currentOrg?._id) return; - - await mutateAsync({ - orgId: currentOrg._id, - email - }); + const { mutateAsync, isLoading } = useAddIncidentContact(); - createNotification({ - text: "Successfully added incident contact", - type: "success" - }); - - if (serverDetails?.emailConfigured){ - handlePopUpClose("addContact"); - } + const onFormSubmit = async ({ email }: TAddContactForm) => { + try { + if (!currentOrg?._id) return; - reset(); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to add incident contact", - type: "error" - }); - } + await mutateAsync({ + orgId: currentOrg._id, + email + }); + + createNotification({ + text: "Successfully added incident contact", + type: "success" + }); + + if (serverDetails?.emailConfigured) { + handlePopUpClose("addContact"); + } + + reset(); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to add incident contact", + type: "error" + }); } + }; - return ( - { - handlePopUpToggle("addContact", isOpen); - reset(); - }} - > - { + handlePopUpToggle("addContact", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + - -
- -
-
- ); -} \ No newline at end of file + Cancel + +
+ + + + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx index 29d1064a2..a4b0ab018 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsSection.tsx @@ -2,42 +2,53 @@ import { useTranslation } from "react-i18next"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Button -} from "@app/components/v2"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Button } from "@app/components/v2"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"; import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable"; -export const OrgIncidentContactsSection = () => { +export const OrgIncidentContactsSection = withPermission( + () => { const { t } = useTranslation(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "addContact" + "addContact" ] as const); return ( -
-
-

- {t("section.incident.incident-contacts")} -

- -
- - +
+
+

+ {t("section.incident.incident-contacts")} +

+ + {(isAllowed) => ( + + )} +
+ + +
); -} - + }, + { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.IncidentAccount } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx index 0d3011572..4976d11f4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx @@ -3,6 +3,7 @@ import { faContactBook, faMagnifyingGlass, faTrash } from "@fortawesome/free-sol import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, EmptyState, @@ -17,7 +18,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteIncidentContact, useGetOrgIncidentContact } from "@app/hooks/api"; @@ -83,13 +84,21 @@ export const OrgIncidentContactsTable = () => { {email} - handlePopUpOpen("removeContact", { email })} + - - + {(isAllowed) => ( + handlePopUpOpen("removeContact", { email })} + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx index 3c22f01f2..c1f1d4498 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgNameChangeSection/OrgNameChangeSection.tsx @@ -4,8 +4,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; -import { useOrganization } from "@app/context"; +import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { withPermission } from "@app/hoc"; import { useRenameOrg } from "@app/hooks/api"; const formSchema = yup.object({ @@ -14,49 +16,46 @@ const formSchema = yup.object({ type FormData = yup.InferType; -export const OrgNameChangeSection = (): JSX.Element => { - const { currentOrg } = useOrganization(); - const { createNotification } = useNotificationContext(); - const { - handleSubmit, - control, - reset - } = useForm({ resolver: yupResolver(formSchema) }); - const { mutateAsync, isLoading } = useRenameOrg(); +export const OrgNameChangeSection = withPermission( + (): JSX.Element => { + const { currentOrg } = useOrganization(); + const { createNotification } = useNotificationContext(); + const { handleSubmit, control, reset } = useForm({ + resolver: yupResolver(formSchema) + }); + const { mutateAsync, isLoading } = useRenameOrg(); - useEffect(() => { - if (currentOrg) { - reset({ name: currentOrg.name }); - } - }, [currentOrg]); + useEffect(() => { + if (currentOrg) { + reset({ name: currentOrg.name }); + } + }, [currentOrg]); - const onFormSubmit = async ({ name }: FormData) => { - try { - if (!currentOrg?._id) return; - if (name === "") return; + const onFormSubmit = async ({ name }: FormData) => { + try { + if (!currentOrg?._id) return; + if (name === "") return; - await mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); - createNotification({ - text: "Successfully renamed organization", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to rename organization", - type: "error" - }); - } - }; + await mutateAsync({ orgId: currentOrg?._id, newOrgName: name }); + createNotification({ + text: "Successfully renamed organization", + type: "success" + }); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to rename organization", + type: "error" + }); + } + }; - return ( -
-

- Organization name -

+ return ( + +

Organization name

{ name="name" />
- -
- ); -}; + + {(isAllowed) => ( + + )} + + + ); + }, + { + action: OrgGeneralPermissionActions.Read, + subject: OrgPermissionSubjects.Settings, + containerClassName: "mb-4" + } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx index 6ead6cf87..eaac5d7c4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx @@ -34,7 +34,13 @@ import { THead, Tr } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; +import { + OrgGeneralPermissionActions, + OrgPermissionSubjects, + useOrganization, + useWorkspace +} from "@app/context"; +import { withPermission } from "@app/hoc"; import { usePopUp, useToggle } from "@app/hooks"; import { // useCreateServiceAccount, @@ -62,313 +68,322 @@ import // Controller, // type TAddServiceAccountForm = yup.InferType; -export const OrgServiceAccountsTable = () => { - const router = useRouter(); - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); +export const OrgServiceAccountsTable = withPermission( + () => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); - const orgId = currentOrg?._id || ""; - const [step, setStep] = useState(0); - const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); - const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false); - const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); - const [accessKey] = useState(""); - const [publicKey] = useState(""); - const [privateKey] = useState(""); - const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(""); - const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ - "addServiceAccount", - "removeServiceAccount" - ] as const); + const orgId = currentOrg?._id || ""; + const [step, setStep] = useState(0); + const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); + const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false); + const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); + const [accessKey] = useState(""); + const [publicKey] = useState(""); + const [privateKey] = useState(""); + const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(""); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "addServiceAccount", + "removeServiceAccount" + ] as const); - const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = - useGetServiceAccounts(orgId); + const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = + useGetServiceAccounts(orgId); - // const createServiceAccount = useCreateServiceAccount(); - const removeServiceAccount = useDeleteServiceAccount(); + // const createServiceAccount = useCreateServiceAccount(); + const removeServiceAccount = useDeleteServiceAccount(); - useEffect(() => { - let timer: NodeJS.Timeout; - if (isAccessKeyCopied) { - timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); - } + useEffect(() => { + let timer: NodeJS.Timeout; + if (isAccessKeyCopied) { + timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); + } - if (isPublicKeyCopied) { - timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000); - } + if (isPublicKeyCopied) { + timer = setTimeout(() => setIsPublicKeyCopied.off(), 2000); + } - if (isPrivateKeyCopied) { - timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); - } + if (isPrivateKeyCopied) { + timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); + } - return () => clearTimeout(timer); - }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]); + return () => clearTimeout(timer); + }, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]); - // const { - // control, - // handleSubmit, - // reset, - // formState: { isSubmitting } - // } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) }); + // const { + // control, + // handleSubmit, + // reset, + // formState: { isSubmitting } + // } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) }); - // const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => { - // if (!currentOrg?._id) return; + // const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => { + // if (!currentOrg?._id) return; - // const keyPair = generateKeyPair(); - // setPublicKey(keyPair.publicKey); - // setPrivateKey(keyPair.privateKey); + // const keyPair = generateKeyPair(); + // setPublicKey(keyPair.publicKey); + // setPrivateKey(keyPair.privateKey); - // const serviceAccountDetails = await createServiceAccount.mutateAsync({ - // name, - // organizationId: currentOrg?._id, - // publicKey: keyPair.publicKey, - // expiresIn: Number(expiresIn) - // }); + // const serviceAccountDetails = await createServiceAccount.mutateAsync({ + // name, + // organizationId: currentOrg?._id, + // publicKey: keyPair.publicKey, + // expiresIn: Number(expiresIn) + // }); - // setAccessKey(serviceAccountDetails.serviceAccountAccessKey); + // setAccessKey(serviceAccountDetails.serviceAccountAccessKey); - // setStep(1); - // reset(); - // } + // setStep(1); + // reset(); + // } - const onRemoveServiceAccount = async () => { - const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; - await removeServiceAccount.mutateAsync(serviceAccountId); - handlePopUpClose("removeServiceAccount"); - }; + const onRemoveServiceAccount = async () => { + const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; + await removeServiceAccount.mutateAsync(serviceAccountId); + handlePopUpClose("removeServiceAccount"); + }; - const filteredServiceAccounts = useMemo( - () => - serviceAccounts.filter(({ name }) => name.toLowerCase().includes(searchServiceAccountFilter)), - [serviceAccounts, searchServiceAccountFilter] - ); + const filteredServiceAccounts = useMemo( + () => + serviceAccounts.filter(({ name }) => + name.toLowerCase().includes(searchServiceAccountFilter) + ), + [serviceAccounts, searchServiceAccountFilter] + ); - const renderStep = (stepToRender: number) => { - switch (stepToRender) { - case 0: - return ( -
- We are currently revising the service account mechanism. In the meantime, please use - service tokens or API key to fetch secrets via API request. -
- //
- // ( - // - // - // - // )} - // /> - // { - // return ( - // - // - // - // ); - // }} - // /> - //
- // - // - //
- // - ); - case 1: - return ( - <> -

Access Key

-
-

{accessKey}

- { - navigator.clipboard.writeText(accessKey); - setIsAccessKeyCopied.on(); - }} - > - - - Copy - - + const renderStep = (stepToRender: number) => { + switch (stepToRender) { + case 0: + return ( +
+ We are currently revising the service account mechanism. In the meantime, please use + service tokens or API key to fetch secrets via API request.
-

Public Key

-
-

{publicKey}

- { - navigator.clipboard.writeText(publicKey); - setIsPublicKeyCopied.on(); - }} - > - - - Copy - - -
-

Private Key

-
-

{privateKey}

- { - navigator.clipboard.writeText(privateKey); - setIsPrivateKeyCopied.on(); - }} - > - - - Copy - - -
- - ); - default: - return
; - } - }; + //
+ // ( + // + // + // + // )} + // /> + // { + // return ( + // + // + // + // ); + // }} + // /> + //
+ // + // + //
+ // + ); + case 1: + return ( + <> +

Access Key

+
+

{accessKey}

+ { + navigator.clipboard.writeText(accessKey); + setIsAccessKeyCopied.on(); + }} + > + + + Copy + + +
+

Public Key

+
+

{publicKey}

+ { + navigator.clipboard.writeText(publicKey); + setIsPublicKeyCopied.on(); + }} + > + + + Copy + + +
+

Private Key

+
+

{privateKey}

+ { + navigator.clipboard.writeText(privateKey); + setIsPrivateKeyCopied.on(); + }} + > + + + Copy + + +
+ + ); + default: + return
; + } + }; - return ( -
-
-

Service Accounts

- +
+ setSearchServiceAccountFilter(e.target.value)} + leftIcon={} + placeholder="Search service accounts..." + /> + + + + + + + + {isServiceAccountsLoading && ( + + )} + {!isServiceAccountsLoading && + filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => { + return ( + + + + + + ); + })} + +
NameValid Until +
{name}{new Date(expiresAt).toUTCString()} +
+ { + if (currentWorkspace?._id) { + router.push( + `/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}` + ); + } + }} + className="mr-2" + > + + + + handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId }) + } + > + + +
+
+ {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( + + )} +
+ { + handlePopUpToggle("addServiceAccount", isOpen); // reset(); - handlePopUpOpen("addServiceAccount"); }} > - Add Service Account - + + {renderStep(step)} + + + handlePopUpToggle("removeServiceAccount", isOpen)} + onDeleteApproved={onRemoveServiceAccount} + />
- setSearchServiceAccountFilter(e.target.value)} - leftIcon={} - placeholder="Search service accounts..." - /> - - - - - - - - {isServiceAccountsLoading && ( - - )} - {!isServiceAccountsLoading && - filteredServiceAccounts.map(({ name, expiresAt, _id: serviceAccountId }) => { - return ( - - - - - - ); - })} - -
NameValid Until -
{name}{new Date(expiresAt).toUTCString()} -
- { - if (currentWorkspace?._id) { - router.push( - `/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}` - ); - } - }} - className="mr-2" - > - - - - handlePopUpOpen("removeServiceAccount", { _id: serviceAccountId }) - } - > - - -
-
- {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( - - )} -
- { - handlePopUpToggle("addServiceAccount", isOpen); - // reset(); - }} - > - - {renderStep(step)} - - - handlePopUpToggle("removeServiceAccount", isOpen)} - onDeleteApproved={onRemoveServiceAccount} - /> -
- ); -}; + ); + }, + { + action: OrgGeneralPermissionActions.Read, + subject: OrgPermissionSubjects.Settings, + containerClassName: "mb-4" + } +); diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 1d0ebeb65..cacbf1b59 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -1,59 +1,40 @@ -import { Fragment } from "react" -import { Tab } from "@headlessui/react" - -import { useOrganization,useUser } from "@app/context"; -import { - useGetOrgUsers -} from "@app/hooks/api"; +import { Fragment } from "react"; +import { Tab } from "@headlessui/react"; import { OrgAuthTab } from "../OrgAuthTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; +const tabs = [ + { name: "General", key: "tab-org-general" }, + { name: "Authentication", key: "tab-org-auth" } +]; export const OrgTabGroup = () => { - const { currentOrg } = useOrganization(); - const { user } = useUser(); - const { data } = useGetOrgUsers(currentOrg?._id ?? ""); - - const isRoleSufficient = data?.some((orgUser) => { - return orgUser.role !== "member" && orgUser.user._id === user._id; - }); - - const tabs = [ - { name: "General", key: "tab-org-general" }, - ]; - - if (isRoleSufficient) { - tabs.push( - { name: "Authentication", key: "tab-org-auth" } - ); - } - - return ( - - - {tabs.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - - - {isRoleSufficient && ( - - - - )} - - - ); -} \ No newline at end of file + return ( + + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + ); +}; From 520a553ea1ad277b7e03bd67f9b66bba21eff443 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 22 Aug 2023 12:34:33 +0530 Subject: [PATCH 11/40] feat(rbac): implemented project based permission loading and role management --- backend/src/controllers/v1/roleController.ts | 79 ++-- backend/src/helpers/membership.ts | 2 +- backend/src/models/membership.ts | 6 +- backend/src/routes/v1/role.ts | 8 +- backend/src/services/ProjectRoleService.ts | 205 +++++++++ backend/src/validation/role.ts | 6 + backend/src/variables/organization.ts | 1 + .../permissions/OrgPermissionCan.tsx | 15 +- .../permissions/ProjectPermissionCan.tsx | 39 ++ frontend/src/components/permissions/index.tsx | 1 + .../context/OrgPermissionContext/index.tsx | 6 +- .../src/context/OrgPermissionContext/types.ts | 24 +- .../ProjectPermissionContext.tsx | 58 +++ .../ProjectPermissionContext/index.tsx | 3 + .../context/ProjectPermissionContext/types.ts | 44 ++ frontend/src/context/index.tsx | 12 +- frontend/src/hooks/api/roles/index.tsx | 2 +- frontend/src/hooks/api/roles/mutation.tsx | 8 +- frontend/src/hooks/api/roles/queries.tsx | 47 +- frontend/src/hooks/api/roles/types.ts | 44 +- frontend/src/hooks/api/users/queries.tsx | 129 +++--- frontend/src/hooks/api/users/types.ts | 12 +- frontend/src/hooks/api/workspace/queries.tsx | 52 ++- frontend/src/pages/_app.tsx | 15 +- frontend/src/pages/org/[id]/billing/index.tsx | 4 +- .../src/pages/org/[id]/overview/index.tsx | 6 +- .../pages/org/[id]/secret-scanning/index.tsx | 6 +- .../src/pages/project/[id]/members/index.tsx | 220 +-------- .../src/views/Org/MembersPage/MembersPage.tsx | 9 +- .../OrgMembersTable/OrgMembersTable.tsx | 10 +- .../OrgRoleModifySection.tsx | 2 +- .../OrgRoleTabSection/OrgRoleTabSection.tsx | 7 +- .../OrgRoleTabSection/OrgRoleTable.tsx | 15 +- .../views/Project/MembersPage/MembersPage.tsx | 58 +++ .../MemberListTab/MemberListTab.tsx | 419 ++++++++++++++++++ .../components/MemberListTab/index.tsx | 1 + .../ProjectRoleListTab/ProjectRoleListTab.tsx | 45 ++ .../ProjectRoleList/ProjectRoleList.tsx | 143 ++++++ .../components/ProjectRoleList/index.tsx | 1 + .../MultiEnvProjectPermission.tsx | 236 ++++++++++ .../ProjectRoleModifySection.tsx | 290 ++++++++++++ .../ProjectRoleModifySection.utils.ts | 171 +++++++ .../SingleProjectPermission.tsx | 171 +++++++ .../ProjectRoleModifySection/index.tsx | 1 + .../components/ProjectRoleListTab/index.tsx | 1 + .../src/views/Project/MembersPage/index.tsx | 1 + .../components/RiskStatusSelection.tsx | 4 +- .../BillingCloudTab/PreviewSection.tsx | 12 +- .../BillingDetailsTab/CompanyNameSection.tsx | 4 +- .../BillingDetailsTab/InvoiceEmailSection.tsx | 4 +- .../BillingDetailsTab/PmtMethodsSection.tsx | 4 +- .../BillingDetailsTab/PmtMethodsTable.tsx | 4 +- .../BillingDetailsTab/TaxIDSection.tsx | 4 +- .../BillingDetailsTab/TaxIDTable.tsx | 4 +- .../BillingTabGroup/BillingTabGroup.tsx | 4 +- .../components/OrgAuthTab/OrgAuthTab.tsx | 4 +- .../components/OrgAuthTab/OrgSSOSection.tsx | 6 +- .../OrgIncidentContactsSection.tsx | 6 +- .../OrgIncidentContactsTable.tsx | 4 +- .../OrgNameChangeSection.tsx | 6 +- .../OrgServiceAccountsTable.tsx | 4 +- package-lock.json | 14 +- package.json | 2 +- 63 files changed, 2237 insertions(+), 488 deletions(-) create mode 100644 backend/src/services/ProjectRoleService.ts create mode 100644 frontend/src/components/permissions/ProjectPermissionCan.tsx create mode 100644 frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx create mode 100644 frontend/src/context/ProjectPermissionContext/index.tsx create mode 100644 frontend/src/context/ProjectPermissionContext/types.ts create mode 100644 frontend/src/views/Project/MembersPage/MembersPage.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx create mode 100644 frontend/src/views/Project/MembersPage/index.tsx diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index 5a7fe7c8f..cf1b1a600 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -14,9 +14,15 @@ import { DeleteRoleSchema, GetRoleSchema, GetUserPermission, + GetUserProjectPermission, UpdateRoleSchema } from "../../validation"; import { packRules } from "@casl/ability/extra"; +import { + adminProjectPermissions, + getUserProjectPermissions, + viewerProjectPermission +} from "../../services/ProjectRoleService"; export const createRole = async (req: Request, res: Response) => { const { @@ -130,35 +136,45 @@ export const getRoles = async (req: Request, res: Response) => { throw BadRequestError({ message: "User doesn't have the permission." }); } - const roles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId }); + const customRoles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId }); + const roles = [ + { + _id: "admin", + name: "Admin", + slug: "admin", + description: "Complete administration access over the organization", + permissions: isOrgRole ? adminPermissions.rules : adminProjectPermissions.rules + }, + { + _id: "member", + name: "Member", + slug: "member", + description: "Non-administrative role in an organization", + permissions: isOrgRole ? memberPermissions.rules : adminProjectPermissions.rules + }, + { + _id: "viewer", + name: "Viewer", + slug: "viewer", + description: "Non-administrative role in an organization", + permissions: isOrgRole ? viewerProjectPermission.rules : viewerProjectPermission.rules + }, + ...customRoles + ]; + if (isOrgRole) { + roles.unshift({ + _id: "owner", + name: "Owner", + slug: "owner", + description: "Complete administration access over the organization.", + permissions: adminPermissions.rules + }); + } res.status(200).json({ message: "Successfully fetched role list", data: { - roles: [ - { - _id: "owner", - name: "Owner", - slug: "owner", - description: "Complete administration access over the organization.", - permissions: adminPermissions.rules - }, - { - _id: "admin", - name: "Admin", - slug: "admin", - description: "Complete administration access over the organization", - permissions: adminPermissions.rules - }, - { - _id: "member", - name: "Member", - slug: "member", - description: "Non-administrative role in an organization", - permissions: memberPermissions.rules - }, - ...roles - ] + roles } }); }; @@ -175,3 +191,16 @@ export const getUserPermissions = async (req: Request, res: Response) => { } }); }; + +export const getUserWorkspacePermissions = async (req: Request, res: Response) => { + const { + params: { workspaceId } + } = await validateRequest(GetUserProjectPermission, req); + const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + + res.status(200).json({ + data: { + permissions: packRules(permission.rules) + } + }); +}; diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 3fd7fa3d8..7700c21ee 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -17,7 +17,7 @@ export const validateMembership = async ({ }: { userId: Types.ObjectId | string; workspaceId: Types.ObjectId | string; - acceptedRoles?: Array<"admin" | "member" | "custom">; + acceptedRoles?: Array<"admin" | "member" | "custom" | "viewer">; }) => { const membership = await Membership.findOne({ user: userId, diff --git a/backend/src/models/membership.ts b/backend/src/models/membership.ts index 0032e7e64..22a3819e2 100644 --- a/backend/src/models/membership.ts +++ b/backend/src/models/membership.ts @@ -1,5 +1,5 @@ import { Schema, Types, model } from "mongoose"; -import { ADMIN, CUSTOM, MEMBER } from "../variables"; +import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables"; export interface IMembershipPermission { environmentSlug: string; @@ -11,7 +11,7 @@ export interface IMembership { user: Types.ObjectId; inviteEmail?: string; workspace: Types.ObjectId; - role: "admin" | "member" | "custom"; + role: "admin" | "member" | "viewer" | "custom"; customRole: Types.ObjectId; deniedPermissions: IMembershipPermission[]; } @@ -44,7 +44,7 @@ const membershipSchema = new Schema( }, role: { type: String, - enum: [ADMIN, MEMBER, CUSTOM], + enum: [ADMIN, MEMBER, VIEWER, CUSTOM], required: true }, customRole: { diff --git a/backend/src/routes/v1/role.ts b/backend/src/routes/v1/role.ts index e2a64eebe..042113954 100644 --- a/backend/src/routes/v1/role.ts +++ b/backend/src/routes/v1/role.ts @@ -19,9 +19,15 @@ router.get("/", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleControll // get a user permissions in an org router.get( - "/:orgId/permissions", + "/organization/:orgId/permissions", requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), roleController.getUserPermissions ); +router.get( + "/workspace/:workspaceId/permissions", + requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), + roleController.getUserWorkspacePermissions +); + export default router; diff --git a/backend/src/services/ProjectRoleService.ts b/backend/src/services/ProjectRoleService.ts new file mode 100644 index 000000000..02f992aa1 --- /dev/null +++ b/backend/src/services/ProjectRoleService.ts @@ -0,0 +1,205 @@ +import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; +import { Membership } from "../models"; +import { IRole } from "../models/role"; +import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; + +export enum GeneralPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermission { + Role = "role", + Member = "member", + Settings = "settings", + Integrations = "integrations", + Webhooks = "webhooks", + ServiceTokens = "service-tokens", + Environments = "environments", + Tags = "tags", + AuditLogs = "audit-logs", + IpAllowList = "ip-allowlist", + Workspace = "workspace", + Secrets = "secrets", + SecretImports = "secret-imports", + Folders = "folders" +} + +export type ProjectPermissionSet = + | [GeneralPermissionActions, ProjectPermission.Secrets] + | [GeneralPermissionActions, ProjectPermission.Folders] + | [GeneralPermissionActions, ProjectPermission.SecretImports] + | [GeneralPermissionActions, ProjectPermission.Role] + | [GeneralPermissionActions, ProjectPermission.Tags] + | [GeneralPermissionActions, ProjectPermission.Member] + | [GeneralPermissionActions, ProjectPermission.Integrations] + | [GeneralPermissionActions, ProjectPermission.Webhooks] + | [GeneralPermissionActions, ProjectPermission.AuditLogs] + | [GeneralPermissionActions, ProjectPermission.Environments] + | [GeneralPermissionActions, ProjectPermission.IpAllowList] + | [GeneralPermissionActions, ProjectPermission.Settings] + | [GeneralPermissionActions, ProjectPermission.ServiceTokens] + | [GeneralPermissionActions.Delete, ProjectPermission.Workspace] + | [GeneralPermissionActions.Edit, ProjectPermission.Workspace]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(GeneralPermissionActions.Read, ProjectPermission.Secrets); + can(GeneralPermissionActions.Create, ProjectPermission.Secrets); + can(GeneralPermissionActions.Edit, ProjectPermission.Secrets); + can(GeneralPermissionActions.Delete, ProjectPermission.Secrets); + + can(GeneralPermissionActions.Read, ProjectPermission.Folders); + can(GeneralPermissionActions.Create, ProjectPermission.Folders); + can(GeneralPermissionActions.Edit, ProjectPermission.Folders); + can(GeneralPermissionActions.Delete, ProjectPermission.Folders); + + can(GeneralPermissionActions.Read, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Create, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Edit, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Delete, ProjectPermission.SecretImports); + + can(GeneralPermissionActions.Read, ProjectPermission.Member); + can(GeneralPermissionActions.Create, ProjectPermission.Member); + can(GeneralPermissionActions.Edit, ProjectPermission.Member); + can(GeneralPermissionActions.Delete, ProjectPermission.Member); + + can(GeneralPermissionActions.Read, ProjectPermission.Role); + can(GeneralPermissionActions.Create, ProjectPermission.Role); + can(GeneralPermissionActions.Edit, ProjectPermission.Role); + can(GeneralPermissionActions.Delete, ProjectPermission.Role); + + can(GeneralPermissionActions.Read, ProjectPermission.Integrations); + can(GeneralPermissionActions.Create, ProjectPermission.Integrations); + can(GeneralPermissionActions.Edit, ProjectPermission.Integrations); + can(GeneralPermissionActions.Delete, ProjectPermission.Integrations); + + can(GeneralPermissionActions.Read, ProjectPermission.Webhooks); + can(GeneralPermissionActions.Create, ProjectPermission.Webhooks); + can(GeneralPermissionActions.Edit, ProjectPermission.Webhooks); + can(GeneralPermissionActions.Delete, ProjectPermission.Webhooks); + + can(GeneralPermissionActions.Read, ProjectPermission.ServiceTokens); + can(GeneralPermissionActions.Create, ProjectPermission.ServiceTokens); + can(GeneralPermissionActions.Edit, ProjectPermission.ServiceTokens); + can(GeneralPermissionActions.Delete, ProjectPermission.ServiceTokens); + + can(GeneralPermissionActions.Read, ProjectPermission.Settings); + can(GeneralPermissionActions.Create, ProjectPermission.Settings); + can(GeneralPermissionActions.Edit, ProjectPermission.Settings); + can(GeneralPermissionActions.Delete, ProjectPermission.Settings); + + can(GeneralPermissionActions.Read, ProjectPermission.Environments); + can(GeneralPermissionActions.Create, ProjectPermission.Environments); + can(GeneralPermissionActions.Edit, ProjectPermission.Environments); + can(GeneralPermissionActions.Delete, ProjectPermission.Environments); + + can(GeneralPermissionActions.Read, ProjectPermission.Tags); + can(GeneralPermissionActions.Create, ProjectPermission.Tags); + can(GeneralPermissionActions.Edit, ProjectPermission.Tags); + can(GeneralPermissionActions.Delete, ProjectPermission.Tags); + + can(GeneralPermissionActions.Read, ProjectPermission.AuditLogs); + can(GeneralPermissionActions.Create, ProjectPermission.AuditLogs); + can(GeneralPermissionActions.Edit, ProjectPermission.AuditLogs); + can(GeneralPermissionActions.Delete, ProjectPermission.AuditLogs); + + can(GeneralPermissionActions.Read, ProjectPermission.IpAllowList); + can(GeneralPermissionActions.Create, ProjectPermission.IpAllowList); + can(GeneralPermissionActions.Edit, ProjectPermission.IpAllowList); + can(GeneralPermissionActions.Delete, ProjectPermission.IpAllowList); + + can(GeneralPermissionActions.Edit, ProjectPermission.Workspace); + can(GeneralPermissionActions.Delete, ProjectPermission.IpAllowList); + + return build(); +}; + +export const adminProjectPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(GeneralPermissionActions.Read, ProjectPermission.Secrets); + can(GeneralPermissionActions.Create, ProjectPermission.Secrets); + can(GeneralPermissionActions.Edit, ProjectPermission.Secrets); + can(GeneralPermissionActions.Delete, ProjectPermission.Secrets); + + can(GeneralPermissionActions.Read, ProjectPermission.Folders); + can(GeneralPermissionActions.Create, ProjectPermission.Folders); + can(GeneralPermissionActions.Edit, ProjectPermission.Folders); + can(GeneralPermissionActions.Delete, ProjectPermission.Folders); + + can(GeneralPermissionActions.Read, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Create, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Edit, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Delete, ProjectPermission.SecretImports); + + can(GeneralPermissionActions.Read, ProjectPermission.Member); + can(GeneralPermissionActions.Read, ProjectPermission.Role); + can(GeneralPermissionActions.Read, ProjectPermission.Integrations); + can(GeneralPermissionActions.Read, ProjectPermission.Webhooks); + can(GeneralPermissionActions.Read, ProjectPermission.ServiceTokens); + can(GeneralPermissionActions.Read, ProjectPermission.Settings); + can(GeneralPermissionActions.Read, ProjectPermission.Environments); + can(GeneralPermissionActions.Read, ProjectPermission.Tags); + can(GeneralPermissionActions.Read, ProjectPermission.AuditLogs); + can(GeneralPermissionActions.Read, ProjectPermission.IpAllowList); + + return build(); +}; + +export const memberProjectPermissions = buildMemberPermission(); + +const buildViewerPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(GeneralPermissionActions.Read, ProjectPermission.Secrets); + can(GeneralPermissionActions.Read, ProjectPermission.Folders); + can(GeneralPermissionActions.Read, ProjectPermission.SecretImports); + can(GeneralPermissionActions.Read, ProjectPermission.Member); + can(GeneralPermissionActions.Read, ProjectPermission.Role); + can(GeneralPermissionActions.Read, ProjectPermission.Integrations); + can(GeneralPermissionActions.Read, ProjectPermission.Webhooks); + can(GeneralPermissionActions.Read, ProjectPermission.ServiceTokens); + can(GeneralPermissionActions.Read, ProjectPermission.Settings); + can(GeneralPermissionActions.Read, ProjectPermission.Environments); + can(GeneralPermissionActions.Read, ProjectPermission.Tags); + can(GeneralPermissionActions.Read, ProjectPermission.AuditLogs); + can(GeneralPermissionActions.Read, ProjectPermission.IpAllowList); + + return build(); +}; + +export const viewerProjectPermission = buildViewerPermission(); + +export const getUserProjectPermissions = async (userId: string, workspaceId: string) => { + // TODO(akhilmhdh): speed this up by pulling from cache later + const membership = await Membership.findOne({ + user: userId, + workspace: workspaceId + }) + .populate<{ + customRole: IRole & { permissions: RawRuleOf>[] }; + }>("customRole") + .exec(); + + console.log(membership, userId, workspaceId); + if (!membership || (membership.role === "custom" && !membership.customRole)) { + throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); + } + + if (membership.role === "admin") return { permission: adminProjectPermissions, membership }; + if (membership.role === "member") return { permission: memberProjectPermissions, membership }; + if (membership.role === "viewer") return { permission: memberProjectPermissions, membership }; + + if (membership.role === "custom") { + const permission = createMongoAbility(membership.customRole.permissions); + return { permission, membership }; + } + + throw BadRequestError({ message: "User role not found" }); +}; diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts index c431efd7c..734ea73f5 100644 --- a/backend/src/validation/role.ts +++ b/backend/src/validation/role.ts @@ -56,3 +56,9 @@ export const GetUserPermission = z.object({ orgId: z.string().trim() }) }); + +export const GetUserProjectPermission = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }) +}); diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index a1f9498e7..bccabc6e6 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -2,6 +2,7 @@ export const OWNER = "owner"; export const ADMIN = "admin"; export const MEMBER = "member"; +export const VIEWER = "viewer"; export const CUSTOM = "custom"; // membership statuses diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index 0bfb44f78..679fe1069 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -1,12 +1,7 @@ import { FunctionComponent, ReactNode } from "react"; import { BoundCanProps, Can } from "@casl/react"; -import { - OrgPermissionSubjects, - OrgWorkspacePermissionActions, - TOrgPermission, - useOrgPermission -} from "@app/context/OrgPermissionContext"; +import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; import { Tooltip } from "../v2"; @@ -23,13 +18,7 @@ export const OrgPermissionCan: FunctionComponent = ({ const permission = useOrgPermission(); return ( - + {(isAllowed, ability) => { // akhilmhdh: This is set as type due to error in casl react type. const finalChild = diff --git a/frontend/src/components/permissions/ProjectPermissionCan.tsx b/frontend/src/components/permissions/ProjectPermissionCan.tsx new file mode 100644 index 000000000..9f6cd39cc --- /dev/null +++ b/frontend/src/components/permissions/ProjectPermissionCan.tsx @@ -0,0 +1,39 @@ +import { FunctionComponent, ReactNode } from "react"; +import { BoundCanProps, Can } from "@casl/react"; + +import { TProjectPermission, useProjectPermission } from "@app/context/ProjectPermissionContext"; + +import { Tooltip } from "../v2"; + +type Props = { + label?: ReactNode; +} & BoundCanProps; + +export const ProjectPermissionCan: FunctionComponent = ({ + label = "Permission Denied. Kindly contact your org admin", + children, + passThrough = true, + ...props +}) => { + const permission = useProjectPermission(); + + return ( + + {(isAllowed, ability) => { + // akhilmhdh: This is set as type due to error in casl react type. + const finalChild = + typeof children === "function" + ? children(isAllowed, ability as TProjectPermission) + : children; + + if (!isAllowed && passThrough) { + return {finalChild}; + } + + if (!isAllowed) return null; + + return finalChild; + }} + + ); +}; diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx index e86fa431a..24854f047 100644 --- a/frontend/src/components/permissions/index.tsx +++ b/frontend/src/components/permissions/index.tsx @@ -1 +1,2 @@ export { OrgPermissionCan } from "./OrgPermissionCan"; +export { ProjectPermissionCan } from "./ProjectPermissionCan"; diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index 336777d62..dddbe4ef9 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -1,7 +1,3 @@ export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; -export { - OrgGeneralPermissionActions, - OrgPermissionSubjects, - OrgWorkspacePermissionActions -} from "./types"; +export { GeneralPermissionActions,OrgPermissionSubjects } from "./types"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index d385981d0..a7bc6dda1 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -1,17 +1,12 @@ import { MongoAbility } from "@casl/ability"; -export enum OrgGeneralPermissionActions { +export enum GeneralPermissionActions { Read = "read", Create = "create", Edit = "edit", Delete = "delete" } -export enum OrgWorkspacePermissionActions { - Read = "read", - Create = "create" -} - export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -24,13 +19,14 @@ export enum OrgPermissionSubjects { } export type OrgPermissionSet = - | [OrgWorkspacePermissionActions, OrgPermissionSubjects.Workspace] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.Role] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.Member] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.Settings] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.Sso] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.SecretScanning] - | [OrgGeneralPermissionActions, OrgPermissionSubjects.Billing]; + | [GeneralPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace] + | [GeneralPermissionActions, OrgPermissionSubjects.Role] + | [GeneralPermissionActions, OrgPermissionSubjects.Member] + | [GeneralPermissionActions, OrgPermissionSubjects.Settings] + | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [GeneralPermissionActions, OrgPermissionSubjects.Sso] + | [GeneralPermissionActions, OrgPermissionSubjects.SecretScanning] + | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx new file mode 100644 index 000000000..6fe0b6e29 --- /dev/null +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -0,0 +1,58 @@ +import { createContext, ReactNode, useContext } from "react"; + +import { useGetUserProjectPermissions } from "@app/hooks/api"; + +import { useWorkspace } from "../WorkspaceContext"; +import { TProjectPermission } from "./types"; + +type Props = { + children: ReactNode; +}; + +const ProjectPermissionContext = createContext(null); + +export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => { + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId }); + + if (isLoading && workspaceId) { + return ( +
+ infisical loading indicator +
+ ); + } + + if (!permission && currentWorkspace) { + return ( +
+ Failed to load user permissions +
+ ); + } + + if (!permission) { + return <>children; + } + + return ( + + {children} + + ); +}; + +export const useProjectPermission = () => { + const ctx = useContext(ProjectPermissionContext); + if (!ctx) { + throw new Error("useProjectPermission to be used within "); + } + + return ctx; +}; diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx new file mode 100644 index 000000000..209b3a24e --- /dev/null +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -0,0 +1,3 @@ +export { ProjectPermissionProvider, useProjectPermission } from "./ProjectPermissionContext"; +export type { ProjectPermissionSet, TProjectPermission } from "./types"; +export { ProjectGeneralPermissionActions, ProjectPermissionSubjects } from "./types"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts new file mode 100644 index 000000000..3f4559251 --- /dev/null +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -0,0 +1,44 @@ +import { MongoAbility } from "@casl/ability"; + +export enum ProjectGeneralPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum ProjectPermissionSubjects { + Role = "role", + Member = "member", + Settings = "settings", + Integrations = "integrations", + Webhooks = "webhooks", + ServiceTokens = "service-tokens", + Environments = "environments", + Tags = "tags", + AuditLogs = "audit-logs", + IpAllowList = "ip-allowlist", + Workspace = "workspace", + Secrets = "secrets", + SecretImports = "secret-imports", + Folders = "folders" +} + +export type ProjectPermissionSet = + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Secrets] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Folders] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.SecretImports] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Role] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Tags] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Member] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Integrations] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Webhooks] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.AuditLogs] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Environments] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.IpAllowList] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.Settings] + | [ProjectGeneralPermissionActions, ProjectPermissionSubjects.ServiceTokens] + | [ProjectGeneralPermissionActions.Delete, ProjectPermissionSubjects.Workspace] + | [ProjectGeneralPermissionActions.Edit, ProjectPermissionSubjects.Workspace]; + +export type TProjectPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index b0279f1f5..3234adccd 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,11 +2,17 @@ export { AuthProvider } from "./AuthContext"; export { OrgProvider, useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { - OrgGeneralPermissionActions, + GeneralPermissionActions, + OrgPermissionProvider, OrgPermissionSubjects, - OrgWorkspacePermissionActions + useOrgPermission } from "./OrgPermissionContext"; -export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; +export { + ProjectGeneralPermissionActions, + ProjectPermissionProvider, + ProjectPermissionSubjects, + useProjectPermission +} from "./ProjectPermissionContext"; export { SubscriptionProvider, useSubscription } from "./SubscriptionContext"; export { UserProvider, useUser } from "./UserContext"; export { useWorkspace, WorkspaceProvider } from "./WorkspaceContext"; diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 5fd5cd3ac..ff7314db8 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -1,2 +1,2 @@ export { useCreateRole, useDeleteRole, useUpdateRole } from "./mutation"; -export { useGetRoles, useGetUserOrgPermissions } from "./queries"; +export { useGetRoles, useGetUserOrgPermissions,useGetUserProjectPermissions } from "./queries"; diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index f9e5336a5..e5fda67b1 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -5,22 +5,22 @@ import { apiRequest } from "@app/config/request"; import { roleQueryKeys } from "./queries"; import { TCreateRoleDTO, TDeleteRoleDTO, TUpdateRoleDTO } from "./types"; -export const useCreateRole = () => { +export const useCreateRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (dto: TCreateRoleDTO) => apiRequest.post("/api/v1/roles", dto), + mutationFn: (dto: TCreateRoleDTO) => apiRequest.post("/api/v1/roles", dto), onSuccess: (_, { orgId, workspaceId }) => { queryClient.invalidateQueries(roleQueryKeys.getRoles({ orgId, workspaceId })); } }); }; -export const useUpdateRole = () => { +export const useUpdateRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, ...dto }: TUpdateRoleDTO) => apiRequest.patch(`/api/v1/roles/${id}`, dto), + mutationFn: ({ id, ...dto }: TUpdateRoleDTO) => apiRequest.patch(`/api/v1/roles/${id}`, dto), onSuccess: (_, { orgId, workspaceId }) => { queryClient.invalidateQueries(roleQueryKeys.getRoles({ orgId, workspaceId })); } diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 8fda90a85..917b6d5f6 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -4,22 +4,33 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types"; +import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/types"; -import { TGetRolesDTO, TGetUserOrgPermissionsDTO, TRole } from "./types"; +import { + TGetRolesDTO, + TGetUserOrgPermissionsDTO, + TGetUserProjectPermissionDTO, + TRole +} from "./types"; export const roleQueryKeys = { getRoles: ({ orgId, workspaceId }: TGetRolesDTO) => ["roles", { orgId, workspaceId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => - ["user-permissions", { orgId }] as const + ["user-permissions", { orgId }] as const, + getUserProjectPermissions: ({ workspaceId }: TGetUserProjectPermissionDTO) => + ["user-project-permissions", { workspaceId }] as const }; const getRoles = async ({ orgId, workspaceId }: TGetRolesDTO) => { - const { data } = await apiRequest.get<{ data: { roles: TRole[] } }>("/api/v1/roles", { - params: { - workspaceId, - orgId + const { data } = await apiRequest.get<{ data: { roles: TRole[] } }>( + "/api/v1/roles", + { + params: { + workspaceId, + orgId + } } - }); + ); return data.data.roles; }; @@ -34,7 +45,7 @@ export const useGetRoles = ({ orgId, workspaceId }: TGetRolesDTO) => const getUserOrgPermissions = async ({ orgId }: TGetUserOrgPermissionsDTO) => { const { data } = await apiRequest.get<{ data: { permissions: PackRule>>[] }; - }>(`/api/v1/roles/${orgId}/permissions`, {}); + }>(`/api/v1/roles/organization/${orgId}/permissions`, {}); return data.data.permissions; }; @@ -50,3 +61,23 @@ export const useGetUserOrgPermissions = ({ orgId }: TGetUserOrgPermissionsDTO) = return ability; } }); + +const getUserProjectPermissions = async ({ workspaceId }: TGetUserProjectPermissionDTO) => { + const { data } = await apiRequest.get<{ + data: { permissions: PackRule>>[] }; + }>(`/api/v1/roles/workspace/${workspaceId}/permissions`, {}); + + return data.data.permissions; +}; + +export const useGetUserProjectPermissions = ({ workspaceId }: TGetUserProjectPermissionDTO) => + useQuery({ + queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId }), + queryFn: () => getUserProjectPermissions({ workspaceId }), + enabled: Boolean(workspaceId), + select: (data) => { + const rule = unpackRules>>(data); + const ability = createMongoAbility(rule); + return ability; + } + }); diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 07b73fae6..b4dd2f508 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -3,14 +3,14 @@ export type TGetRolesDTO = { workspaceId?: string; }; -export type TRole = { +export type TRole = { _id: string; organization: string; - workspace: string; + workspace: T; name: string; description: string; slug: string; - permissions: TPermission[]; + permissions: T extends string ? TProjectPermission[] : TPermission[]; createdAt: string; updatedAt: string; }; @@ -29,20 +29,42 @@ type TWorkspacePermission = { subject: "workspace"; }; -export type TCreateRoleDTO = { +export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission; + +type TProjectGeneralPermission = { + condition?: Record; + action: "read" | "edit" | "create" | "delete"; + subject: + | "member" + | "role" + | "settings" + | "secrets" + | "environments" + | "folders" + | "secret-imports" + | "service-tokens"; +}; + +type TProjectWorkspacePermission = { + condition?: Record; + action: "delete" | "edit"; + subject: "workspace"; +}; + +export type TCreateRoleDTO = { orgId: string; - workspaceId?: string; + workspaceId?: T; name: string; description?: string; slug: string; - permissions: TPermission[]; + permissions: T extends string ? TProjectPermission[] : TPermission[]; }; -export type TUpdateRoleDTO = { +export type TUpdateRoleDTO = { orgId: string; id: string; - workspaceId?: string; -} & Partial>; + workspaceId?: T; +} & Partial, "orgId" | "workspaceId">>; export type TDeleteRoleDTO = { orgId: string; @@ -53,3 +75,7 @@ export type TDeleteRoleDTO = { export type TGetUserOrgPermissionsDTO = { orgId: string; }; + +export type TGetUserProjectPermissionDTO = { + workspaceId: string; +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 90314a1e4..c940c8d61 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -8,6 +8,7 @@ import { apiRequest } from "@app/config/request"; import { setAuthToken } from "@app/reactQuery"; import { useUploadWsKey } from "../keys/queries"; +import { workspaceKeys } from "../workspace/queries"; import { AddUserToOrgDTO, AddUserToWsDTO, @@ -55,27 +56,27 @@ export const useRenameUser = () => { return useMutation<{}, {}, RenameUserDTO>({ mutationFn: ({ newName }) => - apiRequest.patch("/api/v2/users/me/name", { firstName: newName?.split(" ")[0], lastName: newName?.split(" ").slice(1).join(" ") }), + apiRequest.patch("/api/v2/users/me/name", { + firstName: newName?.split(" ")[0], + lastName: newName?.split(" ").slice(1).join(" ") + }), onSuccess: () => { queryClient.invalidateQueries(userKeys.getUser); } }); }; - export const useUpdateUserAuthMethods = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - authMethods - }: { - authMethods: AuthMethod[]; - }) => { - const { data: { user } } = await apiRequest.put("/api/v2/users/me/auth-methods", { + mutationFn: async ({ authMethods }: { authMethods: AuthMethod[] }) => { + const { + data: { user } + } = await apiRequest.put("/api/v2/users/me/auth-methods", { authMethods }); - + return user; }, onSuccess: () => { @@ -108,6 +109,7 @@ export const useGetOrgUsers = (orgId: string) => // mutation export const useAddUserToWs = () => { const uploadWsKey = useUploadWsKey(); + const queryClient = useQueryClient(); return useMutation<{ data: AddUserToWsRes }, {}, AddUserToWsDTO>({ mutationFn: ({ email, workspaceId }) => @@ -136,18 +138,20 @@ export const useAddUserToWs = () => { userId: data.invitee._id, workspaceId }); + + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); } }); }; export const useAddUserToOrg = () => { const queryClient = useQueryClient(); - type Response = { + type Response = { data: { - message: string, - completeInviteLink: string | undefined - } - } + message: string; + completeInviteLink: string | undefined; + }; + }; return useMutation({ mutationFn: (dto) => { @@ -164,7 +168,7 @@ export const useDeleteOrgMembership = () => { return useMutation<{}, {}, DeletOrgMembershipDTO>({ mutationFn: ({ membershipId, orgId }) => { - return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`) + return apiRequest.delete(`/api/v2/organizations/${orgId}/memberships/${membershipId}`); }, onSuccess: (_, { orgId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(orgId)); @@ -177,9 +181,12 @@ export const useUpdateOrgUserRole = () => { return useMutation<{}, {}, UpdateOrgUserRoleDTO>({ mutationFn: ({ organizationId, membershipId, role }) => { - return apiRequest.patch(`/api/v2/organizations/${organizationId}/memberships/${membershipId}`, { - role - }); + return apiRequest.patch( + `/api/v2/organizations/${organizationId}/memberships/${membershipId}`, + { + role + } + ); }, onSuccess: (_, { organizationId }) => { queryClient.invalidateQueries(userKeys.getOrgUsers(organizationId)); @@ -218,64 +225,49 @@ export const useLogoutUser = () => }); export const useGetMyIp = () => { - return useQuery({ + return useQuery({ queryKey: userKeys.myIp, queryFn: async () => { - const { data } = await apiRequest.get<{ ip: string; }>( - "/api/v1/users/me/ip" - ); + const { data } = await apiRequest.get<{ ip: string }>("/api/v1/users/me/ip"); return data.ip; }, enabled: true - }); -} + }); +}; export const useGetMyAPIKeys = () => { return useQuery({ queryKey: userKeys.myAPIKeys, queryFn: async () => { - const { data } = await apiRequest.get( - "/api/v2/users/me/api-keys" - ); + const { data } = await apiRequest.get("/api/v2/users/me/api-keys"); return data; }, enabled: true }); -} +}; export const useCreateAPIKey = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - name, - expiresIn - }: { - name: string; - expiresIn: number; - }) => { - const { data } = await apiRequest.post( - "/api/v2/users/me/api-keys", - { - name, - expiresIn - } - ); - + mutationFn: async ({ name, expiresIn }: { name: string; expiresIn: number }) => { + const { data } = await apiRequest.post("/api/v2/users/me/api-keys", { + name, + expiresIn + }); + return data; }, onSuccess() { queryClient.invalidateQueries(userKeys.myAPIKeys); } }); -} +}; export const useDeleteAPIKey = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (apiKeyDataId: string) => { - const { data } = await apiRequest.delete( - `/api/v2/users/me/api-keys/${apiKeyDataId}` - ); + const { data } = await apiRequest.delete(`/api/v2/users/me/api-keys/${apiKeyDataId}`); return data; }, @@ -283,29 +275,25 @@ export const useDeleteAPIKey = () => { queryClient.invalidateQueries(userKeys.myAPIKeys); } }); -} +}; export const useGetMySessions = () => { return useQuery({ queryKey: userKeys.mySessions, queryFn: async () => { - const { data } = await apiRequest.get( - "/api/v2/users/me/sessions" - ); + const { data } = await apiRequest.get("/api/v2/users/me/sessions"); return data; }, enabled: true }); -} +}; export const useRevokeMySessions = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async () => { - const { data } = await apiRequest.delete( - "/api/v2/users/me/sessions" - ); + const { data } = await apiRequest.delete("/api/v2/users/me/sessions"); return data; }, @@ -313,22 +301,17 @@ export const useRevokeMySessions = () => { queryClient.invalidateQueries(userKeys.mySessions); } }); -} +}; export const useUpdateMfaEnabled = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - isMfaEnabled - }: { - isMfaEnabled: boolean; - }) => { - const { data: { user } } = await apiRequest.patch( - "/api/v2/users/me/mfa", - { - isMfaEnabled - } - ); + mutationFn: async ({ isMfaEnabled }: { isMfaEnabled: boolean }) => { + const { + data: { user } + } = await apiRequest.patch("/api/v2/users/me/mfa", { + isMfaEnabled + }); return user; }, @@ -336,15 +319,15 @@ export const useUpdateMfaEnabled = () => { queryClient.invalidateQueries(userKeys.getUser); } }); -} +}; export const fetchMyOrganizationProjects = async (orgId: string) => { - const { data: { workspaces } } = await apiRequest.get( - `/api/v1/organization/${orgId}/my-workspaces` - ); + const { + data: { workspaces } + } = await apiRequest.get(`/api/v1/organization/${orgId}/my-workspaces`); return workspaces; -} +}; export const useGetMyOrganizationProjects = (orgId: string) => { return useQuery({ @@ -354,4 +337,4 @@ export const useGetMyOrganizationProjects = (orgId: string) => { }, enabled: true }); -} \ No newline at end of file +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 5c3890ee6..07f497c07 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -1,12 +1,12 @@ import { UserWsKeyPair } from "../keys/types"; export enum AuthMethod { - EMAIL = "email", - GOOGLE = "google", + EMAIL = "email", + GOOGLE = "google", GITHUB = "github", - OKTA_SAML = "okta-saml", - AZURE_SAML = "azure-saml", - JUMPCLOUD_SAML = "jumpcloud-saml" + OKTA_SAML = "okta-saml", + AZURE_SAML = "azure-saml", + JUMPCLOUD_SAML = "jumpcloud-saml" } export type User = { @@ -48,6 +48,8 @@ export type OrgUser = { customRole: string; }; +export type TWorkspaceUser = OrgUser; + export type AddUserToWsDTO = { workspaceId: string; email: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 12e571ad9..f9e40d2db 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; import { EncryptedSecret } from "../secrets/types"; +import { TWorkspaceUser } from "../users/types"; import { CreateEnvironmentDTO, CreateWorkspaceDTO, @@ -173,16 +174,17 @@ export const createWorkspace = ({ workspaceName }: CreateWorkspaceDTO): Promise<{ data: { workspace: Workspace } }> => { return apiRequest.post("/api/v1/workspace", { workspaceName, organizationId }); -} +}; export const useCreateWorkspace = () => { const queryClient = useQueryClient(); return useMutation<{ data: { workspace: Workspace } }, {}, CreateWorkspaceDTO>({ - mutationFn: async ({ organizationId, workspaceName }) => createWorkspace({ - organizationId, - workspaceName - }), + mutationFn: async ({ organizationId, workspaceName }) => + createWorkspace({ + organizationId, + workspaceName + }), onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); } @@ -296,32 +298,30 @@ export const useGetWorkspaceUsers = (workspaceId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), queryFn: async () => { - const { data: { users } } = await apiRequest.get( + const { + data: { users } + } = await apiRequest.get<{ users: TWorkspaceUser[] }>( `/api/v1/workspace/${workspaceId}/users` ); return users; }, enabled: true }); -} +}; export const useAddUserToWorkspace = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - email, - workspaceId - }: { - email: string; - workspaceId: string; - }) => { - const { data: { invitee, latestKey } } = await apiRequest.post(`/api/v1/workspace/${workspaceId}/invite-signup`, { email }); - - return ({ + mutationFn: async ({ email, workspaceId }: { email: string; workspaceId: string }) => { + const { + data: { invitee, latestKey } + } = await apiRequest.post(`/api/v1/workspace/${workspaceId}/invite-signup`, { email }); + + return { invitee, latestKey - }); + }; }, onSuccess: (_, dto) => { queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(dto.workspaceId)); @@ -334,7 +334,9 @@ export const useDeleteUserFromWorkspace = () => { return useMutation({ mutationFn: async (membershipId: string) => { - const { data: { deletedMembership } } = await apiRequest.delete(`/api/v1/membership/${membershipId}`); + const { + data: { deletedMembership } + } = await apiRequest.delete(`/api/v1/membership/${membershipId}`); return deletedMembership; }, onSuccess: (res) => { @@ -346,14 +348,10 @@ export const useDeleteUserFromWorkspace = () => { export const useUpdateUserWorkspaceRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ - membershipId, - role - }: { - membershipId: string; - role: string; - }) => { - const { data: { membership } } = await apiRequest.post(`/api/v1/membership/${membershipId}/change-role`, { + mutationFn: async ({ membershipId, role }: { membershipId: string; role: string }) => { + const { + data: { membership } + } = await apiRequest.post(`/api/v1/membership/${membershipId}/change-role`, { role }); return membership; diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index c540798eb..45a1cbe71 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -19,6 +19,7 @@ import { AuthProvider, OrgPermissionProvider, OrgProvider, + ProjectPermissionProvider, SubscriptionProvider, UserProvider, WorkspaceProvider @@ -98,15 +99,17 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => - - - + + + + - - - + + + + diff --git a/frontend/src/pages/org/[id]/billing/index.tsx b/frontend/src/pages/org/[id]/billing/index.tsx index 925852613..f79c210c7 100644 --- a/frontend/src/pages/org/[id]/billing/index.tsx +++ b/frontend/src/pages/org/[id]/billing/index.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; -import { OrgGeneralPermissionActions, OrgPermissionSubjects, TOrgPermission } from "@app/context"; +import { GeneralPermissionActions, OrgPermissionSubjects, TOrgPermission } from "@app/context"; import { withPermission } from "@app/hoc"; import { BillingSettingsPage } from "@app/views/Settings/BillingSettingsPage"; @@ -20,7 +20,7 @@ const SettingsBilling = withPermission<{}, TOrgPermission>(
); }, - { action: OrgGeneralPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } + { action: GeneralPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } ); Object.assign(SettingsBilling, { requireAuth: true }); diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index e53235577..3e0eb1f69 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -45,8 +45,8 @@ import { } from "@app/components/v2"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { + GeneralPermissionActions, OrgPermissionSubjects, - OrgWorkspacePermissionActions, useSubscription, useUser, useWorkspace @@ -590,7 +590,7 @@ const OrganizationPage = withPermission( leftIcon={} /> {(isAllowed) => ( @@ -877,7 +877,7 @@ const OrganizationPage = withPermission( ); }, { - action: OrgWorkspacePermissionActions.Read, + action: GeneralPermissionActions.Read, subject: OrgPermissionSubjects.Workspace } ); diff --git a/frontend/src/pages/org/[id]/secret-scanning/index.tsx b/frontend/src/pages/org/[id]/secret-scanning/index.tsx index 626333e47..56e0f6a33 100644 --- a/frontend/src/pages/org/[id]/secret-scanning/index.tsx +++ b/frontend/src/pages/org/[id]/secret-scanning/index.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; -import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { GeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; import { SecretScanningLogsTable } from "@app/views/SecretScanning/components"; @@ -101,7 +101,7 @@ const SecretScanning = withPermission( ) : (
{(isAllowed) => ( @@ -125,7 +125,7 @@ const SecretScanning = withPermission(
); }, - { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.SecretScanning } + { action: GeneralPermissionActions.Read, subject: OrgPermissionSubjects.SecretScanning } ); Object.assign(SecretScanning, { requireAuth: true }); diff --git a/frontend/src/pages/project/[id]/members/index.tsx b/frontend/src/pages/project/[id]/members/index.tsx index 997606b1d..4bcbb833e 100644 --- a/frontend/src/pages/project/[id]/members/index.tsx +++ b/frontend/src/pages/project/[id]/members/index.tsx @@ -1,225 +1,21 @@ -import { useEffect, useState } from "react"; +/* eslint-disable @typescript-eslint/no-unused-vars */ import { useTranslation } from "react-i18next"; import Head from "next/head"; -import Image from "next/image"; -import { useRouter } from "next/router"; -import { faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Button from "@app/components/basic/buttons/Button"; -import AddProjectMemberDialog from "@app/components/basic/dialog/AddProjectMemberDialog"; -import ProjectUsersTable from "@app/components/basic/table/ProjectUsersTable"; -import guidGenerator from "@app/components/utilities/randomId"; -import { Input } from "@app/components/v2"; -import { useOrganization } from "@app/context"; -import { - useAddUserToWorkspace, - useGetOrgUsers, - useGetUser, - useGetWorkspaceUsers} from "@app/hooks/api"; -import { uploadWsKey } from "@app/hooks/api/keys/queries"; - -import { - decryptAssymmetric, - encryptAssymmetric -} from "../../../../components/utilities/cryptography/crypto"; - -interface UserProps { - firstName: string; - lastName: string; - email: string; - _id: string; - publicKey: string; -} - -interface MembershipProps { - deniedPermissions: any[]; - user: UserProps; - inviteEmail: string; - role: string; - status: string; - _id: string; -} - -// #TODO: Update all the workspaceIds - -export default function Users() { - const router = useRouter(); - const workspaceId = router.query.id as string; - - const { data: user } = useGetUser(); - const { currentOrg } = useOrganization(); - const { data: orgUsers } = useGetOrgUsers(currentOrg?._id ?? ""); - - const { data: workspaceUsers } = useGetWorkspaceUsers(workspaceId); - const { mutateAsync: addUserToWorkspaceMutateAsync } = useAddUserToWorkspace(); - - const [isAddOpen, setIsAddOpen] = useState(false); - // let [isDeleteOpen, setIsDeleteOpen] = useState(false); - // let [userIdToBeDeleted, setUserIdToBeDeleted] = useState(false); - const [email, setEmail] = useState(""); - const [personalEmail, setPersonalEmail] = useState(""); - const [searchUsers, setSearchUsers] = useState(""); +import { MembersPage } from "@app/views/Project/MembersPage"; +export default function WorkspaceMemberSettings() { const { t } = useTranslation(); - - const [userList, setUserList] = useState([]); - const [isUserListLoading, setIsUserListLoading] = useState(true); - const [orgUserList, setOrgUserList] = useState([]); - - useEffect(() => { - if (user && workspaceUsers && orgUsers) { - (async () => { - setPersonalEmail(user.email); - - const tempUserList = workspaceUsers.map((membership: MembershipProps) => ({ - key: guidGenerator(), - firstName: membership.user?.firstName, - lastName: membership.user?.lastName, - email: membership.user?.email === null ? membership.inviteEmail : membership.user?.email, - role: membership?.role, - status: membership?.status, - userId: membership.user?._id, - membershipId: membership._id, - deniedPermissions: membership.deniedPermissions, - publicKey: membership.user?.publicKey - })); - setUserList(tempUserList); - - setIsUserListLoading(false); - - setOrgUserList(orgUsers); - setEmail( - orgUsers - ?.filter((membership: MembershipProps) => membership.status === "accepted") - .map((membership: MembershipProps) => membership.user.email) - .filter( - (usEmail: string) => - !tempUserList?.map((user1: UserProps) => user1.email).includes(usEmail) - )[0] - ); - })(); - } - }, [user, workspaceUsers, orgUsers]); - - const closeAddModal = () => { - setIsAddOpen(false); - }; - - const openAddModal = () => { - setIsAddOpen(true); - }; - - // function closeDeleteModal() { - // setIsDeleteOpen(false); - // } - - // function deleteMembership(userId) { - // deleteUserFromWorkspace(userId, router.query.id) - // } - - // function openDeleteModal() { - // setIsDeleteOpen(true); - // } - - const submitAddModal = async () => { - const result = await addUserToWorkspaceMutateAsync({ - email, - workspaceId - }); - - if (result?.invitee && result?.latestKey) { - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; - - // assymmetrically decrypt symmetric key with local private key - const key = decryptAssymmetric({ - ciphertext: result.latestKey.encryptedKey, - nonce: result.latestKey.nonce, - publicKey: result.latestKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); - - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: key, - publicKey: result.invitee.publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey({ - workspaceId, - userId: result.invitee._id, - encryptedKey: ciphertext, - nonce - }); - } - setEmail(""); - setIsAddOpen(false); - }; - - return userList ? ( -
+ return ( + <> {t("common.head-title", { title: t("settings.members.title") })} -
-

{t("settings.members.title")}

-
- membership.status === "accepted") - .map((membership: MembershipProps) => membership.user.email) - .filter( - (orgEmail) => !userList?.map((user1: UserProps) => user1.email).includes(orgEmail) - )} - setEmail={setEmail} - /> - {/* */} -
-
- setSearchUsers(e.target.value)} - leftIcon={} - /> -
-
-
-
-
- -
-
- ) : ( -
- loading animation -
+ + ); } -Users.requireAuth = true; +WorkspaceMemberSettings.requireAuth = true; diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index d2c213dc4..a57d2f235 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -3,9 +3,10 @@ import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { OrgGeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { GeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { withPermission } from "@app/hoc"; import { useGetRoles } from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; import { OrgMembersTable } from "./components/OrgMembersTable"; import { OrgRoleTabSection } from "./components/OrgRoleTabSection"; @@ -47,16 +48,16 @@ export const MembersPage = withPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - + []} /> - + []} />
); }, - { action: OrgGeneralPermissionActions.Read, subject: OrgPermissionSubjects.Member } + { action: GeneralPermissionActions.Read, subject: OrgPermissionSubjects.Member } ); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index 2cdb0036b..608268de7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -43,7 +43,7 @@ import { UpgradePlanModal } from "@app/components/v2"; import { - OrgGeneralPermissionActions, + GeneralPermissionActions, OrgPermissionSubjects, useOrganization, useSubscription, @@ -65,7 +65,7 @@ import { TRole } from "@app/hooks/api/roles/types"; import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; type Props = { - roles?: TRole[]; + roles?: TRole[]; }; const addMemberFormSchema = yup.object({ @@ -305,7 +305,7 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { placeholder="Search members..." />
- + {(isAllowed) => ( + )} + +
+
+ + + + + + + + + + + {isLoading && } + {!isLoading && + filterdUsers?.map( + ({ user: u, inviteEmail, _id: membershipId, status, customRole, role }) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + + return ( + + + + + + + ); + } + )} + +
NameEmailRole +
{name}{email} + + {(isAllowed) => ( + <> + + {status === "completed" && user.email !== email && ( +
+ +
+ )} + + )} +
+
+ {userId !== u?._id && ( + + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { id: membershipId }) + } + > + + + )} + + )} +
+ {!isLoading && filterdUsers?.length === 0 && ( + + )} +
+
+ handlePopUpToggle("addMember", isOpen)} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ handlePopUpToggle("removeMember", isOpen)} + onDeleteApproved={handleRemoveUser} + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx new file mode 100644 index 000000000..695e806c5 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/index.tsx @@ -0,0 +1 @@ +export { MemberListTab } from "./MemberListTab"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx new file mode 100644 index 000000000..17c17b122 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -0,0 +1,45 @@ +import { motion } from "framer-motion"; + +import { usePopUp } from "@app/hooks"; +import { TRole } from "@app/hooks/api/roles/types"; + +import { ProjectRoleList } from "./components/ProjectRoleList"; +import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; + +type Props = { + roles?: TRole[]; + isRolesLoading?: boolean; +}; + +export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); + + return popUp.editRole.isOpen ? ( + + } + onGoBack={() => handlePopUpClose("editRole")} + /> + + ) : ( + + handlePopUpOpen("editRole", role)} + /> + + ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx new file mode 100644 index 000000000..0230a248a --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + IconButton, + Input, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteRole } from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; + +type Props = { + isRolesLoading?: boolean; + roles?: TRole[]; + onSelectRole: (role?: TRole) => void; +}; + +export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Props) => { + const [searchRoles, setSearchRoles] = useState(""); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + const orgId = currentOrg?._id || ""; + const workspaceId = currentWorkspace?._id || ""; + + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); + + const { mutateAsync: deleteRole } = useDeleteRole(); + + const handleRoleDelete = async () => { + const { _id: id } = popUp?.deleteRole?.data as TRole; + try { + await deleteRole({ + orgId, + workspaceId, + id + }); + createNotification({ type: "success", text: "Successfully removed the role" }); + handlePopUpClose("deleteRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to create role" }); + } + }; + + return ( +
+
+
+ setSearchRoles(e.target.value)} + leftIcon={} + placeholder="Search roles..." + /> +
+ +
+
+ + + + + + + + + + + {isRolesLoading && } + {roles?.map((role) => { + const { _id: id, name, createdAt, slug } = role; + const isNonMutatable = ["owner", "admin", "member"].includes(slug); + + return ( + + + + + + + ); + })} + +
NameSlugCreated At +
{name}{slug} + {createdAt ? format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa") : "-"} + +
+ + onSelectRole(role)} + variant="plain" + > + + + + + handlePopUpOpen("deleteRole", role)} + variant="plain" + isDisabled={isNonMutatable} + > + + + +
+
+
+
+ )?.name || " " + } role?`} + deleteKey={(popUp?.deleteRole?.data as TRole)?.slug || ""} + onClose={() => handlePopUpClose("deleteRole")} + onDeleteApproved={handleRoleDelete} + /> +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx new file mode 100644 index 000000000..9f8e88a82 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleList } from "./ProjectRoleList"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx new file mode 100644 index 000000000..0700724d2 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { + Checkbox, + Input, + Select, + SelectItem, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + formName: "secrets" | "folders" | "secret-imports"; + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; + title: string; + subtitle: string; + icon: IconProp; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +export const MultiEnvProjectPermission = ({ + isNonEditable, + setValue, + control, + formName, + title, + subtitle, + icon +}: Props) => { + const { currentWorkspace } = useWorkspace(); + + const environments = currentWorkspace?.environments || []; + const customRule = useWatch({ + control, + name: `permissions.${formName}.custom` + }); + const isCustom = Boolean(customRule); + const allRule = useWatch({ control, name: `permissions.${formName}.all` }); + + const selectedPermissionCategory = useMemo(() => { + const { read, delete: del, edit, create } = allRule || {}; + if (read && del && edit && create) return Permission.FullAccess; + if (read) return Permission.ReadOnly; + return Permission.NoAccess; + }, [allRule]); + + const handlePermissionChange = (val: Permission) => { + switch (val) { + case Permission.NoAccess: + setValue(`permissions.${formName}`, {}, { shouldDirty: true }); + break; + case Permission.FullAccess: + setValue( + `permissions.${formName}`, + { all: { read: true, edit: true, create: true, delete: true } }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + `permissions.${formName}`, + { all: { read: true, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + default: + setValue( + `permissions.${formName}`, + { custom: { read: false, edit: false, create: false, delete: false } }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
{title}
+
{subtitle}
+
+
+ +
+
+ + + + + + + + + + + + + + {isCustom && + environments.map(({ name, slug }) => ( + + + + + + + + + ))} + +
+ Secret PathReadCreateEditDelete
{name} + ( + + )} + /> + + ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+ ( +
+ +
+ )} + /> +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx new file mode 100644 index 000000000..c74df03ce --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -0,0 +1,290 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { faElementor } from "@fortawesome/free-brands-svg-icons"; +import { + faAnchorLock, + faArrowLeft, + faBook, + faCog, + faFolder, + faKey, + faLink, + faLock, + faMagnifyingGlass, + faNetworkWired, + faPuzzlePiece, + faTags, + faUser, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { useCreateRole, useUpdateRole } from "@app/hooks/api"; +import { TRole } from "@app/hooks/api/roles/types"; + +import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission"; +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + TFormSchema +} from "./ProjectRoleModifySection.utils"; +import { SingleProjectPermission } from "./SingleProjectPermission"; + +const SINGLE_PERMISSION_LIST = [ + { + title: "Integrations", + subtitle: "Integration management control", + icon: faPuzzlePiece, + formName: "integrations" + }, + { + title: "Roles", + subtitle: "Role management control", + icon: faUsers, + formName: "role" + }, + { + title: "Project Members", + subtitle: "Project members management control", + icon: faUser, + formName: "member" + }, + { + title: "Webhooks", + subtitle: "Webhook management control", + icon: faAnchorLock, + formName: "webhooks" + }, + { + title: "Service Tokens", + subtitle: "Token management control", + icon: faKey, + formName: "service-tokens" + }, + { + title: "Settings", + subtitle: "Settings control", + icon: faCog, + formName: "settings" + }, + { + title: "Environments", + subtitle: "Environment management control", + icon: faElementor, + formName: "environments" + }, + { + title: "Tags", + subtitle: "Tag management control", + icon: faTags, + formName: "tags" + }, + { + title: "Audit Logs", + subtitle: "Audit log management control", + icon: faBook, + formName: "audit-logs" + }, + { + title: "IP Allowlist", + subtitle: "IP allowlist management control", + icon: faNetworkWired, + formName: "ip-allowlist" + } +] as const; + +type Props = { + role?: TRole; + onGoBack: VoidFunction; +}; + +export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { + const [searchPermission, setSearchPermission] = useState(""); + + const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || ""); + const isNewRole = !role?.slug; + + const { createNotification } = useNotificationContext(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?._id || ""; + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + + const { + handleSubmit, + register, + formState: { isSubmitting, isDirty, errors }, + setValue, + control + } = useForm({ + defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + resolver: zodResolver(formSchema) + }); + const { mutateAsync: createRole } = useCreateRole(); + const { mutateAsync: updateRole } = useUpdateRole(); + + const handleRoleUpdate = async (el: TFormSchema) => { + if (!role?._id) return; + + try { + await updateRole({ + orgId, + id: role?._id, + workspaceId, + ...el, + permissions: formRolePermission2API(el.permissions) + }); + createNotification({ type: "success", text: "Successfully updated role" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update role" }); + } + }; + + const handleFormSubmit = async (el: TFormSchema) => { + if (!isNewRole) { + await handleRoleUpdate(el); + return; + } + + try { + await createRole({ + orgId, + workspaceId, + ...el, + permissions: formRolePermission2API(el.permissions) + }); + createNotification({ type: "success", text: "Created new role" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to create role" }); + } + }; + + return ( +
+
+
+

+ {isNewRole ? "New" : "Edit"} Role +

+ +
+

+ Roles are used to grant access to particular resources in your organization +

+
+ + + + + + + + + +
+
+

Add Permission

+
+
+ setSearchPermission(e.target.value)} + leftIcon={} + placeholder="Search permissions..." + /> +
+
+
+ +
+
+ +
+
+ +
+ {SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => ( +
+ +
+ ))} +
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts new file mode 100644 index 000000000..2c4ab23d5 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -0,0 +1,171 @@ +/* eslint-disable no-param-reassign */ +import { z } from "zod"; + +import { TProjectPermission } from "@app/hooks/api/roles/types"; + +const generalPermissionSchema = z + .object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() + }) + .optional(); + +const multiEnvPermissionSchema = z + .object({ + secretPath: z.string().optional(), + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional() + }) + .optional(); + +const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const; +const MULTI_ENV_KEY = ["secrets", "folders", "secret-imports"] as const; + +export const formSchema = z.object({ + name: z.string(), + description: z.string().optional(), + slug: z.string(), + permissions: z.object({ + secrets: z.record(multiEnvPermissionSchema).optional(), + folders: z.record(multiEnvPermissionSchema).optional(), + "secret-imports": z.record(multiEnvPermissionSchema).optional(), + member: generalPermissionSchema, + role: generalPermissionSchema, + integrations: generalPermissionSchema, + webhooks: generalPermissionSchema, + "service-tokens": generalPermissionSchema, + settings: generalPermissionSchema, + environments: generalPermissionSchema, + tags: generalPermissionSchema, + "audit-logs": generalPermissionSchema, + "ip-allowlist": generalPermissionSchema, + workspace: z + .object({ + edit: z.boolean().optional(), + delete: z.boolean().optional() + }) + .optional() + }) +}); + +export type TFormSchema = z.infer; + +const multiEnvApi2Form = ( + formVal: TFormSchema["permissions"]["secrets"], + permission: TProjectPermission +) => { + const isCustomRule = Boolean(permission?.condition?.slug); + // full access + if (isCustomRule && formVal && !formVal?.custom) { + formVal.custom = { read: true, edit: true, delete: true, create: true }; + } + + const secretEnv = permission?.condition?.slug || "all"; + const secretPath = permission?.condition?.secretPath; + // initialize + if (formVal && !formVal?.[secretEnv]) { + formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; + } + formVal![secretEnv]![permission.action] = true; +}; + +// convert role permission to form compatiable data structure +export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { + const formVal: TFormSchema["permissions"] = { + secrets: {}, + folders: {}, + integrations: {}, + settings: {}, + role: {}, + member: {}, + "service-tokens": {}, + workspace: {}, + environments: {}, + tags: {}, + webhooks: {}, + "audit-logs": {}, + "ip-allowlist": {}, + "secret-imports": {} + }; + + permissions.forEach((permission) => { + if (["secrets", "folders", "secret-imports"].includes(permission.subject)) { + multiEnvApi2Form(formVal?.secrets, permission); + } else { + // everything else follows same pattern + // formVal[settings][read | write] = true + const key = permission.subject as keyof Omit< + TFormSchema["permissions"], + "secrets" | "workspace" + >; + formVal[key]![permission.action] = true; + } + }); + + return formVal; +}; + +const multiEnvForm2Api = ( + permissions: TProjectPermission[], + formVal: TFormSchema["permissions"]["secrets"], + subject: (typeof MULTI_ENV_KEY)[number] +) => { + const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); + // if any of them is set in all push it without any condition + PERMISSION_ACTIONS.forEach((action) => { + if (formVal?.all?.[action]) permissions.push({ action, subject }); + }); + + if (!isFullAccess) { + Object.keys(formVal || {}) + .filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter + .forEach((slug) => { + const actions = Object.keys(formVal?.[slug] || {}) as [ + "read", + "edit", + "create", + "delete", + "secretPath" + ]; + actions.forEach((action) => { + // if not full access for an action + if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { + permissions.push({ + action, + subject, + condition: { slug, secretPath: formVal[slug]?.secretPath } + }); + } + }); + }); + } +}; + +export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { + const permissions: TProjectPermission[] = []; + MULTI_ENV_KEY.forEach((formName) => { + multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(formVal[formName] || {})), formName); + }); + // other than workspace everything else follows same + // if in future there is a different follow the above on how workspace is done + (Object.keys(formVal) as Array) + .filter((key) => !["secret-imports", "folders", "secrets"].includes(key)) + .forEach((rule) => { + // all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] + // quite annoying i know + const actions = Object.keys(formVal[rule] || {}) as Array< + keyof z.infer + >; + actions.forEach((action) => { + // akhilmhdh: set it as any due to the union type bug i would end up writing an if else with same condition on both side + if (formVal[rule]?.[action as keyof typeof formVal.workspace]) { + permissions.push({ subject: rule, action } as any); + } + }); + }); + return permissions; +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx new file mode 100644 index 000000000..8ff81be4f --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -0,0 +1,171 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + formName: + | "role" + | "member" + | "integrations" + | "webhooks" + | "service-tokens" + | "settings" + | "environments" + | "tags" + | "audit-logs" + | "ip-allowlist"; + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; + title: string; + subtitle: string; + icon: IconProp; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Update" }, + { action: "delete", label: "Remove" } +] as const; + +export const SingleProjectPermission = ({ + isNonEditable, + setValue, + control, + formName, + subtitle, + title, + icon +}: Props) => { + const rule = useWatch({ + control, + name: `permissions.${formName}` + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + if (score === 1 && rule?.read) return Permission.ReadOnly; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + + switch (val) { + case Permission.NoAccess: + setValue( + `permissions.${formName}`, + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + `permissions.${formName}`, + { read: true, edit: true, create: true, delete: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + `permissions.${formName}`, + { read: true, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + `permissions.${formName}`, + { read: false, edit: false, create: false, delete: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
{title}
+
{subtitle}
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx new file mode 100644 index 000000000..b664a1d9b --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleModifySection } from "./ProjectRoleModifySection"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx new file mode 100644 index 000000000..5dc87a2d7 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/index.tsx @@ -0,0 +1 @@ +export { ProjectRoleListTab } from "./ProjectRoleListTab"; diff --git a/frontend/src/views/Project/MembersPage/index.tsx b/frontend/src/views/Project/MembersPage/index.tsx new file mode 100644 index 000000000..93d51dccd --- /dev/null +++ b/frontend/src/views/Project/MembersPage/index.tsx @@ -0,0 +1 @@ +export { MembersPage } from "./MembersPage"; diff --git a/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx b/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx index e434f6536..b0d3ca37c 100644 --- a/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx +++ b/frontend/src/views/SecretScanning/components/RiskStatusSelection.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { OrgPermissionCan } from "@app/components/permissions"; -import { OrgGeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { GeneralPermissionActions, OrgPermissionSubjects } from "@app/context"; import updateRiskStatus, { RiskStatus } from "@app/pages/api/secret-scanning/updateRiskStatus"; export const RiskStatusSelection = ({ @@ -26,7 +26,7 @@ export const RiskStatusSelection = ({ }, [selectedRiskStatus]); return ( - + {(isAllowed) => ( setSearchFilter(e.target.value)} - leftIcon={} + ); + } + + const userAvailableEnvs = wsEnv?.filter( + ({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied + ); + + return ( +
+
+ {/* breadcrumb row */} +
+ envir.slug === envQuery)[0].name || "" + } + isFolderMode + folders={folderData?.dir} + isProjectRelated + userAvailableEnvs={userAvailableEnvs} + onEnvChange={onEnvChange} />
-
-
- - - - +
+
{isRollbackMode ? "Secret Snapshot" : ""}
+ {isRollbackMode && Boolean(snapshotSecret) && ( + + {new Date(snapshotSecret?.createdAt || "").toLocaleString()} + + )} +
+ {/* Environment, search and other action row */} +
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+
+
+ + + + + + + +
+ +
+
+
+
+
+ + setIsSecretValueHidden.toggle()} + > + - - -
+ +
+ + {(isAllowed) => ( +
+ + handlePopUpOpen("secretSnapshots")} + > + + + +
+ )} +
+ + {(isAllowed) => ( +
-
- -
-
- - setIsSecretValueHidden.toggle()} - > - - - -
-
- - handlePopUpOpen("secretSnapshots")} - > - - - -
-
- -
- {!isReadOnly && !isRollbackMode && ( -
- - - -
- -
-
- -
-
- -
-
- -
-
-
-
-
- )} - {isRollbackMode && ( - - )} - -
-
-
- {!isEmptyPage && ( - - - - - - - - {fields.map(({ id, _id }, index) => ( - - ))} - {!isReadOnly && !isRollbackMode && ( - - - + )} + + {!isReadOnly && !isRollbackMode && ( +
+ + {(isAllowed) => ( + )} -
-
- -
-
-
- )} - - handlePopUpToggle("secretSnapshots", isOpen)} - fetchNextPage={fetchNextPage} - hasNextPage={hasNextPage} - snapshotId={snapshotId} - isFetchingNextPage={isFetchingNextPage} - secretSnaphots={secretSnaphots} - onSelectSnapshot={setSnaphotId} - /> - handlePopUpToggle("secretDetails", isOpen)} - secretVersion={secretVersion} - index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} - onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} - /> - - - -
- {/* secrets table and drawers, modals */} - - {/* Create a new tag modal */} - { - handlePopUpToggle("addTag", open); - }} - > - - - - - {/* Uploaded env override or not confirmation modal */} - handlePopUpToggle("uploadedSecOpts", open)} - > - handlePopUpClose("uploadedSecOpts")} - > - Keep old - , - - ]} - > -
-
Your file contains following duplicate secrets
-
- {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) - ?.map((key) => key) - .join(", ")} + + + +
+ +
+
+ +
+
+ + {(isAllowed) => ( + + )} + +
+
+ + {(isAllowed) => ( + + )} + +
+
+
+
+
+ )} + {isRollbackMode && ( + + )} + + {(isAllowed) => ( + + )} +
-
Are you sure you want to overwrite these secrets?
- - - handlePopUpToggle("folderForm", isOpen)} - > - - - - - handlePopUpToggle("addSecretImport", isOpen)} - > - + {!isEmptyPage && ( + + + + + + + + {fields.map(({ id, _id }, index) => ( + + ))} + {!isReadOnly && !isRollbackMode && ( + + + + )} + +
+ + {(isAllowed) => ( + + )} + +
+
+
+ )} + + handlePopUpToggle("secretSnapshots", isOpen)} + fetchNextPage={fetchNextPage} + hasNextPage={hasNextPage} + snapshotId={snapshotId} + isFetchingNextPage={isFetchingNextPage} + secretSnaphots={secretSnaphots} + onSelectSnapshot={setSnaphotId} + /> + handlePopUpToggle("secretDetails", isOpen)} + secretVersion={secretVersion} + index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} + onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} + /> + + + +
+ {/* secrets table and drawers, modals */} + + {/* Create a new tag modal */} + { + handlePopUpToggle("addTag", open); + }} > - - - - handlePopUpToggle("deleteFolder", isOpen)} - onDeleteApproved={handleFolderDelete} - /> - handlePopUpToggle("deleteSecretImport", isOpen)} - onDeleteApproved={handleSecretImportDelete} - /> - handlePopUpToggle("compareSecrets", open)} - > - + + + + {/* Uploaded env override or not confirmation modal */} + handlePopUpToggle("uploadedSecOpts", open)} > - - - - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can perform point-in-time recovery under an Enterprise license" - : "You can perform point-in-time recovery if you switch to Infisical's Team plan" - } + handlePopUpClose("uploadedSecOpts")} + > + Keep old + , + + ]} + > +
+
Your file contains following duplicate secrets
+
+ {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) + ?.map((key) => key) + .join(", ")} +
+
Are you sure you want to overwrite these secrets?
+
+
+ + handlePopUpToggle("folderForm", isOpen)} + > + + + + + handlePopUpToggle("addSecretImport", isOpen)} + > + + + + + handlePopUpToggle("deleteFolder", isOpen)} + onDeleteApproved={handleFolderDelete} /> - )} -
- ); -}; + handlePopUpToggle("deleteSecretImport", isOpen)} + onDeleteApproved={handleSecretImportDelete} + /> + handlePopUpToggle("compareSecrets", open)} + > + + + + + {subscription && ( + handlePopUpToggle("upgradePlan", isOpen)} + text={ + subscription.slug === null + ? "You can perform point-in-time recovery under an Enterprise license" + : "You can perform point-in-time recovery if you switch to Infisical's Team plan" + } + /> + )} +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Secrets } +); diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx index b80de5e3b..4fa6df286 100644 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx @@ -2,7 +2,9 @@ import { memo } from "react"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { IconButton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; type Props = { folders?: Array<{ id: string; name: string }>; @@ -47,32 +49,48 @@ export const FolderSection = memo( {name}
-
- - handleFolderUpdate(id, name)} - ariaLabel="expand" - > - - - -
-
- - handleFolderDelete(id, name)} - > - - - -
+ + {(isAllowed) => ( +
+ + handleFolderUpdate(id, name)} + ariaLabel="expand" + > + + + +
+ )} +
+ + {(isAllowed) => ( +
+ + handleFolderDelete(id, name)} + > + + + +
+ )} +
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx index 0f8446fcc..9668fe819 100644 --- a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx @@ -2,6 +2,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, Drawer, @@ -14,6 +15,7 @@ import { Switch, TextArea } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; import { FormData, SecretActionType } from "../../DashboardPage.utils"; @@ -85,20 +87,34 @@ export const SecretDetailDrawer = ({
- - + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} +
} diff --git a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx index 1c992df25..5e9505241 100644 --- a/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -32,6 +32,8 @@ import { Skeleton, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { useDebounce, usePopUp, useToggle } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; import { UserWsKeyPair } from "@app/hooks/api/types"; @@ -78,333 +80,343 @@ type Props = { decryptFileKey: UserWsKeyPair; }; -export const SecretDropzone = ({ - isSmaller, - onParsedEnv, - onAddNewSecret, - environments = [], - workspaceId, - decryptFileKey -}: Props): JSX.Element => { - const { t } = useTranslation(); - const [isDragActive, setDragActive] = useToggle(); - const [isLoading, setIsLoading] = useToggle(); - const { createNotification } = useNotificationContext(); - const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const); - const [searchFilter, setSearchFilter] = useState(""); - const [shouldIncludeValues, setShouldIncludeValues] = useState(true); - - const { - handleSubmit, - control, - watch, - register, - reset, - setValue, - formState: { isDirty } - } = useForm({ - resolver: yupResolver(formSchema), - defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } - }); - - const secretPath = watch("secretPath"); - const selectedEnvSlug = watch("environment"); - const debouncedSecretPath = useDebounce(secretPath); - - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ +export const SecretDropzone = withProjectPermission( + ({ + isSmaller, + onParsedEnv, + onAddNewSecret, + environments = [], workspaceId, - env: selectedEnvSlug, - secretPath: debouncedSecretPath, - isPaused: - !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && - !popUp.importSecEnv.isOpen, decryptFileKey - }); + }: Props): JSX.Element => { + const { t } = useTranslation(); + const [isDragActive, setDragActive] = useToggle(); + const [isLoading, setIsLoading] = useToggle(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const); + const [searchFilter, setSearchFilter] = useState(""); + const [shouldIncludeValues, setShouldIncludeValues] = useState(true); - useEffect(() => { - setValue("secrets", {}); - setSearchFilter(""); - }, [debouncedSecretPath]); + const { + handleSubmit, + control, + watch, + register, + reset, + setValue, + formState: { isDirty } + } = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + }); - const handleDrag = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive.on(); - } else if (e.type === "dragleave") { - setDragActive.off(); - } - }; + const secretPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const debouncedSecretPath = useDebounce(secretPath); - const parseFile = (file?: File, isJson?: boolean) => { - const reader = new FileReader(); - if (!file) { - createNotification({ - text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", - type: "error", - timeoutMs: 10000 - }); - return; - } - // const fileType = file.name.split('.')[1]; - setIsLoading.on(); - reader.onload = (event) => { - if (!event?.target?.result) return; - // parse function's argument looks like to be ArrayBuffer - const env = isJson - ? parseJson(event.target.result as ArrayBuffer) - : parseDotEnv(event.target.result as ArrayBuffer); - setIsLoading.off(); - onParsedEnv(env); + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: selectedEnvSlug, + secretPath: debouncedSecretPath, + isPaused: + !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && + !popUp.importSecEnv.isOpen, + decryptFileKey + }); + + useEffect(() => { + setValue("secrets", {}); + setSearchFilter(""); + }, [debouncedSecretPath]); + + const handleDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive.on(); + } else if (e.type === "dragleave") { + setDragActive.off(); + } }; - // If something is wrong show an error - try { - reader.readAsText(file); - } catch (error) { - console.log(error); - } - }; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!e.dataTransfer) { - return; - } - - e.dataTransfer.dropEffect = "copy"; - setDragActive.off(); - parseFile(e.dataTransfer.files[0], e.dataTransfer?.files?.[0]?.type === "application/json"); - }; - - const handleFileUpload = (e: ChangeEvent) => { - e.preventDefault(); - parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); - }; - - const handleFormSubmit = (data: TFormSchema) => { - const secretsToBePulled: Record = {}; - Object.keys(data.secrets || {}).forEach((key) => { - if (data.secrets[key]) { - secretsToBePulled[key] = { - value: (shouldIncludeValues && data.secrets[key]) || "", - comments: [""] - }; + const parseFile = (file?: File, isJson?: boolean) => { + const reader = new FileReader(); + if (!file) { + createNotification({ + text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", + type: "error", + timeoutMs: 10000 + }); + return; } - }); - onParsedEnv(secretsToBePulled); - handlePopUpClose("importSecEnv"); - reset(); - }; + // const fileType = file.name.split('.')[1]; + setIsLoading.on(); + reader.onload = (event) => { + if (!event?.target?.result) return; + // parse function's argument looks like to be ArrayBuffer + const env = isJson + ? parseJson(event.target.result as ArrayBuffer) + : parseDotEnv(event.target.result as ArrayBuffer); + setIsLoading.off(); + onParsedEnv(env); + }; - const handleSecSelectAll = () => { - if (secrets?.secrets) { - setValue( - "secrets", - secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), - { shouldDirty: true } - ); - } - }; + // If something is wrong show an error + try { + reader.readAsText(file); + } catch (error) { + console.log(error); + } + }; - return ( -
- {isLoading ? ( -
- loading animation -
- ) : ( -
-
-
- -
-
-

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

-
- { + e.preventDefault(); + e.stopPropagation(); + if (!e.dataTransfer) { + return; + } + + e.dataTransfer.dropEffect = "copy"; + setDragActive.off(); + parseFile(e.dataTransfer.files[0]); + }; + + const handleFileUpload = (e: ChangeEvent) => { + e.preventDefault(); + parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); + }; + + const handleFormSubmit = (data: TFormSchema) => { + const secretsToBePulled: Record = {}; + Object.keys(data.secrets || {}).forEach((key) => { + if (data.secrets[key]) { + secretsToBePulled[key] = { + value: (shouldIncludeValues && data.secrets[key]) || "", + comments: [""] + }; + } + }); + onParsedEnv(secretsToBePulled); + handlePopUpClose("importSecEnv"); + reset(); + }; + + const handleSecSelectAll = () => { + if (secrets?.secrets) { + setValue( + "secrets", + secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), + { shouldDirty: true } + ); + } + }; + + return ( +
+ {isLoading ? ( +
+ loading animation -
-
-

OR

-
-
-
- { - handlePopUpToggle("importSecEnv", isOpen); - reset(); - setSearchFilter(""); - }} +
+ ) : ( + +
+
+ +
+
+

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

+
+ +
- - - - +

OR

+
+
+
+ { + handlePopUpToggle("importSecEnv", isOpen); + reset(); + setSearchFilter(""); + }} > - -
- ( - - - - )} - /> - - + + + + +
+ ( + + + + )} /> - -
-
-
-
Secrets
-
+ } - onChange={(evt) => setSearchFilter(evt.target.value)} + {...register("secretPath")} + placeholder="Provide a path, default is /" /> - - +
+
+
+
Secrets
+
+ - - - - - reset()} - > - - - + leftIcon={} + onChange={(evt) => setSearchFilter(evt.target.value)} + /> + + + + + + + reset()} + > + + + +
+
+ {!isSecretsLoading && !secrets?.secrets?.length && ( + + )} +
+ {isSecretsLoading && + Array.apply(0, Array(2)).map((_x, i) => ( + + ))} + + {secrets?.secrets + ?.filter(({ key }) => + key.toLowerCase().includes(searchFilter.toLowerCase()) + ) + ?.map(({ _id, key, value: secVal }) => ( + ( + + onChange(isChecked ? secVal : "") + } + > + {key} + + )} + /> + ))} +
+
+ + setShouldIncludeValues(isChecked as boolean) + } + > + Include secret values + +
+
+ +
- {!isSecretsLoading && !secrets?.secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} - - {secrets?.secrets - ?.filter(({ key }) => - key.toLowerCase().includes(searchFilter.toLowerCase()) - ) - ?.map(({ _id, key, value: secVal }) => ( - ( - onChange(isChecked ? secVal : "")} - > - {key} - - )} - /> - ))} -
-
- - setShouldIncludeValues(isChecked as boolean) - } - > - Include secret values - -
-
- - -
-
- - - - {!isSmaller && ( - - )} + + + + {!isSmaller && ( + + )} +
-
- - )} -
- ); -}; + + )} +
+ ); + }, + { action: ProjectPermissionActions.Create, subject: ProjectPermissionSub.Secrets } +); diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx index 31752aa02..866578b73 100644 --- a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx @@ -9,9 +9,10 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks/useToggle"; +import { ProjectPermissionActions, ProjectPermissionSub,useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks"; type Props = { onDelete: (environment: string, secretPath: string) => void; @@ -49,7 +50,9 @@ export const SecretImportItem = ({ const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); useEffect(() => { - const filteredSecrets = importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())) + const filteredSecrets = importedSecrets.filter((secret) => + secret.key.toUpperCase().includes(searchTerm.toUpperCase()) + ); if (filteredSecrets.length > 0 && searchTerm) { setIsExpanded.on(); @@ -58,7 +61,6 @@ export const SecretImportItem = ({ } }, [searchTerm]); - useEffect(() => { if (isDragging) { setIsExpanded.off(); @@ -78,7 +80,11 @@ export const SecretImportItem = ({ className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700" onClick={() => setIsExpanded.toggle()} > - + @@ -106,28 +112,39 @@ export const SecretImportItem = ({
-
- - { - evt.stopPropagation(); - onDelete(importedEnv, importedSecPath); - }} - > - - - -
+ + {(isAllowed) => ( +
+ + { + evt.stopPropagation(); + onDelete(importedEnv, importedSecPath); + }} + > + + + +
+ )} +
{isExpanded && !isDragging && ( - +
@@ -146,19 +163,26 @@ export const SecretImportItem = ({ )} - {importedSecrets.filter(secret => secret.key.toUpperCase().includes(searchTerm.toUpperCase())).map(({ key, value, overriden }, index) => ( - - - - - - ))} + {importedSecrets + .filter((secret) => + secret.key.toUpperCase().includes(searchTerm.toUpperCase()) + ) + .map(({ key, value, overriden }, index) => ( + + + + + + ))}
- {key} - - - - -
+ {key} + + + + +
diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index 6c089d695..307cc981b 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -22,6 +22,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { cx } from "cva"; import { twMerge } from "tailwind-merge"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { HoverCard, HoverCardContent, @@ -34,6 +35,10 @@ import { Tag, Tooltip } from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { useToggle } from "@app/hooks"; import { WsTag } from "@app/hooks/api/types"; @@ -452,22 +457,29 @@ export const SecretInputRow = memo(
)} -
- - { - onSecretDelete(index, secKey, secId, idOverride); - }} - > - - - -
+ + {(isAllowed) => ( +
+ + { + onSecretDelete(index, secKey, secId, idOverride); + }} + > + + + +
+ )} +
diff --git a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx index 0a192110b..ce3ce4ece 100644 --- a/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/views/Project/AuditLogsPage/AuditLogsPage.tsx @@ -1,17 +1,21 @@ -import { - LogsSection -} from "./components"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; -export const AuditLogsPage = () => { +import { LogsSection } from "./components"; + +export const AuditLogsPage = withProjectPermission( + () => { return ( -
-
-
-

Audit Logs

-
-
- -
-
+
+
+
+

Audit Logs

+
+
+ +
+
); -} \ No newline at end of file + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); diff --git a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx index 9940d6bae..64eec6a2f 100644 --- a/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx +++ b/frontend/src/views/Project/IPAllowListPage/IPAllowlistPage.tsx @@ -1,15 +1,21 @@ +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + import { IPAllowlistSection } from "./components"; -export const IPAllowlistPage = () => { +export const IPAllowlistPage = withProjectPermission( + () => { return ( -
-
-
-

IP Allowlist

-
-
- -
-
+
+
+
+

IP Allowlist

+
+
+ +
+
); -} \ No newline at end of file + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.IpAllowList } +); diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx index 729ab549e..3fa46051e 100644 --- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistSection.tsx @@ -2,104 +2,111 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - DeleteActionModal, - UpgradePlanModal -} from "@app/components/v2"; -import { useSubscription,useWorkspace } from "@app/context"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; import { - useDeleteTrustedIp -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useDeleteTrustedIp } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { IPAllowlistModal } from "./IPAllowlistModal"; import { IPAllowlistTable } from "./IPAllowlistTable"; export const IPAllowlistSection = () => { - const { createNotification } = useNotificationContext(); - const { mutateAsync } = useDeleteTrustedIp(); - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "trustedIp", - "deleteTrustedIp", - "upgradePlan" - ] as const); - - const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { - try { - - if (!currentWorkspace?._id) return; + const { createNotification } = useNotificationContext(); + const { mutateAsync } = useDeleteTrustedIp(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); - await mutateAsync({ - workspaceId: currentWorkspace._id, - trustedIpId - }); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "trustedIp", + "deleteTrustedIp", + "upgradePlan" + ] as const); - createNotification({ - text: "Successfully deleted IP access range", - type: "success" - }); + const onDeleteTrustedIpSubmit = async (trustedIpId: string) => { + try { + if (!currentWorkspace?._id) return; - handlePopUpClose("deleteTrustedIp"); - } catch (err) { - console.log(err); - createNotification({ - text: "Failed to delete IP access range", - type: "error" - }); - } + await mutateAsync({ + workspaceId: currentWorkspace._id, + trustedIpId + }); + + createNotification({ + text: "Successfully deleted IP access range", + type: "success" + }); + + handlePopUpClose("deleteTrustedIp"); + } catch (err) { + console.log(err); + createNotification({ + text: "Failed to delete IP access range", + type: "error" + }); } - - return ( -
-
-

- IP Allowlist -

- -
- - - handlePopUpToggle("deleteTrustedIp", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onDeleteTrustedIpSubmit((popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId) + }; + + return ( +
+
+

IP Allowlist

+ + {(isAllowed) => ( +
- ); -} \ No newline at end of file + }} + colorSchema="secondary" + isLoading={false} + isDisabled={!isAllowed} + leftIcon={} + > + Add IP + + )} + +
+ + + handlePopUpToggle("deleteTrustedIp", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteTrustedIpSubmit( + (popUp?.deleteTrustedIp?.data as { trustedIpId: string })?.trustedIpId + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx index 05f65e3ad..a9a84a608 100644 --- a/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx +++ b/frontend/src/views/Project/IPAllowListPage/components/IPAllowlistTable.tsx @@ -1,92 +1,81 @@ import { faGlobe, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { - EmptyState, - IconButton, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr, - UpgradePlanModal + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr, + UpgradePlanModal } from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; import { - useGetTrustedIps -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { useGetTrustedIps } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - popUp: UsePopUpState<["upgradePlan"]>; - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, - data?: { - trustedIpId: string; - ipAddress?: string; - comment?: string; - isActive?: boolean; - prefix?: number; - }, - ) => void; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; + popUp: UsePopUpState<["upgradePlan"]>; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["trustedIp", "deleteTrustedIp", "upgradePlan"]>, + data?: { + trustedIpId: string; + ipAddress?: string; + comment?: string; + isActive?: boolean; + prefix?: number; + } + ) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["upgradePlan"]>, state?: boolean) => void; }; -export const IPAllowlistTable = ({ - popUp, - handlePopUpOpen, - handlePopUpToggle -}: Props) => { - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); - - const formatType = (type: string, prefix?: number) => { - return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${(prefix !== undefined) ? "CIDR" : ""}`; - } - - return ( -
- - - - - - - - {/* */} - - - - {!isLoading && data && data?.length > 0 && data - .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) - .map(({ - _id, - ipAddress, - comment, - type, - prefix, - isActive - }) => { - return ( - - - - - {/* + + ); + })} + {isLoading && ( + + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
IP Address / RangeFormatCommentStatus -
- {`${ipAddress}${(prefix !== undefined) ? `/${prefix}` : ""}`} - - {formatType(type, prefix)} - - {comment} - +export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useGetTrustedIps(currentWorkspace?._id ?? ""); + + const formatType = (type: string, prefix?: number) => { + return `${type.slice(0, 2).toUpperCase() + type.slice(2)} ${ + prefix !== undefined ? "CIDR" : "" + }`; + }; + + return ( +
+ + + + + + + + {/* */} + + + + {!isLoading && + data && + data?.length > 0 && + data + .sort((a, b) => a.ipAddress.localeCompare(b.ipAddress)) + .map(({ _id, ipAddress, comment, type, prefix, isActive }) => { + return ( + + + + + {/* */} - - - ); - })} - {isLoading && } - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
IP Address / RangeFormatCommentStatus +
{`${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}`}{formatType(type, prefix)}{comment}
Active

- { - if (subscription?.ipAllowlisting) { - handlePopUpOpen("trustedIp", { - trustedIpId: _id, - ipAddress, - comment, - prefix, - isActive - }); - } else { - handlePopUpOpen("upgradePlan"); - } - }} - colorSchema="primary" - variant="plain" - ariaLabel="update" - > - - - { - if (subscription?.ipAllowlisting) { - handlePopUpOpen("deleteTrustedIp", { - trustedIpId: _id - }); - } else { - handlePopUpOpen("upgradePlan"); - } - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - > - - -
- -
-
- handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." - /> -
- ); -} \ No newline at end of file +
+ + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("trustedIp", { + trustedIpId: _id, + ipAddress, + comment, + prefix, + isActive + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + + + {(isAllowed) => ( + { + if (subscription?.ipAllowlisting) { + handlePopUpOpen("deleteTrustedIp", { + trustedIpId: _id + }); + } else { + handlePopUpOpen("upgradePlan"); + } + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + +
+ +
+
+ handlePopUpToggle("upgradePlan", isOpen)} + text="You can use IP allowlisting if you switch to Infisical's Pro plan." + /> +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index 4f8f7a655..3c6a1f680 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { useGetRoles } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -15,44 +16,50 @@ enum TabSections { Roles = "roles" } -export const MembersPage = () => { - const { t } = useTranslation(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?._id || ""; - const orgId = currentWorkspace?.organization || ""; +export const MembersPage = withProjectPermission( + () => { + const { t } = useTranslation(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const orgId = currentWorkspace?.organization || ""; - const { data: roles, isLoading: isRolesLoading } = useGetRoles({ - orgId, - workspaceId - }); + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ + orgId, + workspaceId + }); - return ( -
-
-

{t("settings.members.title")}

- - - Members - {process.env.NEXT_PUBLIC_NEW_PERMISSION_FLAG === "true" && ( + return ( +
+
+

+ {t("settings.members.title")} +

+ + + Members Roles - )} - - - - []} /> - - - - []} isRolesLoading={isRolesLoading} /> - - + + + + []} /> + + + + []} + isRolesLoading={isRolesLoading} + /> + + +
-
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Member } +); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 8ece655ea..51897280e 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; import { decryptAssymmetric, encryptAssymmetric @@ -36,6 +36,8 @@ import { import { GeneralPermissionActions, OrgPermissionSubjects, + ProjectPermissionActions, + ProjectPermissionSub, useOrganization, useUser, useWorkspace @@ -240,7 +242,7 @@ export const MemberListTab = ({ roles = [] }: Props) => { placeholder="Search members..." />
- + {(isAllowed) => ( )} - +
@@ -276,9 +278,9 @@ export const MemberListTab = ({ roles = [] }: Props) => { {name} {email} - {(isAllowed) => ( <> @@ -316,13 +318,13 @@ export const MemberListTab = ({ roles = [] }: Props) => { )} )} - + {userId !== u?._id && ( - {(isAllowed) => ( { )} - + )} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 17c17b122..b433ed7e4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -1,5 +1,7 @@ import { motion } from "framer-motion"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { TRole } from "@app/hooks/api/roles/types"; @@ -11,35 +13,38 @@ type Props = { isRolesLoading?: boolean; }; -export const ProjectRoleListTab = ({ roles = [], isRolesLoading }: Props) => { - const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); +export const ProjectRoleListTab = withProjectPermission( + ({ roles = [], isRolesLoading }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const); - return popUp.editRole.isOpen ? ( - - } - onGoBack={() => handlePopUpClose("editRole")} - /> - - ) : ( - - handlePopUpOpen("editRole", role)} - /> - - ); -}; + return popUp.editRole.isOpen ? ( + + } + onGoBack={() => handlePopUpClose("editRole")} + /> + + ) : ( + + handlePopUpOpen("editRole", role)} + /> + + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role } +); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index 0230a248a..c18458127 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -19,7 +20,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -69,9 +75,17 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr placeholder="Search roles..." />
- + + {(isAllowed) => ( + + )} +
@@ -99,27 +113,48 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
- - onSelectRole(role)} - variant="plain" - > - - - - - handlePopUpOpen("deleteRole", role)} - variant="plain" - isDisabled={isNonMutatable} - > - - - + {(isAllowed) => ( +
+ + onSelectRole(role)} + variant="plain" + > + + + +
+ )} + + + {(isAllowed) => ( +
+ + handlePopUpOpen("deleteRole", role)} + variant="plain" + isDisabled={isNonMutatable || !isAllowed} + > + + + +
+ )} +
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index c74df03ce..f2dbc40fe 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -34,6 +34,7 @@ import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { SingleProjectPermission } from "./SingleProjectPermission"; +import { WsProjectPermission } from "./WsProjectPermission"; const SINGLE_PERMISSION_LIST = [ { @@ -271,6 +272,13 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { />
))} +
+ +
); }; + +export const SecretOverviewPage = withProjectPermission(SecretOverview, { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Secrets +}); diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index e8f3730cb..c2d15a924 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -3,7 +3,9 @@ import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-i import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { IconButton, SecretInput, Tooltip } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; type Props = { @@ -91,19 +93,26 @@ export const SecretEditRow = ({
{isDirty ? ( <> -
- - - - - -
+ + {(isAllowed) => ( +
+ + + + + +
+ )} +
-
- - - - - -
+ + {(isAllowed) => ( +
+ + + + + +
+ )} +
)}
diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index 8f9fbb441..864d906a8 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { Tab } from "@headlessui/react"; import NavHeader from "@app/components/navigation/NavHeader"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { ProjectGeneralTab } from "./components/ProjectGeneralTab"; import { ProjectServiceTokensTab } from "./components/ProjectServiceTokensTab"; @@ -14,45 +16,50 @@ const tabs = [ { name: "Webhooks", key: "tab-project-webhooks" } ]; -export const ProjectSettingsPage = () => { - const { t } = useTranslation(); - return ( -
-
-
- +export const ProjectSettingsPage = withProjectPermission( + () => { + const { t } = useTranslation(); + return ( +
+
+
+ +
+
+

{t("settings.project.title")}

+
+ + + {tabs.map((tab) => ( + + {({ selected }) => ( + + )} + + ))} + + + + + + + + + + + + +
-
-

{t("settings.project.title")}

-
- - - {tabs.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - - - - - - - - - -
-
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx index f41de5ec6..c23770227 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/AutoCapitalizationSection/AutoCapitalizationSection.tsx @@ -1,52 +1,62 @@ import { useTranslation } from "react-i18next"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Checkbox } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useToggleAutoCapitalization } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useToggleAutoCapitalization } from "@app/hooks/api"; -export const AutoCapitalizationSection = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { mutateAsync } = useToggleAutoCapitalization(); - - const handleToggleCapitalizationToggle = async (state: boolean) => { - try { - if (!currentWorkspace?._id) return; +export const AutoCapitalizationSection = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { mutateAsync } = useToggleAutoCapitalization(); - await mutateAsync({ - workspaceID: currentWorkspace._id, - state - }); + const handleToggleCapitalizationToggle = async (state: boolean) => { + try { + if (!currentWorkspace?._id) return; - const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; - createNotification({ - text, - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update auto capitalization", - type: "error" - }); - } - } + await mutateAsync({ + workspaceID: currentWorkspace._id, + state + }); - return ( -
-

{t("settings.project.auto-capitalization")}

- { - handleToggleCapitalizationToggle(state as boolean); - }} - > - {t("settings.project.auto-capitalization-description")} - -
- ); -}; + const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`; + createNotification({ + text, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update auto capitalization", + type: "error" + }); + } + }; + + return ( +
+

{t("settings.project.auto-capitalization")}

+ + {(isAllowed) => ( + { + handleToggleCapitalizationToggle(state as boolean); + }} + > + {t("settings.project.auto-capitalization-description")} + + )} + +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx index d217334e2..18f27d8d5 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/DeleteProjectSection/DeleteProjectSection.tsx @@ -3,30 +3,34 @@ import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; -import { useOrganization, useWorkspace } from "@app/context"; -import { useToggle } from "@app/hooks"; import { - useDeleteWorkspace -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useWorkspace +} from "@app/context"; +import { useToggle } from "@app/hooks"; +import { useDeleteWorkspace } from "@app/hooks/api"; export const DeleteProjectSection = () => { - const { t } = useTranslation(); - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const { currentOrg } = useOrganization() - const [isDeleting, setIsDeleting] = useToggle(); - const [deleteProjectInput, setDeleteProjectInput] = useState(""); - const deleteWorkspace = useDeleteWorkspace(); + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const { currentOrg } = useOrganization(); + const [isDeleting, setIsDeleting] = useToggle(); + const [deleteProjectInput, setDeleteProjectInput] = useState(""); + const deleteWorkspace = useDeleteWorkspace(); - const onDeleteWorkspace = async () => { + const onDeleteWorkspace = async () => { setIsDeleting.on(); try { - if (!currentWorkspace?._id) return; - await deleteWorkspace.mutateAsync({ + if (!currentWorkspace?._id) return; + await deleteWorkspace.mutateAsync({ workspaceID: currentWorkspace?._id - }); + }); // redirect user to the org overview router.push(`/org/${currentOrg?._id}/overview`); @@ -45,38 +49,42 @@ export const DeleteProjectSection = () => { } }; - return ( -
-

{t("settings.project.danger-zone")}

-

{t("settings.project.danger-zone-note")}

-
- - Type {currentWorkspace?.name} to delete the - workspace -
- } - > - setDeleteProjectInput(e.target.value)} - value={deleteProjectInput} - placeholder="Type the project name to delete" - className="bg-mineshaft-800" - /> - -
+ return ( +
+

{t("settings.project.danger-zone")}

+

{t("settings.project.danger-zone-note")}

+
+ + Type {currentWorkspace?.name} to delete the + workspace +
+ } + > + setDeleteProjectInput(e.target.value)} + value={deleteProjectInput} + placeholder="Type the project name to delete" + className="bg-mineshaft-800" + /> + +
+ + {(isAllowed) => ( -

- {t("settings.project.delete-project-note")} -

-
- ); -} \ No newline at end of file + )} + +

+ {t("settings.project.delete-project-note")} +

+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx index 374681003..815b50f24 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/E2EESection/E2EESection.tsx @@ -1,99 +1,115 @@ +import { ProjectPermissionCan } from "@app/components/permissions"; import { - decryptAssymmetric, - encryptAssymmetric + decryptAssymmetric, + encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Checkbox } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { useGetUserWsKey,useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useGetUserWsKey, useGetWorkspaceBot, useUpdateBotActiveStatus } from "@app/hooks/api"; -export const E2EESection = () => { +export const E2EESection = withProjectPermission( + () => { const { currentWorkspace } = useWorkspace(); const { data: bot } = useGetWorkspaceBot(currentWorkspace?._id ?? ""); const { mutateAsync: updateBotActiveStatus } = useUpdateBotActiveStatus(); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); /** - * Activate bot for project by performing the following steps: - * 1. Get the (encrypted) project key - * 2. Decrypt project key with user's private key - * 3. Encrypt project key with bot's public key - * 4. Send encrypted project key to backend and set bot status to active - */ + * Activate bot for project by performing the following steps: + * 1. Get the (encrypted) project key + * 2. Decrypt project key with user's private key + * 3. Encrypt project key with bot's public key + * 4. Send encrypted project key to backend and set bot status to active + */ const toggleBotActivate = async () => { - let botKey; - try { - if (!currentWorkspace?._id) return; + let botKey; + try { + if (!currentWorkspace?._id) return; - if (bot && wsKey) { - // case: there is a bot - - if (!bot.isActive) { - // bot is not active -> activate bot - - const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); + if (bot && wsKey) { + // case: there is a bot - if (!PRIVATE_KEY) { - throw new Error("Private Key missing"); - } + if (!bot.isActive) { + // bot is not active -> activate bot - const WORKSPACE_KEY = decryptAssymmetric({ - ciphertext: wsKey.encryptedKey, - nonce: wsKey.nonce, - publicKey: wsKey.sender.publicKey, - privateKey: PRIVATE_KEY - }); + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY"); - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: WORKSPACE_KEY, - publicKey: bot.publicKey, - privateKey: PRIVATE_KEY - }); - - botKey = { - encryptedKey: ciphertext, - nonce - }; - - await updateBotActiveStatus({ - workspaceId: currentWorkspace._id, - botKey, - isActive: true, - botId: bot._id - }); - } else { - // bot is active -> deactivate bot - await updateBotActiveStatus({ - isActive: false, - botId: bot._id, - workspaceId: currentWorkspace._id - }); - } + if (!PRIVATE_KEY) { + throw new Error("Private Key missing"); } - } catch (err) { - console.error(err); + + const WORKSPACE_KEY = decryptAssymmetric({ + ciphertext: wsKey.encryptedKey, + nonce: wsKey.nonce, + publicKey: wsKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: WORKSPACE_KEY, + publicKey: bot.publicKey, + privateKey: PRIVATE_KEY + }); + + botKey = { + encryptedKey: ciphertext, + nonce + }; + + await updateBotActiveStatus({ + workspaceId: currentWorkspace._id, + botKey, + isActive: true, + botId: bot._id + }); + } else { + // bot is active -> deactivate bot + await updateBotActiveStatus({ + isActive: false, + botId: bot._id, + workspaceId: currentWorkspace._id + }); + } } + } catch (err) { + console.error(err); + } }; return bot ? (

End-to-End Encryption

- Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values. + Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to + cloud providers as well as HTTP calls to get secrets back raw but enables the server to + read/decrypt your secret values.

- Note that, even with E2EE disabled, your secrets are always encrypted at rest. + Note that, even with E2EE disabled, your secrets are always encrypted at rest.

- { - await toggleBotActivate(); - }} - > - End-to-end encryption enabled - + + {(isAllowed) => ( + { + await toggleBotActivate(); + }} + > + End-to-end encryption enabled + + )} +
- ) :
; - }; - \ No newline at end of file + ) : ( +
+ ); + }, + { + action: ProjectPermissionActions.Read, + subject: ProjectPermissionSub.Settings + } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index 62a66e72c..229fc9b5f 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -2,115 +2,127 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; import { - Button, - DeleteActionModal, - UpgradePlanModal -} from "@app/components/v2"; -import { useSubscription,useWorkspace } from "@app/context"; -import { - useDeleteWsEnvironment -} from "@app/hooks/api"; + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace +} from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteWsEnvironment } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddEnvironmentModal } from "./AddEnvironmentModal"; import { EnvironmentTable } from "./EnvironmentTable"; import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal"; -export const EnvironmentSection = () => { - const { createNotification } = useNotificationContext(); - const { subscription } = useSubscription(); - const { currentWorkspace } = useWorkspace(); +export const EnvironmentSection = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); - const deleteWsEnvironment = useDeleteWsEnvironment(); + const deleteWsEnvironment = useDeleteWsEnvironment(); - const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true; - - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "createEnv", - "updateEnv", - "deleteEnv", - "upgradePlan" - ] as const); + const isMoreEnvironmentsAllowed = + subscription?.environmentLimit && currentWorkspace?.environments + ? currentWorkspace.environments.length < subscription.environmentLimit + : true; - const onEnvDeleteSubmit = async (environmentSlug: string) => { - try { - if (!currentWorkspace?._id) return; - - await deleteWsEnvironment.mutateAsync({ - workspaceID: currentWorkspace._id, - environmentSlug - }); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "createEnv", + "updateEnv", + "deleteEnv", + "upgradePlan" + ] as const); - createNotification({ - text: "Successfully deleted environment", - type: "success" - }); - - handlePopUpClose("deleteEnv"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete environment", - type: "error" - }); - } - }; + const onEnvDeleteSubmit = async (environmentSlug: string) => { + try { + if (!currentWorkspace?._id) return; - return ( -
-
-

- Environments -

-
- + await deleteWsEnvironment.mutateAsync({ + workspaceID: currentWorkspace._id, + environmentSlug + }); + + createNotification({ + text: "Successfully deleted environment", + type: "success" + }); + + handlePopUpClose("deleteEnv"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete environment", + type: "error" + }); + } + }; + + return ( +
+
+

Environments

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

+ Choose which environments will show up in your dashboard like development, staging, + production +

+ + + + handlePopUpToggle("deleteEnv", isOpen)} + deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""} + onDeleteApproved={() => + onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text="You can add custom environments if you switch to Infisical's Team plan." + />
-

- Choose which environments will show up in your dashboard like development, staging, production -

- - - - handlePopUpToggle("deleteEnv", isOpen)} - deleteKey={(popUp?.deleteEnv?.data as { slug: string })?.slug || ""} - onDeleteApproved={() => - onEnvDeleteSubmit((popUp?.deleteEnv?.data as { slug: string })?.slug) - } - /> - handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Team plan." - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Environments } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx index e799f9e9a..6b2f26881 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/EnvironmentTable.tsx @@ -2,6 +2,7 @@ import { faArrowDown,faArrowUp, faPencil, faXmark } from "@fortawesome/free-soli import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -14,7 +15,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useReorderWsEnvironment } from "@app/hooks/api"; @@ -115,28 +116,42 @@ export const EnvironmentTable = ({ handlePopUpOpen }: Props) => { > - { - handlePopUpOpen("updateEnv", { name, slug }); - }} - colorSchema="primary" - variant="plain" - ariaLabel="update" + - - - { - handlePopUpOpen("deleteEnv", { name, slug }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" + {(isAllowed) => ( + { + handlePopUpOpen("updateEnv", { name, slug }); + }} + isDisabled={!isAllowed} + colorSchema="primary" + variant="plain" + ariaLabel="update" + > + + + )} + + - - + {(isAllowed) => ( + { + handlePopUpOpen("deleteEnv", { name, slug }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx index dc8f443ba..6c5502439 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx @@ -1,71 +1,77 @@ import { - decryptAssymmetric, - decryptSymmetric + decryptAssymmetric, + decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { - useGetUserWsKey, - useGetWorkspaceIndexStatus, - useGetWorkspaceSecrets, - useNameWorkspaceSecrets + useGetUserWsKey, + useGetWorkspaceIndexStatus, + useGetWorkspaceSecrets, + useNameWorkspaceSecrets } from "@app/hooks/api"; -export const ProjectIndexSecretsSection = () => { +// TODO: add check so that this only shows up if user is +// an admin in the workspace + +export const ProjectIndexSecretsSection = withProjectPermission( + () => { const { currentWorkspace } = useWorkspace(); - const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? ""); + const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus( + currentWorkspace?._id ?? "" + ); const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? ""); const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? ""); const nameWorkspaceSecrets = useNameWorkspaceSecrets(); const onEnableBlindIndices = async () => { - if (!currentWorkspace?._id) return; - if (!encryptedSecrets) return; - if (!latestFileKey) return; + if (!currentWorkspace?._id) return; + if (!encryptedSecrets) return; + if (!latestFileKey) return; - const key = decryptAssymmetric({ - ciphertext: latestFileKey.encryptedKey, - nonce: latestFileKey.nonce, - publicKey: latestFileKey.sender.publicKey, - privateKey: localStorage.getItem("PRIVATE_KEY") as string - }); - - const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { - const secretName = decryptSymmetric({ - ciphertext: encryptedSecret.secretKeyCiphertext, - iv: encryptedSecret.secretKeyIV, - tag: encryptedSecret.secretKeyTag, - key + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem("PRIVATE_KEY") as string }); - return { - secretName, - _id: encryptedSecret._id - }; - }); + const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { + const secretName = decryptSymmetric({ + ciphertext: encryptedSecret.secretKeyCiphertext, + iv: encryptedSecret.secretKeyIV, + tag: encryptedSecret.secretKeyTag, + key + }); - await nameWorkspaceSecrets.mutateAsync({ - workspaceId: currentWorkspace._id, - secretsToUpdate - }); - }; + return { + secretName, + _id: encryptedSecret._id + }; + }); - return (!isBlindIndexedLoading && (isBlindIndexed === false)) ? ( -
-

Blind Indices

-

- Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing. -

- -
+ await nameWorkspaceSecrets.mutateAsync({ + workspaceId: currentWorkspace._id, + secretsToUpdate + }); + }; + + return !isBlindIndexedLoading && !isBlindIndexed ? ( +
+

Blind Indices

+

+ Your project, created before the introduction of blind indexing, contains unindexed + secrets. To access individual secrets by name through the SDK and public API, please + enable blind indexing. +

+ +
) : ( -
- ) -} \ No newline at end of file +
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Settings } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx index 66ad4c5b0..137674ef0 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx @@ -4,11 +4,10 @@ import { yupResolver } from "@hookform/resolvers/yup"; import * as yup from "yup"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, FormControl, Input } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { - useRenameWorkspace -} from "@app/hooks/api"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useRenameWorkspace } from "@app/hooks/api"; const formSchema = yup.object({ name: yup.string().required().label("Project Name") @@ -21,25 +20,20 @@ export const ProjectNameChangeSection = () => { const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useRenameWorkspace(); - const { - handleSubmit, - control, - reset - } = useForm({ resolver: yupResolver(formSchema) }); + const { handleSubmit, control, reset } = useForm({ resolver: yupResolver(formSchema) }); useEffect(() => { if (currentWorkspace) { - reset({ + reset({ name: currentWorkspace.name }); } - }, [currentWorkspace]); const onFormSubmit = async ({ name }: FormData) => { try { if (!currentWorkspace?._id) return; - + await mutateAsync({ workspaceID: currentWorkspace._id, newWorkspaceName: name @@ -49,7 +43,6 @@ export const ProjectNameChangeSection = () => { text: "Successfully renamed workspace", type: "success" }); - } catch (err) { console.error(err); createNotification({ @@ -60,37 +53,35 @@ export const ProjectNameChangeSection = () => { }; return ( -
-

- Project Name -

-
- ( - - - - )} - control={control} - name="name" - /> -
- +

Project Name

+
+ ( + + + + )} + control={control} + name="name" + /> +
+ + {(isAllowed) => ( + + )} +
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx index 7d1db7305..6dc894926 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsSection.tsx @@ -2,10 +2,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { - Button, - DeleteActionModal -} from "@app/components/v2"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteWsTag } from "@app/hooks/api"; @@ -14,74 +14,80 @@ import { SecretTagsTable } from "./SecretTagsTable"; type DeleteModalData = { name: string; id: string }; -export const SecretTagsSection = (): JSX.Element => { - const { createNotification } = useNotificationContext(); - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "CreateSecretTag", - "deleteTagConfirmation" - ] as const); +export const SecretTagsSection = withProjectPermission( + (): JSX.Element => { + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "CreateSecretTag", + "deleteTagConfirmation" + ] as const); - const deleteWsTag = useDeleteWsTag(); + const deleteWsTag = useDeleteWsTag(); - const onDeleteApproved = async () => { - try { - await deleteWsTag.mutateAsync({ - tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id - }); + const onDeleteApproved = async () => { + try { + await deleteWsTag.mutateAsync({ + tagID: (popUp?.deleteTagConfirmation?.data as DeleteModalData)?.id + }); - createNotification({ - text: "Successfully deleted tag", - type: "success" - }); + createNotification({ + text: "Successfully deleted tag", + type: "success" + }); - handlePopUpClose("deleteTagConfirmation"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete the tag", - type: "error" - }); - } - }; + handlePopUpClose("deleteTagConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete the tag", + type: "error" + }); + } + }; - return ( -
-
-

Secret Tags

- + return ( +
+
+

Secret Tags

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

+ Every secret can be assigned to one or more tags. Here you can add and remove tags for the + current project. +

+ + + handlePopUpToggle("deleteTagConfirmation", isOpen)} + deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteTagConfirmation")} + onDeleteApproved={onDeleteApproved} + />
-

- Every secret can be assigned to one or more tags. Here you can add and remove tags for - the current project. -

- - - handlePopUpToggle("deleteTagConfirmation", isOpen)} - deleteKey={(popUp?.deleteTagConfirmation?.data as DeleteModalData)?.name} - onClose={() => handlePopUpClose("deleteTagConfirmation")} - onDeleteApproved={onDeleteApproved} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Tags } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx index cb8a0afd4..e20e605fa 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/SecretTagsSection/SecretTagsTable.tsx @@ -1,6 +1,7 @@ import { faTags, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useGetWsTags } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -53,18 +54,26 @@ export const SecretTagsTable = ({ handlePopUpOpen }: Props) => { {name} {slug} - - handlePopUpOpen("deleteTagConfirmation", { - name, - id: _id - }) - } - colorSchema="danger" - ariaLabel="update" + - - + {(isAllowed) => ( + + handlePopUpOpen("deleteTagConfirmation", { + name, + id: _id + }) + } + colorSchema="danger" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index 0c28e446d..b560165f2 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -3,7 +3,10 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteServiceToken } from "@app/hooks/api"; @@ -12,65 +15,76 @@ import { ServiceTokenTable } from "./ServiceTokenTable"; type DeleteModalData = { name: string; id: string }; -export const ServiceTokenSection = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const deleteServiceToken = useDeleteServiceToken(); +export const ServiceTokenSection = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const deleteServiceToken = useDeleteServiceToken(); - const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ - "createAPIToken", - "deleteAPITokenConfirmation" - ] as const); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createAPIToken", + "deleteAPITokenConfirmation" + ] as const); - const onDeleteApproved = async () => { - try { - deleteServiceToken.mutateAsync( - (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id - ); - createNotification({ - text: "Successfully deleted service token", - type: "success" - }); + const onDeleteApproved = async () => { + try { + deleteServiceToken.mutateAsync( + (popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.id + ); + createNotification({ + text: "Successfully deleted service token", + type: "success" + }); - handlePopUpClose("deleteAPITokenConfirmation"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to delete service token", - type: "error" - }); - } - }; + handlePopUpClose("deleteAPITokenConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete service token", + type: "error" + }); + } + }; - return ( -
-
-

- {t("section.token.service-tokens")} -

- + return ( +
+
+

+ {t("section.token.service-tokens")} +

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

{t("section.token.service-tokens-description")}

+ + + handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} + deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} + onDeleteApproved={onDeleteApproved} + />
-

{t("section.token.service-tokens-description")}

- - - handlePopUpToggle("deleteAPITokenConfirmation", isOpen)} - deleteKey={(popUp?.deleteAPITokenConfirmation?.data as DeleteModalData)?.name} - onClose={() => handlePopUpClose("deleteAPITokenConfirmation")} - onDeleteApproved={onDeleteApproved} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.ServiceTokens } +); diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx index 8c40cef35..9c71e4408 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -1,6 +1,7 @@ import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { EmptyState, IconButton, @@ -13,7 +14,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useGetUserWsServiceTokens } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -70,18 +71,26 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { {row.expiresAt && new Date(row.expiresAt).toUTCString()} - - handlePopUpOpen("deleteAPITokenConfirmation", { - name: row.name, - id: row._id - }) - } - colorSchema="danger" - ariaLabel="delete" + - - + {(isAllowed) => ( + + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row._id + }) + } + colorSchema="danger" + ariaLabel="delete" + isDisabled={!isAllowed} + > + + + )} + ))} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx index 3b48ac8b3..6fc6b230c 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/WebhooksTab/WebhooksTab.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -17,7 +18,8 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { useWorkspace } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useCreateWebhook, @@ -29,252 +31,291 @@ import { import { AddWebhookForm, TFormSchema } from "./AddWebhookForm"; -export const WebhooksTab = () => { - const { t } = useTranslation(); - const { createNotification } = useNotificationContext(); - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?._id || ""; - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "addWebhook", - "deleteWebhook" - ] as const); +export const WebhooksTab = withProjectPermission( + () => { + const { t } = useTranslation(); + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?._id || ""; + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "addWebhook", + "deleteWebhook" + ] as const); - const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); + const { data: webhooks, isLoading: isWebhooksLoading } = useGetWebhooks(workspaceId); - // mutation - const { mutateAsync: createWebhook } = useCreateWebhook(); - const { - mutateAsync: testWebhook, - variables: testWebhookVars, - isLoading: isTestWebhookSubmitting - } = useTestWebhook(); - const { - mutateAsync: updateWebhook, - variables: updateWebhookVars, - isLoading: isUpdateWebhookSubmitting - } = useUpdateWebhook(); - const { mutateAsync: deleteWebhook } = useDeleteWebhook(); + // mutation + const { mutateAsync: createWebhook } = useCreateWebhook(); + const { + mutateAsync: testWebhook, + variables: testWebhookVars, + isLoading: isTestWebhookSubmitting + } = useTestWebhook(); + const { + mutateAsync: updateWebhook, + variables: updateWebhookVars, + isLoading: isUpdateWebhookSubmitting + } = useUpdateWebhook(); + const { mutateAsync: deleteWebhook } = useDeleteWebhook(); - const handleWebhookCreate = async (data: TFormSchema) => { - try { - await createWebhook({ - ...data, - workspaceId - }); - handlePopUpClose("addWebhook"); - createNotification({ - type: "success", - text: "Successfully created webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to create webhook" - }); - } - }; + const handleWebhookCreate = async (data: TFormSchema) => { + try { + await createWebhook({ + ...data, + workspaceId + }); + handlePopUpClose("addWebhook"); + createNotification({ + type: "success", + text: "Successfully created webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to create webhook" + }); + } + }; - const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { - try { - await updateWebhook({ - webhookId, - workspaceId, - isDisabled - }); - createNotification({ - type: "success", - text: "Successfully updated webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to update webhook" - }); - } - }; + const handleWebhookDisable = async (webhookId: string, isDisabled: boolean) => { + try { + await updateWebhook({ + webhookId, + workspaceId, + isDisabled + }); + createNotification({ + type: "success", + text: "Successfully updated webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to update webhook" + }); + } + }; - const handleWebhookDelete = async () => { - try { - const webhookId = popUp?.deleteWebhook?.data as string; - await deleteWebhook({ - webhookId, - workspaceId - }); - handlePopUpClose("deleteWebhook"); - createNotification({ - type: "success", - text: "Successfully deleted webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to delete webhook" - }); - } - }; + const handleWebhookDelete = async () => { + try { + const webhookId = popUp?.deleteWebhook?.data as string; + await deleteWebhook({ + webhookId, + workspaceId + }); + handlePopUpClose("deleteWebhook"); + createNotification({ + type: "success", + text: "Successfully deleted webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to delete webhook" + }); + } + }; - const handleWebhookTest = async (webhookId: string) => { - try { - await testWebhook({ - webhookId, - workspaceId - }); - createNotification({ - type: "success", - text: "Successfully triggered webhook" - }); - } catch (err) { - console.log(err); - createNotification({ - type: "error", - text: "Failed to trigger webhook" - }); - } - }; + const handleWebhookTest = async (webhookId: string) => { + try { + await testWebhook({ + webhookId, + workspaceId + }); + createNotification({ + type: "success", + text: "Successfully triggered webhook" + }); + } catch (err) { + console.log(err); + createNotification({ + type: "error", + text: "Failed to trigger webhook" + }); + } + }; - return ( -
-
-

{t("settings.webhooks.title")}

- -
-

{t("settings.webhooks.description")}

-
- - - - - - - - - - - - - {isWebhooksLoading && } - {!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( + return ( +
+
+

{t("settings.webhooks.title")}

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

{t("settings.webhooks.description")}

+
+ +
URLEnvironmentSecret PathStatusAction
+ - + + + + + - )} - {!isWebhooksLoading && - webhooks?.map( - ({ - _id: id, - url, - environment, - secretPath, - lastStatus, - isDisabled, - updatedAt, - lastRunErrorMessage - }) => ( - - - - - - - - ) + + + {isWebhooksLoading && } + {!isWebhooksLoading && webhooks && webhooks?.length === 0 && ( + + + )} - -
- - URLEnvironmentSecret PathStatusAction
- {url} - {environment}{secretPath} - {!lastStatus ? ( - "-" - ) : ( -
- {lastStatus}{" "} - -
- Updated At:{" "} - {format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")} -
- {lastRunErrorMessage && ( -
- Error: {lastRunErrorMessage} -
- )} -
- } - > - - - - )} -
-
- - - -
-
+ +
-
+ {!isWebhooksLoading && + webhooks?.map( + ({ + _id: id, + url, + environment, + secretPath, + lastStatus, + isDisabled, + updatedAt, + lastRunErrorMessage + }) => ( + + + {url} + + {environment} + {secretPath} + + {!lastStatus ? ( + "-" + ) : ( +
+ {lastStatus}{" "} + +
+ Updated At:{" "} + {format(new Date(updatedAt), "yyyy-MM-dd, hh:mm aaa")} +
+ {lastRunErrorMessage && ( +
+ Error: {lastRunErrorMessage} +
+ )} +
+ } + > + + +
+ )} + + +
+ + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + + + {(isAllowed) => ( + + )} + +
+ + + ) + )} + + + +
+ handlePopUpToggle("addWebhook", isOpen)} + onCreateWebhook={handleWebhookCreate} + /> + handlePopUpToggle("deleteWebhook", isOpen)} + onClose={() => handlePopUpClose("deleteWebhook")} + onDeleteApproved={handleWebhookDelete} + />
- handlePopUpToggle("addWebhook", isOpen)} - onCreateWebhook={handleWebhookCreate} - /> - handlePopUpToggle("deleteWebhook", isOpen)} - onClose={() => handlePopUpClose("deleteWebhook")} - onDeleteApproved={handleWebhookDelete} - /> -
- ); -}; + ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Webhooks } +); From ea9e638d03f902a4352486c994ff2b5dd1d1e881 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 28 Aug 2023 16:02:46 +0530 Subject: [PATCH 14/40] feat(rbac): resolved merge conflict --- .../controllers/v1/integrationController.ts | 18 ++++++--- .../controllers/v2/environmentController.ts | 38 ++++++++++++------- .../src/controllers/v3/signupController.ts | 3 +- backend/src/ee/routes/v1/secretSnapshot.ts | 28 ++++---------- backend/src/routes/v1/integration.ts | 4 +- backend/src/routes/v2/environment.ts | 12 +----- backend/src/validation/environments.ts | 12 ++++++ backend/src/validation/integration.ts | 7 ++++ .../ProjectPermissionContext.tsx | 5 +-- .../src/views/DashboardPage/DashboardPage.tsx | 13 +++++-- .../MemberListTab/MemberListTab.tsx | 4 +- 11 files changed, 81 insertions(+), 63 deletions(-) diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index c9d4180b0..8b18fcb17 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -275,14 +275,22 @@ export const deleteIntegration = async (req: Request, res: Response) => { }); }; -// Will trigger sync for all integrations within the given env and workspace id +// Will trigger sync for all integrations within the given env and workspace id export const manualSync = async (req: Request, res: Response) => { - const { workspaceId, environment } = req.body; + const { + body: { workspaceId, environment } + } = await validateRequest(reqValidator.ManualSyncV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Integrations + ); + syncSecretsToActiveIntegrationsQueue({ workspaceId, environment - }) + }); - res.status(200).send() + res.status(200).send(); }; - diff --git a/backend/src/controllers/v2/environmentController.ts b/backend/src/controllers/v2/environmentController.ts index 8137029dc..1d43630db 100644 --- a/backend/src/controllers/v2/environmentController.ts +++ b/backend/src/controllers/v2/environmentController.ts @@ -105,34 +105,46 @@ export const createWorkspaceEnvironment = async (req: Request, res: Response) => * @param res * @returns */ -export const reorderWorkspaceEnvironments = async ( - req: Request, - res: Response -) => { - const { workspaceId } = req.params; - const { environmentSlug, environmentName, otherEnvironmentSlug, otherEnvironmentName } = req.body; +export const reorderWorkspaceEnvironments = async (req: Request, res: Response) => { + const { + params: { workspaceId }, + body: { environmentName, environmentSlug, otherEnvironmentSlug, otherEnvironmentName } + } = await validateRequest(reqValidator.ReorderWorkspaceEnvironmentsV2, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Environments + ); // atomic update the env to avoid conflict const workspace = await Workspace.findById(workspaceId).exec(); if (!workspace) { - throw BadRequestError({message: "Couldn't load workspace"}); + throw BadRequestError({ message: "Couldn't load workspace" }); } - const environmentIndex = workspace.environments.findIndex((env) => env.name === environmentName && env.slug === environmentSlug) - const otherEnvironmentIndex = workspace.environments.findIndex((env) => env.name === otherEnvironmentName && env.slug === otherEnvironmentSlug) + const environmentIndex = workspace.environments.findIndex( + (env) => env.name === environmentName && env.slug === environmentSlug + ); + const otherEnvironmentIndex = workspace.environments.findIndex( + (env) => env.name === otherEnvironmentName && env.slug === otherEnvironmentSlug + ); if (environmentIndex === -1 || otherEnvironmentIndex === -1) { - throw BadRequestError({message: "environment or otherEnvironment couldn't be found"}) + throw BadRequestError({ message: "environment or otherEnvironment couldn't be found" }); } // swap the order of the environments - [workspace.environments[environmentIndex], workspace.environments[otherEnvironmentIndex]] = [workspace.environments[otherEnvironmentIndex], workspace.environments[environmentIndex]] + [workspace.environments[environmentIndex], workspace.environments[otherEnvironmentIndex]] = [ + workspace.environments[otherEnvironmentIndex], + workspace.environments[environmentIndex] + ]; - await workspace.save() + await workspace.save(); return res.status(200).send({ message: "Successfully reordered environments", - workspace: workspaceId, + workspace: workspaceId }); }; diff --git a/backend/src/controllers/v3/signupController.ts b/backend/src/controllers/v3/signupController.ts index aa466cd11..79d661b58 100644 --- a/backend/src/controllers/v3/signupController.ts +++ b/backend/src/controllers/v3/signupController.ts @@ -57,8 +57,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { if (providerAuthToken) { await validateProviderAuthToken({ email, - providerAuthToken, - user + providerAuthToken }); } else { const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>( diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index ecfe47ca5..f8c643e60 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -1,27 +1,15 @@ import express from "express"; const router = express.Router(); -import { - requireSecretSnapshotAuth, -} from "../../middleware"; -import { - requireAuth, - validateRequest, -} from "../../../middleware"; -import { param } from "express-validator"; -import { ADMIN, AuthMode, MEMBER } from "../../../variables"; +import { requireAuth } from "../../../middleware"; +import { AuthMode } from "../../../variables"; import { secretSnapshotController } from "../../controllers/v1"; router.get( - "/:secretSnapshotId", - requireAuth({ - acceptedAuthModes: [AuthMode.JWT], - }), - requireSecretSnapshotAuth({ - acceptedRoles: [ADMIN, MEMBER], - }), - param("secretSnapshotId").exists().trim(), - validateRequest, - secretSnapshotController.getSecretSnapshot + "/:secretSnapshotId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + secretSnapshotController.getSecretSnapshot ); -export default router; \ No newline at end of file +export default router; diff --git a/backend/src/routes/v1/integration.ts b/backend/src/routes/v1/integration.ts index e97132238..6dda7527b 100644 --- a/backend/src/routes/v1/integration.ts +++ b/backend/src/routes/v1/integration.ts @@ -1,8 +1,6 @@ import express from "express"; const router = express.Router(); -import { - requireAuth -} from "../../middleware"; +import { requireAuth } from "../../middleware"; import { AuthMode } from "../../variables"; import { integrationController } from "../../controllers/v1"; diff --git a/backend/src/routes/v2/environment.ts b/backend/src/routes/v2/environment.ts index b870f2813..45ea51594 100644 --- a/backend/src/routes/v2/environment.ts +++ b/backend/src/routes/v2/environment.ts @@ -23,18 +23,8 @@ router.put( router.patch( "/:workspaceId/environments", requireAuth({ - acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY], + acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params", - }), - param("workspaceId").exists().trim(), - body("environmentSlug").exists().isString().trim(), - body("environmentName").exists().isString().trim(), - body("otherEnvironmentSlug").exists().isString().trim(), - body("otherEnvironmentName").exists().isString().trim(), - validateRequest, environmentController.reorderWorkspaceEnvironments ); diff --git a/backend/src/validation/environments.ts b/backend/src/validation/environments.ts index dcdab72be..6cf7cf68a 100644 --- a/backend/src/validation/environments.ts +++ b/backend/src/validation/environments.ts @@ -35,3 +35,15 @@ export const GetAllAccessibileEnvironmentsOfWorkspaceV2 = z.object({ workspaceId: z.string().trim() }) }); + +export const ReorderWorkspaceEnvironmentsV2 = z.object({ + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + environmentSlug: z.string().trim(), + environmentName: z.string().trim(), + otherEnvironmentSlug: z.string().trim(), + otherEnvironmentName: z.string().trim() + }) +}); diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index 781b8bba4..e1b47b6d5 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -99,3 +99,10 @@ export const DeleteIntegrationV1 = z.object({ integrationId: z.string().trim() }) }); + +export const ManualSyncV1 = z.object({ + body: z.object({ + environment: z.string(), + workspaceId: z.string() + }) +}); diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx index 087882263..732466cf6 100644 --- a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -12,11 +12,10 @@ type Props = { const ProjectPermissionContext = createContext(null); export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => { - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, isLoading: isWsLoading } = useWorkspace(); const workspaceId = currentWorkspace?._id || ""; const { data: permission, isLoading } = useGetUserProjectPermissions({ workspaceId }); - console.log(workspaceId); if (!permission && currentWorkspace) { return (
@@ -25,7 +24,7 @@ export const ProjectPermissionProvider = ({ children }: Props): JSX.Element => { ); } - if (isLoading && workspaceId) { + if ((isLoading && currentWorkspace) || isWsLoading) { return (
{ + async (tagName: string, tagColor: string) => { try { await createWsTag({ workspaceID: workspaceId, tagName, + tagColor, tagSlug: tagName.replace(" ", "_") }); handlePopUpClose("addTag"); @@ -862,7 +869,7 @@ export const DashboardPage = withProjectPermission( }} leftIcon={} isLoading={isLoadingSnapshotCount} - isDisabled={!canDoRollback || !isAllowed} + isDisabled={!canDoRollback && !isAllowed} className="h-10" > {snapshotCount} Commits diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 51897280e..bcf272607 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { decryptAssymmetric, encryptAssymmetric @@ -34,8 +34,6 @@ import { UpgradePlanModal } from "@app/components/v2"; import { - GeneralPermissionActions, - OrgPermissionSubjects, ProjectPermissionActions, ProjectPermissionSub, useOrganization, From 4b0bc238fc6e716ad2663e30b8a6621d550aa1a0 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 28 Aug 2023 19:37:23 +0530 Subject: [PATCH 15/40] feat(rbac): changed the action names for org --- .../controllers/v1/membershipOrgController.ts | 6 +- .../controllers/v1/organizationController.ts | 21 +- backend/src/controllers/v1/roleController.ts | 10 +- .../v1/secretScanningController.ts | 10 +- .../src/controllers/v1/workspaceController.ts | 4 +- .../controllers/v2/organizationsController.ts | 11 +- .../controllers/v1/organizationsController.ts | 34 ++-- .../src/ee/controllers/v1/ssoController.ts | 8 +- backend/src/services/RoleService.ts | 102 +++++----- .../context/OrgPermissionContext/index.tsx | 2 +- .../src/context/OrgPermissionContext/types.ts | 20 +- frontend/src/context/index.tsx | 2 +- frontend/src/pages/org/[id]/billing/index.tsx | 4 +- .../src/pages/org/[id]/overview/index.tsx | 184 +++++++++--------- .../pages/org/[id]/secret-scanning/index.tsx | 6 +- .../src/views/Org/MembersPage/MembersPage.tsx | 4 +- .../OrgMembersTable/OrgMembersTable.tsx | 8 +- .../components/RiskStatusSelection.tsx | 4 +- .../BillingCloudTab/PreviewSection.tsx | 6 +- .../BillingDetailsTab/CompanyNameSection.tsx | 4 +- .../BillingDetailsTab/InvoiceEmailSection.tsx | 4 +- .../BillingDetailsTab/PmtMethodsSection.tsx | 4 +- .../BillingDetailsTab/PmtMethodsTable.tsx | 4 +- .../BillingDetailsTab/TaxIDSection.tsx | 4 +- .../BillingDetailsTab/TaxIDTable.tsx | 4 +- .../BillingTabGroup/BillingTabGroup.tsx | 4 +- .../components/OrgAuthTab/OrgAuthTab.tsx | 4 +- .../components/OrgAuthTab/OrgSSOSection.tsx | 6 +- .../OrgIncidentContactsSection.tsx | 6 +- .../OrgIncidentContactsTable.tsx | 4 +- .../OrgNameChangeSection.tsx | 6 +- .../OrgServiceAccountsTable.tsx | 4 +- 32 files changed, 252 insertions(+), 252 deletions(-) diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 9bc7e243b..6e32fd662 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -19,7 +19,7 @@ import { import { validateUserEmail } from "../../validation"; import { validateRequest } from "../../helpers/validation"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, getUserOrgPermissions } from "../../services/RoleService"; @@ -50,7 +50,7 @@ export const deleteMembershipOrg = async (req: Request, _res: Response) => { membershipOrgToDelete.organization.toString() ); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.Member ); @@ -98,7 +98,7 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Member ); diff --git a/backend/src/controllers/v1/organizationController.ts b/backend/src/controllers/v1/organizationController.ts index 4bad1db25..3386c8767 100644 --- a/backend/src/controllers/v1/organizationController.ts +++ b/backend/src/controllers/v1/organizationController.ts @@ -14,9 +14,8 @@ import { licenseServerKeyRequest } from "../../config/request"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/organization"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, - WorkspacePermissionActions, getUserOrgPermissions } from "../../services/RoleService"; import { OrganizationNotFoundError } from "../../utils/errors"; @@ -104,7 +103,7 @@ export const getOrganizationMembers = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Member ); @@ -130,7 +129,7 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - WorkspacePermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Workspace ); @@ -172,7 +171,7 @@ export const changeOrganizationName = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Settings ); @@ -207,7 +206,7 @@ export const getOrganizationIncidentContacts = async (req: Request, res: Respons const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount ); @@ -234,7 +233,7 @@ export const addOrganizationIncidentContact = async (req: Request, res: Response const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount ); @@ -263,7 +262,7 @@ export const deleteOrganizationIncidentContact = async (req: Request, res: Respo const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount ); @@ -292,7 +291,7 @@ export const createOrganizationPortalSession = async (req: Request, res: Respons const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Billing ); @@ -356,11 +355,11 @@ export const getOrganizationMembersAndTheirWorkspaces = async (req: Request, res const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Member ); ForbiddenError.from(permission).throwUnlessCan( - WorkspacePermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Workspace ); diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index cf1b1a600..f5d50d22f 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { BadRequestError } from "../../utils/errors"; import Role from "../../models/role"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, adminPermissions, getUserOrgPermissions, @@ -30,7 +30,7 @@ export const createRole = async (req: Request, res: Response) => { } = await validateRequest(CreateRoleSchema, req); const { permission } = await getUserOrgPermissions(req.user.id, orgId); - if (permission.cannot(GeneralPermissionActions.Create, OrgPermissionSubjects.Role)) { + if (permission.cannot(OrgPermissionActions.Create, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } @@ -68,7 +68,7 @@ export const updateRole = async (req: Request, res: Response) => { const isOrgRole = !workspaceId; // if workspaceid is provided then its a workspace rule const { permission } = await getUserOrgPermissions(req.user.id, orgId); - if (permission.cannot(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role)) { + if (permission.cannot(OrgPermissionActions.Edit, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } @@ -112,7 +112,7 @@ export const deleteRole = async (req: Request, res: Response) => { } const { permission } = await getUserOrgPermissions(req.user.id, role.organization.toString()); - if (permission.cannot(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role)) { + if (permission.cannot(OrgPermissionActions.Delete, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } await Role.findByIdAndDelete(role.id); @@ -132,7 +132,7 @@ export const getRoles = async (req: Request, res: Response) => { const isOrgRole = !workspaceId; const { permission } = await getUserOrgPermissions(req.user.id, orgId); - if (permission.cannot(GeneralPermissionActions.Read, OrgPermissionSubjects.Role)) { + if (permission.cannot(OrgPermissionActions.Read, OrgPermissionSubjects.Role)) { throw BadRequestError({ message: "User doesn't have the permission." }); } diff --git a/backend/src/controllers/v1/secretScanningController.ts b/backend/src/controllers/v1/secretScanningController.ts index bb0403b3b..df2fe173a 100644 --- a/backend/src/controllers/v1/secretScanningController.ts +++ b/backend/src/controllers/v1/secretScanningController.ts @@ -12,7 +12,7 @@ import { Organization } from "../../models"; import { validateRequest } from "../../helpers/validation"; import * as reqValidator from "../../validation/secretScanning"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, getUserOrgPermissions } from "../../services/RoleService"; @@ -33,7 +33,7 @@ export const createInstallationSession = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning ); @@ -69,7 +69,7 @@ export const linkInstallationToOrganization = async (req: Request, res: Response installationSession.organization.toString() ); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning ); @@ -131,7 +131,7 @@ export const getRisksForOrganization = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning ); @@ -151,7 +151,7 @@ export const updateRisksStatus = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning ); diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 29ed27297..1b5787671 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -15,8 +15,8 @@ import { addMemberships } from "../../helpers/membership"; import { ADMIN } from "../../variables"; import { OrganizationNotFoundError } from "../../utils/errors"; import { + OrgPermissionActions, OrgPermissionSubjects, - WorkspacePermissionActions, getUserOrgPermissions } from "../../services/RoleService"; import { ForbiddenError } from "@casl/ability"; @@ -146,7 +146,7 @@ export const createWorkspace = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - WorkspacePermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Workspace ); diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index d74ac27ba..2044183af 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -9,9 +9,8 @@ import { CUSTOM } from "../../variables"; import * as reqValidator from "../../validation/organization"; import { validateRequest } from "../../helpers/validation"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, - WorkspacePermissionActions, getUserOrgPermissions } from "../../services/RoleService"; import { ForbiddenError } from "@casl/ability"; @@ -61,7 +60,7 @@ export const getOrganizationMemberships = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Member ); @@ -139,7 +138,7 @@ export const updateOrganizationMembership = async (req: Request, res: Response) } = await validateRequest(reqValidator.UpdateOrgMemberv2, req); const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Member ); @@ -220,7 +219,7 @@ export const deleteOrganizationMembership = async (req: Request, res: Response) } = await validateRequest(reqValidator.DeleteOrgMemberv2, req); const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.Member ); @@ -284,7 +283,7 @@ export const getOrganizationWorkspaces = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - WorkspacePermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Workspace ); diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts index 6bc8b2533..1811a46f3 100644 --- a/backend/src/ee/controllers/v1/organizationsController.ts +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -6,7 +6,7 @@ import { EELicenseService } from "../../services"; import { validateRequest } from "../../../helpers/validation"; import * as reqValidator from "../../../validation/organization"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, getUserOrgPermissions } from "../../../services/RoleService"; @@ -22,7 +22,7 @@ export const getOrganizationPlansTable = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -44,7 +44,7 @@ export const getOrganizationPlan = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -72,11 +72,11 @@ export const startOrganizationTrial = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Billing ); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Billing ); @@ -118,7 +118,7 @@ export const getOrganizationPlanBillingInfo = async (req: Request, res: Response const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -151,7 +151,7 @@ export const getOrganizationPlanTable = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -178,7 +178,7 @@ export const getOrganizationBillingDetails = async (req: Request, res: Response) const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -206,7 +206,7 @@ export const updateOrganizationBillingDetails = async (req: Request, res: Respon const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Billing ); @@ -240,7 +240,7 @@ export const getOrganizationPmtMethods = async (req: Request, res: Response) => const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -273,7 +273,7 @@ export const addOrganizationPmtMethod = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Billing ); @@ -314,7 +314,7 @@ export const deleteOrganizationPmtMethod = async (req: Request, res: Response) = const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.Billing ); @@ -344,7 +344,7 @@ export const getOrganizationTaxIds = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -377,7 +377,7 @@ export const addOrganizationTaxId = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Billing ); @@ -414,7 +414,7 @@ export const deleteOrganizationTaxId = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Delete, + OrgPermissionActions.Delete, OrgPermissionSubjects.Billing ); @@ -447,7 +447,7 @@ export const getOrganizationInvoices = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); @@ -482,7 +482,7 @@ export const getOrganizationLicenses = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Billing ); diff --git a/backend/src/ee/controllers/v1/ssoController.ts b/backend/src/ee/controllers/v1/ssoController.ts index 3e83711d6..bacc6befc 100644 --- a/backend/src/ee/controllers/v1/ssoController.ts +++ b/backend/src/ee/controllers/v1/ssoController.ts @@ -11,7 +11,7 @@ import { EELicenseService } from "../../services"; import * as reqValidator from "../../../validation/sso"; import { validateRequest } from "../../../helpers/validation"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, getUserOrgPermissions } from "../../../services/RoleService"; @@ -49,7 +49,7 @@ export const getSSOConfig = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Read, + OrgPermissionActions.Read, OrgPermissionSubjects.Sso ); @@ -73,7 +73,7 @@ export const updateSSOConfig = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Edit, + OrgPermissionActions.Edit, OrgPermissionSubjects.Sso ); @@ -208,7 +208,7 @@ export const createSSOConfig = async (req: Request, res: Response) => { const { permission } = await getUserOrgPermissions(req.user._id, organizationId); ForbiddenError.from(permission).throwUnlessCan( - GeneralPermissionActions.Create, + OrgPermissionActions.Create, OrgPermissionSubjects.Sso ); diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 178ab7c90..648128246 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -4,18 +4,13 @@ import { IRole } from "../models/role"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; import { ACCEPTED } from "../variables"; -export enum GeneralPermissionActions { +export enum OrgPermissionActions { Read = "read", Create = "create", Edit = "edit", Delete = "delete" } -export enum WorkspacePermissionActions { - Read = "read", - Create = "create" -} - export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -28,55 +23,56 @@ export enum OrgPermissionSubjects { } export type OrgPermissionSet = - | [WorkspacePermissionActions, OrgPermissionSubjects.Workspace] - | [GeneralPermissionActions, OrgPermissionSubjects.Role] - | [GeneralPermissionActions, OrgPermissionSubjects.Member] - | [GeneralPermissionActions, OrgPermissionSubjects.Settings] - | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] - | [GeneralPermissionActions, OrgPermissionSubjects.Sso] - | [GeneralPermissionActions, OrgPermissionSubjects.SecretScanning] - | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionActions, OrgPermissionSubjects.Member] + | [OrgPermissionActions, OrgPermissionSubjects.Settings] + | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] + | [OrgPermissionActions, OrgPermissionSubjects.Billing]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); // ws permissions - can(WorkspacePermissionActions.Read, OrgPermissionSubjects.Workspace); - can(WorkspacePermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); // role permission - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Role); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Role); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Member); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Member); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.SecretScanning); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.SecretScanning); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Sso); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Sso); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); - can(GeneralPermissionActions.Create, OrgPermissionSubjects.Billing); - can(GeneralPermissionActions.Edit, OrgPermissionSubjects.Billing); - can(GeneralPermissionActions.Delete, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); return build(); }; @@ -86,15 +82,15 @@ export const adminPermissions = buildAdminPermission(); const buildMemberPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); - can(WorkspacePermissionActions.Read, OrgPermissionSubjects.Workspace); - can(WorkspacePermissionActions.Create, OrgPermissionSubjects.Workspace); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Member); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Role); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Settings); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Billing); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.Sso); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); - can(GeneralPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); return build(); }; diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index dddbe4ef9..730fe55b1 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -1,3 +1,3 @@ export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; -export { GeneralPermissionActions,OrgPermissionSubjects } from "./types"; +export { OrgPermissionActions, OrgPermissionSubjects } from "./types"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index a7bc6dda1..a4f1ab5ef 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -1,6 +1,6 @@ import { MongoAbility } from "@casl/ability"; -export enum GeneralPermissionActions { +export enum OrgPermissionActions { Read = "read", Create = "create", Edit = "edit", @@ -19,14 +19,14 @@ export enum OrgPermissionSubjects { } export type OrgPermissionSet = - | [GeneralPermissionActions.Create, OrgPermissionSubjects.Workspace] - | [GeneralPermissionActions.Read, OrgPermissionSubjects.Workspace] - | [GeneralPermissionActions, OrgPermissionSubjects.Role] - | [GeneralPermissionActions, OrgPermissionSubjects.Member] - | [GeneralPermissionActions, OrgPermissionSubjects.Settings] - | [GeneralPermissionActions, OrgPermissionSubjects.IncidentAccount] - | [GeneralPermissionActions, OrgPermissionSubjects.Sso] - | [GeneralPermissionActions, OrgPermissionSubjects.SecretScanning] - | [GeneralPermissionActions, OrgPermissionSubjects.Billing]; + | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionActions, OrgPermissionSubjects.Member] + | [OrgPermissionActions, OrgPermissionSubjects.Settings] + | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] + | [OrgPermissionActions, OrgPermissionSubjects.Billing]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 491fa2439..35c39f533 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,7 +2,7 @@ export { AuthProvider } from "./AuthContext"; export { OrgProvider, useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionProvider, OrgPermissionSubjects, useOrgPermission diff --git a/frontend/src/pages/org/[id]/billing/index.tsx b/frontend/src/pages/org/[id]/billing/index.tsx index f79c210c7..643ad4b30 100644 --- a/frontend/src/pages/org/[id]/billing/index.tsx +++ b/frontend/src/pages/org/[id]/billing/index.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import Head from "next/head"; -import { GeneralPermissionActions, OrgPermissionSubjects, TOrgPermission } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, TOrgPermission } from "@app/context"; import { withPermission } from "@app/hoc"; import { BillingSettingsPage } from "@app/views/Settings/BillingSettingsPage"; @@ -20,7 +20,7 @@ const SettingsBilling = withPermission<{}, TOrgPermission>(
); }, - { action: GeneralPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } + { action: OrgPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } ); Object.assign(SettingsBilling, { requireAuth: true }); diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 3e0eb1f69..ea72be394 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -16,8 +16,8 @@ import { faArrowUpRightFromSquare, faCheck, faCheckCircle, - faExclamationCircle, faClipboard, + faExclamationCircle, faHandPeace, faMagnifyingGlass, faNetworkWired, @@ -43,9 +43,8 @@ import { Skeleton, UpgradePlanModal } from "@app/components/v2"; -import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, useSubscription, useUser, @@ -59,6 +58,7 @@ import { useRegisterUserAction, useUploadWsKey } from "@app/hooks/api"; +import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; import { usePopUp } from "@app/hooks/usePopUp"; import { encryptAssymmetric } from "../../../../components/utilities/cryptography/crypto"; @@ -473,82 +473,81 @@ const OrganizationPage = withPermission( const { createNotification } = useNotificationContext(); const addWsUser = useAddUserToWs(); - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "addNewWs", - "upgradePlan" - ] as const); - const { - control, - formState: { isSubmitting }, - reset, - handleSubmit - } = useForm({ - resolver: yupResolver(formSchema) - }); + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "addNewWs", + "upgradePlan" + ] as const); + const { + control, + formState: { isSubmitting }, + reset, + handleSubmit + } = useForm({ + resolver: yupResolver(formSchema) + }); - const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false); - const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false); - const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); - const [usersInOrg, setUsersInOrg] = useState(false); - const [searchFilter, setSearchFilter] = useState(""); - const createWs = useCreateWorkspace(); - const { user } = useUser(); - const uploadWsKey = useUploadWsKey(); - const { data: serverDetails } = useFetchServerStatus(); - + const [hasUserClickedSlack, setHasUserClickedSlack] = useState(false); + const [hasUserClickedIntro, setHasUserClickedIntro] = useState(false); + const [hasUserPushedSecrets, setHasUserPushedSecrets] = useState(false); + const [usersInOrg, setUsersInOrg] = useState(false); + const [searchFilter, setSearchFilter] = useState(""); + const createWs = useCreateWorkspace(); + const { user } = useUser(); + const uploadWsKey = useUploadWsKey(); + const { data: serverDetails } = useFetchServerStatus(); - const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { - // type check - if (!currentOrg) return; - try { - const { - data: { - workspace: { _id: newWorkspaceId } - } - } = await createWs.mutateAsync({ - organizationId: currentOrg, - workspaceName: name - }); - - const randomBytes = crypto.randomBytes(16).toString("hex"); - const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY")); - const { ciphertext, nonce } = encryptAssymmetric({ - plaintext: randomBytes, - publicKey: user.publicKey, - privateKey: PRIVATE_KEY - }); - - await uploadWsKey.mutateAsync({ - encryptedKey: ciphertext, - nonce, - userId: user?._id, - workspaceId: newWorkspaceId - }); - - if (addMembers) { - // not using hooks because need at this point only - const orgUsers = await fetchOrgUsers(currentOrg); - orgUsers.forEach(({ status, user: orgUser }) => { - // skip if status of org user is not accepted - // this orgUser is the person who created the ws - if (status !== "accepted" || user.email === orgUser.email) return; - addWsUser.mutate({ email: orgUser.email, workspaceId: newWorkspaceId }); + const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { + // type check + if (!currentOrg) return; + try { + const { + data: { + workspace: { _id: newWorkspaceId } + } + } = await createWs.mutateAsync({ + organizationId: currentOrg, + workspaceName: name }); + + const randomBytes = crypto.randomBytes(16).toString("hex"); + const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY")); + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: randomBytes, + publicKey: user.publicKey, + privateKey: PRIVATE_KEY + }); + + await uploadWsKey.mutateAsync({ + encryptedKey: ciphertext, + nonce, + userId: user?._id, + workspaceId: newWorkspaceId + }); + + if (addMembers) { + // not using hooks because need at this point only + const orgUsers = await fetchOrgUsers(currentOrg); + orgUsers.forEach(({ status, user: orgUser }) => { + // skip if status of org user is not accepted + // this orgUser is the person who created the ws + if (status !== "accepted" || user.email === orgUser.email) return; + addWsUser.mutate({ email: orgUser.email, workspaceId: newWorkspaceId }); + }); + } + createNotification({ text: "Workspace created", type: "success" }); + handlePopUpClose("addNewWs"); + router.push(`/project/${newWorkspaceId}/secrets/overview`); + } catch (err) { + console.error(err); + createNotification({ text: "Failed to create workspace", type: "error" }); } - createNotification({ text: "Workspace created", type: "success" }); - handlePopUpClose("addNewWs"); - router.push(`/project/${newWorkspaceId}/secrets/overview`); - } catch (err) { - console.error(err); - createNotification({ text: "Failed to create workspace", type: "error" }); - } - }; + }; - const { subscription } = useSubscription(); + const { subscription } = useSubscription(); - const isAddingProjectsAllowed = subscription?.workspaceLimit - ? subscription.workspacesUsed < subscription.workspaceLimit - : true; + const isAddingProjectsAllowed = subscription?.workspaceLimit + ? subscription.workspacesUsed < subscription.workspaceLimit + : true; useEffect(() => { onboardingCheck({ @@ -567,18 +566,28 @@ const OrganizationPage = withPermission( {t("common.head-title", { title: t("settings.members.title") })} - {!serverDetails?.redisConfigured &&
-

Announcements

-
- - Attention: Updated versions of Infisical now require Redis for full functionality. Learn how to configure it - - - here - - . + {!serverDetails?.redisConfigured && ( +
+

Announcements

+
+ + Attention: Updated versions of Infisical now require Redis for full functionality. + Learn how to configure it + + + here + + + . +
-
} + )}

Projects

@@ -589,10 +598,7 @@ const OrganizationPage = withPermission( onChange={(e) => setSearchFilter(e.target.value)} leftIcon={} /> - + {(isAllowed) => (
); }, - { action: GeneralPermissionActions.Read, subject: OrgPermissionSubjects.SecretScanning } + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.SecretScanning } ); Object.assign(SecretScanning, { requireAuth: true }); diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index a57d2f235..57b69c5b7 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { GeneralPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { withPermission } from "@app/hoc"; import { useGetRoles } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; @@ -59,5 +59,5 @@ export const MembersPage = withPermission(
); }, - { action: GeneralPermissionActions.Read, subject: OrgPermissionSubjects.Member } + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Member } ); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index 608268de7..459d5f47a 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -43,7 +43,7 @@ import { UpgradePlanModal } from "@app/components/v2"; import { - GeneralPermissionActions, + OrgPermissionActions, OrgPermissionSubjects, useOrganization, useSubscription, @@ -305,7 +305,7 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { placeholder="Search members..." />
- + {(isAllowed) => (
-
- - {isCustom && - PERMISSIONS.map(({ action, label }) => ( - ( - - {label} - - )} - /> - ))} - -
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx deleted file mode 100644 index de758b480..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/MemberPermission.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; -import { faUsers } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; -import { twMerge } from "tailwind-merge"; - -import { Checkbox, Select, SelectItem } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -import { TFormSchema } from "./OrgRoleModifySection.utils"; - -type Props = { - isNonEditable?: boolean; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -const PERMISSIONS = [ - { action: "read", label: "Read" }, - { action: "create", label: "Invite" }, - { action: "edit", label: "Update" }, - { action: "delete", label: "Remove" } -] as const; - -export const MemberPermission = ({ isNonEditable, setValue, control }: Props) => { - const rule = useWatch({ - control, - name: "permissions.member" - }); - const [isCustom, setIsCustom] = useToggle(); - - const selectedPermissionCategory = useMemo(() => { - const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; - const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); - - if (isCustom) return Permission.Custom; - if (score === 0) return Permission.NoAccess; - if (score === totalActions) return Permission.FullAccess; - if (score === 1 && rule.read) return Permission.ReadOnly; - - return Permission.Custom; - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - const handlePermissionChange = (val: Permission) => { - if (val === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - - switch (val) { - case Permission.NoAccess: - setValue( - "permissions.member", - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - case Permission.FullAccess: - setValue( - "permissions.member", - { read: true, edit: true, create: true, delete: true }, - { shouldDirty: true } - ); - break; - case Permission.ReadOnly: - setValue( - "permissions.member", - { read: true, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - default: - setValue( - "permissions.member", - { read: false, edit: false, create: false, delete: false }, - { shouldDirty: true } - ); - break; - } - }; - - return ( -
-
-
- -
-
-
Members
-
Project member management control
-
-
- -
-
- - {isCustom && - PERMISSIONS.map(({ action, label }) => ( - ( - - {label} - - )} - /> - ))} - -
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index f0e1037f3..8c648c532 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -1,6 +1,15 @@ import { useState } from "react"; import { useForm } from "react-hook-form"; -import { faArrowLeft, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowLeft, + faCog, + faContactCard, + faMagnifyingGlass, + faMoneyBill, + faSignIn, + faUserCog, + faUsers +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -10,19 +19,13 @@ import { useOrganization } from "@app/context"; import { useCreateRole, useUpdateRole } from "@app/hooks/api"; import { TRole } from "@app/hooks/api/roles/types"; -import { BillingPermission } from "./BillingPermission"; -import { IncidentContactPermission } from "./IncidentContactPermission"; -import { MemberPermission } from "./MemberPermission"; import { formRolePermission2API, formSchema, rolePermission2Form, TFormSchema } from "./OrgRoleModifySection.utils"; -import { RolePermission } from "./RolePermission"; -import { SecretScannigPermission } from "./SecretScanningPermission"; -import { SettingsPermission } from "./SettingsPermission"; -import { SsoPermission } from "./SsoPermission"; +import { SimpleLevelPermissionOption } from "./SimpleLevelPermissionOptions"; import { WorkspacePermission } from "./WorkspacePermission"; type Props = { @@ -30,6 +33,51 @@ type Props = { onGoBack: VoidFunction; }; +const SIMPLE_PERMISSION_OPTIONS = [ + { + title: "Members", + subtitle: "Project member management control", + icon: faUsers, + formName: "member" + }, + { + title: "Billing", + subtitle: "Billing management control", + icon: faMoneyBill, + formName: "billing" + }, + { + title: "Role", + subtitle: "Org role management control", + icon: faUserCog, + formName: "role" + }, + { + title: "Incident Contacts", + subtitle: "Incident contacts management control", + icon: faContactCard, + formName: "incident-contact" + }, + { + title: "Settings", + subtitle: "Settings management control", + icon: faCog, + formName: "settings" + }, + { + title: "Secret Scanning", + subtitle: "Secret scanning management control", + icon: faMagnifyingGlass, + formName: "secret-scanning" + }, + { + title: "SSO", + subtitle: "SSO management control", + icon: faSignIn, + formName: "sso" + } +] as const; + export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { const [searchPermission, setSearchPermission] = useState(""); @@ -148,50 +196,26 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => { />
-
+
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
+ {SIMPLE_PERMISSION_OPTIONS.map(({ title, subtitle, icon, formName }) => ( +
+ +
+ ))}
- ); -}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx index 8e1abc87f..2fb6f9374 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx @@ -24,7 +24,7 @@ enum Permission { } const PERMISSIONS = [ - { action: "read", label: "Read" }, + { action: "read", label: "View" }, { action: "create", label: "Create" } ] as const; @@ -38,12 +38,12 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) const selectedPermissionCategory = useMemo(() => { const actions = Object.keys(rule || {}) as Array; const totalActions = PERMISSIONS.length; - const score = actions.map((key) => (rule[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; if (score === 0) return Permission.NoAccess; if (score === totalActions) return Permission.FullAccess; - if (score === 1 && rule.read) return Permission.ReadOnly; + if (score === 1 && rule?.read) return Permission.ReadOnly; return Permission.Custom; }, [rule, isCustom]); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx index 0700724d2..780c50bd2 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -135,9 +135,9 @@ export const MultiEnvProjectPermission = ({ Secret Path - Read + View Create - Edit + Modify Delete diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index f2dbc40fe..26b6c4104 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -260,7 +260,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { />
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => ( -
+
{ permissions.forEach((permission) => { if (["secrets", "folders", "secret-imports"].includes(permission.subject)) { - multiEnvApi2Form(formVal[permission.subject], permission); + multiEnvApi2Form( + formVal[permission.subject] as TFormSchema["permissions"]["secrets"], + permission + ); } else { // everything else follows same pattern // formVal[settings][read | write] = true @@ -135,7 +138,7 @@ const multiEnvForm2Api = ( // if not full access for an action if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { const conditions: Record = { environment: slug }; - if (formVal[slug]?.secretPath) conditions.secretPath = formVal[slug].secretPath; + if (formVal[slug]?.secretPath) conditions.secretPath = formVal?.[slug]?.secretPath; permissions.push({ action, subject, conditions }); } diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index 8ff81be4f..0adc5974a 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -38,9 +38,9 @@ enum Permission { } const PERMISSIONS = [ - { action: "read", label: "Read" }, + { action: "read", label: "View" }, { action: "create", label: "Create" }, - { action: "edit", label: "Update" }, + { action: "edit", label: "Modify" }, { action: "delete", label: "Remove" } ] as const; diff --git a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx index 4be813183..0edadfe91 100644 --- a/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx +++ b/frontend/src/views/SecretOverviewPage/SecretOverviewPage.tsx @@ -42,7 +42,6 @@ import { useDeleteSecretV3, useGetFoldersByEnv, useGetProjectSecretsAllEnv, - useGetUserWsEnvironments, useGetUserWsKey, useUpdateSecretV3 } from "@app/hooks/api"; From 6bbdc4a405895118ca9dd49f2485a450a91fa8ce Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 4 Sep 2023 20:59:00 +0530 Subject: [PATCH 21/40] feat(rbac): fixed broken invite and role missing in dropdown --- .../controllers/v1/membershipController.ts | 20 ++++++++++++++----- .../src/controllers/v3/secretsController.ts | 6 +++--- backend/src/routes/v1/workspace.ts | 14 +++---------- backend/src/validation/auth.ts | 2 +- backend/src/validation/secrets.ts | 6 ++++-- .../src/views/Org/MembersPage/MembersPage.tsx | 12 ++++++++--- .../OrgMembersTable/OrgMembersTable.tsx | 5 +++-- .../views/Project/MembersPage/MembersPage.tsx | 2 +- .../MemberListTab/MemberListTab.tsx | 5 +++-- 9 files changed, 42 insertions(+), 30 deletions(-) diff --git a/backend/src/controllers/v1/membershipController.ts b/backend/src/controllers/v1/membershipController.ts index 49a169f8a..bc2dc0e8d 100644 --- a/backend/src/controllers/v1/membershipController.ts +++ b/backend/src/controllers/v1/membershipController.ts @@ -1,6 +1,6 @@ import { Request, Response } from "express"; import { Types } from "mongoose"; -import { IUser, Key, Membership, MembershipOrg, User } from "../../models"; +import { IUser, Key, Membership, MembershipOrg, User, Workspace } from "../../models"; import { EventType } from "../../ee/models"; import { deleteMembership as deleteMember, findMembership } from "../../helpers/membership"; import { sendMail } from "../../helpers/nodemailer"; @@ -17,6 +17,7 @@ import { import { ForbiddenError } from "@casl/ability"; import Role from "../../models/role"; import { BadRequestError } from "../../utils/errors"; +import { InviteUserToWorkspaceV1 } from "../../validation/workspace"; /** * Check that user is a member of workspace with id [workspaceId] @@ -182,8 +183,15 @@ export const changeMembershipRole = async (req: Request, res: Response) => { * @returns */ export const inviteUserToWorkspace = async (req: Request, res: Response) => { - const { workspaceId } = req.params; - const { email }: { email: string } = req.body; + const { + params: { workspaceId }, + body: { email } + } = await validateRequest(InviteUserToWorkspaceV1, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.Member + ); const invitee = await User.findOne({ email @@ -200,11 +208,13 @@ export const inviteUserToWorkspace = async (req: Request, res: Response) => { if (inviteeMembership) throw new Error("Failed to add existing member of workspace"); + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw new Error("Failed to find workspace"); // validate invitee's organization membership - ensure that only // (accepted) organization members can be added to the workspace const membershipOrg = await MembershipOrg.findOne({ user: invitee._id, - organization: req.membership.workspace.organization, + organization: workspace.organization, status: ACCEPTED }); @@ -232,7 +242,7 @@ export const inviteUserToWorkspace = async (req: Request, res: Response) => { substitutions: { inviterFirstName: req.user.firstName, inviterEmail: req.user.email, - workspaceName: req.membership.workspace.name, + workspaceName: workspace.name, callback_url: (await getSiteURL()) + "/login" } }); diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index ca94bc9ce..e2b405483 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -368,7 +368,7 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => { */ export const getSecrets = async (req: Request, res: Response) => { const { - query: { secretPath, environment, workspaceId, include_imports: includeImports,folderId } + query: { secretPath, environment, workspaceId, include_imports: includeImports, folderId } } = await validateRequest(reqValidator.GetSecretsV3, req); if (req.user?._id) { @@ -468,7 +468,6 @@ export const createSecret = async (req: Request, res: Response) => { const { body: { workspaceId, - secretName, secretPath, environment, metadata, @@ -482,7 +481,8 @@ export const createSecret = async (req: Request, res: Response) => { secretKeyCiphertext, secretValueCiphertext, secretCommentCiphertext - } + }, + params: { secretName } } = await validateRequest(reqValidator.CreateSecretV3, req); if (req.user?._id) { diff --git a/backend/src/routes/v1/workspace.ts b/backend/src/routes/v1/workspace.ts index c8acc1846..b6a28819a 100644 --- a/backend/src/routes/v1/workspace.ts +++ b/backend/src/routes/v1/workspace.ts @@ -1,8 +1,7 @@ import express from "express"; const router = express.Router(); -import { body, param } from "express-validator"; -import { requireAuth, requireWorkspaceAuth, validateRequest } from "../../middleware"; -import { ADMIN, AuthMode, MEMBER } from "../../variables"; +import { requireAuth } from "../../middleware"; +import { AuthMode } from "../../variables"; import { membershipController, workspaceController } from "../../controllers/v1"; router.get( @@ -42,7 +41,7 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - workspaceController.deleteWorkspace + workspaceController.createWorkspace ); router.post( @@ -50,13 +49,6 @@ router.post( requireAuth({ acceptedAuthModes: [AuthMode.JWT] }), - requireWorkspaceAuth({ - acceptedRoles: [ADMIN, MEMBER], - locationWorkspaceId: "params" - }), - param("workspaceId").exists().trim(), - body("name").exists().trim().notEmpty(), - validateRequest, workspaceController.changeWorkspaceName ); diff --git a/backend/src/validation/auth.ts b/backend/src/validation/auth.ts index e9ffec8de..f32fc1197 100644 --- a/backend/src/validation/auth.ts +++ b/backend/src/validation/auth.ts @@ -9,7 +9,7 @@ export const BeginEmailSignUpV1 = z.object({ export const VerifyEmailSignUpV1 = z.object({ body: z.object({ email: z.string().email().trim(), - code: z.string().email().trim() + code: z.string().trim() }) }); diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 0e733112f..c4529039b 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -228,7 +228,7 @@ export const GetSecretsRawV3 = z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/"), - folderId:z.string().trim().optional(), + folderId: z.string().trim().optional(), include_imports: z .enum(["true", "false"]) .default("false") @@ -319,7 +319,6 @@ export const CreateSecretV3 = z.object({ environment: z.string().trim(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]), secretPath: z.string().trim().default("/"), - secretName: z.string().trim(), secretKeyCiphertext: z.string().trim(), secretKeyIV: z.string().trim(), secretKeyTag: z.string().trim(), @@ -332,6 +331,9 @@ export const CreateSecretV3 = z.object({ metadata: z.object({ source: z.string() }) + }), + params: z.object({ + secretName: z.string().trim() }) }); diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index 57b69c5b7..9e7855e53 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -23,7 +23,7 @@ export const MembersPage = withPermission( const orgId = currentOrg?._id || ""; - const { data: roles } = useGetRoles({ + const { data: roles, isLoading: isRolesLoading } = useGetRoles({ orgId }); @@ -48,11 +48,17 @@ export const MembersPage = withPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - []} /> + []} + isRolesLoading={isRolesLoading} + /> - []} /> + []} + isRolesLoading={isRolesLoading} + />
diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx index 459d5f47a..c08a1cf1e 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -66,6 +66,7 @@ import { useFetchServerStatus } from "@app/hooks/api/serverDetails"; type Props = { roles?: TRole[]; + isRolesLoading?: boolean; }; const addMemberFormSchema = yup.object({ @@ -74,7 +75,7 @@ const addMemberFormSchema = yup.object({ type TAddMemberForm = yup.InferType; -export const OrgMembersTable = ({ roles = [] }: Props) => { +export const OrgMembersTable = ({ roles = [], isRolesLoading }: Props) => { const router = useRouter(); const { createNotification } = useNotificationContext(); @@ -292,7 +293,7 @@ export const OrgMembersTable = ({ roles = [] }: Props) => { setInviteLinkCopied.on(); }; - const isLoading = isMembersLoading || IsWsMembershipLoading; + const isLoading = isMembersLoading || IsWsMembershipLoading || isRolesLoading; return (
diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index 3c6a1f680..4c83e087f 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -47,7 +47,7 @@ export const MembersPage = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > - []} /> + []} isRolesLoading={isRolesLoading} /> diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index bcf272607..3dddd4ef8 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -54,6 +54,7 @@ import { TRole } from "@app/hooks/api/roles/types"; type Props = { roles?: TRole[]; + isRolesLoading?: boolean; }; const addMemberFormSchema = z.object({ @@ -62,7 +63,7 @@ const addMemberFormSchema = z.object({ type TAddMemberForm = z.infer; -export const MemberListTab = ({ roles = [] }: Props) => { +export const MemberListTab = ({ roles = [], isRolesLoading }: Props) => { const { createNotification } = useNotificationContext(); const { t } = useTranslation(); @@ -227,7 +228,7 @@ export const MemberListTab = ({ roles = [] }: Props) => { } }; - const isLoading = isMembersLoading; + const isLoading = isMembersLoading || isRolesLoading; return (
From 26028e73124c0c0f14df70cea68a872a5a3785a6 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 5 Sep 2023 12:25:06 +0530 Subject: [PATCH 22/40] feat(rbac): fixed broken view when clicking editing predefined role --- backend/src/controllers/v1/roleController.ts | 31 ++-- backend/src/services/ProjectRoleService.ts | 2 +- .../OrgRoleModifySection.utils.ts | 20 +-- .../ProjectRoleModifySection.tsx | 18 ++- .../ProjectRoleModifySection.utils.ts | 44 +++--- .../SecretRollbackPermission.tsx | 146 ++++++++++++++++++ .../WsProjectPermission.tsx | 2 - 7 files changed, 202 insertions(+), 61 deletions(-) create mode 100644 frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index e290c6a6e..4eb304e6c 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -1,14 +1,4 @@ import { Request, Response } from "express"; -import { BadRequestError } from "../../utils/errors"; -import Role from "../../models/role"; -import { - OrgPermissionActions, - OrgPermissionSubjects, - adminPermissions, - getUserOrgPermissions, - memberPermissions -} from "../../services/RoleService"; -import { validateRequest } from "../../helpers/validation"; import { CreateRoleSchema, DeleteRoleSchema, @@ -17,12 +7,23 @@ import { GetUserProjectPermission, UpdateRoleSchema } from "../../validation"; -import { packRules } from "@casl/ability/extra"; import { adminProjectPermissions, getUserProjectPermissions, + memberProjectPermissions, viewerProjectPermission } from "../../services/ProjectRoleService"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + adminPermissions, + getUserOrgPermissions, + memberPermissions +} from "../../services/RoleService"; +import { BadRequestError } from "../../utils/errors"; +import Role from "../../models/role"; +import { validateRequest } from "../../helpers/validation"; +import { packRules } from "@casl/ability/extra"; export const createRole = async (req: Request, res: Response) => { const { @@ -137,7 +138,9 @@ export const getRoles = async (req: Request, res: Response) => { } const customRoles = await Role.find({ organization: orgId, isOrgRole, workspace: workspaceId }); + // as this is shared between org and workspace switch the rule set based on it const roles = [ + // owner is only in org level role ...(isOrgRole ? [ { @@ -161,8 +164,9 @@ export const getRoles = async (req: Request, res: Response) => { name: "Member", slug: "member", description: "Non-administrative role in an organization", - permissions: isOrgRole ? memberPermissions.rules : adminProjectPermissions.rules + permissions: isOrgRole ? memberPermissions.rules : memberProjectPermissions.rules }, + // viewer role only for project level ...(isOrgRole ? [] : [ @@ -171,7 +175,7 @@ export const getRoles = async (req: Request, res: Response) => { name: "Viewer", slug: "viewer", description: "Non-administrative role in an organization", - permissions: isOrgRole ? viewerProjectPermission.rules : viewerProjectPermission.rules + permissions: viewerProjectPermission.rules } ]), ...customRoles @@ -203,6 +207,7 @@ export const getUserWorkspacePermissions = async (req: Request, res: Response) = params: { workspaceId } } = await validateRequest(GetUserProjectPermission, req); const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + res.status(200).json({ data: { permissions: packRules(permission.rules) diff --git a/backend/src/services/ProjectRoleService.ts b/backend/src/services/ProjectRoleService.ts index 9a86c048e..1c16518f7 100644 --- a/backend/src/services/ProjectRoleService.ts +++ b/backend/src/services/ProjectRoleService.ts @@ -142,7 +142,7 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace); - can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace); return build(); }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index d9e109e79..05e29e96d 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -38,23 +38,15 @@ export type TFormSchema = z.infer; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TPermission[] = []) => { - const formVal: TFormSchema["permissions"] = { - workspace: {}, - billing: {}, - settings: {}, - role: {}, - sso: {}, - member: {}, - "service-account": {}, - "incident-contact": {}, - "secret-scanning": {} - }; + const formVal: Partial = {}; permissions.forEach((permission) => { + const { subject, action } = permission; + if (!formVal?.[subject]) formVal[subject] = {}; + // akhilmhdh: this is typecast as something other than workspace key else i would need an if loop with same condition on both side - const key = permission.subject as keyof TFormSchema["permissions"]; - (formVal[key] as Exclude)[permission.action] = - true; + const key = subject as keyof TFormSchema["permissions"]; + (formVal[key] as Exclude)[action] = true; }); return formVal; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 26b6c4104..b5f1d7254 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -33,6 +33,7 @@ import { rolePermission2Form, TFormSchema } from "./ProjectRoleModifySection.utils"; +import { SecretRollbackPermission } from "./SecretRollbackPermission"; import { SingleProjectPermission } from "./SingleProjectPermission"; import { WsProjectPermission } from "./WsProjectPermission"; @@ -226,7 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { />
-
+
{ formName="secrets" />
-
+
{ formName="folders" />
-
+
{ formName="secret-imports" />
+
+ +
{SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => (
{ />
))} -
- + { - const formVal: TFormSchema["permissions"] = { - secrets: {}, - folders: {}, - integrations: {}, - settings: {}, - role: {}, - member: {}, - "service-tokens": {}, - workspace: {}, - environments: {}, - tags: {}, - webhooks: {}, - "audit-logs": {}, - "ip-allowlist": {}, - "secret-imports": {} - }; + const formVal: Partial = {}; permissions.forEach((permission) => { - if (["secrets", "folders", "secret-imports"].includes(permission.subject)) { - multiEnvApi2Form( - formVal[permission.subject] as TFormSchema["permissions"]["secrets"], - permission - ); + const { subject, action } = permission; + if (!formVal?.[subject]) formVal[subject] = {}; + + if (["secrets", "folders", "secret-imports"].includes(subject)) { + multiEnvApi2Form(formVal[subject] as TFormSchema["permissions"]["secrets"], permission); } else { // everything else follows same pattern // formVal[settings][read | write] = true - const key = permission.subject as keyof Omit< - TFormSchema["permissions"], - "secrets" | "workspace" - >; - formVal[key]![permission.action] = true; + formVal[ + subject as keyof Omit< + TFormSchema["permissions"], + "secrets" | "workspace" | "secret-rollback" + > + ]![action] = true; } }); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx new file mode 100644 index 000000000..9d36b58da --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx @@ -0,0 +1,146 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faPuzzlePiece } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; + +import { Checkbox, Select, SelectItem } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; + +import { TFormSchema } from "./ProjectRoleModifySection.utils"; + +type Props = { + isNonEditable?: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + ReadOnly = "read-only", + FullAccess = "full-acess", + Custom = "custom" +} + +const PERMISSIONS = [ + { action: "create", label: "Perform Rollback" }, + { action: "read", label: "View" } +] as const; + +export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: Props) => { + const rule = useWatch({ + control, + name: "permissions.secret-rollback" + }); + const [isCustom, setIsCustom] = useToggle(); + + const selectedPermissionCategory = useMemo(() => { + const actions = Object.keys(rule || {}) as Array; + const totalActions = PERMISSIONS.length; + const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); + + if (isCustom) return Permission.Custom; + if (score === 0) return Permission.NoAccess; + if (score === totalActions) return Permission.FullAccess; + + return Permission.Custom; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + const handlePermissionChange = (val: Permission) => { + if (val === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + + switch (val) { + case Permission.NoAccess: + setValue( + "permissions.secret-rollback", + { read: false, create: false }, + { shouldDirty: true } + ); + break; + case Permission.FullAccess: + setValue( + "permissions.secret-rollback", + { read: true, create: true }, + { shouldDirty: true } + ); + break; + case Permission.ReadOnly: + setValue( + "permissions.secret-rollback", + { read: true, create: false }, + { shouldDirty: true } + ); + break; + default: + setValue( + "permissions.secret-rollback", + { read: false, create: false }, + { shouldDirty: true } + ); + break; + } + }; + + return ( +
+
+
+ +
+
+
Secret Rollback
+
Secret rollback control actions
+
+
+ +
+
+ + {isCustom && + PERMISSIONS.map(({ action, label }) => ( + ( + + {label} + + )} + /> + ))} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx index 08ce12099..b52848ca4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx @@ -18,7 +18,6 @@ type Props = { enum Permission { NoAccess = "no-access", - ReadOnly = "read-only", FullAccess = "full-acess", Custom = "custom" } @@ -92,7 +91,6 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) onValueChange={handlePermissionChange} > No Access - Read Only Full Access Custom From 4ca95f4d79a68857d9269a06d5bd69c9901b43dd Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 5 Sep 2023 19:58:57 +0530 Subject: [PATCH 23/40] feat(rbac): resolved zod validator issues and trim in frontend --- backend/src/validation/integration.ts | 4 +-- backend/src/validation/role.ts | 34 +++++++++---------- backend/src/validation/secrets.ts | 4 +-- .../OrgRoleModifySection.utils.ts | 6 ++-- .../ProjectRoleModifySection.utils.ts | 8 ++--- 5 files changed, 27 insertions(+), 29 deletions(-) diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts index e1b47b6d5..db8b0ee8b 100644 --- a/backend/src/validation/integration.ts +++ b/backend/src/validation/integration.ts @@ -102,7 +102,7 @@ export const DeleteIntegrationV1 = z.object({ export const ManualSyncV1 = z.object({ body: z.object({ - environment: z.string(), - workspaceId: z.string() + environment: z.string().trim(), + workspaceId: z.string().trim() }) }); diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts index 61252a39d..efc659a23 100644 --- a/backend/src/validation/role.ts +++ b/backend/src/validation/role.ts @@ -2,16 +2,16 @@ import { z } from "zod"; export const CreateRoleSchema = z.object({ body: z.object({ - slug: z.string(), - name: z.string(), - description: z.string().optional(), - workspaceId: z.string().optional(), - orgId: z.string(), + slug: z.string().trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), permissions: z .object({ - subject: z.string(), - action: z.string(), - conditions: z.record(z.union([z.string(), z.number()])).optional() + subject: z.string().trim(), + action: z.string().trim(), + conditions: z.record(z.union([z.string().trim(), z.number()])).optional() }) .array() }) @@ -19,19 +19,19 @@ export const CreateRoleSchema = z.object({ export const UpdateRoleSchema = z.object({ params: z.object({ - id: z.string() + id: z.string().trim() }), body: z.object({ - slug: z.string().optional(), - name: z.string().optional(), - description: z.string().optional(), - workspaceId: z.string().optional(), - orgId: z.string(), + slug: z.string().trim().optional(), + name: z.string().trim().optional(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), permissions: z .object({ - subject: z.string(), - action: z.string(), - conditions: z.record(z.union([z.string(), z.number()])).optional() + subject: z.string().trim(), + action: z.string().trim(), + conditions: z.record(z.union([z.string().trim(), z.number()])).optional() }) .array() .optional() diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index c4529039b..afa77cbf0 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -328,9 +328,7 @@ export const CreateSecretV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - metadata: z.object({ - source: z.string() - }) + metadata: z.record(z.string()).optional(), }), params: z.object({ secretName: z.string().trim() diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index 05e29e96d..d121d4538 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -13,9 +13,9 @@ const generalPermissionSchema = z .optional(); export const formSchema = z.object({ - name: z.string(), - description: z.string().optional(), - slug: z.string(), + name: z.string().trim(), + description: z.string().trim().optional(), + slug: z.string().trim(), permissions: z.object({ workspace: z .object({ diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 9ec0e6469..55ad78bb8 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -14,7 +14,7 @@ const generalPermissionSchema = z const multiEnvPermissionSchema = z .object({ - secretPath: z.string().optional(), + secretPath: z.string().trim().optional(), read: z.boolean().optional(), edit: z.boolean().optional(), delete: z.boolean().optional(), @@ -26,9 +26,9 @@ const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const; const MULTI_ENV_KEY = ["secrets", "folders", "secret-imports"] as const; export const formSchema = z.object({ - name: z.string(), - description: z.string().optional(), - slug: z.string(), + name: z.string().trim(), + description: z.string().trim().optional(), + slug: z.string().trim(), permissions: z.object({ secrets: z.record(multiEnvPermissionSchema).optional(), folders: z.record(multiEnvPermissionSchema).optional(), From 252042fb209325c6debddbd6ee2ddb59886c9d57 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 5 Sep 2023 20:19:51 +0530 Subject: [PATCH 24/40] feat(rbac): resolved viewer permission in list --- backend/src/controllers/v1/roleController.ts | 4 ++-- backend/src/services/ProjectRoleService.ts | 2 +- .../components/ProjectRoleList/ProjectRoleList.tsx | 2 +- .../ProjectRoleModifySection/ProjectRoleModifySection.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/v1/roleController.ts b/backend/src/controllers/v1/roleController.ts index 4eb304e6c..e2e4e30e6 100644 --- a/backend/src/controllers/v1/roleController.ts +++ b/backend/src/controllers/v1/roleController.ts @@ -193,7 +193,7 @@ export const getUserPermissions = async (req: Request, res: Response) => { const { params: { orgId } } = await validateRequest(GetUserPermission, req); - const { permission } = await getUserOrgPermissions(req.user.id, orgId); + const { permission } = await getUserOrgPermissions(req.user._id, orgId); res.status(200).json({ data: { @@ -206,7 +206,7 @@ export const getUserWorkspacePermissions = async (req: Request, res: Response) = const { params: { workspaceId } } = await validateRequest(GetUserProjectPermission, req); - const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); res.status(200).json({ data: { diff --git a/backend/src/services/ProjectRoleService.ts b/backend/src/services/ProjectRoleService.ts index 1c16518f7..5a2d24e00 100644 --- a/backend/src/services/ProjectRoleService.ts +++ b/backend/src/services/ProjectRoleService.ts @@ -225,7 +225,7 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str if (membership.role === "admin") return { permission: adminProjectPermissions, membership }; if (membership.role === "member") return { permission: memberProjectPermissions, membership }; - if (membership.role === "viewer") return { permission: memberProjectPermissions, membership }; + if (membership.role === "viewer") return { permission: viewerProjectPermission, membership }; if (membership.role === "custom") { const permission = createMongoAbility(membership.customRole.permissions); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index c18458127..c8545683d 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -102,7 +102,7 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr {isRolesLoading && } {roles?.map((role) => { const { _id: id, name, createdAt, slug } = role; - const isNonMutatable = ["owner", "admin", "member"].includes(slug); + const isNonMutatable = ["admin", "member", "viewer"].includes(slug); return ( diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index b5f1d7254..61f056258 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -108,7 +108,7 @@ type Props = { export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { const [searchPermission, setSearchPermission] = useState(""); - const isNonEditable = ["owner", "admin", "member"].includes(role?.slug || ""); + const isNonEditable = ["admin", "member", "viewer"].includes(role?.slug || ""); const isNewRole = !role?.slug; const { createNotification } = useNotificationContext(); From 98893a40f140ab412be6195031fa6f631feb79e1 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 6 Sep 2023 17:06:28 +0530 Subject: [PATCH 25/40] feat(rbac): made changes from testing with maidul --- .../src/controllers/v1/workspaceController.ts | 19 +- backend/src/routes/v1/workspace.ts | 8 + .../permissions/OrgPermissionCan.tsx | 10 + .../permissions/ProjectPermissionCan.tsx | 10 + .../src/components/v2/Checkbox/Checkbox.tsx | 2 +- frontend/src/hooks/api/roles/types.ts | 36 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 48 +- frontend/src/pages/org/[id]/billing/index.tsx | 2 +- frontend/src/styles/globals.css | 12 +- .../src/views/DashboardPage/DashboardPage.tsx | 1 - .../SecretDropzone/SecretDropzone.tsx | 668 +++++++++--------- .../OrgRoleModifySection.utils.ts | 60 +- .../SimpleLevelPermissionOptions.tsx | 29 +- .../WorkspacePermission.tsx | 4 +- .../OrgRoleTabSection/OrgRoleTable.tsx | 73 +- .../ProjectRoleList/ProjectRoleList.tsx | 51 +- .../ProjectRoleModifySection.utils.ts | 98 ++- .../WsProjectPermission.tsx | 8 +- .../OrgGeneralTab/OrgGeneralTab.tsx | 2 - 19 files changed, 612 insertions(+), 529 deletions(-) diff --git a/backend/src/controllers/v1/workspaceController.ts b/backend/src/controllers/v1/workspaceController.ts index 1b5787671..6c017d6be 100644 --- a/backend/src/controllers/v1/workspaceController.ts +++ b/backend/src/controllers/v1/workspaceController.ts @@ -191,7 +191,15 @@ export const createWorkspace = async (req: Request, res: Response) => { * @returns */ export const deleteWorkspace = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.DeleteWorkspaceV1, req); + + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.Workspace + ); // delete workspace await deleteWork({ @@ -246,7 +254,14 @@ export const changeWorkspaceName = async (req: Request, res: Response) => { * @returns */ export const getWorkspaceIntegrations = async (req: Request, res: Response) => { - const { workspaceId } = req.params; + const { + params: { workspaceId } + } = await validateRequest(reqValidator.GetWorkspaceIntegrationsV1, req); + const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.Integrations + ); const integrations = await Integration.find({ workspace: workspaceId diff --git a/backend/src/routes/v1/workspace.ts b/backend/src/routes/v1/workspace.ts index b6a28819a..4dbc121b9 100644 --- a/backend/src/routes/v1/workspace.ts +++ b/backend/src/routes/v1/workspace.ts @@ -44,6 +44,14 @@ router.post( workspaceController.createWorkspace ); +router.delete( + "/:workspaceId", + requireAuth({ + acceptedAuthModes: [AuthMode.JWT] + }), + workspaceController.deleteWorkspace +); + router.post( "/:workspaceId/name", requireAuth({ diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index 679fe1069..8d1f3fb7b 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -7,12 +7,18 @@ import { Tooltip } from "../v2"; type Props = { label?: ReactNode; + // this prop is used when there exist already a tooltip as helper text for users + // so when permission is allowed same tooltip will be reused to show helpertext + renderTooltip?: boolean; + allowedLabel?: string; } & BoundCanProps; export const OrgPermissionCan: FunctionComponent = ({ label = "Permission Denied. Kindly contact your org admin", children, passThrough = true, + renderTooltip, + allowedLabel, ...props }) => { const permission = useOrgPermission(); @@ -30,6 +36,10 @@ export const OrgPermissionCan: FunctionComponent = ({ return {finalChild}; } + if (isAllowed && renderTooltip) { + return {finalChild}; + } + if (!isAllowed) return null; return finalChild; diff --git a/frontend/src/components/permissions/ProjectPermissionCan.tsx b/frontend/src/components/permissions/ProjectPermissionCan.tsx index 2371450fb..6c76afde9 100644 --- a/frontend/src/components/permissions/ProjectPermissionCan.tsx +++ b/frontend/src/components/permissions/ProjectPermissionCan.tsx @@ -7,12 +7,18 @@ import { Tooltip } from "../v2"; type Props = { label?: ReactNode; + // this prop is used when there exist already a tooltip as helper text for users + // so when permission is allowed same tooltip will be reused to show helpertext + renderTooltip?: boolean; + allowedLabel?: string; } & BoundCanProps; export const ProjectPermissionCan: FunctionComponent = ({ label = "Permission Denied. Kindly contact your project admin", children, passThrough = true, + renderTooltip, + allowedLabel, ...props }) => { const permission = useProjectPermission(); @@ -30,6 +36,10 @@ export const ProjectPermissionCan: FunctionComponent = ({ return {finalChild}; } + if (isAllowed && renderTooltip) { + return {finalChild}; + } + if (!isAllowed) return null; return finalChild; diff --git a/frontend/src/components/v2/Checkbox/Checkbox.tsx b/frontend/src/components/v2/Checkbox/Checkbox.tsx index 64ec0a54a..8c79a5ff2 100644 --- a/frontend/src/components/v2/Checkbox/Checkbox.tsx +++ b/frontend/src/components/v2/Checkbox/Checkbox.tsx @@ -45,7 +45,7 @@ export const Checkbox = ({ -

- + {(isAllowed) => ( + + )} +
diff --git a/frontend/src/pages/org/[id]/billing/index.tsx b/frontend/src/pages/org/[id]/billing/index.tsx index 643ad4b30..12e71e69a 100644 --- a/frontend/src/pages/org/[id]/billing/index.tsx +++ b/frontend/src/pages/org/[id]/billing/index.tsx @@ -20,7 +20,7 @@ const SettingsBilling = withPermission<{}, TOrgPermission>(
); }, - { action: OrgPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } + { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing } ); Object.assign(SettingsBilling, { requireAuth: true }); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 6d5070fe0..fe1015485 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -1,6 +1,10 @@ @tailwind base; @tailwind components; +html { + @apply overflow-hidden; +} + .rdp-day, .rdp-nav_button { @apply rounded-md hover:text-mineshaft-500; @@ -108,7 +112,13 @@ } } .tags-conic-bg { - background: conic-gradient(rgb(235, 87, 87), rgb(242, 201, 76), rgb(76, 183, 130), rgb(78, 167, 252), rgb(250, 96, 122)); + background: conic-gradient( + rgb(235, 87, 87), + rgb(242, 201, 76), + rgb(76, 183, 130), + rgb(78, 167, 252), + rgb(250, 96, 122) + ); } .show-tags { diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 50efcf487..5c93b6192 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -1138,7 +1138,6 @@ export const DashboardPage = withProjectPermission( onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} /> - { + const { t } = useTranslation(); + const [isDragActive, setDragActive] = useToggle(); + const [isLoading, setIsLoading] = useToggle(); + const { createNotification } = useNotificationContext(); + const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const); + const [searchFilter, setSearchFilter] = useState(""); + const [shouldIncludeValues, setShouldIncludeValues] = useState(true); + + const { + handleSubmit, + control, + watch, + register, + reset, + setValue, + formState: { isDirty } + } = useForm({ + resolver: yupResolver(formSchema), + defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } + }); + + const secretPath = watch("secretPath"); + const selectedEnvSlug = watch("environment"); + const debouncedSecretPath = useDebounce(secretPath); + + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ workspaceId, + env: selectedEnvSlug, + secretPath: debouncedSecretPath, + isPaused: + !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && + !popUp.importSecEnv.isOpen, decryptFileKey - }: Props): JSX.Element => { - const { t } = useTranslation(); - const [isDragActive, setDragActive] = useToggle(); - const [isLoading, setIsLoading] = useToggle(); - const { createNotification } = useNotificationContext(); - const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const); - const [searchFilter, setSearchFilter] = useState(""); - const [shouldIncludeValues, setShouldIncludeValues] = useState(true); + }); - const { - handleSubmit, - control, - watch, - register, - reset, - setValue, - formState: { isDirty } - } = useForm({ - resolver: yupResolver(formSchema), - defaultValues: { secretPath: "/", environment: environments?.[0]?.slug } - }); + useEffect(() => { + setValue("secrets", {}); + setSearchFilter(""); + }, [debouncedSecretPath]); - const secretPath = watch("secretPath"); - const selectedEnvSlug = watch("environment"); - const debouncedSecretPath = useDebounce(secretPath); - - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - workspaceId, - env: selectedEnvSlug, - secretPath: debouncedSecretPath, - isPaused: - !(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedSecretPath)) && - !popUp.importSecEnv.isOpen, - decryptFileKey - }); - - useEffect(() => { - setValue("secrets", {}); - setSearchFilter(""); - }, [debouncedSecretPath]); - - const handleDrag = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive.on(); - } else if (e.type === "dragleave") { - setDragActive.off(); - } - }; - - const parseFile = (file?: File, isJson?: boolean) => { - const reader = new FileReader(); - if (!file) { - createNotification({ - text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", - type: "error", - timeoutMs: 10000 - }); - return; - } - // const fileType = file.name.split('.')[1]; - setIsLoading.on(); - reader.onload = (event) => { - if (!event?.target?.result) return; - // parse function's argument looks like to be ArrayBuffer - const env = isJson - ? parseJson(event.target.result as ArrayBuffer) - : parseDotEnv(event.target.result as ArrayBuffer); - setIsLoading.off(); - onParsedEnv(env); - }; - - // If something is wrong show an error - try { - reader.readAsText(file); - } catch (error) { - console.log(error); - } - }; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!e.dataTransfer) { - return; - } - - e.dataTransfer.dropEffect = "copy"; + const handleDrag = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive.on(); + } else if (e.type === "dragleave") { setDragActive.off(); - parseFile(e.dataTransfer.files[0]); - }; + } + }; - const handleFileUpload = (e: ChangeEvent) => { - e.preventDefault(); - parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); - }; - - const handleFormSubmit = (data: TFormSchema) => { - const secretsToBePulled: Record = {}; - Object.keys(data.secrets || {}).forEach((key) => { - if (data.secrets[key]) { - secretsToBePulled[key] = { - value: (shouldIncludeValues && data.secrets[key]) || "", - comments: [""] - }; - } + const parseFile = (file?: File, isJson?: boolean) => { + const reader = new FileReader(); + if (!file) { + createNotification({ + text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.", + type: "error", + timeoutMs: 10000 }); - onParsedEnv(secretsToBePulled); - handlePopUpClose("importSecEnv"); - reset(); + return; + } + // const fileType = file.name.split('.')[1]; + setIsLoading.on(); + reader.onload = (event) => { + if (!event?.target?.result) return; + // parse function's argument looks like to be ArrayBuffer + const env = isJson + ? parseJson(event.target.result as ArrayBuffer) + : parseDotEnv(event.target.result as ArrayBuffer); + setIsLoading.off(); + onParsedEnv(env); }; - const handleSecSelectAll = () => { - if (secrets?.secrets) { - setValue( - "secrets", - secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), - { shouldDirty: true } - ); + // If something is wrong show an error + try { + reader.readAsText(file); + } catch (error) { + console.log(error); + } + }; + + const handleDrop = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.dataTransfer) { + return; + } + + e.dataTransfer.dropEffect = "copy"; + setDragActive.off(); + parseFile(e.dataTransfer.files[0]); + }; + + const handleFileUpload = (e: ChangeEvent) => { + e.preventDefault(); + parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json"); + }; + + const handleFormSubmit = (data: TFormSchema) => { + const secretsToBePulled: Record = {}; + Object.keys(data.secrets || {}).forEach((key) => { + if (data.secrets[key]) { + secretsToBePulled[key] = { + value: (shouldIncludeValues && data.secrets[key]) || "", + comments: [""] + }; } - }; + }); + onParsedEnv(secretsToBePulled); + handlePopUpClose("importSecEnv"); + reset(); + }; - return ( -
- {isLoading ? ( -
- loading animation -
- ) : ( -
-
-
- -
-
-

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

-
- -
-
-

OR

-
-
-
- { - handlePopUpToggle("importSecEnv", isOpen); - reset(); - setSearchFilter(""); - }} - > - - - - - -
- ( - - - - )} - /> - - - -
-
-
-
Secrets
-
- } - onChange={(evt) => setSearchFilter(evt.target.value)} - /> - - - - - - - reset()} - > - - - -
-
- {!isSecretsLoading && !secrets?.secrets?.length && ( - - )} -
- {isSecretsLoading && - Array.apply(0, Array(2)).map((_x, i) => ( - - ))} + const handleSecSelectAll = () => { + if (secrets?.secrets) { + setValue( + "secrets", + secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}), + { shouldDirty: true } + ); + } + }; - {secrets?.secrets - ?.filter(({ key }) => - key.toLowerCase().includes(searchFilter.toLowerCase()) - ) - ?.map(({ _id, key, value: secVal }) => ( - ( - - onChange(isChecked ? secVal : "") - } - > - {key} - - )} - /> - ))} -
-
- - setShouldIncludeValues(isChecked as boolean) - } - > - Include secret values - -
-
- - -
-
- -
-
- {!isSmaller && ( - - )} -
+ return ( +
+ {isLoading ? ( +
+ loading animation +
+ ) : ( +
+
+
+
- - )} -
- ); - }, - { action: ProjectPermissionActions.Create, subject: ProjectPermissionSub.Secrets } -); +
+

{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}

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

OR

+
+
+
+ { + handlePopUpToggle("importSecEnv", isOpen); + reset(); + setSearchFilter(""); + }} + > + + + {(isAllowed) => ( + + )} + + + +
+
+ ( + + + + )} + /> + + + +
+
+
+
Secrets
+
+ } + onChange={(evt) => setSearchFilter(evt.target.value)} + /> + + + + + + + reset()} + > + + + +
+
+ {!isSecretsLoading && !secrets?.secrets?.length && ( + + )} +
+ {isSecretsLoading && + Array.apply(0, Array(2)).map((_x, i) => ( + + ))} + + {secrets?.secrets + ?.filter(({ key }) => + key.toLowerCase().includes(searchFilter.toLowerCase()) + ) + ?.map(({ _id, key, value: secVal }) => ( + ( + onChange(isChecked ? secVal : "")} + > + {key} + + )} + /> + ))} +
+
+ + setShouldIncludeValues(isChecked as boolean) + } + > + Include secret values + +
+
+ + +
+
+
+
+
+ {!isSmaller && ( + + {(isAllowed) => ( + + )} + + )} +
+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index d121d4538..92353d653 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -16,37 +16,37 @@ export const formSchema = z.object({ name: z.string().trim(), description: z.string().trim().optional(), slug: z.string().trim(), - permissions: z.object({ - workspace: z - .object({ - read: z.boolean().optional(), - create: z.boolean().optional() - }) - .optional(), - member: generalPermissionSchema, - role: generalPermissionSchema, - settings: generalPermissionSchema, - "service-account": generalPermissionSchema, - "incident-contact": generalPermissionSchema, - "secret-scanning": generalPermissionSchema, - sso: generalPermissionSchema, - billing: generalPermissionSchema - }) + permissions: z + .object({ + workspace: z + .object({ + read: z.boolean().optional(), + create: z.boolean().optional() + }) + .optional(), + member: generalPermissionSchema, + role: generalPermissionSchema, + settings: generalPermissionSchema, + "service-account": generalPermissionSchema, + "incident-contact": generalPermissionSchema, + "secret-scanning": generalPermissionSchema, + sso: generalPermissionSchema, + billing: generalPermissionSchema + }) + .optional() }); export type TFormSchema = z.infer; // convert role permission to form compatiable data structure export const rolePermission2Form = (permissions: TPermission[] = []) => { - const formVal: Partial = {}; - + // any because if it set it as form type due to the discriminated union type of ts + // i would have to write a if loop with both conditions same + const formVal: Record = {}; permissions.forEach((permission) => { const { subject, action } = permission; if (!formVal?.[subject]) formVal[subject] = {}; - - // akhilmhdh: this is typecast as something other than workspace key else i would need an if loop with same condition on both side - const key = subject as keyof TFormSchema["permissions"]; - (formVal[key] as Exclude)[action] = true; + formVal[subject][action] = true; }); return formVal; @@ -54,19 +54,13 @@ export const rolePermission2Form = (permissions: TPermission[] = []) => { export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { const permissions: TPermission[] = []; - (Object.keys(formVal) as Array).forEach((rule) => { - // all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] - // quite annoying i know - const actions = Object.keys(formVal[rule] || {}) as Array< - keyof z.infer - >; - - actions.forEach((action) => { - // akhilmhdh: set it as any due to the union type bug i would end up writing an if else with same condition on both side - if (formVal?.[rule]?.[action as keyof typeof formVal.workspace]) { - permissions.push({ subject: rule, action } as any); + Object.entries(formVal || {}).forEach(([rule, actions]) => { + Object.entries(actions).forEach(([action, isAllowed]) => { + if (isAllowed) { + permissions.push({ subject: rule, action }); } }); }); + return permissions; }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx index 59690b9f9..060d5fe66 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/SimpleLevelPermissionOptions.tsx @@ -11,7 +11,7 @@ import { useToggle } from "@app/hooks"; import { TFormSchema } from "./OrgRoleModifySection.utils"; type Props = { - formName: keyof Omit; + formName: keyof Omit, "workspace">; isNonEditable?: boolean; setValue: UseFormSetValue; control: Control; @@ -34,6 +34,31 @@ const PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; +const SECRET_SCANNING_PERMISSIONS = [ + { action: "read", label: "View risks" }, + { action: "create", label: "Add integrations" }, + { action: "edit", label: "Edit risk status" }, + { action: "delete", label: "Remove integrations" } +] as const; + +const BILLING_PERMISSIONS = [ + { action: "read", label: "View bills" }, + { action: "create", label: "Add payment methods" }, + { action: "edit", label: "Edit payments" }, + { action: "delete", label: "Remove payments" } +] as const; + +const getPermissionList = (option: Props["formName"]) => { + switch (option) { + case "secret-scanning": + return SECRET_SCANNING_PERMISSIONS; + case "billing": + return BILLING_PERMISSIONS; + default: + return PERMISSIONS; + } +}; + export const SimpleLevelPermissionOption = ({ isNonEditable, setValue, @@ -138,7 +163,7 @@ export const SimpleLevelPermissionOption = ({ className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min" > {isCustom && - PERMISSIONS.map(({ action, label }) => ( + getPermissionList(formName).map(({ action, label }) => (
Project
-
Project management control
+
+ More fine granined project access control can be defined with project level roles +
{ return (
-
); From 2ed079830ada5c8f6f1a0d6cb969828c202ab60d Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Wed, 6 Sep 2023 11:27:50 -0400 Subject: [PATCH 26/40] make org permissions more readable --- .../OrgRoleModifySection.tsx | 20 +++++++++---------- .../SimpleLevelPermissionOptions.tsx | 18 +++++++++++++++++ .../WorkspacePermission.tsx | 6 +++--- .../ProjectRoleModifySection.tsx | 2 +- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 8c648c532..c1dfa5691 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -35,20 +35,20 @@ type Props = { const SIMPLE_PERMISSION_OPTIONS = [ { - title: "Members", - subtitle: "Project member management control", + title: "Members management", + subtitle: "Invite, view and remove members from the organization", icon: faUsers, formName: "member" }, { - title: "Billing", - subtitle: "Billing management control", + title: "Billing & usage", + subtitle: "Modify organization subscription plan", icon: faMoneyBill, formName: "billing" }, { - title: "Role", - subtitle: "Org role management control", + title: "Role management", + subtitle: "Create, modify and remove organization roles", icon: faUserCog, formName: "role" }, @@ -59,8 +59,8 @@ const SIMPLE_PERMISSION_OPTIONS = [ formName: "incident-contact" }, { - title: "Settings", - subtitle: "Settings management control", + title: "Organization profile", + subtitle: "View & update organization metadata such as name", icon: faCog, formName: "settings" }, @@ -72,7 +72,7 @@ const SIMPLE_PERMISSION_OPTIONS = [ }, { title: "SSO", - subtitle: "SSO management control", + subtitle: "Define organization level SSO requirements", icon: faSignIn, formName: "sso" } @@ -155,7 +155,7 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {

- Roles are used to grant access to particular resources in your organization + Organization-level roles allow you to define permissions for resources at a high level across the organization

{ return SECRET_SCANNING_PERMISSIONS; case "billing": return BILLING_PERMISSIONS; + case "incident-contact": + return INCIDENT_CONTACTS_PERMISSIONS; + case "member": + return MEMBERS_PERMISSIONS default: return PERMISSIONS; } diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx index 6d54a56b9..915fa12c5 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx @@ -24,8 +24,8 @@ enum Permission { } const PERMISSIONS = [ - { action: "read", label: "View" }, - { action: "create", label: "Create" } + { action: "read", label: "View projects" }, + { action: "create", label: "Create new projects" } ] as const; export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { @@ -87,7 +87,7 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
Project
- More fine granined project access control can be defined with project level roles + View and create new projects in this organization
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 61f056258..31406c1a9 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -186,7 +186,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {

- Roles are used to grant access to particular resources in your organization + Project-level roles allow you to define permissions for resources within projects at a granular level

Date: Thu, 7 Sep 2023 15:20:05 +0530 Subject: [PATCH 27/40] feat(rbac): added glob support in permission and revealed settings --- backend/package-lock.json | 1 + backend/package.json | 1 + .../src/ee/controllers/v1/secretController.ts | 16 +- backend/src/services/ProjectRoleService.ts | 32 +- backend/src/services/RoleService.ts | 9 +- backend/src/validation/role.ts | 8 +- backend/src/validation/secrets.ts | 4 +- frontend/package-lock.json | 15 + frontend/package.json | 3 + .../AddTagPopoverContent.tsx | 144 +- .../permissions/PermissionDeniedBanner.tsx | 40 + .../permissions/ProjectPermissionCan.tsx | 7 +- frontend/src/components/permissions/index.tsx | 1 + .../context/ProjectPermissionContext/types.ts | 2 +- frontend/src/hooks/api/roles/queries.tsx | 29 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 26 +- .../src/pages/org/[id]/overview/index.tsx | 26 +- .../src/views/DashboardPage/DashboardPage.tsx | 2046 +++++++++-------- .../FolderSection/FolderSection.tsx | 11 +- .../SecretDetailDrawer/SecretDetailDrawer.tsx | 13 +- .../SecretDropzone/SecretDropzone.tsx | 47 +- .../SecretImportSection/SecretImportItem.tsx | 11 +- .../SecretImportSection.tsx | 14 +- .../SecretInputRow/SecretInputRow.tsx | 206 +- .../MultiEnvProjectPermission.tsx | 12 +- .../ProjectRoleModifySection.tsx | 1 + .../ProjectRoleModifySection.utils.ts | 28 +- .../SecretOverviewPage/SecretOverviewPage.tsx | 57 +- .../SecretOverviewTableRow/SecretEditRow.tsx | 5 +- .../SecretOverviewTableRow.tsx | 11 +- .../OrgIncidentContactsSection.tsx | 80 +- .../OrgNameChangeSection.tsx | 132 +- .../ProjectSettingsPage.tsx | 89 +- .../AutoCapitalizationSection.tsx | 100 +- .../components/E2EESection/E2EESection.tsx | 195 +- .../EnvironmentSection/EnvironmentSection.tsx | 202 +- .../ProjectIndexSecretsSection.tsx | 126 +- .../SecretTagsSection/SecretTagsSection.tsx | 149 +- 38 files changed, 2068 insertions(+), 1831 deletions(-) create mode 100644 frontend/src/components/permissions/PermissionDeniedBanner.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index 3a65f61d6..f5c55cf56 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "@sentry/tracing": "^7.48.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "@ucast/mongo2js": "^1.3.4", "argon2": "^0.30.3", "aws-sdk": "^2.1364.0", "axios": "^1.3.5", diff --git a/backend/package.json b/backend/package.json index 21f7af45a..08db10ecc 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "@sentry/tracing": "^7.48.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "@ucast/mongo2js": "^1.3.4", "argon2": "^0.30.3", "aws-sdk": "^2.1364.0", "axios": "^1.3.5", diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 1203f406d..58b7e6264 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -1,7 +1,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import { Request, Response } from "express"; import { validateRequest } from "../../../helpers/validation"; -import { Secret } from "../../../models"; +import { Folder, Secret } from "../../../models"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -11,6 +11,7 @@ import { BadRequestError } from "../../../utils/errors"; import * as reqValidator from "../../../validation"; import { SecretVersion } from "../../models"; import { EESecretService } from "../../services"; +import { getFolderWithPathFromId } from "../../../services/FolderService"; /** * Return secret versions for secret with id [secretId] @@ -164,10 +165,6 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback ); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment: toBeUpdatedSec.environment }) - ); // validate secret version const oldSecretVersion = await SecretVersion.findOne({ @@ -194,6 +191,15 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { keyEncoding } = oldSecretVersion; + let secretPath = "/"; + const folders = await Folder.findOne({ workspace, environment }); + if (folders) + secretPath = getFolderWithPathFromId(folders.nodes, folder || "root")?.folderPath || "/"; + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment: toBeUpdatedSec.environment, secretPath }) + ); + // update secret const secret = await Secret.findByIdAndUpdate( secretId, diff --git a/backend/src/services/ProjectRoleService.ts b/backend/src/services/ProjectRoleService.ts index 5a2d24e00..10dbd2302 100644 --- a/backend/src/services/ProjectRoleService.ts +++ b/backend/src/services/ProjectRoleService.ts @@ -3,11 +3,31 @@ import { ForcedSubject, MongoAbility, RawRuleOf, + buildMongoQueryMatcher, createMongoAbility } from "@casl/ability"; import { Membership } from "../models"; import { IRole } from "../models/role"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; +import picomatch from "picomatch"; + +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export enum ProjectPermissionActions { Read = "read", @@ -36,7 +56,7 @@ export enum ProjectPermissionSub { type SubjectFields = { environment: string; - secretPath?: string; + secretPath: string; }; export type ProjectPermissionSet = @@ -144,7 +164,7 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace); - return build(); + return build({ conditionsMatcher }); }; export const adminProjectPermissions = buildAdminPermission(); @@ -180,7 +200,7 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build(); + return build({ conditionsMatcher }); }; export const memberProjectPermissions = buildMemberPermission(); @@ -203,7 +223,7 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - return build(); + return build({ conditionsMatcher }); }; export const viewerProjectPermission = buildViewerPermission(); @@ -228,7 +248,9 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str if (membership.role === "viewer") return { permission: viewerProjectPermission, membership }; if (membership.role === "custom") { - const permission = createMongoAbility(membership.customRole.permissions); + const permission = createMongoAbility(membership.customRole.permissions, { + conditionsMatcher + }); return { permission, membership }; } diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 648128246..bc5ef7453 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -3,6 +3,7 @@ import { MembershipOrg } from "../models"; import { IRole } from "../models/role"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; import { ACCEPTED } from "../variables"; +import { conditionsMatcher } from "./ProjectRoleService"; export enum OrgPermissionActions { Read = "read", @@ -74,7 +75,7 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); - return build(); + return build({ conditionsMatcher }); }; export const adminPermissions = buildAdminPermission(); @@ -92,7 +93,7 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); - return build(); + return build({ conditionsMatcher }); }; export const memberPermissions = buildMemberPermission(); @@ -119,7 +120,9 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { if (membership.role === "member") return { permission: memberPermissions, membership }; if (membership.role === "custom") { - const permission = createMongoAbility(membership.customRole.permissions); + const permission = createMongoAbility(membership.customRole.permissions, { + conditionsMatcher + }); return { permission, membership }; } diff --git a/backend/src/validation/role.ts b/backend/src/validation/role.ts index efc659a23..e3ecafe59 100644 --- a/backend/src/validation/role.ts +++ b/backend/src/validation/role.ts @@ -11,7 +11,9 @@ export const CreateRoleSchema = z.object({ .object({ subject: z.string().trim(), action: z.string().trim(), - conditions: z.record(z.union([z.string().trim(), z.number()])).optional() + conditions: z + .record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })])) + .optional() }) .array() }) @@ -31,7 +33,9 @@ export const UpdateRoleSchema = z.object({ .object({ subject: z.string().trim(), action: z.string().trim(), - conditions: z.record(z.union([z.string().trim(), z.number()])).optional() + conditions: z + .record(z.union([z.string().trim(), z.number(), z.object({ $glob: z.string() })])) + .optional() }) .array() .optional() diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index afa77cbf0..700a3ea5d 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -189,7 +189,7 @@ export const BatchSecretsV2 = z.object({ workspaceId: z.string().trim(), folderId: z.string().trim().default("root"), environment: z.string().trim(), - secretPath: z.string().trim().optional(), + secretPath: z.string().trim().default("/"), requests: z .discriminatedUnion("method", [ z.object({ @@ -328,7 +328,7 @@ export const CreateSecretV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string()).optional() }), params: z.object({ secretName: z.string().trim() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 262051bcf..e6847b42c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", "@types/argon2-browser": "^1.18.1", + "@ucast/mongo2js": "^1.3.4", "add": "^2.0.6", "argon2-browser": "^1.18.0", "axios": "^0.27.2", @@ -65,6 +66,7 @@ "markdown-it": "^13.0.1", "next": "^12.3.4", "nprogress": "^0.2.0", + "picomatch": "^2.3.1", "posthog-js": "^1.58.0", "query-string": "^7.1.3", "react": "^17.0.2", @@ -106,6 +108,7 @@ "@tailwindcss/typography": "^0.5.4", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", + "@types/picomatch": "^2.3.0", "@types/react": "^18.0.26", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.48.1", @@ -8391,6 +8394,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "node_modules/@types/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==", + "dev": true + }, "node_modules/@types/pretty-hrtime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", @@ -29414,6 +29423,12 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, + "@types/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==", + "dev": true + }, "@types/pretty-hrtime": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/pretty-hrtime/-/pretty-hrtime-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1e73e4c7e..d954da2a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,6 +49,7 @@ "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", "@types/argon2-browser": "^1.18.1", + "@ucast/mongo2js": "^1.3.4", "add": "^2.0.6", "argon2-browser": "^1.18.0", "axios": "^0.27.2", @@ -73,6 +74,7 @@ "markdown-it": "^13.0.1", "next": "^12.3.4", "nprogress": "^0.2.0", + "picomatch": "^2.3.1", "posthog-js": "^1.58.0", "query-string": "^7.1.3", "react": "^17.0.2", @@ -114,6 +116,7 @@ "@tailwindcss/typography": "^0.5.4", "@types/jsrp": "^0.2.4", "@types/node": "^18.11.9", + "@types/picomatch": "^2.3.0", "@types/react": "^18.0.26", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.48.1", diff --git a/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx b/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx index a8b965269..77ca8c24f 100644 --- a/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx +++ b/frontend/src/components/AddTagPopoverContent/AddTagPopoverContent.tsx @@ -1,78 +1,92 @@ - import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Checkbox, PopoverContent } from "@app/components/v2"; +import { Button, Checkbox, PopoverContent } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { WsTag } from "../../hooks/api/tags/types"; +import { ProjectPermissionCan } from "../permissions"; interface Props { - wsTags: WsTag[] | undefined; - secKey: string; - selectedTagIds: Record; - handleSelectTag: (wsTag: WsTag) => void; - handleTagOnMouseEnter: (wsTag: WsTag) => void; - handleTagOnMouseLeave: () => void; - checkIfTagIsVisible: (wsTag: WsTag) => boolean; - handleOnCreateTagOpen: () => void + wsTags: WsTag[] | undefined; + secKey: string; + selectedTagIds: Record; + handleSelectTag: (wsTag: WsTag) => void; + handleTagOnMouseEnter: (wsTag: WsTag) => void; + handleTagOnMouseLeave: () => void; + checkIfTagIsVisible: (wsTag: WsTag) => boolean; + handleOnCreateTagOpen: () => void; } const AddTagPopoverContent = ({ - wsTags, - secKey, - selectedTagIds, - handleSelectTag, - handleTagOnMouseEnter, - handleTagOnMouseLeave, - checkIfTagIsVisible, - handleOnCreateTagOpen + wsTags, + secKey, + selectedTagIds, + handleSelectTag, + handleTagOnMouseEnter, + handleTagOnMouseLeave, + checkIfTagIsVisible, + handleOnCreateTagOpen }: Props) => { - return ( - -
- Add tags to {secKey || "this secret"} + return ( + +
+ Add tags to {secKey || "this secret"} +
+
+
+ {wsTags?.map((wsTag: WsTag) => ( +
handleSelectTag(wsTag)} + onMouseEnter={() => handleTagOnMouseEnter(wsTag)} + onMouseLeave={() => handleTagOnMouseLeave()} + tabIndex={0} + role="button" + onKeyDown={() => {}} + > + {(checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && ( + + )} +
+
+ {" "} +
+ {wsTag.slug}
-
-
- {wsTags?.map((wsTag: WsTag) => ( -
handleSelectTag(wsTag)} - onMouseEnter={() => handleTagOnMouseEnter(wsTag)} - onMouseLeave={() => handleTagOnMouseLeave()} - tabIndex={0} role="button" - onKeyDown={() => { }}> - { +
+ ))} + + {(isAllowed) => ( + + )} + +
+ + ); +}; - (checkIfTagIsVisible(wsTag) || selectedTagIds?.[wsTag.slug]) && - } -
-
- - {wsTag.slug} - -
-
- ))} -
handleOnCreateTagOpen()} - tabIndex={0} role="button" - onKeyDown={() => { }}> - - Add new tag -
-
- - ) -} - -export default AddTagPopoverContent \ No newline at end of file +export default AddTagPopoverContent; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx new file mode 100644 index 000000000..5e70d9e72 --- /dev/null +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from "react"; +import { faLock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +type Props = { + containerClassName?: string; + className?: string; + children?: ReactNode; +}; + +export const PermissionDeniedBanner = ({ containerClassName, className, children }: Props) => { + return ( +
+
+
+ +
+
+
Permission Denied
+ {children || ( +
+ You do not have permission.
Kindly contact your organization administrator +
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/components/permissions/ProjectPermissionCan.tsx b/frontend/src/components/permissions/ProjectPermissionCan.tsx index 6c76afde9..7857ac052 100644 --- a/frontend/src/components/permissions/ProjectPermissionCan.tsx +++ b/frontend/src/components/permissions/ProjectPermissionCan.tsx @@ -11,7 +11,11 @@ type Props = { // so when permission is allowed same tooltip will be reused to show helpertext renderTooltip?: boolean; allowedLabel?: string; -} & BoundCanProps; + // BUG(akhilmhdh): As a workaround for now i put any but this should be TProjectPermission + // For some reason when i put TProjectPermission in a wrapper component it just wont work causes a weird ts error + // tried a lot combinations + // REF: https://github.com/stalniy/casl/blob/ac081a34f56366a7eaaed05d21689d27041ef005/packages/casl-react/src/factory.ts#L15 +} & BoundCanProps; export const ProjectPermissionCan: FunctionComponent = ({ label = "Permission Denied. Kindly contact your project admin", @@ -22,7 +26,6 @@ export const ProjectPermissionCan: FunctionComponent = ({ ...props }) => { const permission = useProjectPermission(); - return ( {(isAllowed, ability) => { diff --git a/frontend/src/components/permissions/index.tsx b/frontend/src/components/permissions/index.tsx index 24854f047..8d523c311 100644 --- a/frontend/src/components/permissions/index.tsx +++ b/frontend/src/components/permissions/index.tsx @@ -1,2 +1,3 @@ export { OrgPermissionCan } from "./OrgPermissionCan"; +export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; export { ProjectPermissionCan } from "./ProjectPermissionCan"; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d81ed068e..ca1ca13ae 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -27,7 +27,7 @@ export enum ProjectPermissionSub { type SubjectFields = { environment: string; - secretPath?: string; + secretPath: string; }; export type ProjectPermissionSet = diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 917b6d5f6..5415d201c 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -1,6 +1,8 @@ -import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; +import { buildMongoQueryMatcher, createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, unpackRules } from "@casl/ability/extra"; import { useQuery } from "@tanstack/react-query"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; +import picomatch from "picomatch"; import { apiRequest } from "@app/config/request"; import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types"; @@ -13,6 +15,29 @@ import { TRole } from "./types"; +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + if (!secretPath) return false; + // console.log( + // secretPath, + // picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }), + // permissionSecretGlobPath + // ); + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); + export const roleQueryKeys = { getRoles: ({ orgId, workspaceId }: TGetRolesDTO) => ["roles", { orgId, workspaceId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => @@ -57,7 +82,7 @@ export const useGetUserOrgPermissions = ({ orgId }: TGetUserOrgPermissionsDTO) = enabled: Boolean(orgId), select: (data) => { const rule = unpackRules>>(data); - const ability = createMongoAbility(rule); + const ability = createMongoAbility(rule, { conditionsMatcher }); return ability; } }); diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index bd4152b7c..d03161f30 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -739,16 +739,26 @@ export const AppLayout = ({ children }: LayoutProps) => { ( - - Add all members of my organization to this project - + {(isAllowed) => ( +
+ + Add all members of my organization to this project + +
+ )} + )} />
diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index ea72be394..fdd5c2cdf 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -838,16 +838,26 @@ const OrganizationPage = withPermission( ( - - Add all members of my organization to this project - + {(isAllowed) => ( +
+ + Add all members of my organization to this project + +
+ )} + )} />
diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 5c93b6192..6e8509aba 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -3,6 +3,7 @@ import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; import { subject } from "@casl/ability"; +import { Can } from "@casl/react"; import { closestCenter, DndContext, @@ -26,7 +27,6 @@ import { faEyeSlash, faFileImport, faFolderPlus, - faLock, faMagnifyingGlass, faPlus } from "@fortawesome/free-solid-svg-icons"; @@ -41,7 +41,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import NavHeader from "@app/components/navigation/NavHeader"; -import { ProjectPermissionCan } from "@app/components/permissions"; +import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -66,7 +66,6 @@ import { useSubscription, useWorkspace } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; import { useBatchSecretsOp, @@ -92,7 +91,6 @@ import { useUpdateSecretImport } from "@app/hooks/api"; import { secretKeys } from "@app/hooks/api/secrets/queries"; -import { WorkspaceEnv } from "@app/hooks/api/types"; import { CompareSecret } from "./components/CompareSecret"; import { CreateTagModal } from "./components/CreateTagModal"; @@ -133,944 +131,936 @@ type TDeleteSecretImport = { environment: string; secretPath: string }; * Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving * They will get it back */ -export const DashboardPage = withProjectPermission( - () => { - const { subscription } = useSubscription(); - const { t } = useTranslation(); - const router = useRouter(); - const { createNotification } = useNotificationContext(); - const queryClient = useQueryClient(); - const envQuery = router.query.env as string; - const permission = useProjectPermission(); +export const DashboardPage = () => { + const { subscription } = useSubscription(); + const { t } = useTranslation(); + const router = useRouter(); + const { createNotification } = useNotificationContext(); + const queryClient = useQueryClient(); + const environment = router.query.env as string; + const permission = useProjectPermission(); - const secretContainer = useRef(null); - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "secretDetails", - "addTag", - "secretSnapshots", - "uploadedSecOpts", - "compareSecrets", - "folderForm", - "deleteFolder", - "upgradePlan", - "addSecretImport", - "deleteSecretImport" - ] as const); - const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); - const [searchFilter, setSearchFilter] = useState(""); - const [snapshotId, setSnaphotId] = useState(null); - const [selectedEnv, setSelectedEnv] = useState(null); - const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); - const deletedSecretIds = useRef<{ id: string; secretName: string }[]>([]); - const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); + const secretContainer = useRef(null); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "secretDetails", + "addTag", + "secretSnapshots", + "uploadedSecOpts", + "compareSecrets", + "folderForm", + "deleteFolder", + "upgradePlan", + "addSecretImport", + "deleteSecretImport" + ] as const); + const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); + const [searchFilter, setSearchFilter] = useState(""); + const [snapshotId, setSnaphotId] = useState(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const deletedSecretIds = useRef<{ id: string; secretName: string }[]>([]); + const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false }); - const folderId = router.query.folderId as string; - const isRollbackMode = Boolean(snapshotId); + const folderId = router.query.folderId as string; + const isRollbackMode = Boolean(snapshotId); - const { currentWorkspace, isLoading } = useWorkspace(); - const { currentOrg } = useOrganization(); - const workspaceId = currentWorkspace?._id as string; - const selectedEnvSlug = selectedEnv?.slug || ""; + const { currentWorkspace, isLoading } = useWorkspace(); + const { currentOrg } = useOrganization(); + const workspaceId = currentWorkspace?._id as string; - const { data: latestFileKey } = useGetUserWsKey(workspaceId); + const { data: latestFileKey } = useGetUserWsKey(workspaceId); - useEffect(() => { - if (!isLoading && !workspaceId && router.isReady) { - router.push(`/org/${currentOrg?._id}/overview`); - } - }, [isLoading, workspaceId, router.isReady]); + useEffect(() => { + if (!isLoading && !workspaceId && router.isReady) { + router.push(`/org/${currentOrg?._id}/overview`); + } + }, [isLoading, workspaceId, router.isReady]); - // fetching data - const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); - const hasUserPushed = Boolean(userAction); + // fetching data + const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); + const hasUserPushed = Boolean(userAction); - const { data: secretVersion } = useGetSecretVersion({ - limit: 10, - offset: 0, - secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id, - decryptFileKey: latestFileKey! - }); + const { data: secretVersion } = useGetSecretVersion({ + limit: 10, + offset: 0, + secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id, + decryptFileKey: latestFileKey! + }); - const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ - workspaceId, - env: selectedEnvSlug, - decryptFileKey: latestFileKey!, - isPaused: Boolean(snapshotId), - folderId - }); + const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ + workspaceId, + env: environment, + decryptFileKey: latestFileKey!, + isPaused: Boolean(snapshotId), + folderId + }); - const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ - workspaceId: workspaceId || "", - environment: selectedEnvSlug, - parentFolderId: folderId, - isPaused: isRollbackMode, - sortDir - }); + const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ + workspaceId: workspaceId || "", + environment, + parentFolderId: folderId, + isPaused: isRollbackMode, + sortDir + }); - const { - data: secretSnaphots, - fetchNextPage, - hasNextPage, - isFetchingNextPage - } = useGetWorkspaceSecretSnapshots({ - workspaceId, - environment: selectedEnvSlug, - folder: folderId, - limit: 10 - }); + const { + data: secretSnaphots, + fetchNextPage, + hasNextPage, + isFetchingNextPage + } = useGetWorkspaceSecretSnapshots({ + workspaceId, + environment, + folder: folderId, + limit: 10 + }); - const { - data: snapshotSecret, - isLoading: isSnapshotSecretsLoading, - isFetching: isSnapshotChanging - } = useGetSnapshotSecrets({ - snapshotId: snapshotId || "", - env: selectedEnvSlug, - decryptFileKey: latestFileKey! - }); + const { + data: snapshotSecret, + isLoading: isSnapshotSecretsLoading, + isFetching: isSnapshotChanging + } = useGetSnapshotSecrets({ + snapshotId: snapshotId || "", + env: environment, + decryptFileKey: latestFileKey! + }); - const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( - workspaceId, - selectedEnvSlug, - folderId - ); + const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( + workspaceId, + environment, + folderId + ); - const { data: wsTags } = useGetWsTags(workspaceId); + const { data: wsTags } = useGetWsTags(workspaceId); - // mutation calls - const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); - const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); - const { mutateAsync: registerUserAction } = useRegisterUserAction(); - const { mutateAsync: createWsTag } = useCreateWsTag(); - const { mutateAsync: createFolder } = useCreateFolder(); - const { mutateAsync: updateFolder } = useUpdateFolder(folderId); - const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); + // mutation calls + const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); + const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); + const { mutateAsync: registerUserAction } = useRegisterUserAction(); + const { mutateAsync: createWsTag } = useCreateWsTag(); + const { mutateAsync: createFolder } = useCreateFolder(); + const { mutateAsync: updateFolder } = useUpdateFolder(folderId); + const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); - const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( - workspaceId, - selectedEnvSlug, - folderId - ); + const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( + workspaceId, + environment, + folderId + ); - const { data: importedSecrets } = useGetImportedSecrets({ - workspaceId, - decryptFileKey: latestFileKey!, - environment: selectedEnvSlug, - folderId - }); + const { data: importedSecrets } = useGetImportedSecrets({ + workspaceId, + decryptFileKey: latestFileKey!, + environment, + folderId + }); - const secretPath = `/${(folderData?.dir || []) - ?.filter(({ name }) => name !== "root") - .join("/")}`; + const secretPath = `/${(folderData?.dir || []) + ?.filter(({ name }) => name !== "root") + ?.map(({ name }) => name) + .join("/")}`; - const userAvailableEnvs = currentWorkspace?.environments?.filter(({ slug }) => + const userAvailableEnvs = currentWorkspace?.environments?.filter( + ({ slug }) => permission.can( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath }) - ) - ); - - useEffect(() => { - if (!isLoading && currentWorkspace) { - const env = userAvailableEnvs?.find(({ slug }) => slug === envQuery); - if (env) setSelectedEnv(env); - } - }, [isLoading, workspaceId, userAvailableEnvs]); - - // This is for dnd-kit. As react-query state mutation async - // This will act as a placeholder to avoid a glitching animation on dropping items - const [items, setItems] = useState< - Array<{ environment: string; secretPath: string; id: string }> - >([]); - - useEffect(() => { - if ( - !isSecretImportCfgFetching || - // case in which u go to a folder and come back to fill in with cache data - (items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching) - ) { - setItems( - secretImportCfg?.imports?.map((el) => ({ - ...el, - id: `${el.environment}-${el.secretPath}` - })) || [] - ); - } - }, [isSecretImportCfgFetching]); - - const { mutateAsync: createSecretImport } = useCreateSecretImport(); - const { mutate: updateSecretImportSync } = useUpdateSecretImport(); - const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); - - const sensors = useSensors( - useSensor(MouseSensor, {}), - useSensor(TouchSensor, {}), - useSensor(KeyboardSensor, {}) - ); - - const method = useForm({ - // why any: well yup inferred ts expects other keys to defined as undefined - defaultValues: secrets as any, - values: secrets as any, - mode: "onBlur", - resolver: yupResolver(schema) - }); - - const { - register, - control, - handleSubmit, - getValues, - setValue, - formState: { isSubmitting, isDirty, errors }, - reset - } = method; - const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); - - const isReadOnly = + ) || permission.can( ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug }) + subject(ProjectPermissionSub.Folders, { environment: slug, secretPath }) + ) || + permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.SecretImports, { environment: slug, secretPath }) + ) + ); + + // This is for dnd-kit. As react-query state mutation async + // This will act as a placeholder to avoid a glitching animation on dropping items + const [items, setItems] = useState< + Array<{ environment: string; secretPath: string; id: string }> + >([]); + + useEffect(() => { + if ( + !isSecretImportCfgFetching || + // case in which u go to a folder and come back to fill in with cache data + (items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching) + ) { + setItems( + secretImportCfg?.imports?.map((el) => ({ + ...el, + id: `${el.environment}-${el.secretPath}` + })) || [] + ); + } + }, [isSecretImportCfgFetching]); + + const { mutateAsync: createSecretImport } = useCreateSecretImport(); + const { mutate: updateSecretImportSync } = useUpdateSecretImport(); + const { mutateAsync: deleteSecretImport } = useDeleteSecretImport(); + + const sensors = useSensors( + useSensor(MouseSensor, {}), + useSensor(TouchSensor, {}), + useSensor(KeyboardSensor, {}) + ); + + const method = useForm({ + // why any: well yup inferred ts expects other keys to defined as undefined + defaultValues: secrets as any, + values: secrets as any, + mode: "onBlur", + resolver: yupResolver(schema) + }); + + const { + register, + control, + handleSubmit, + getValues, + setValue, + formState: { isSubmitting, isDirty, errors }, + reset + } = method; + const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); + + const isReadOnly = isFoldersLoading + ? true + : permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ) && permission.cannot( ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const canDoRollback = !isReadOnly; - const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isSubmitting; + const canDoRollback = !isReadOnly; + const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isSubmitting; - useEffect(() => { - if (!isSnapshotChanging && Boolean(snapshotId)) { - reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true }); - } - }, [isSnapshotChanging]); + useEffect(() => { + if (!isSnapshotChanging && Boolean(snapshotId)) { + reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true }); + } + }, [isSnapshotChanging]); - useEffect(() => { - setHasUnsavedChanges(!isSubmitDisabled); - }, [isSubmitDisabled]); + useEffect(() => { + setHasUnsavedChanges(!isSubmitDisabled); + }, [isSubmitDisabled]); - const onSortSecrets = () => { - const dir = sortDir === "asc" ? "desc" : "asc"; - const sec = getValues("secrets") || []; - const sortedSec = sec.sort((a, b) => - dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "") - ); - setValue("secrets", sortedSec); - setSortDir(dir); - }; + const onSortSecrets = () => { + const dir = sortDir === "asc" ? "desc" : "asc"; + const sec = getValues("secrets") || []; + const sortedSec = sec.sort((a, b) => + dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "") + ); + setValue("secrets", sortedSec); + setSortDir(dir); + }; - const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => { - const sec = getValues("secrets") || []; - const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key])); - const conflictingSecIds = conflictingSec.reduce>( - (prev, curr) => ({ - ...prev, - [curr.key]: true - }), - {} - ); - // filter to get all conflicting ones - const conflictingUploadedSec = { ...uploadedSec }; - // append non conflicting ones - Object.keys(uploadedSec).forEach((key) => { - if (!conflictingSecIds?.[key]) { - delete conflictingUploadedSec[key]; - sec.push({ - ...DEFAULT_SECRET_VALUE, - key, - value: uploadedSec[key].value, - comment: uploadedSec[key].comments.join(",") - }); - } - }); - setValue("secrets", sec, { shouldDirty: true }); - if (conflictingSec.length > 0) { - handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec }); - } - }; - - const onOverwriteSecrets = () => { - const sec = getValues("secrets") || []; - const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets; - const data: Array<{ key: string; index: number }> = []; - sec.forEach(({ key }, index) => { - if (uploadedSec?.[key]) data.push({ key, index }); - }); - data.forEach(({ key, index }) => { - const { value, comments } = uploadedSec[key]; - const comment = comments.join(", "); - sec[index] = { + const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => { + const sec = getValues("secrets") || []; + const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key])); + const conflictingSecIds = conflictingSec.reduce>( + (prev, curr) => ({ + ...prev, + [curr.key]: true + }), + {} + ); + // filter to get all conflicting ones + const conflictingUploadedSec = { ...uploadedSec }; + // append non conflicting ones + Object.keys(uploadedSec).forEach((key) => { + if (!conflictingSecIds?.[key]) { + delete conflictingUploadedSec[key]; + sec.push({ ...DEFAULT_SECRET_VALUE, key, - value, - comment, - tags: sec[index].tags - }; + value: uploadedSec[key].value, + comment: uploadedSec[key].comments.join(",") + }); + } + }); + setValue("secrets", sec, { shouldDirty: true }); + if (conflictingSec.length > 0) { + handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec }); + } + }; + + const onOverwriteSecrets = () => { + const sec = getValues("secrets") || []; + const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets; + const data: Array<{ key: string; index: number }> = []; + sec.forEach(({ key }, index) => { + if (uploadedSec?.[key]) data.push({ key, index }); + }); + data.forEach(({ key, index }) => { + const { value, comments } = uploadedSec[key]; + const comment = comments.join(", "); + sec[index] = { + ...DEFAULT_SECRET_VALUE, + key, + value, + comment, + tags: sec[index].tags + }; + }); + setValue("secrets", sec, { shouldDirty: true }); + handlePopUpClose("uploadedSecOpts"); + }; + + const onSecretRollback = async () => { + if (!snapshotSecret?.version) { + createNotification({ + text: "Failed to find secret version", + type: "success" }); - setValue("secrets", sec, { shouldDirty: true }); - handlePopUpClose("uploadedSecOpts"); - }; + return; + } + try { + await performSecretRollback({ + workspaceId, + version: snapshotSecret.version, + environment, + folderId + }); + setValue("isSnapshotMode", false); + setSnaphotId(null); + queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, environment)); + createNotification({ + text: "Successfully rollback secrets", + type: "success" + }); + } catch (error) { + console.log(error); + createNotification({ + text: "Failed to rollback secrets", + type: "error" + }); + } + }; - const onSecretRollback = async () => { - if (!snapshotSecret?.version) { - createNotification({ - text: "Failed to find secret version", - type: "success" - }); - return; - } - try { - await performSecretRollback({ - workspaceId, - version: snapshotSecret.version, - environment: selectedEnvSlug, - folderId - }); - setValue("isSnapshotMode", false); - setSnaphotId(null); - queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug)); - createNotification({ - text: "Successfully rollback secrets", - type: "success" - }); - } catch (error) { - console.log(error); - createNotification({ - text: "Failed to rollback secrets", - type: "error" - }); - } - }; + const onAppendSecret = () => { + setSearchFilter(""); + append(DEFAULT_SECRET_VALUE); + }; - const onAppendSecret = () => { - setSearchFilter(""); - append(DEFAULT_SECRET_VALUE); - }; - - const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { - if (isSnapshotMode) { - await onSecretRollback(); - return; + const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => { + if (isSnapshotMode) { + await onSecretRollback(); + return; + } + // just closing this if save is triggered from drawer + handlePopUpClose("secretDetails"); + // encrypt and format the secrets to batch api format + // requests = [ {method:"", secret:""} ] + const batchedSecret = transformSecretsToBatchSecretReq( + deletedSecretIds.current, + latestFileKey, + userSec, + secrets?.secrets + ); + // type check + if (batchedSecret.length === 0) { + reset(); + return; + } + try { + await batchSecretOp({ + requests: batchedSecret, + workspaceId, + folderId, + environment + }); + createNotification({ + text: "Successfully saved changes", + type: "success" + }); + deletedSecretIds.current = []; + if (!hasUserPushed) { + await registerUserAction(USER_ACTION_PUSH); } - // just closing this if save is triggered from drawer + } catch (error) { + console.log(error); + createNotification({ + text: "Failed to save changes", + type: "error" + }); + } + }; + + const onDrawerOpen = useCallback((id: string | undefined, index: number) => { + handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen); + }, []); + + const onEnvChange = (slug: string) => { + if (hasUnsavedChanges) { + // eslint-disable-next-line no-alert + if (!window.confirm(leaveConfirmDefaultMessage)) return; + } + + const query: Record = { ...router.query, env: slug }; + delete query.folderId; + router.push({ + pathname: router.pathname, + query + }); + }; + + const handleDownloadSecret = () => { + const secretsFromImport: { key: string; value: string; comment: string }[] = []; + importedSecrets?.forEach(({ secrets: impSec }) => { + impSec.forEach((el) => { + secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment }); + }); + }); + downloadSecret(getValues("secrets"), secretsFromImport, environment); + }; + + // record all deleted ids + // This will make final deletion easier + const onSecretDelete = useCallback( + (index: number, secretName: string, id?: string, overrideId?: string) => { + if (id) + deletedSecretIds.current.push({ + id, + secretName + }); + if (overrideId) + deletedSecretIds.current.push({ + id: overrideId, + secretName + }); + remove(index); + // just the case if this is called from drawer handlePopUpClose("secretDetails"); - // encrypt and format the secrets to batch api format - // requests = [ {method:"", secret:""} ] - const batchedSecret = transformSecretsToBatchSecretReq( - deletedSecretIds.current, - latestFileKey, - userSec, - secrets?.secrets - ); - // type check - if (!selectedEnv?.slug) return; - if (batchedSecret.length === 0) { - reset(); - return; - } + }, + [] + ); + + const onCreateWsTag = useCallback( + async (tagName: string, tagColor: string) => { try { - await batchSecretOp({ - requests: batchedSecret, - workspaceId, - folderId, - environment: selectedEnv?.slug + await createWsTag({ + workspaceID: workspaceId, + tagName, + tagColor, + tagSlug: tagName.replace(" ", "_") }); + handlePopUpClose("addTag"); createNotification({ - text: "Successfully saved changes", + text: "Successfully created a tag", type: "success" }); - deletedSecretIds.current = []; - if (!hasUserPushed) { - await registerUserAction(USER_ACTION_PUSH); - } } catch (error) { - console.log(error); + console.error(error); createNotification({ - text: "Failed to save changes", + text: "Failed to create a tag", type: "error" }); } - }; + }, + [workspaceId] + ); - const onDrawerOpen = useCallback((id: string | undefined, index: number) => { - handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen); - }, []); - - const onEnvChange = (slug: string) => { - if (hasUnsavedChanges) { - // eslint-disable-next-line no-alert - if (!window.confirm(leaveConfirmDefaultMessage)) return; - } - - const env = userAvailableEnvs?.find((el) => el.slug === slug); - if (env) setSelectedEnv(env); - const query: Record = { ...router.query, env: slug }; - delete query.folderId; + const handleFolderOpen = useCallback( + (id: string) => { + setSearchFilter(""); router.push({ pathname: router.pathname, - query - }); - }; - - const handleDownloadSecret = () => { - const secretsFromImport: { key: string; value: string; comment: string }[] = []; - importedSecrets?.forEach(({ secrets: impSec }) => { - impSec.forEach((el) => { - secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment }); - }); - }); - downloadSecret(getValues("secrets"), secretsFromImport, selectedEnv?.slug); - }; - - // record all deleted ids - // This will make final deletion easier - const onSecretDelete = useCallback( - (index: number, secretName: string, id?: string, overrideId?: string) => { - if (id) - deletedSecretIds.current.push({ - id, - secretName - }); - if (overrideId) - deletedSecretIds.current.push({ - id: overrideId, - secretName - }); - remove(index); - // just the case if this is called from drawer - handlePopUpClose("secretDetails"); - }, - [] - ); - - const onCreateWsTag = useCallback( - async (tagName: string, tagColor: string) => { - try { - await createWsTag({ - workspaceID: workspaceId, - tagName, - tagColor, - tagSlug: tagName.replace(" ", "_") - }); - handlePopUpClose("addTag"); - createNotification({ - text: "Successfully created a tag", - type: "success" - }); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to create a tag", - type: "error" - }); + query: { + id: workspaceId, + env: environment, + folderId: id } - }, - [workspaceId] - ); + }); + }, + [environment, workspaceId] + ); - const handleFolderOpen = useCallback( - (id: string) => { - setSearchFilter(""); - router.push({ - pathname: router.pathname, - query: { - id: workspaceId, - env: envQuery, - folderId: id - } - }); - }, - [envQuery, workspaceId] - ); + const isEditFolder = Boolean(popUp?.folderForm?.data); - const isEditFolder = Boolean(popUp?.folderForm?.data); + // FOLDER SECTION + const handleFolderCreate = async (name: string) => { + try { + await createFolder({ + workspaceId, + environment, + folderName: name, + parentFolderId: folderId + }); + createNotification({ + type: "success", + text: "Successfully created folder" + }); + handlePopUpClose("folderForm"); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to create folder", + type: "error" + }); + } + }; - // FOLDER SECTION - const handleFolderCreate = async (name: string) => { + const handleFolderUpdate = useCallback( + async (name: string) => { + const { id } = popUp?.folderForm?.data as TDeleteFolderForm; try { - await createFolder({ + await updateFolder({ + folderId: id, workspaceId, - environment: selectedEnv?.slug || "", - folderName: name, - parentFolderId: folderId + environment, + name }); createNotification({ type: "success", - text: "Successfully created folder" + text: "Successfully updated folder" }); handlePopUpClose("folderForm"); } catch (error) { console.error(error); createNotification({ - text: "Failed to create folder", + text: "Failed to update folder", type: "error" }); } - }; + }, + [environment, (popUp?.folderForm?.data as TDeleteFolderForm)?.id] + ); - const handleFolderUpdate = useCallback( - async (name: string) => { - const { id } = popUp?.folderForm?.data as TDeleteFolderForm; - try { - await updateFolder({ - folderId: id, - workspaceId, - environment: selectedEnv?.slug || "", - name - }); - createNotification({ - type: "success", - text: "Successfully updated folder" - }); - handlePopUpClose("folderForm"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to update folder", - type: "error" - }); + const handleFolderDelete = useCallback(async () => { + const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; + try { + deleteFolder({ + workspaceId, + environment, + folderId: id + }); + createNotification({ + type: "success", + text: "Successfully removed folder" + }); + handlePopUpClose("deleteFolder"); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to remove folder", + type: "error" + }); + } + }, [(popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]); + + // SECRET IMPORT SECTION + const handleSecretImportCreate = async (env: string, secPath: string) => { + try { + await createSecretImport({ + workspaceId, + environment, + folderId, + secretImport: { + environment: env, + secretPath: secPath } - }, - [selectedEnv?.slug, (popUp?.folderForm?.data as TDeleteFolderForm)?.id] - ); + }); + createNotification({ + type: "success", + text: "Successfully create secret link" + }); + handlePopUpClose("addSecretImport"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create secret link", + type: "error" + }); + } + }; - const handleFolderDelete = useCallback(async () => { - const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm; - try { - deleteFolder({ + const handleSecretImportDelete = async () => { + const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport + ?.data as TDeleteSecretImport; + try { + if (secretImportCfg?._id) { + await deleteSecretImport({ workspaceId, - environment: selectedEnv?.slug || "", - folderId: id + environment, + folderId, + id: secretImportCfg?._id, + secretImportEnv: importEnv, + secretImportPath: impSecPath }); + handlePopUpClose("deleteSecretImport"); createNotification({ type: "success", - text: "Successfully removed folder" - }); - handlePopUpClose("deleteFolder"); - } catch (error) { - console.error(error); - createNotification({ - text: "Failed to remove folder", - type: "error" + text: "Successfully removed secret link" }); } - }, [selectedEnv?.slug, (popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]); - - // SECRET IMPORT SECTION - const handleSecretImportCreate = async (env: string, secPath: string) => { - try { - await createSecretImport({ - workspaceId, - environment: selectedEnv?.slug || "", - folderId, - secretImport: { - environment: env, - secretPath: secPath - } - }); - createNotification({ - type: "success", - text: "Successfully create secret link" - }); - handlePopUpClose("addSecretImport"); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to create secret link", - type: "error" - }); - } - }; - - const handleSecretImportDelete = async () => { - const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport - ?.data as TDeleteSecretImport; - try { - if (secretImportCfg?._id) { - await deleteSecretImport({ - workspaceId, - environment: selectedEnvSlug, - folderId, - id: secretImportCfg?._id, - secretImportEnv: importEnv, - secretImportPath: impSecPath - }); - handlePopUpClose("deleteSecretImport"); - createNotification({ - type: "success", - text: "Successfully removed secret link" - }); - } - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to remove secret link", - type: "error" - }); - } - }; - - const handleDragEnd = (evt: DragEndEvent) => { - const { active, over } = evt; - if (over?.id && active.id !== over.id) { - const oldIndex = items.findIndex(({ id }) => id === active.id); - const newIndex = items.findIndex(({ id }) => id === over.id); - const newImportOrder = arrayMove(items, oldIndex, newIndex); - setItems(newImportOrder); - updateSecretImportSync({ - workspaceId, - environment: selectedEnvSlug, - folderId, - id: secretImportCfg?._id || "", - secretImports: newImportOrder.map((el) => ({ - environment: el.environment, - secretPath: el.secretPath - })) - }); - } - }; - - // OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING - const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []); - const handleFolderCreatePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("folderForm", { id, name }), - [] - ); - const handleFolderDeletePopUpOpen = useCallback( - (id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }), - [] - ); - const handleSecretImportDelPopUpOpen = useCallback( - (impSecEnv: string, impSecPath: string) => - handlePopUpOpen("deleteSecretImport", { - environment: impSecEnv, - secretPath: impSecPath - }), - [] - ); - - // when secrets is not loading and secrets list is empty - const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; - - // folder list checks - const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading; - const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders; - - // when using snapshot mode and snapshot is loading and snapshot list is empty - const isFoldersEmpty = !isFolderListLoading && !folderList?.length; - const isSnapshotSecretEmtpy = - isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; - const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; - const isSecretImportEmpty = !secretImportCfg?.imports?.length; - const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; - - if (isSecretsLoading) { - return ( -
- loading animation -
- ); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to remove secret link", + type: "error" + }); } + }; - if ( - permission.cannot( - ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment: envQuery, secretPath }) - ) - ) { - return ( -
-
-
- -
-
-
Permission Denied
-
- You do not have permission to this page.
Kindly contact your organization - administrator -
-
-
-
- ); + const handleDragEnd = (evt: DragEndEvent) => { + const { active, over } = evt; + if (over?.id && active.id !== over.id) { + const oldIndex = items.findIndex(({ id }) => id === active.id); + const newIndex = items.findIndex(({ id }) => id === over.id); + const newImportOrder = arrayMove(items, oldIndex, newIndex); + setItems(newImportOrder); + updateSecretImportSync({ + workspaceId, + environment, + folderId, + id: secretImportCfg?._id || "", + secretImports: newImportOrder.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath + })) + }); } + }; + // OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING + const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []); + const handleFolderCreatePopUpOpen = useCallback( + (id: string, name: string) => handlePopUpOpen("folderForm", { id, name }), + [] + ); + const handleFolderDeletePopUpOpen = useCallback( + (id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }), + [] + ); + const handleSecretImportDelPopUpOpen = useCallback( + (impSecEnv: string, impSecPath: string) => + handlePopUpOpen("deleteSecretImport", { + environment: impSecEnv, + secretPath: impSecPath + }), + [] + ); + + // when secrets is not loading and secrets list is empty + const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; + + // folder list checks + const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading; + const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders; + + // when using snapshot mode and snapshot is loading and snapshot list is empty + const isFoldersEmpty = !isFolderListLoading && !folderList?.length; + const isSnapshotSecretEmtpy = + isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length; + const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy; + const isSecretImportEmpty = !secretImportCfg?.imports?.length; + const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; + + if (isSecretsLoading) { return ( -
-
- {/* breadcrumb row */} -
- envir.slug === envQuery)?.[0]?.name || "" - } - isFolderMode - folders={folderData?.dir} - isProjectRelated - userAvailableEnvs={userAvailableEnvs} - onEnvChange={onEnvChange} +
+ loading animation +
+ ); + } + + return ( +
+ + {/* breadcrumb row */} +
+ envir.slug === environment)?.[0]?.name || "" + } + isFolderMode + folders={folderData?.dir} + isProjectRelated + userAvailableEnvs={userAvailableEnvs} + onEnvChange={onEnvChange} + /> +
+
+
{isRollbackMode ? "Secret Snapshot" : ""}
+ {isRollbackMode && Boolean(snapshotSecret) && ( + + {new Date(snapshotSecret?.createdAt || "").toLocaleString()} + + )} +
+ {/* Environment, search and other action row */} +
+
+ setSearchFilter(e.target.value)} + leftIcon={} />
-
-
{isRollbackMode ? "Secret Snapshot" : ""}
- {isRollbackMode && Boolean(snapshotSecret) && ( - - {new Date(snapshotSecret?.createdAt || "").toLocaleString()} - - )} -
- {/* Environment, search and other action row */} -
-
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
-
-
- - - - - - - -
- -
-
-
-
-
- - setIsSecretValueHidden.toggle()} - > - +
+
+ + + + - -
- - {(isAllowed) => ( -
- - handlePopUpOpen("secretSnapshots")} - > - - - -
- )} -
- - {(isAllowed) => ( -
+ + +
- )} - - {!isReadOnly && !isRollbackMode && ( -
- - {(isAllowed) => ( - - )} - - - -
- -
-
- -
-
- - {(isAllowed) => ( - - )} - -
-
- - {(isAllowed) => ( - - )} - -
-
-
-
+ + +
+
+ + setIsSecretValueHidden.toggle()} + > + + + +
+ + {(isAllowed) => ( +
+ + handlePopUpOpen("secretSnapshots")} + > + + +
)} - {isRollbackMode && ( - +
+ )} +
+ {!isReadOnly && !isRollbackMode && ( +
+ - Go back + {(isAllowed) => ( + + )} + + + +
+ +
+
+ +
+
+ + {(isAllowed) => ( + + )} + +
+
+ + {(isAllowed) => ( + + )} + +
+
+
+
+
+ )} + {isRollbackMode && ( + + )} + + {(isAllowed) => ( + )} - - {(isAllowed) => ( - - )} - -
+
-
- {!isEmptyPage && ( - - - - - - - - {fields.map(({ id, _id }, index) => ( + +
+ {!isEmptyPage && ( + + +
+ + + + + {permission.can( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ) ? ( + fields.map(({ id, _id }, index) => ( - ))} - {!isReadOnly && !isRollbackMode && ( - - - - )} - -
- - {(isAllowed) => ( - - )} - -
-
-
- )} - - handlePopUpToggle("secretSnapshots", isOpen)} - fetchNextPage={fetchNextPage} - hasNextPage={hasNextPage} - snapshotId={snapshotId} - isFetchingNextPage={isFetchingNextPage} - secretSnaphots={secretSnaphots} - onSelectSnapshot={setSnaphotId} - /> - handlePopUpToggle("secretDetails", isOpen)} - secretVersion={secretVersion} - index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} - onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} - /> - - + + + + + )} + {!isReadOnly && !isRollbackMode && ( + + + + {(isAllowed) => ( + + )} + + + + )} + + + + + )} + + handlePopUpToggle("secretSnapshots", isOpen)} + fetchNextPage={fetchNextPage} + hasNextPage={hasNextPage} + snapshotId={snapshotId} + isFetchingNextPage={isFetchingNextPage} + secretSnaphots={secretSnaphots} + onSelectSnapshot={setSnaphotId} /> -
- {/* secrets table and drawers, modals */} - - {/* Create a new tag modal */} - { - handlePopUpToggle("addTag", open); - }} - > - - - - - {/* Uploaded env override or not confirmation modal */} - handlePopUpToggle("uploadedSecOpts", open)} - > - handlePopUpClose("uploadedSecOpts")} - > - Keep old - , - - ]} - > -
-
Your file contains following duplicate secrets
-
- {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) - ?.map((key) => key) - .join(", ")} -
-
Are you sure you want to overwrite these secrets?
-
-
-
- handlePopUpToggle("folderForm", isOpen)} - > - - handlePopUpToggle("secretDetails", isOpen)} + secretVersion={secretVersion} + index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} + onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} /> - - - handlePopUpToggle("addSecretImport", isOpen)} - > - - - - - handlePopUpToggle("deleteFolder", isOpen)} - onDeleteApproved={handleFolderDelete} - /> - handlePopUpToggle("deleteSecretImport", isOpen)} - onDeleteApproved={handleSecretImportDelete} - /> - handlePopUpToggle("compareSecrets", open)} - > - - - - - {subscription && ( - handlePopUpToggle("upgradePlan", isOpen)} - text={ - subscription.slug === null - ? "You can perform point-in-time recovery under an Enterprise license" - : "You can perform point-in-time recovery if you switch to Infisical's Team plan" - } + + - )} -
- ); - }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Secrets } -); +
+ {/* secrets table and drawers, modals */} + + {/* Create a new tag modal */} + { + handlePopUpToggle("addTag", open); + }} + > + + + + + {/* Uploaded env override or not confirmation modal */} + handlePopUpToggle("uploadedSecOpts", open)} + > + handlePopUpClose("uploadedSecOpts")} + > + Keep old + , + + ]} + > +
+
Your file contains following duplicate secrets
+
+ {Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) + ?.map((key) => key) + .join(", ")} +
+
Are you sure you want to overwrite these secrets?
+
+
+
+ handlePopUpToggle("folderForm", isOpen)} + > + + + + + handlePopUpToggle("addSecretImport", isOpen)} + > + + + + + handlePopUpToggle("deleteFolder", isOpen)} + onDeleteApproved={handleFolderDelete} + /> + handlePopUpToggle("deleteSecretImport", isOpen)} + onDeleteApproved={handleSecretImportDelete} + /> + handlePopUpToggle("compareSecrets", open)} + > + + + + + {subscription && ( + handlePopUpToggle("upgradePlan", isOpen)} + text={ + subscription.slug === null + ? "You can perform point-in-time recovery under an Enterprise license" + : "You can perform point-in-time recovery if you switch to Infisical's Team plan" + } + /> + )} +
+ ); +}; diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx index 4fa6df286..516bb74b4 100644 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { subject } from "@casl/ability"; import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -9,6 +10,8 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; type Props = { folders?: Array<{ id: string; name: string }>; search?: string; + environment: string; + secretPath: string; onFolderUpdate: (folderId: string, name: string) => void; onFolderDelete: (folderId: string, name: string) => void; onFolderOpen: (folderId: string) => void; @@ -20,7 +23,9 @@ export const FolderSection = memo( onFolderDelete: handleFolderDelete, onFolderOpen: handleFolderOpen, search = "", - folders = [] + folders = [], + environment, + secretPath }: Props) => { return ( <> @@ -51,7 +56,7 @@ export const FolderSection = memo(
{(isAllowed) => (
@@ -72,7 +77,7 @@ export const FolderSection = memo( {(isAllowed) => (
diff --git a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx index 9668fe819..ef85c4896 100644 --- a/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx +++ b/frontend/src/views/DashboardPage/components/SecretDetailDrawer/SecretDetailDrawer.tsx @@ -1,4 +1,5 @@ import { useFormContext, useWatch } from "react-hook-form"; +import { subject } from "@casl/ability"; import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -23,6 +24,8 @@ import { GenRandomNumber } from "./GenRandomNumber"; type Props = { isDrawerOpen: boolean; + environment: string; + secretPath: string; onOpenChange: (isOpen: boolean) => void; index: number; isReadOnly?: boolean; @@ -41,7 +44,9 @@ export const SecretDetailDrawer = ({ isReadOnly, onSecretDelete, onSave, - onEnvCompare + onEnvCompare, + environment, + secretPath }: Props): JSX.Element => { const [canRevealSecVal, setCanRevealSecVal] = useToggle(); const [canRevealSecOverride, setCanRevealSecOverride] = useToggle(); @@ -89,7 +94,7 @@ export const SecretDetailDrawer = ({
{(isAllowed) => (
{(isAllowed) => ( - - {(isAllowed) => ( - - )} - +
+ + {(isAllowed) => ( + + )} + +
{(isAllowed) => (
{(isAllowed) => (
diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx index 9d796d079..db12e84a4 100644 --- a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx @@ -59,13 +59,23 @@ export const computeImportedSecretRows = ( type Props = { secrets?: DecryptedSecret[]; importedSecrets?: TImportedSecrets; + environment: string; + secretPath: string; onSecretImportDelete: (env: string, secPath: string) => void; items: { id: string; environment: string; secretPath: string }[]; searchTerm: string; }; export const SecretImportSection = memo( - ({ secrets = [], importedSecrets = [], onSecretImportDelete, items = [], searchTerm = "" }: Props) => { + ({ + secrets = [], + environment, + secretPath, + importedSecrets = [], + onSecretImportDelete, + items = [], + searchTerm = "" + }: Props) => { const { currentWorkspace } = useWorkspace(); const environments = currentWorkspace?.environments || []; @@ -82,6 +92,8 @@ export const SecretImportSection = memo( secrets, environments )} + secretPath={secretPath} + environment={environment} onDelete={onSecretImportDelete} importedSecPath={impSecPath} searchTerm={searchTerm} diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index 307cc981b..c31c6df2e 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -8,6 +8,7 @@ import { UseFormSetValue, useWatch } from "react-hook-form"; +import { subject } from "@casl/ability"; import { faCheck, faCodeBranch, @@ -22,31 +23,34 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { cx } from "cva"; import { twMerge } from "tailwind-merge"; +// TODO:(akhilmhdh): Refactor this +import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + FormControl, HoverCard, HoverCardContent, HoverCardTrigger, IconButton, Input, Popover, + PopoverContent, PopoverTrigger, SecretInput, Tag, + TextArea, Tooltip } from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionSub -} from "@app/context/ProjectPermissionContext/types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; import { WsTag } from "@app/hooks/api/types"; -import AddTagPopoverContent from "../../../../components/AddTagPopoverContent/AddTagPopoverContent"; import { FormData, SecretActionType } from "../../DashboardPage.utils"; type Props = { index: number; + environment: string; + secretPath: string; // backend generated unique id secUniqId?: string; // permission and external state's that decided to hide or show @@ -74,6 +78,8 @@ type Props = { export const SecretInputRow = memo( ({ index, + secretPath, + environment, isSecretValueHidden, onRowExpand, isReadOnly, @@ -84,7 +90,7 @@ export const SecretInputRow = memo( onSecretDelete, searchTerm, control, - // register, + register, setValue, isKeyError, keyError, @@ -222,7 +228,6 @@ export const SecretInputRow = memo(
{index + 1}
-
- - - - - + + {(isAllowed) => ( + + + + )} +
{!isAddOnly && (
- - -
- -
-
-
+ + {(isAllowed) => ( + +
+ +
+
+ )} +
)} - -
- - - + + +
+ - - - - onSelectTag(wsTag)} - handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)} - handleTagOnMouseLeave={() => handleTagOnMouseLeave()} - checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)} - handleOnCreateTagOpen={() => onCreateTagOpen()} - /> - -
- + {(isAllowed) => ( + + + + )} + +
+ + + +