From c2d0c1142f5beb18c90c14e35c3eb329aafc26b4 Mon Sep 17 00:00:00 2001 From: Novak Zaballa <41410593+novakzaballa@users.noreply.github.com> Date: Tue, 7 Nov 2023 10:55:02 -0400 Subject: [PATCH] feat: Add or remove user and groups from roles (#2791) --- frontend/common/services/useRole.ts | 123 +++ frontend/common/services/useRolePermission.ts | 227 +++++ .../common/services/useRolePermissionGroup.ts | 140 +++ frontend/common/services/useRolesUser.ts | 90 ++ frontend/common/types/requests.ts | 23 +- frontend/common/types/responses.ts | 17 + .../CollapsibleNestedRolePermissionsList.tsx | 182 ++++ frontend/web/components/EditPermissions.tsx | 920 ++++++++++++++---- frontend/web/components/GroupSelect.tsx | 6 +- frontend/web/components/MyRoleSelect.tsx | 17 + frontend/web/components/RolesSelect.tsx | 83 ++ frontend/web/components/UserSelect.js | 4 +- frontend/web/components/base/forms/Tabs.js | 1 + .../components/modals/ConfirmDeleteRole.tsx | 53 + frontend/web/components/modals/CreateRole.tsx | 574 +++++++++++ .../web/components/pages/ChangeRequestPage.js | 1 - .../pages/EnvironmentSettingsPage.js | 38 +- .../pages/OrganisationSettingsPage.js | 241 ++++- .../components/pages/ProjectSettingsPage.js | 33 +- frontend/web/styles/project/_lists.scss | 17 + frontend/web/styles/project/_modals.scss | 7 + 21 files changed, 2568 insertions(+), 229 deletions(-) create mode 100644 frontend/common/services/useRole.ts create mode 100644 frontend/common/services/useRolePermission.ts create mode 100644 frontend/common/services/useRolePermissionGroup.ts create mode 100644 frontend/common/services/useRolesUser.ts create mode 100644 frontend/web/components/CollapsibleNestedRolePermissionsList.tsx create mode 100644 frontend/web/components/MyRoleSelect.tsx create mode 100644 frontend/web/components/RolesSelect.tsx create mode 100644 frontend/web/components/modals/ConfirmDeleteRole.tsx create mode 100644 frontend/web/components/modals/CreateRole.tsx diff --git a/frontend/common/services/useRole.ts b/frontend/common/services/useRole.ts new file mode 100644 index 000000000000..bb20e97ebe1c --- /dev/null +++ b/frontend/common/services/useRole.ts @@ -0,0 +1,123 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const roleService = service + .enhanceEndpoints({ addTagTypes: ['Role'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createRole: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'Role' }], + query: (query: Req['createRoles']) => ({ + body: query, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/`, + }), + }), + deleteRole: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'DeleteRole' }], + query: (query: Req['deleteRole']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/`, + }), + }), + getRole: builder.query({ + providesTags: (res) => [{ id: res?.id, type: 'RolesById' }], + query: (query: Req['getRolesById']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/`, + }), + }), + getRoles: builder.query({ + providesTags: (res) => [{ id: res?.id, type: 'Role' }], + query: (query: Req['getRoles']) => ({ + url: `organisations/${query.organisation_id}/roles/`, + }), + }), + updateRole: builder.mutation({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'RolesById' }, + { id: res?.id, type: 'RolesById' }, + ], + query: (query: Req['updateRole']) => ({ + body: query.body, + method: 'PUT', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createRole( + store: any, + data: Req['createRoles'], + options?: Parameters[1], +) { + store.dispatch(roleService.endpoints.createRoles.initiate(data, options)) + return Promise.all(store.dispatch(roleService.util.getRunningQueriesThunk())) +} +export async function getRoles( + store: any, + data: Req['getRoles'], + options?: Parameters[1], +) { + return store.dispatch(roleService.endpoints.getRoles.initiate(data, options)) +} +export async function deleteRole( + store: any, + data: Req['deleteRolesById'], + options?: Parameters< + typeof rolesByIdService.endpoints.deleteRolesById.initiate + >[1], +) { + store.dispatch( + rolesByIdService.endpoints.deleteRolesById.initiate(data, options), + ) + return Promise.all( + store.dispatch(rolesByIdService.util.getRunningQueriesThunk()), + ) +} +export async function getRole( + store: any, + data: Req['getRolesById'], + options?: Parameters< + typeof rolesByIdService.endpoints.getRolesById.initiate + >[1], +) { + store.dispatch( + rolesByIdService.endpoints.getRolesById.initiate(data, options), + ) + return Promise.all( + store.dispatch(rolesByIdService.util.getRunningQueriesThunk()), + ) +} +export async function updateRole( + store: any, + data: Req['updateRolesById'], + options?: Parameters< + typeof rolesByIdService.endpoints.updateRolesById.initiate + >[1], +) { + store.dispatch( + rolesByIdService.endpoints.updateRolesById.initiate(data, options), + ) + return Promise.all( + store.dispatch(rolesByIdService.util.getRunningQueriesThunk()), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateRoleMutation, + useDeleteRoleMutation, + useGetRoleQuery, + useGetRolesQuery, + useUpdateRoleMutation, + // END OF EXPORTS +} = roleService + +/* Usage examples: +const { data, isLoading } = useGetRolesQuery({ id: 2 }, {}) //get hook +const [createRole, { isLoading, data, isSuccess }] = useCreateRoleMutation() //create hook +roleService.endpoints.getRoles.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useRolePermission.ts b/frontend/common/services/useRolePermission.ts new file mode 100644 index 000000000000..535ee22c30a4 --- /dev/null +++ b/frontend/common/services/useRolePermission.ts @@ -0,0 +1,227 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const rolePermissionService = service + .enhanceEndpoints({ addTagTypes: ['rolePermission'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createRolePermissions: builder.mutation< + Res['rolePermission'], + Req['createRolePermission'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'rolePermission' }, + { id: res?.id, type: 'rolePermission' }, + ], + query: (query: Req['updateRolePermission']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/${query.level}-permissions/`, + }), + transformErrorResponse: () => { + toast('Failed to Save', 'danger') + }, + }), + + getRoleEnvironmentPermissions: builder.query< + Res['rolePermission'], + Req['getRolePermission'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'rolePermission' }], + query: (query: Req['getRolePermission']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/environments-permissions/?environment=${query.env_id}`, + }), + }), + getRoleOrganisationPermissions: builder.query< + Res['rolePermission'], + Req['getRolePermission'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'rolePermission' }], + query: (query: Req['getRolePermission']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/organisation-permissions/`, + }), + }), + getRoleProjectPermissions: builder.query< + Res['rolePermission'], + Req['getRolePermission'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'RolePermission' }], + query: (query: Req['getRolePermission']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/projects-permissions/?project=${query.project_id}`, + }), + }), + + getRolesEnvironmentPermissions: builder.query< + Res['rolePermission'], + Req['getRolePermission'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'RolePermission' }], + query: (query: Req['getRolePermission']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/environments-permissions/?environment=${query.env_id}`, + }), + }), + + getRolesProjectPermissions: builder.query< + Res['rolePermission'], + Req['getRolePermission'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'RolePermission' }], + query: (query: Req['getRolePermission']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/projects-permissions/?project=${query.project_id}`, + }), + }), + + updateRolePermissions: builder.mutation< + Res['rolePermission'], + Req['updateRolePermission'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'rolePermission' }], + query: (query: Req['updateRolePermission']) => ({ + body: query.body, + method: 'PUT', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/${query.level}-permissions/${query.id}/`, + }), + transformErrorResponse: () => { + toast('Failed to Save', 'danger') + }, + }), + + // END OF ENDPOINTS + }), + }) + +export async function getRoleOrganisationPermissions( + store: any, + data: Req['getRolePermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.getRoleOrganisationPermissions.initiate + >[1], +) { + store.dispatch( + rolePermissionService.endpoints.getRoleOrganisationPermissions.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), + ) +} +export async function updateRolePermissions( + store: any, + data: Req['updateRolePermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.updateRolePermissions.initiate + >[1], +) { + store.dispatch( + rolePermissionService.endpoints.updateRolePermissions.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), + ) +} + +export async function getRoleProjectPermissions( + store: any, + data: Req['getRolePermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.getRoleProjectPermissions.initiate + >[1], +) { + store.dispatch( + rolePermissionService.endpoints.getRoleProjectPermissions.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), + ) +} + +export async function getRoleEnvironmentPermissions( + store: any, + data: Req['getRolePermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate + >[1], +) { + store.dispatch( + rolePermissionService.endpoints.getRoleEnvironmentPermissions.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), + ) +} + +export async function createRolePermissions( + store: any, + data: Req['updateRolePermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.createRolePermissions.initiate + >[1], +) { + store.dispatch( + rolePermissionService.endpoints.createRolePermissions.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionService.util.getRunningQueriesThunk()), + ) +} +export async function getRolesProjectPermissions( + store: any, + data: Req['getRolesPermission'], + options?: Parameters< + typeof rolePermissionService.endpoints.getRolesProjectPermissions.initiate + >[1], +) { + return store.dispatch( + rolePermissionService.endpoints.getRolesProjectPermissions.initiate( + data, + options, + ), + ) +} + +export async function getRolesEnvironmentPermissions( + store: any, + data: Req['getRolesEnvironment'], + options?: Parameters< + typeof rolePermissionService.endpoints.getRolesProjectPermissions.initiate + >[1], +) { + return store.dispatch( + rolePermissionService.endpoints.getRolesEnvironmentPermissions.initiate( + data, + options, + ), + ) +} + +// END OF FUNCTION_EXPORTS + +export const { + useCreateRolePermissionsMutation, + useGetRoleEnvironmentPermissionsQuery, + useGetRoleOrganisationPermissionsQuery, + useGetRoleProjectPermissionsQuery, + useUpdateRolePermissionsMutation, + // END OF EXPORTS +} = rolePermissionService + +/* Usage examples: +const { data, isLoading } = useGetRoleOrganisationPermissionsQuery({ id: 2 }, {}) //get hook +const [createRolePermission, { isLoading, data, isSuccess }] = useCreateRolePermissionMutation() //create hook +rolePermissionService.endpoints.getRolePermission.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useRolePermissionGroup.ts b/frontend/common/services/useRolePermissionGroup.ts new file mode 100644 index 000000000000..85389dbd4862 --- /dev/null +++ b/frontend/common/services/useRolePermissionGroup.ts @@ -0,0 +1,140 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const rolePermissionGroupService = service + .enhanceEndpoints({ addTagTypes: ['RolePermissionGroup'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createRolePermissionGroup: builder.mutation< + Res['rolePermissionGroup'], + Req['createRolePermissionGroup'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'RolePermissionGroup' }], + query: (query: Req['createRolePermissionGroup']) => ({ + body: query.data, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/groups/`, + }), + }), + deleteRolePermissionGroup: builder.mutation< + Res['rolePermissionGroup'], + Req['deleteRolePermissionGroup'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'RolePermissionGroup' }], + query: (query: Req['deleteRolePermissionGroup']) => ({ + body: query, + method: 'DELETE', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/groups/${query.group_id}/`, + }), + }), + getRolePermissionGroup: builder.query< + Res['rolePermissionGroup'], + Req['getRolePermissionGroup'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'RolePermissionGroup' }], + query: (query: Req['getRolePermissionGroup']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/groups/`, + }), + }), + updateRolePermissionGroup: builder.mutation< + Res['rolePermissionGroup'], + Req['updateRolePermissionGroup'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'RolePermissionGroup' }, + { id: res?.id, type: 'RolePermissionGroup' }, + ], + query: (query: Req['updateRolePermissionGroup']) => ({ + body: query, + method: 'PUT', + url: `organisations/${query.id}/roles/${query.role_id}/groups/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createRolePermissionGroup( + store: any, + data: Req['createRolePermissionGroup'], + options?: Parameters< + typeof rolePermissionGroupService.endpoints.createRolePermissionGroup.initiate + >[1], +) { + store.dispatch( + rolePermissionGroupService.endpoints.createRolePermissionGroup.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionGroupService.util.getRunningQueriesThunk()), + ) +} +export async function deleteRolePermissionGroup( + store: any, + data: Req['deleteRolePermissionGroup'], + options?: Parameters< + typeof rolePermissionGroupService.endpoints.deleteRolePermissionGroup.initiate + >[1], +) { + store.dispatch( + rolePermissionGroupService.endpoints.deleteRolePermissionGroup.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionGroupService.util.getRunningQueriesThunk()), + ) +} +export async function getRolePermissionGroup( + store: any, + data: Req['getRolePermissionGroup'], + options?: Parameters< + typeof rolePermissionGroupService.endpoints.getRolePermissionGroup.initiate + >[1], +) { + store.dispatch( + rolePermissionGroupService.endpoints.getRolePermissionGroup.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionGroupService.util.getRunningQueriesThunk()), + ) +} +export async function updateRolePermissionGroup( + store: any, + data: Req['updateRolePermissionGroup'], + options?: Parameters< + typeof rolePermissionGroupService.endpoints.updateRolePermissionGroup.initiate + >[1], +) { + store.dispatch( + rolePermissionGroupService.endpoints.updateRolePermissionGroup.initiate( + data, + options, + ), + ) + return Promise.all( + store.dispatch(rolePermissionGroupService.util.getRunningQueriesThunk()), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateRolePermissionGroupMutation, + useDeleteRolePermissionGroupMutation, + useGetRolePermissionGroupQuery, + useUpdateRolePermissionGroupMutation, + // END OF EXPORTS +} = rolePermissionGroupService + +/* Usage examples: +const { data, isLoading } = useGetRolePermissionGroupQuery({ id: 2 }, {}) //get hook +const [createRolePermissionGroup, { isLoading, data, isSuccess }] = useCreateRolePermissionGroupMutation() //create hook +rolePermissionGroupService.endpoints.getRolePermissionGroup.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/services/useRolesUser.ts b/frontend/common/services/useRolesUser.ts new file mode 100644 index 000000000000..4f007877ddcb --- /dev/null +++ b/frontend/common/services/useRolesUser.ts @@ -0,0 +1,90 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const rolesUserService = service + .enhanceEndpoints({ addTagTypes: ['RolesUser'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createRolesPermissionUsers: builder.mutation< + Res['rolesUsers'], + Req['createRolesPermissionUsers'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'RolesUser' }], + query: (query: Req['createRolesPermissionUsers']) => ({ + body: query.data, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/users/`, + }), + }), + deleteRolesPermissionUsers: builder.mutation< + Res['rolesUsers'], + Req['deleteRolesPermissionUsers'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'RolesUser' }], + query: (query: Req['deleteRolesPermissionUsers']) => ({ + body: query, + method: 'DELETE', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/users/${query.user_id}/`, + }), + }), + getRolesPermissionUsers: builder.query< + Res['rolesUsers'], + Req['getRolesPermissionUsers'] + >({ + providesTags: (res) => [{ id: res?.id, type: 'RolesUser' }], + query: (query: Req['getRolesPermissionUsers']) => ({ + url: `organisations/${query.organisation_id}/roles/${query.role_id}/users/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function createPermissionRolesUsers( + store: any, + data: Req['createRolesUsers'], + options?: Parameters< + typeof rolesUserService.endpoints.createRolesUsers.initiate + >[1], +) { + return store.dispatch( + rolesUserService.endpoints.createRolesUsers.initiate(data, options), + ) +} +export async function deletePermissionRolesUsers( + store: any, + data: Req['deleteRolesPermissionUsers'], + options?: Parameters< + typeof rolesUserService.endpoints.deleteRolesUsers.initiate + >[1], +) { + return store.dispatch( + rolesUserService.endpoints.deleteRolesUsers.initiate(data, options), + ) +} +export async function getRolesPermissionUsers( + store: any, + data: Req['getRolesPermissionUsers'], + options?: Parameters< + typeof rolesUserService.endpoints.getRolesUsers.initiate + >[1], +) { + return store.dispatch( + rolesUserService.endpoints.getRolesUsers.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateRolesPermissionUsersMutation, + useDeleteRolesPermissionUsersMutation, + useGetRolesPermissionUsersQuery, + // END OF EXPORTS +} = rolesUserService + +/* Usage examples: +const { data, isLoading } = useGetRolesPermissionUsersQuery({ id: 2 }, {}) //get hook +const [createRolesUsers, { isLoading, data, isSuccess }] = useCreateRolesPermissionUsersMutation() //create hook +rolesUserService.endpoints.getRolesPermissionUsers.select({id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 039802097876..31c69f25bfed 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -1,4 +1,4 @@ -import { Account, Segment, Tag, FeatureStateValue } from './responses' +import { Account, Segment, Tag, FeatureStateValue, Role } from './responses' export type PagedRequest = T & { page?: number @@ -100,11 +100,32 @@ export type Req = { feature_segment: featureSegment feature_state_value: FeatureStateValue } + getRoles: { organisation_id: string } + createRole: { organisation_id: string; body: Role } + getRole: { organisation_id: string; role_id: string } + updateRole: { organisation_id: string; role_id: string; body: Role } + deleteRole: { organisation_id: string; role_id: string } + getRolePermission: { organisation_id: string; role_id: string } + updateRolePermission: { organisation_id: string; role_id: string } + deleteRolePermission: { organisation_id: string; role_id: string } + createRolePermission: { organisation_id: string; role_id: string } getIdentityFeatureStates: { environment: string user: string } getProjectFlags: { project: string } + getRolesPermissionUsers: { organisation_id: string; role_id: string } + deleteRolesPermissionUsers: { + organisation_id: string + role_id: string + user_id: string + level?: string + } + createRolesPermissionUsers: { organisation_id: string; role_id: string } + getRolePermissionGroup: { id: string } + updateRolePermissionGroup: { id: string } + deleteRolePermissionGroup: { id: string } + createRolePermissionGroup: { organisation_id: string; role_id: string } getGetSubscriptionMetadata: { id: string } getEnvironment: { id: string } getSubscriptionMetadata: { id: string } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 8d16bec8eda2..1db478daaead 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -295,6 +295,19 @@ export type Account = { is_superuser: boolean } +export type Role = { + id: number + name: string + description?: string + organisation: number +} + +export type RolePermissionUser = { + user: number + role: number + id: number +} + export type Res = { segments: PagedResponse segment: Segment @@ -354,9 +367,13 @@ export type Res = { } value: string } + roles: Role[] + rolePermission: { id: string } projectFlags: PagedResponse identityFeatureStates: IdentityFeatureState[] + rolesPermissionUsers: RolePermissionUser + rolePermissionGroup: { id: string } getSubscriptionMetadata: { id: string } environment: Environment launchDarklyProjectImport: LaunchDarklyProjectImport diff --git a/frontend/web/components/CollapsibleNestedRolePermissionsList.tsx b/frontend/web/components/CollapsibleNestedRolePermissionsList.tsx new file mode 100644 index 000000000000..58e00da5eee4 --- /dev/null +++ b/frontend/web/components/CollapsibleNestedRolePermissionsList.tsx @@ -0,0 +1,182 @@ +import React, { useState, forwardRef, useImperativeHandle } from 'react' +import Icon from 'components/Icon' +import { EditPermissionsModal } from 'components/EditPermissions' +import { + useGetRoleProjectPermissionsQuery, + useGetRoleEnvironmentPermissionsQuery, +} from 'common/services/useRolePermission' +import Format from 'common/utils/format' + +type MainItem = { + name: string + id: string +} + +type CollapsibleNestedRolePermissionsListProps = { + mainItems: MainItem[] + role: Role + level: string + filter: string +} + +const PermissionsSummary = ({ level, levelId, role }) => { + const { data: projectPermissions, isLoading: projectIsLoading } = + useGetRoleProjectPermissionsQuery( + { + organisation_id: role?.organisation, + project_id: levelId, + role_id: role?.id, + }, + { skip: !levelId || level !== 'project' }, + ) + + const { data: envPermissions, isLoading: envIsLoading } = + useGetRoleEnvironmentPermissionsQuery( + { + env_id: levelId, + organisation_id: role?.organisation, + role_id: role?.id, + }, + { skip: !levelId || level !== 'environment' }, + ) + + const permissions = projectPermissions || envPermissions + const roleResult = permissions?.results.filter( + (item) => item.role === role?.id, + ) + const roleRermissions = + roleResult && roleResult.length > 0 ? roleResult[0].permissions : [] + + const isAdmin = + roleResult && roleResult.length > 0 ? roleResult[0].admin : false + + const permissionsSummary = + (roleRermissions && + roleRermissions.length > 0 && + roleRermissions.map((item) => Format.enumeration.get(item)).join(', ')) || + '' + + return projectIsLoading || envIsLoading ? ( +
+ +
+ ) : ( +
{isAdmin ? 'Administrator' : permissionsSummary}
+ ) +} + +const CollapsibleNestedRolePermissionsList: React.FC = + forwardRef(({ filter, level, mainItems, role }, ref) => { + const [expandedItems, setExpandedItems] = useState([]) + const [unsavedProjects, setUnsavedProjects] = useState([]) + + const mainItemsFiltered = + mainItems && + mainItems?.filter((v) => { + const search = filter.toLowerCase() + if (!search) return true + return `${v.name}`.toLowerCase().includes(search) + }) + + const toggleExpand = (id: string) => { + setExpandedItems((prevExpanded) => + prevExpanded.includes(id) + ? prevExpanded.filter((item) => item !== id) + : [...prevExpanded, id], + ) + } + + const removeUnsavedProject = (projectId) => { + setUnsavedProjects((prevUnsavedProjects) => + prevUnsavedProjects.filter((id) => id !== projectId), + ) + } + + useImperativeHandle( + ref, + () => { + return { + onClosing() { + if (unsavedProjects.length > 0) { + return new Promise((resolve) => { + openConfirm( + 'Are you sure?', + 'Closing this will discard your unsaved changes.', + () => resolve(true), + () => resolve(false), + 'Ok', + 'Cancel', + ) + }) + } else { + return Promise.resolve(true) + } + }, + tabChanged() { + return unsavedProjects.length > 0 + }, + } + }, + [unsavedProjects], + ) + + return ( +
+ {mainItemsFiltered?.map((mainItem, index) => ( +
+ toggleExpand(mainItem.id)} + className='clickable cursor-pointer list-item-sm px-3 list-row' + > + +
+ {mainItem.name}{' '} + {unsavedProjects.includes(mainItem.id) && ( +
Unsaved
+ )} +
+
+ +
+ +
+
+ +
+
+ {expandedItems.includes(mainItem.id) && ( + { + if (!unsavedProjects.includes(mainItem.id)) { + setUnsavedProjects((prevUnsavedProjects) => [ + ...prevUnsavedProjects, + mainItem.id, + ]) + } + }} + onSave={() => removeUnsavedProject(mainItem.id)} + /> + )} +
+
+ ))} +
+ ) + }) + +export default CollapsibleNestedRolePermissionsList diff --git a/frontend/web/components/EditPermissions.tsx b/frontend/web/components/EditPermissions.tsx index 6c047c7d00dc..b8920a1dbd7e 100644 --- a/frontend/web/components/EditPermissions.tsx +++ b/frontend/web/components/EditPermissions.tsx @@ -1,4 +1,10 @@ -import React, { FC, useEffect, useState } from 'react' +import React, { + FC, + useEffect, + useState, + forwardRef, + useImperativeHandle, +} from 'react' import { find } from 'lodash' import _data from 'common/data/base/_data' import { @@ -23,7 +29,25 @@ import { RouterChildContext } from 'react-router' import { useGetAvailablePermissionsQuery } from 'common/services/useAvailablePermissions' import ConfigProvider from 'common/providers/ConfigProvider' import Icon from './Icon' +import { + useGetRoleEnvironmentPermissionsQuery, + useGetRoleOrganisationPermissionsQuery, + useGetRoleProjectPermissionsQuery, + useCreateRolePermissionsMutation, + useUpdateRolePermissionsMutation, +} from 'common/services/useRolePermission' + +import { + useCreateRolesPermissionUsersMutation, + useDeleteRolesPermissionUsersMutation, +} from 'common/services/useRolesUser' + +import { + useCreateRolePermissionGroupMutation, + useDeleteRolePermissionGroupMutation, +} from 'common/services/useRolePermissionGroup' +import MyRoleSelect from './MyRoleSelect' const OrganisationProvider = require('common/providers/OrganisationProvider') const Project = require('common/project') @@ -40,6 +64,9 @@ type EditPermissionModalType = { permissions?: UserPermission[] push: (route: string) => void user?: User + role?: Role + permissionChanged: () => void + editPermissionsFromSettings?: boolean } type EditPermissionsType = Omit & { @@ -56,38 +83,68 @@ type EntityPermissions = Omit< user?: number } -const _EditPermissionsModal: FC = (props) => { - const [entityPermissions, setEntityPermissions] = useState( - { admin: false, permissions: [] }, - ) - const [parentError, setParentError] = useState(false) - const [saving, setSaving] = useState(false) - const { - group, - id, - isGroup, - level, - name, - onSave, - parentId, - parentLevel, - parentSettingsLink, - push, - user, - } = props - - const { data: permissions } = useGetAvailablePermissionsQuery({ level }) - - useEffect(() => { - let parentGet = Promise.resolve() +const _EditPermissionsModal: FC = forwardRef( + (props, ref) => { + const [entityPermissions, setEntityPermissions] = + useState({ admin: false, permissions: [] }) + const [parentError, setParentError] = useState(false) + const [saving, setSaving] = useState(false) + const [showRoles, setShowRoles] = useState(false) + const [rolesSelected, setRolesSelected] = useState([]) + const { + editPermissionsFromSettings, + envId, + group, + id, + isGroup, + level, + name, + onSave, + parentId, + parentLevel, + parentSettingsLink, + permissionChanged, + push, + role, + roles, + user, + } = props + useImperativeHandle( + ref, + () => { + return { + onClosing() { + if (valueChanged) { + return new Promise((resolve) => { + openConfirm( + 'Are you sure?', + 'Closing this will discard your unsaved changes.', + () => resolve(true), + () => resolve(false), + 'Ok', + 'Cancel', + ) + }) + } else { + return Promise.resolve(true) + } + }, + } + }, + [], + ) + const { data: permissions } = useGetAvailablePermissionsQuery({ level }) const processResults = (results: (UserPermission & GroupPermission)[]) => { let entityPermissions: - | (Omit & { + | (Omit & { user?: any group?: any + role?: any }) | undefined = isGroup ? find(results || [], (r) => r.group.id === group?.id) + : role + ? find(results || [], (r) => r.role === role?.id) : find(results || [], (r) => r.user?.id === user?.id) if (!entityPermissions) { @@ -101,220 +158,558 @@ const _EditPermissionsModal: FC = (props) => { } return entityPermissions } - if (parentLevel) { - const parentUrl = isGroup - ? `${parentLevel}s/${parentId}/user-group-permissions/` - : `${parentLevel}s/${parentId}/user-permissions/` - parentGet = _data - .get(`${Project.api}${parentUrl}`) - .then((results: (UserPermission & GroupPermission)[]) => { - const entityPermissions = processResults(results) - if ( - !entityPermissions.admin && - !entityPermissions.permissions.find( - (v) => v === `VIEW_${parentLevel.toUpperCase()}`, - ) - ) { - // e.g. trying to set an environment permission but don't have view_projec - setParentError(true) - } else { - setParentError(false) - } - }) - } - parentGet - .then(() => { - const url = isGroup - ? `${level}s/${id}/user-group-permissions/` - : `${level}s/${id}/user-permissions/` - _data - .get(`${Project.api}${url}`) + const [ + createRolePermissionUser, + { data: usersData, isSuccess: userAdded }, + ] = useCreateRolesPermissionUsersMutation() + + const [deleteRolePermissionUser] = useDeleteRolesPermissionUsersMutation() + + const [ + createRolePermissionGroup, + { data: groupsData, isSuccess: groupAdded }, + ] = useCreateRolePermissionGroupMutation() + + const [deleteRolePermissionGroup] = useDeleteRolePermissionGroupMutation() + + const [ + updateRolePermissions, + { + isError: errorUpdating, + isLoading: isRolePermUpdating, + isSuccess: isRolePermUpdated, + }, + ] = useUpdateRolePermissionsMutation() + + const [ + createRolePermissions, + { + isError: errorCreating, + isLoading: isRolePermCreating, + isSuccess: isRolePermCreated, + }, + ] = useCreateRolePermissionsMutation() + + useEffect(() => { + const isSaving = isRolePermCreating || isRolePermUpdating + if (isSaving) { + setSaving(true) + } + if (isRolePermCreated || isRolePermUpdated) { + toast( + `${level.charAt(0).toUpperCase() + level.slice(1)} permissions Saved`, + ) + permissionChanged?.() + onSave?.() + setSaving(false) + if (editPermissionsFromSettings) { + close() + } + } + if (errorUpdating || errorCreating) { + setSaving(false) + } + }, [ + errorUpdating, + errorCreating, + isRolePermCreated, + isRolePermUpdated, + isRolePermCreating, + isRolePermUpdating, + ]) + + const { data: organisationPermissions, isLoading: organisationIsLoading } = + useGetRoleOrganisationPermissionsQuery( + { + organisation_id: role?.organisation, + role_id: role?.id, + }, + { skip: !role }, + ) + + const { data: projectPermissions, isLoading: projectIsLoading } = + useGetRoleProjectPermissionsQuery( + { + organisation_id: role?.organisation, + project_id: id, + role_id: role?.id, + }, + { + skip: + !id || + envId || + !Utils.getFlagsmithHasFeature('show_role_management'), + }, + ) + + const { data: envPermissions, isLoading: envIsLoading } = + useGetRoleEnvironmentPermissionsQuery( + { + env_id: envId || id, + organisation_id: role?.organisation, + role_id: role?.id, + }, + { + skip: + !role || + !id || + !Utils.getFlagsmithHasFeature('show_role_management'), + }, + ) + useEffect(() => { + if ( + !organisationIsLoading && + organisationPermissions && + level === 'organisation' + ) { + const entityPermissions = processResults( + organisationPermissions.results, + ) + setEntityPermissions(entityPermissions) + } + }, [organisationPermissions, organisationIsLoading]) + + useEffect(() => { + if (!projectIsLoading && projectPermissions && level === 'project') { + const entityPermissions = processResults(projectPermissions?.results) + setEntityPermissions(entityPermissions) + } + }, [projectPermissions, projectIsLoading]) + + useEffect(() => { + if (!envIsLoading && envPermissions && level === 'environment') { + const entityPermissions = processResults(envPermissions?.results) + setEntityPermissions(entityPermissions) + } + }, [envPermissions, envIsLoading]) + + useEffect(() => { + let parentGet = Promise.resolve() + if (!role && parentLevel) { + const parentUrl = isGroup + ? `${parentLevel}s/${parentId}/user-group-permissions/` + : `${parentLevel}s/${parentId}/user-permissions/` + parentGet = _data + .get(`${Project.api}${parentUrl}`) .then((results: (UserPermission & GroupPermission)[]) => { - // @ts-ignore const entityPermissions = processResults(results) - setEntityPermissions(entityPermissions) + if ( + !entityPermissions.admin && + !entityPermissions.permissions.find( + (v) => v === `VIEW_${parentLevel.toUpperCase()}`, + ) + ) { + // e.g. trying to set an environment permission but don't have view_projec + setParentError(true) + } else { + setParentError(false) + } }) - }) - .catch(() => { - setParentError(true) - }) - //eslint-disable-next-line + } + if (!role) { + parentGet + .then(() => { + const url = isGroup + ? `${level}s/${id}/user-group-permissions/` + : `${level}s/${id}/user-permissions/` + _data + .get(`${Project.api}${url}`) + .then((results: (UserPermission & GroupPermission)[]) => { + // @ts-ignore + const entityPermissions = processResults(results) + setEntityPermissions(entityPermissions) + }) + }) + .catch(() => { + setParentError(true) + }) + } + //eslint-disable-next-line }, []) - const admin = () => entityPermissions && entityPermissions.admin + const admin = () => entityPermissions && entityPermissions.admin - const hasPermission = (key: string) => { - if (admin()) return true - return entityPermissions.permissions.includes(key) - } + const hasPermission = (key: string) => { + if (admin()) return true + return entityPermissions.permissions.includes(key) + } - const close = () => { - closeModal() - } + const close = () => { + closeModal() + } - const save = () => { - const entityId = - typeof entityPermissions.id === 'undefined' ? '' : entityPermissions.id - const url = isGroup - ? `${level}s/${id}/user-group-permissions/${entityId}` - : `${level}s/${id}/user-permissions/${entityId}` - setSaving(true) - const action = entityId ? 'put' : 'post' - _data[action](`${Project.api}${url}${entityId && '/'}`, entityPermissions) - .then(() => { - onSave && onSave() - close() - }) - .catch(() => { - setSaving(false) + const save = () => { + const entityId = + typeof entityPermissions.id === 'undefined' ? '' : entityPermissions.id + if (!role) { + const url = isGroup + ? `${level}s/${id}/user-group-permissions/${entityId}` + : `${level}s/${id}/user-permissions/${entityId}` + setSaving(true) + const action = entityId ? 'put' : 'post' + _data[action]( + `${Project.api}${url}${entityId && '/'}`, + entityPermissions, + ) + .then(() => { + onSave && onSave() + close() + }) + .catch(() => { + setSaving(false) + }) + } else { + const body = { + permissions: entityPermissions.permissions, + } + if (level === 'project') { + body.admin = entityPermissions.admin + body.project = id + } + if (level === 'environment') { + body.admin = entityPermissions.admin + body.environment = envId || id + } + if (entityId) { + updateRolePermissions({ + body, + id: entityId, + level: level === 'organisation' ? level : `${level}s`, + organisation_id: role.organisation, + role_id: role.id, + }) + } else { + createRolePermissions({ + body, + id: entityId, + level: level === 'organisation' ? level : `${level}s`, + organisation_id: role.organisation, + role_id: role.id, + }) + } + } + } + + const togglePermission = (key: string) => { + if (role) { + permissionChanged?.() + const updatedPermissions = [...entityPermissions.permissions] + const index = updatedPermissions.indexOf(key) + if (index === -1) { + updatedPermissions.push(key) + } else { + updatedPermissions.splice(index, 1) + } + + setEntityPermissions({ + ...entityPermissions, + permissions: updatedPermissions, + }) + } else { + const newEntityPermissions = { ...entityPermissions } + + const index = newEntityPermissions.permissions.indexOf(key) + + if (index === -1) { + newEntityPermissions.permissions.push(key) + } else { + newEntityPermissions.permissions.splice(index, 1) + } + setEntityPermissions(newEntityPermissions) + } + } + + const toggleAdmin = () => { + permissionChanged?.() + setEntityPermissions({ + ...entityPermissions, + admin: !entityPermissions.admin, }) - } + } + const addRole = (roleId: string) => { + if (level === 'organisation') { + if (user) { + createRolePermissionUser({ + data: { + user: user.id, + }, + organisation_id: id, + role_id: roleId, + }) + } + if (group) { + createRolePermissionGroup({ + data: { + group: group.id, + }, + organisation_id: id, + role_id: roleId, + }) + } + } + } - const togglePermission = (key: string) => { - const newEntityPermissions = { ...entityPermissions } - const index = newEntityPermissions.permissions.indexOf(key) - if (index === -1) { - newEntityPermissions.permissions.push(key) - } else { - newEntityPermissions.permissions.splice(index, 1) + const removeOwner = (roleId: string) => { + const roleSelected = rolesAdded.find((item) => item.id === roleId) + if (level === 'organisation') { + if (user) { + deleteRolePermissionUser({ + organisation_id: id, + role_id: roleId, + user_id: roleSelected.user_role_id, + }) + } + if (group) { + deleteRolePermissionGroup({ + group_id: roleSelected.group_role_id, + organisation_id: id, + role_id: roleId, + }) + } + } + setRolesSelected((rolesSelected || []).filter((v) => v.role !== roleId)) + toast('User role was removed') } - setEntityPermissions(newEntityPermissions) - } - const toggleAdmin = () => { - setEntityPermissions({ - ...entityPermissions, - admin: !entityPermissions.admin, - }) - } + useEffect(() => { + if (userAdded || groupAdded) { + if (user) { + setRolesSelected( + (rolesSelected || []).concat({ + role: usersData?.role, + user_role_id: usersData?.id, + }), + ) + } + if (group) { + setRolesSelected( + (rolesSelected || []).concat({ + group_role_id: groupsData?.id, + role: groupsData?.role, + }), + ) + } + toast('Role assigned') + } + }, [userAdded, usersData, groupsData, groupAdded]) - const isAdmin = admin() - const hasRbacPermission = Utils.getPlansPermission('RBAC') + const getRoles = (roles = [], selectedRoles) => { + return roles + .filter((v) => selectedRoles.find((a) => a.role === v.id)) + .map((role) => { + const matchedRole = selectedRoles.find((r) => r.role === role.id) + if (matchedRole) { + if (user) { + return { + ...role, + user_role_id: matchedRole.user_role_id, + } + } + if (group) { + return { + ...role, + group_role_id: matchedRole.group_role_id, + } + } + } + return role + }) + } - return !permissions || !entityPermissions ? ( -
- -
- ) : ( -
-
-
- {level !== 'organisation' && ( - - -
- Administrator -
-
- {hasRbacPermission ? ( - `Full View and Write permissions for the given ${Format.camelCase( - level, - )}.` - ) : ( - - Role-based access is not available on our Free Plan. - Please visit{' '} - - our Pricing Page - {' '} - for more information on our licensing options. - - )} -
-
- -
- )} -
- { - const levelUpperCase = level.toUpperCase() - const disabled = - level !== 'organisation' && - p.key !== `VIEW_${levelUpperCase}` && - !hasPermission(`VIEW_${levelUpperCase}`) - return ( - - - - {Format.enumeration.get(p.key)} -
- {p.description} -
-
- togglePermission(p.key)} - disabled={disabled || admin() || !hasRbacPermission} - checked={!disabled && hasPermission(p.key)} - /> -
+ const rolesAdded = getRoles(roles, rolesSelected || []) + + const isAdmin = admin() + const hasRbacPermission = Utils.getPlansPermission('RBAC') + + return !permissions || !entityPermissions ? ( +
+ +
+ ) : ( +
+
+
+ {level !== 'organisation' && ( + + +
+ Administrator +
+
+ {hasRbacPermission ? ( + `Full View and Write permissions for the given ${Format.camelCase( + level, + )}.` + ) : ( + + Role-based access is not available on our Free Plan. + Please visit{' '} + + our Pricing Page + {' '} + for more information on our licensing options. + + )} +
+
+
- ) - }} - /> + )} +
+ { + const levelUpperCase = level.toUpperCase() + const disabled = + level !== 'organisation' && + p.key !== `VIEW_${levelUpperCase}` && + !hasPermission(`VIEW_${levelUpperCase}`) + return ( + + + + {Format.enumeration.get(p.key)} +
{p.description}
+
+ togglePermission(p.key)} + disabled={disabled || admin() || !hasRbacPermission} + checked={!disabled && hasPermission(p.key)} + /> +
+
+ ) + }} + /> -
- This will edit the permissions for{' '} - {isGroup ? `the ${name} group` : ` ${name}`}. -
+

+ This will edit the permissions for{' '} + + {isGroup + ? `the ${name} group` + : role + ? ` ${role.name}` + : ` ${name}`} + + . +

- {parentError && ( - + {Utils.getFlagsmithHasFeature('show_role_management') && + roles && + level === 'organisation' && ( + + + + Roles: + {rolesAdded?.map((r) => ( + removeOwner(r.id)} + className='chip' + style={{ marginBottom: 4, marginTop: 4 }} + > + {r.name} + + + ))} + + +
+ } + type='text' + title='Assign roles' + tooltip='Assigns what role the user/group will have' + inputProps={{ + className: 'full-width', + style: { minHeight: 80 }, }} - > - {parentLevel} settings - - . - + className='full-width' + placeholder='Add an optional description...' + /> + + )} + {Utils.getFlagsmithHasFeature('show_role_management') && ( +
+ v.role)} + onAdd={addRole} + onRemove={removeOwner} + isOpen={showRoles} + onToggle={() => setShowRoles(!showRoles)} + />
)} +
+ {!role && ( + + )} + +
-
- - -
-
- ) -} + ) + }, +) export const EditPermissionsModal = ConfigProvider(_EditPermissionsModal) +const rolesWidths = [250, 600, 100] const EditPermissions: FC = (props) => { const { + envId, id, level, onSaveGroup, @@ -323,6 +718,8 @@ const EditPermissions: FC = (props) => { parentLevel, parentSettingsLink, permissions, + roleTabTitle, + roles, router, tabClassName, } = props @@ -363,10 +760,25 @@ const EditPermissions: FC = (props) => { 'p-0 side-modal', ) } + const editRolePermissions = (role) => { + openModal( + `Edit ${Format.camelCase(level)} Role Permissions`, + , + 'p-0 side-modal', + ) + } + const hasRbacPermission = Utils.getPlansPermission('RBAC') return (
-
Manage Users and Permissions
+
Manage Permissions

Flagsmith lets you manage fine-grained permissions for your projects and environments.{' '} @@ -523,6 +935,84 @@ const EditPermissions: FC = (props) => {

+ {Utils.getFlagsmithHasFeature('show_role_management') && ( + + {hasRbacPermission ? ( + <> + +
{roleTabTitle}
+
+ +
+ Roles +
+
+ Description +
+ + } + renderRow={(role) => ( + + editRolePermissions(role)} + className='table-column px-3' + style={{ + width: rolesWidths[0], + }} + > + {role.name} + + editRolePermissions(role)} + style={{ + width: rolesWidths[1], + }} + > + {role.description} + + + )} + renderNoResults={ + +
+ + {`You currently have no roles with ${level} permissions.`} + +
+
+ } + isLoading={false} + /> + + ) : ( +
+ + To use role features you have to upgrade your + plan. + +
+ )} +
+ )}
) diff --git a/frontend/web/components/GroupSelect.tsx b/frontend/web/components/GroupSelect.tsx index 9fe7aad104d7..64403efa5881 100644 --- a/frontend/web/components/GroupSelect.tsx +++ b/frontend/web/components/GroupSelect.tsx @@ -10,6 +10,7 @@ export type GroupSelectType = { groups: UserGroup[] | UserGroupSummary[] | undefined value: number[] | undefined isOpen: boolean + size: string onAdd: (id: number, isUser: boolean) => void onRemove: (id: number, isUser: boolean) => void onToggle: () => void @@ -21,6 +22,7 @@ const GroupSelect: FC = ({ onAdd, onRemove, onToggle, + size, value, }) => { const [filter, setFilter] = useState('') @@ -31,12 +33,13 @@ const GroupSelect: FC = ({ if (!search) return true return `${v.name}`.toLowerCase().includes(search) }) + const modalClassName = `inline-modal--tags${size}` return ( = ({ onChange={(e: InputEvent) => setFilter(Utils.safeParseEventValue(e))} className='full-width mb-2' placeholder='Type or choose a Group' + search />
{grouplist && diff --git a/frontend/web/components/MyRoleSelect.tsx b/frontend/web/components/MyRoleSelect.tsx new file mode 100644 index 000000000000..1a8491f4ee9d --- /dev/null +++ b/frontend/web/components/MyRoleSelect.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react' +import { useGetRolesQuery } from 'common/services/useRole' +import RolesSelect, { RoleSelectType } from './RolesSelect' // we need this to make JSX compile + +type MyRoleSelectType = RoleSelectType & { + orgId: string +} + +const MyRoleSelect: FC = ({ orgId, ...props }) => { + const { data } = useGetRolesQuery( + { organisation_id: orgId }, + { skip: !orgId }, + ) + return +} + +export default MyRoleSelect diff --git a/frontend/web/components/RolesSelect.tsx b/frontend/web/components/RolesSelect.tsx new file mode 100644 index 000000000000..525d6c54ec11 --- /dev/null +++ b/frontend/web/components/RolesSelect.tsx @@ -0,0 +1,83 @@ +import React, { FC, useState } from 'react' +import InlineModal from './InlineModal' +import { Role } from 'common/types/responses' +import Input from './base/forms/Input' +import Utils from 'common/utils/utils' +import Icon from './Icon' + +export type RoleSelectType = { + disabled: boolean + roles: Role[] | undefined + value: number[] | undefined + isOpen: boolean + onAdd: (id: number, isUser: boolean) => void + onRemove: (id: number, isUser: boolean) => void + onToggle: () => void +} +const RoleSelect: FC = ({ + disabled, + isOpen, + onAdd, + onRemove, + onToggle, + roles, + value, +}) => { + const [filter, setFilter] = useState('') + const rolelist = + roles && + roles.filter((v) => { + const search = filter.toLowerCase() + if (!search) return true + return `${v.name}`.toLowerCase().includes(search) + }) + + return ( + + setFilter(Utils.safeParseEventValue(e))} + className='full-width mb-2' + placeholder='Type or choose a Role' + search + /> +
+ {rolelist && + rolelist.map((v) => ( +
+ { + const isRemove = value?.includes(v.id) + if (isRemove && onRemove) { + onRemove(v.id, false) + } else if (!isRemove && onAdd) { + onAdd(v.id, false) + } + }} + space + > + + {v.name} + + {value?.includes(v.id) && ( + + + + )} + +
+ ))} +
+
+ ) +} + +export default RoleSelect diff --git a/frontend/web/components/UserSelect.js b/frontend/web/components/UserSelect.js index 2b87e5105715..593c3bd07f01 100644 --- a/frontend/web/components/UserSelect.js +++ b/frontend/web/components/UserSelect.js @@ -16,12 +16,14 @@ class TheComponent extends Component { return `${v.first_name} ${v.last_name}`.toLowerCase().includes(search) }) const value = this.props.value || [] + const modalClassName = `inline-modal--tags${this.props.size}` + return ( void +} +const ConfirmDeleteRole: FC = ({ onComplete, role }) => { + const [deleteRole, { isError, isSuccess: deleted }] = useDeleteRoleMutation() + + useEffect(() => { + if (deleted) { + onComplete?.() + closeModal() + } + }, [deleted, onComplete]) + + return ( +
+
{ + Utils.preventDefault(e) + deleteRole({ organisation_id: role.organisation, role_id: role.id }) + }} + > +
+ +

+ Are you sure you want to delete {role.name} from + this organization? +

+
+ {isError && } +
+ +
+ + +
+ +
+ ) +} + +export default ConfirmDeleteRole diff --git a/frontend/web/components/modals/CreateRole.tsx b/frontend/web/components/modals/CreateRole.tsx new file mode 100644 index 000000000000..53236253f2ee --- /dev/null +++ b/frontend/web/components/modals/CreateRole.tsx @@ -0,0 +1,574 @@ +import React, { + FC, + useEffect, + useState, + useRef, + forwardRef, + useImperativeHandle, +} from 'react' +import InputGroup from 'components/base/forms/InputGroup' +import Tabs from 'components/base/forms/Tabs' +import TabItem from 'components/base/forms/TabItem' +import CollapsibleNestedRolePermissionsList from 'components/CollapsibleNestedRolePermissionsList' +import { + useGetRoleQuery, + useCreateRoleMutation, + useUpdateRoleMutation, +} from 'common/services/useRole' + +import { EditPermissionsModal } from 'components/EditPermissions' +import OrganisationStore from 'common/stores/organisation-store' +import ProjectFilter from 'components/ProjectFilter' +import { Environment, User } from 'common/types/responses' +import { setInterceptClose } from './base/ModalDefault' +import UserSelect from 'components/UserSelect' +import MyGroupsSelect from 'components/MyGroupsSelect' +import { + useCreateRolesPermissionUsersMutation, + useDeleteRolesPermissionUsersMutation, + useGetRolesPermissionUsersQuery, +} from 'common/services/useRolesUser' +import { + useCreateRolePermissionGroupMutation, + useDeleteRolePermissionGroupMutation, + useGetRolePermissionGroupQuery, +} from 'common/services/useRolePermissionGroup' + +type CreateRoleType = { + organisationId?: string + role: Role + onComplete: () => void + isEdit?: boolean + users: User[] + groups: Array +} +const CreateRole: FC = ({ + groups, + isEdit, + onComplete, + organisationId, + role, + users, +}) => { + const buttonText = isEdit ? 'Update Role' : 'Create Role' + const [tab, setTab] = useState(0) + const [userGroupTab, setUserGroupTab] = useState(0) + const [project, setProject] = useState('') + const [environments, setEnvironments] = useState([]) + const [showUserSelect, setShowUserSelect] = useState(false) + const [showGroupSelect, setShowGroupSelect] = useState(false) + const [userSelected, setUserSelected] = useState([]) + const [groupSelected, setGroupSelected] = useState([]) + + const projectData = OrganisationStore.getProjects() + + const [createRolePermissionUser, { data: usersData, isSuccess: userAdded }] = + useCreateRolesPermissionUsersMutation() + + const [deleteRolePermissionUser, { deleted: roleUserDeleted }] = + useDeleteRolesPermissionUsersMutation() + + const [ + createRolePermissionGroup, + { data: groupsData, isSuccess: groupAdded }, + ] = useCreateRolePermissionGroupMutation() + + const [deleteRolePermissionGroup, { deleted: roleGroupDeleted }] = + useDeleteRolePermissionGroupMutation() + + const { + data: userList, + isSuccess, + refetch, + } = useGetRolesPermissionUsersQuery( + { + organisation_id: organisationId, + role_id: role?.id, + }, + { skip: !role || !organisationId }, + ) + + const { + data: groupList, + isSuccess: groupListLoaded, + refetch: refetchGroups, + } = useGetRolePermissionGroupQuery( + { + organisation_id: organisationId, + role_id: role?.id, + }, + { skip: !role || !organisationId }, + ) + + useEffect(() => { + if (userAdded || roleUserDeleted) { + refetch() + } + }, [userAdded, roleUserDeleted, refetch]) + + useEffect(() => { + if (groupAdded || roleGroupDeleted) { + refetchGroups() + } + }, [groupAdded, roleGroupDeleted, refetchGroups]) + + useEffect(() => { + if (isSuccess) { + setUserSelected(() => { + return userList.results.map((u) => ({ + user: u.user, + user_role_id: u.id, + })) + }) + } + }, [userList, isSuccess]) + + useEffect(() => { + if (groupListLoaded && groupList?.results) { + setGroupSelected(() => { + return groupList.results.map((g) => ({ + group: g.group, + role_group_id: g.id, + })) + }) + } + }, [groupList, groupListLoaded]) + + const addUserOrGroup = (id: string, isUser = true) => { + if (isUser) { + createRolePermissionUser({ + data: { + user: id, + }, + organisation_id: organisationId, + role_id: role.id, + }) + } else { + createRolePermissionGroup({ + data: { + group: id, + }, + organisation_id: organisationId, + role_id: role.id, + }) + } + } + + const removeUserOrGroup = (id: string, isUser = true) => { + const userRole = usersAdded.find((item) => item.id === id) + if (isUser) { + deleteRolePermissionUser({ + organisation_id: organisationId, + role_id: role.id, + user_id: userRole.user_role_id, + }) + setUserSelected((userSelected || []).filter((v) => v.user !== id)) + toast('User role was removed') + } else { + const groupRole = groupsAdded.find((item) => item.id === id) + deleteRolePermissionGroup({ + group_id: groupRole.role_group_id, + organisation_id: organisationId, + role_id: role.id, + }) + setGroupSelected((groupSelected || []).filter((v) => v.group !== id)) + toast('Group role was removed') + } + } + + const getUsers = (users = [], selectedRoles) => { + return users + .filter((v) => selectedRoles.find((a) => a.user === v.id)) + .map((user) => { + const matchedRole = selectedRoles.find((role) => role.user === user.id) + if (matchedRole) { + return { + ...user, + user_role_id: matchedRole.user_role_id, + } + } + return user + }) + } + + const getGroup = (groups = [], groupSelected) => { + return groups + .filter((v) => groupSelected.find((a) => a.group === v.id)) + .map((group) => { + const matchingGroup = groupSelected.find( + (selected) => selected.group === group.id, + ) + if (matchingGroup) { + return { ...group, role_group_id: matchingGroup.role_group_id } + } + return group + }) + } + + const usersAdded = getUsers(users, userSelected || []) + const groupsAdded = getGroup(groups, groupSelected || []) + + useEffect(() => { + if (project) { + const environments = projectData.find( + (p) => p.id === parseInt(project), + ).environments + setEnvironments(environments) + } + }, [project, projectData]) + + useEffect(() => { + if (userAdded) { + setUserSelected( + (userSelected || []).concat({ + user: usersData?.user, + user_role_id: usersData?.id, + }), + ) + toast('Role assigned') + } + }, [userAdded, usersData]) + + useEffect(() => { + if (groupAdded) { + setGroupSelected( + (groupSelected || []).concat({ + group: groupsData?.group, + role_group_id: groupsData?.id, + }), + ) + toast('Role assigned') + } + }, [groupAdded, groupsData]) + + const Tab1 = forwardRef((props, ref) => { + const { data: roleData, isLoading } = useGetRoleQuery( + { + organisation_id: role?.organisation, + role_id: role?.id, + }, + { skip: !role }, + ) + const [roleName, setRoleName] = useState('') + const [roleDesc, setRoleDesc] = useState('') + const [isSaving, setIsSaving] = useState(false) + const [roleNameChanged, setRoleNameChanged] = useState(false) + const [roleDescChanged, setRoleDescChanged] = useState(false) + + useImperativeHandle( + ref, + () => { + return { + onClosing() { + if (roleNameChanged || roleDescChanged) { + return new Promise((resolve) => { + openConfirm( + 'Are you sure?', + 'Closing this will discard your unsaved changes.', + () => resolve(true), + () => resolve(false), + 'Ok', + 'Cancel', + ) + }) + } else { + return Promise.resolve(true) + } + }, + tabChanged() { + return roleNameChanged || roleDescChanged + }, + } + }, + [roleNameChanged, roleDescChanged], + ) + useEffect(() => { + if (!isLoading && isEdit && roleData) { + setRoleName(roleData.name) + setRoleDesc(roleData.description) + } + }, [roleData, isLoading]) + + const [createRole, { isSuccess: createSuccess }] = useCreateRoleMutation() + + const [editRole, { isSuccess: updateSuccess }] = useUpdateRoleMutation() + + useEffect(() => { + if (createSuccess || updateSuccess) { + setRoleNameChanged(false) + setRoleDescChanged(false) + setIsSaving(false) + onComplete?.() + } + }, [createSuccess, updateSuccess]) + + const save = () => { + if (isEdit) { + editRole({ + body: { description: roleDesc, name: roleName }, + organisation_id: role.organisation, + role_id: role.id, + }) + } else { + createRole({ + description: roleDesc, + name: roleName, + organisation_id: organisationId, + }) + } + } + + return isLoading ? ( +
+ +
+ ) : ( +
+ { + setRoleNameChanged(true) + setRoleName(Utils.safeParseEventValue(event)) + }} + id='roleName' + placeholder='E.g. Viewers' + /> + { + setRoleDescChanged(true) + setRoleDesc(Utils.safeParseEventValue(event)) + }} + id='description' + placeholder='E.g. Some role description' + /> +
+ +
+
+ ) + }) + + const TabValue = () => { + const [searchProject, setSearchProject] = useState('') + const [searchEnv, setSearchEnv] = useState('') + const ref = useRef(null) + const ref2 = useRef(null) + useEffect(() => { + if (isEdit) { + setInterceptClose(() => ref.current.onClosing()) + } + }, []) + + const changeTab = (newTab) => { + const changed = ref.current.tabChanged() + if (changed && newTab !== tab) { + return new Promise((resolve) => { + openConfirm( + 'Are you sure?', + 'Changing this tab will discard your unsaved changes.', + () => { + resolve(true), setTab(newTab) + }, + () => resolve(false), + 'Ok', + 'Cancel', + ) + }) + } else { + setTab(newTab) + } + } + + return isEdit ? ( + + Role}> + + + Members} + > +
Assign Roles
+ + Users} + > +
+
+ Users List: +
+ + + {showUserSelect && ( + v.id)} + onAdd={addUserOrGroup} + onRemove={removeUserOrGroup} + isOpen={showUserSelect} + onToggle={() => setShowUserSelect(!showUserSelect)} + size='-sm' + /> + )} + + {usersAdded.length !== 0 && + usersAdded.map((u) => ( + removeUserOrGroup(u.id)} + className='chip my-1 role-list' + > + + {u.first_name} {u.last_name} + + + + ))} +
+
+ Groups} + > +
+
+ Groups List: +
+ + + {showGroupSelect && ( + v.id)} + onAdd={addUserOrGroup} + onRemove={removeUserOrGroup} + isOpen={showGroupSelect} + onToggle={() => setShowGroupSelect(!showGroupSelect)} + size='-sm' + /> + )} + + {groupsAdded.length !== 0 && + groupsAdded.map((g) => ( + removeUserOrGroup(g.id, false)} + className='chip my-1 role-list' + > + {g.name} + + + ))} +
+
+
+
+ Organisation} + > + + + Project} + > + +
Edit Permissions
+ + setSearchProject(Utils.safeParseEventValue(e)) + } + size='small' + placeholder='Search' + search + /> +
+ +
+ Environment} + > + +
Edit Permissions
+ + setSearchEnv(Utils.safeParseEventValue(e)) + } + size='small' + placeholder='Search' + search + /> +
+ + {environments.length > 0 && ( + + )} +
+
+ ) : ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} +export default CreateRole +module.exports = CreateRole diff --git a/frontend/web/components/pages/ChangeRequestPage.js b/frontend/web/components/pages/ChangeRequestPage.js index 26a296f73451..2beae33c5571 100644 --- a/frontend/web/components/pages/ChangeRequestPage.js +++ b/frontend/web/components/pages/ChangeRequestPage.js @@ -30,7 +30,6 @@ const ChangeRequestsPage = class extends Component { static contextTypes = { router: propTypes.object.isRequired, } - getApprovals = (users, approvals) => users?.filter((v) => approvals?.includes(v.id)) diff --git a/frontend/web/components/pages/EnvironmentSettingsPage.js b/frontend/web/components/pages/EnvironmentSettingsPage.js index c6a7d8f7a262..03310ab6c231 100644 --- a/frontend/web/components/pages/EnvironmentSettingsPage.js +++ b/frontend/web/components/pages/EnvironmentSettingsPage.js @@ -17,6 +17,10 @@ import Constants from 'common/constants' import Switch from 'components/Switch' import Icon from 'components/Icon' import PageTitle from 'components/PageTitle' +import { getStore } from 'common/store' +import { getRoles } from 'common/services/useRole' +import { getRolesEnvironmentPermissions } from 'common/services/useRolePermission' +import AccountStore from 'common/stores/account-store' const showDisabledFlagOptions = [ { label: 'Inherit from Project', value: null }, @@ -33,12 +37,39 @@ const EnvironmentSettingsPage = class extends Component { constructor(props, context) { super(props, context) - this.state = {} + this.state = { env: {}, roles: [] } AppActions.getProject(this.props.match.params.projectId) } componentDidMount = () => { API.trackPage(Constants.pages.ENVIRONMENT_SETTINGS) + if (Utils.getFlagsmithHasFeature('show_role_management')) { + const env = ProjectStore.getEnvs().find( + (v) => v.api_key === this.props.match.params.environmentId, + ) + this.setState({ env }) + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + getRolesEnvironmentPermissions( + getStore(), + { + env_id: env.id, + organisation_id: AccountStore.getOrganisation().id, + role_id: roles.data.results[0].id, + }, + { forceRefetch: true }, + ).then((res) => { + const matchingItems = roles.data.results.filter((item1) => + res.data.results.some((item2) => item2.role === item1.id), + ) + this.setState({ roles: matchingItems }) + }) + }) + } + this.props.getWebhooks() } @@ -678,7 +709,7 @@ const EnvironmentSettingsPage = class extends Component { environmentId={this.props.match.params.environmentId} /> - + diff --git a/frontend/web/components/pages/OrganisationSettingsPage.js b/frontend/web/components/pages/OrganisationSettingsPage.js index 27d5e31cf6fa..ec87e7785ded 100644 --- a/frontend/web/components/pages/OrganisationSettingsPage.js +++ b/frontend/web/components/pages/OrganisationSettingsPage.js @@ -7,6 +7,7 @@ import CreateGroupModal from 'components/modals/CreateGroup' import withAuditWebhooks from 'common/providers/withAuditWebhooks' import CreateAuditWebhookModal from 'components/modals/CreateAuditWebhook' import ConfirmRemoveAuditWebhook from 'components/modals/ConfirmRemoveAuditWebhook' +import ConfirmDeleteRole from 'components/modals/ConfirmDeleteRole' import Button from 'components/base/forms/Button' import { EditPermissionsModal } from 'components/EditPermissions' import AdminAPIKeys from 'components/AdminAPIKeys' @@ -19,10 +20,14 @@ import OrganisationUsage from 'components/OrganisationUsage' import Constants from 'common/constants' import ErrorMessage from 'components/ErrorMessage' import Format from 'common/utils/format' +import CreateRole from 'components/modals/CreateRole' import Icon from 'components/Icon' import PageTitle from 'components/PageTitle' +import { getStore } from 'common/store' +import { getRoles } from 'common/services/useRole' -const widths = [170, 150, 80] +const widths = [450, 150, 100] +const rolesWidths = [250, 600, 100] const OrganisationSettingsPage = class extends Component { static contextTypes = { router: propTypes.object.isRequired, @@ -35,6 +40,7 @@ const OrganisationSettingsPage = class extends Component { this.state = { manageSubscriptionLoaded: true, role: 'ADMIN', + roles: [], } if (!AccountStore.getOrganisation()) { return @@ -44,6 +50,14 @@ const OrganisationSettingsPage = class extends Component { } componentDidMount = () => { + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + this.setState({ roles: roles.data.results }) + }) + AppActions.getGroups(AccountStore.getOrganisation().id) API.trackPage(Constants.pages.ORGANISATION_SETTINGS) $('body').trigger('click') if ( @@ -210,7 +224,7 @@ const OrganisationSettingsPage = class extends Component { ) } - editUserPermissions = (user) => { + editUserPermissions = (user, roles) => { openModal( 'Edit Organisation Permissions', , 'p-0 side-modal', ) } - editGroupPermissions = (group) => { + editGroupPermissions = (group, roles) => { openModal( 'Edit Organisation Permissions', , 'p-0 side-modal', @@ -262,6 +278,69 @@ const OrganisationSettingsPage = class extends Component { return 'Within 30 days' } + createRole = (organisationId) => { + openModal( + 'Create Role', + { + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + this.setState({ roles: roles.data.results }) + toast('Role created') + closeModal() + }) + }} + />, + 'side-modal', + ) + } + deleteRole = (role) => { + openModal( + 'Remove Role', + { + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + this.setState({ roles: roles.data.results }) + toast('Role Deleted') + }) + }} + />, + 'p-0', + ) + } + editRole = (role, users, groups) => { + openModal( + 'Edit Role and Permissions', + { + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + this.setState({ roles: roles.data.results }) + toast('Role updated') + }) + }} + users={users} + groups={groups} + />, + 'side-modal', + ) + } + render() { const { props: { webhooks, webhooksLoading }, @@ -282,6 +361,7 @@ const OrganisationSettingsPage = class extends Component { {({ error, + groups, invalidateInviteLink, inviteLinks, invites, @@ -304,7 +384,6 @@ const OrganisationSettingsPage = class extends Component { const needsUpgradeForAdditionalSeats = (overSeats && (!verifySeatsLimit || !autoSeats)) || (!autoSeats && usedSeats) - return (
+ this.editGroupPermissions( + group, + this.state.roles, + ) } showRemove orgId={ @@ -1244,6 +1327,152 @@ const OrganisationSettingsPage = class extends Component { />
+ {Utils.getFlagsmithHasFeature( + 'show_role_management', + ) && ( + + {hasRbacPermission ? ( + <> + +
+ Roles +
+ +
+

+ Create custom roles, assign + permissions, and keys to the + role, and then you can assign + roles to users and/or groups. +

+ +
+ Roles +
+
+ Description +
+
+ Remove +
+ + } + renderRow={(role) => ( + + { + this.editRole( + role, + users, + groups, + ) + }} + className='table-column px-3' + style={{ + width: rolesWidths[0], + }} + > + {role.name} + + { + this.editRole( + role, + users, + groups, + ) + }} + style={{ + width: rolesWidths[1], + }} + > + {role.description} + +
+ +
+
+ )} + renderNoResults={ + +
+ + You currently have no + organisation roles + +
+
+ } + isLoading={false} + /> + + ) : ( +
+ + To use role{' '} + features you have to upgrade + your plan. + +
+ )} +
+ )}
)} diff --git a/frontend/web/components/pages/ProjectSettingsPage.js b/frontend/web/components/pages/ProjectSettingsPage.js index 37cb388ebb08..676df2a3760c 100644 --- a/frontend/web/components/pages/ProjectSettingsPage.js +++ b/frontend/web/components/pages/ProjectSettingsPage.js @@ -12,6 +12,10 @@ import Constants from 'common/constants' import JSONReference from 'components/JSONReference' import PageTitle from 'components/PageTitle' import Icon from 'components/Icon' +import { getStore } from 'common/store' +import { getRoles } from 'common/services/useRole' +import { getRolesProjectPermissions } from 'common/services/useRolePermission' +import AccountStore from 'common/stores/account-store' import ImportPage from './ImportPage' const ProjectSettingsPage = class extends Component { @@ -23,7 +27,7 @@ const ProjectSettingsPage = class extends Component { constructor(props, context) { super(props, context) - this.state = {} + this.state = { roles: [] } AppActions.getProject(this.props.match.params.projectId) this.getPermissions() } @@ -40,6 +44,28 @@ const ProjectSettingsPage = class extends Component { componentDidMount = () => { API.trackPage(Constants.pages.PROJECT_SETTINGS) + if (Utils.getFlagsmithHasFeature('show_role_management')) { + getRoles( + getStore(), + { organisation_id: AccountStore.getOrganisation().id }, + { forceRefetch: true }, + ).then((roles) => { + getRolesProjectPermissions( + getStore(), + { + organisation_id: AccountStore.getOrganisation().id, + project_id: this.props.match.params.projectId, + role_id: roles.data.results[0].id, + }, + { forceRefetch: true }, + ).then((res) => { + const matchingItems = roles.data.results.filter((item1) => + res.data.results.some((item2) => item2.role === item1.id), + ) + this.setState({ roles: matchingItems }) + }) + }) + } } onSave = () => { @@ -453,7 +479,7 @@ const ProjectSettingsPage = class extends Component { - + { this.getPermissions() @@ -462,6 +488,9 @@ const ProjectSettingsPage = class extends Component { tabClassName='flat-panel' id={this.props.match.params.projectId} level='project' + roleTabTitle='Project Permissions' + role + roles={this.state.roles} /> {Utils.getFlagsmithHasFeature('import_project') && ( diff --git a/frontend/web/styles/project/_lists.scss b/frontend/web/styles/project/_lists.scss index 30ca67518436..dad5e0cc0893 100644 --- a/frontend/web/styles/project/_lists.scss +++ b/frontend/web/styles/project/_lists.scss @@ -53,3 +53,20 @@ background-color: transparent; } } + +.collapsible-nested-list { + .list-container{ + border-radius: $border-radius-default; + min-height: 60px; + } + .list-row{ + background-color: #fafafb; + border: 1px solid rgba(101, 109, 123, 0.16); + border-radius: $border-radius-default; + opacity: 1; + } +} + +.role-list { + justify-content: space-between +} \ No newline at end of file diff --git a/frontend/web/styles/project/_modals.scss b/frontend/web/styles/project/_modals.scss index fbe8ac99a16b..278b6acd74ee 100644 --- a/frontend/web/styles/project/_modals.scss +++ b/frontend/web/styles/project/_modals.scss @@ -172,6 +172,13 @@ $side-width: 660px; } } +.inline-modal--tags-sm { + width: 450px; + input.input { + border: 1px solid $input-border-color !important; + } +} + .modal-back-btn { margin-right: 12px; }