From 520a553ea1ad277b7e03bd67f9b66bba21eff443 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Tue, 22 Aug 2023 12:34:33 +0530 Subject: [PATCH] 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 5a7fe7c8..cf1b1a60 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 3fd7fa3d..7700c21e 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 0032e7e6..22a3819e 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 e2a64eeb..04211395 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 00000000..02f992aa --- /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 c431efd7..734ea73f 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 a1f9498e..bccabc6e 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 0bfb44f7..679fe106 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 00000000..9f6cd39c --- /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 e86fa431..24854f04 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 336777d6..dddbe4ef 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 d385981d..a7bc6dda 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 00000000..6fe0b6e2 --- /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 00000000..209b3a24 --- /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 00000000..3f455925 --- /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 b0279f1f..3234adcc 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 5fd5cd3a..ff7314db 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 f9e5336a..e5fda67b 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 8fda90a8..917b6d5f 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 07b73fae..b4dd2f50 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 90314a1e..c940c8d6 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 5c3890ee..07f497c0 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 12e571ad..f9e40d2d 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 c540798e..45a1cbe7 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 92585261..f79c210c 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 e5323557..3e0eb1f6 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 626333e4..56e0f6a3 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 997606b1..4bcbb833 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 d2c213dc..a57d2f23 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 2cdb0036..608268de 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 00000000..695e806c --- /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 00000000..17c17b12 --- /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 00000000..0230a248 --- /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 00000000..9f8e88a8 --- /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 00000000..0700724d --- /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 00000000..c74df03c --- /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 00000000..2c4ab23d --- /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 00000000..8ff81be4 --- /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 00000000..b664a1d9 --- /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 00000000..5dc87a2d --- /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 00000000..93d51dcc --- /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 e434f653..b0d3ca37 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) => (