From 1bf9041ac934d73e74d90afea314875745093fa2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 14 Aug 2023 20:44:55 +0530 Subject: [PATCH 01/37] 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 02/37] 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 03/37] 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 04/37] 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 05/37] 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 08/37] 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 11/37] 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 12/37] 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 18/37] 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 19/37] 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 20/37] 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 21/37] 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 22/37] 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 23/37] 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 24/37] 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) => ( + + + + )} + +
+ + + +