diff --git a/frontend/common/services/useRolePermission.ts b/frontend/common/services/useRolePermission.ts index 22ca8ca14e83..2fdc20b24cc1 100644 --- a/frontend/common/services/useRolePermission.ts +++ b/frontend/common/services/useRolePermission.ts @@ -6,6 +6,18 @@ export const rolePermissionService = service .enhanceEndpoints({ addTagTypes: ['rolePermission'] }) .injectEndpoints({ endpoints: (builder) => ({ + createEnvironmentRolePermission: builder.mutation< + Res['rolePermission'], + Req['createEnvironmentRolePermission'] + >({ + invalidatesTags: () => [{ type: 'rolePermission' }], + query: (query: Req['createEnvironmentRolePermission']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/environments-permissions/`, + }), + }), + createProjectRolePermission: builder.mutation< Res['rolePermission'], Req['createProjectRolePermission'] @@ -177,6 +189,7 @@ export async function createRolePermissions( // END OF FUNCTION_EXPORTS export const { + useCreateEnvironmentRolePermissionMutation, useCreateProjectRolePermissionMutation, useCreateRolePermissionsMutation, useGetRoleEnvironmentPermissionsQuery, diff --git a/frontend/common/services/useUserPermissions.ts b/frontend/common/services/useUserPermissions.ts index ac07cf465b16..890ee84efe90 100644 --- a/frontend/common/services/useUserPermissions.ts +++ b/frontend/common/services/useUserPermissions.ts @@ -6,6 +6,20 @@ export const userPermissionsService = service .enhanceEndpoints({ addTagTypes: ['UserPermissions'] }) .injectEndpoints({ endpoints: (builder) => ({ + createEnvironmentUserPermission: builder.mutation< + Res['userPermissions'], + Req['createEnvironmentUserPermission'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'UserPermissions' }], + query: ({ + body, + environmentId, + }: Req['createEnvironmentUserPermission']) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/user-permissions/`, + }), + }), createProjectUserPermission: builder.mutation< Res['userPermissions'], Req['createProjectUserPermission'] @@ -44,6 +58,7 @@ export async function getUserPermissions( // END OF FUNCTION_EXPORTS export const { + useCreateEnvironmentUserPermissionMutation, useCreateProjectUserPermissionMutation, useGetUserPermissionsQuery, // END OF EXPORTS diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index aae324f493d2..50e0238173cd 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -357,6 +357,15 @@ export type Req = { project: number } } + createEnvironmentRolePermission: { + organisation_id: number + role_id: number + body: { + admin?: boolean + permissions: RolePermission['permissions'] + environment: number + } + } updateRolePermission: Req['createRolePermission'] & { id: number } deleteRolePermission: { organisation_id: number; role_id: number } @@ -656,6 +665,14 @@ export type Req = { user: number } } + createEnvironmentUserPermission: { + environmentId: string + body: { + admin?: boolean + permissions: string[] + user: number + } + } createGroup: { orgId: number data: Omit diff --git a/frontend/web/components/pages/CreateEnvironmentPage.tsx b/frontend/web/components/pages/CreateEnvironmentPage.tsx index 619966d8d837..9034248fa89d 100644 --- a/frontend/web/components/pages/CreateEnvironmentPage.tsx +++ b/frontend/web/components/pages/CreateEnvironmentPage.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' import ConfigProvider from 'common/providers/ConfigProvider' import Permission from 'common/providers/Permission' import Constants from 'common/constants' @@ -16,8 +18,16 @@ import Utils from 'common/utils/utils' import { useHistory } from 'react-router-dom' import API from 'project/api' import InputGroup from 'components/base/forms/InputGroup' -import { Environment } from 'common/types/responses' +import { Environment, Role, User } from 'common/types/responses' import Button from 'components/base/forms/Button' +import UserSelect from 'components/UserSelect' +import MyRoleSelect from 'components/MyRoleSelect' +import SettingsButton from 'components/SettingsButton' +import getUserDisplayName from 'common/utils/getUserDisplayName' +import { useGetUsersQuery } from 'common/services/useUser' +import { useGetRolesQuery } from 'common/services/useRole' +import { useCreateEnvironmentUserPermissionMutation } from 'common/services/useUserPermissions' +import { useCreateEnvironmentRolePermissionMutation } from 'common/services/useRolePermission' import { useRouteContext } from 'components/providers/RouteContext' import { ProjectPermission } from 'common/types/permissions.types' @@ -28,13 +38,100 @@ const CreateEnvironmentPage: React.FC = () => { const [description, setDescription] = useState() const [selectedEnv, setSelectedEnv] = useState() const [hasMetadataRequired, setHasMetadataRequired] = useState(false) + const [adminIds, setAdminIds] = useState([]) + const [adminRoleIds, setAdminRoleIds] = useState([]) + const [showUsers, setShowUsers] = useState(false) + const [showRoles, setShowRoles] = useState(false) + const [assigningAdmins, setAssigningAdmins] = useState(false) const inputRef = useRef(null) const history = useHistory() const { projectId } = useRouteContext() - const onSave = (environment: Environment) => { + + // ProjectProvider wires onSave into a Flux listener once on mount, so any + // state we read in the saved handler is stale. Mirror selections into refs. + const adminIdsRef = useRef(adminIds) + const adminRoleIdsRef = useRef(adminRoleIds) + const projectIdRef = useRef(projectId) + adminIdsRef.current = adminIds + adminRoleIdsRef.current = adminRoleIds + projectIdRef.current = projectId + + const organisationId = AccountStore.getOrganisation()?.id as + | number + | undefined + const currentUserId = AccountStore.getUser()?.id as number | undefined + const hasRbac = Utils.getPlansPermission('RBAC') + const { data: users } = useGetUsersQuery( + { organisationId: organisationId! }, + { skip: !organisationId }, + ) + const { data: rolesData } = useGetRolesQuery( + { organisation_id: organisationId! }, + { skip: !organisationId || !hasRbac }, + ) + const roles = useMemo(() => rolesData?.results ?? [], [rolesData]) + const [createUserPermission] = useCreateEnvironmentUserPermissionMutation() + const [createRolePermission] = useCreateEnvironmentRolePermissionMutation() + + // Org administrators already have permissions on every environment, and the + // creator obviously has permissions on their own environment — exclude both. + const eligibleAdmins = useMemo( + () => + (users ?? []).filter( + (u: User) => u.role !== 'ADMIN' && u.id !== currentUserId, + ), + [users, currentUserId], + ) + + const selectedAdmins = useMemo( + () => eligibleAdmins.filter((u) => adminIds.includes(u.id)), + [eligibleAdmins, adminIds], + ) + const selectedRoles = useMemo( + () => roles.filter((r) => adminRoleIds.includes(r.id)), + [roles, adminRoleIds], + ) + + const assignEnvironmentAdmins = async (environment: Environment) => { + const userIds = adminIdsRef.current + const roleIds = adminRoleIdsRef.current + const orgId = AccountStore.getOrganisation()?.id + const userRequests = userIds.map((userId) => + createUserPermission({ + body: { admin: true, permissions: [], user: userId }, + environmentId: environment.api_key, + }).unwrap(), + ) + const roleRequests = roleIds.map((roleId) => + createRolePermission({ + body: { admin: true, environment: environment.id, permissions: [] }, + organisation_id: orgId!, + role_id: roleId, + }).unwrap(), + ) + const results = await Promise.allSettled([...userRequests, ...roleRequests]) + return results.filter((r) => r.status === 'rejected').length + } + + const onSave = async (environment: Environment) => { + const hasAssignments = + adminIdsRef.current.length || adminRoleIdsRef.current.length + if (hasAssignments) { + setAssigningAdmins(true) + const failures = await assignEnvironmentAdmins(environment) + setAssigningAdmins(false) + if (failures) { + toast( + `Environment created — ${failures} admin assignment${ + failures > 1 ? 's' : '' + } failed. Retry in Environment Settings → Permissions.`, + 'danger', + ) + } + } history.push( - `/project/${projectId}/environment/${environment.api_key}/features`, + `/project/${projectIdRef.current}/environment/${environment.api_key}/features`, ) } @@ -64,9 +161,9 @@ const CreateEnvironmentPage: React.FC = () => { }, []) const handleCreateEnv = - (createEnv: CreateEnvType, isSaving: boolean) => (e: React.FormEvent) => { + (createEnv: CreateEnvType, busy: boolean) => (e: React.FormEvent) => { e.preventDefault() - if (name && !isSaving && projectId) { + if (name && !busy && projectId) { createEnv({ cloneFeatureStatesAsync: true, cloneId: selectedEnv?.api_key, @@ -118,128 +215,255 @@ const CreateEnvironmentPage: React.FC = () => { ) } + const showUserSelector = !!eligibleAdmins.length + const showRoleSelector = !!hasRbac && !!roles.length + const showAdminSelector = showUserSelector || showRoleSelector return ( - {({ createEnv, error, isSaving, project }) => ( -
-
- - - setName(Utils.safeParseEventValue(e)) - } - isValid={!!name} - type='text' - title='Name*' - placeholder='An environment name e.g. Develop' - /> - - - - setDescription(Utils.safeParseEventValue(e)) - } - isValid={!!name} - type='text' - title='Description' - placeholder='Environment Description' - /> - - - {!!project?.environments?.length && ( + {({ createEnv, error, isSaving, project }) => { + const busy = isSaving || assigningAdmins + return ( + +
+ { - setSelectedEnv( - project?.environments.find( - (v) => v.api_key === env.value, - ), - ) - }} - options={project.environments.map((env) => ({ - label: env.name, - value: env.api_key, - }))} - value={ - selectedEnv - ? { - label: selectedEnv.name, - value: selectedEnv.api_key, - } - : { label: 'Please select an environment' } - } - /> + ref={inputRef as any} + inputProps={{ + className: 'full-width', + name: 'envName', + }} + onChange={(e: InputEvent) => + setName(Utils.safeParseEventValue(e)) } + isValid={!!name} + type='text' + title='Name*' + placeholder='An environment name e.g. Develop' /> - )} - -
- {Utils.getPlansPermission('METADATA') && - envContentType?.id && ( +
+ + + setDescription(Utils.safeParseEventValue(e)) + } + isValid={!!name} + type='text' + title='Description' + placeholder='Environment Description' + /> + - + {!!project?.environments?.length && ( { + setSelectedEnv( + project?.environments.find( + (v) => v.api_key === env.value, + ), + ) + }} + options={project.environments.map((env) => ({ + label: env.name, + value: env.api_key, + }))} + value={ + selectedEnv + ? { + label: selectedEnv.name, + value: selectedEnv.api_key, + } + : { label: 'Please select an environment' } } - projectId={projectId} - entityId={selectedEnv?.api_key} - envName={name} - entityContentType={envContentType.id} - entity={envContentType.model} - isCloningEnvironment - onChange={setMetadata} - setHasMetadataRequired={setHasMetadataRequired} /> } /> - + )} + + {showAdminSelector && ( + +
+ +
+ Optionally grant other users or roles + administrator access to this environment. + Organisation administrators already have full + access to all environments. +
+ {showUserSelector && ( +
+ setShowUsers(!showUsers)} + dropdown={ + + setAdminIds([...adminIds, id]) + } + onRemove={(id: number) => + setAdminIds( + adminIds.filter((v) => v !== id), + ) + } + isOpen={showUsers} + onToggle={() => setShowUsers(!showUsers)} + /> + } + content={ + + {selectedAdmins.map((u) => ( + + setAdminIds( + adminIds.filter( + (id) => id !== u.id, + ), + ) + } + className='chip mr-2' + > + {getUserDisplayName(u)} + + + + + ))} + {!selectedAdmins.length && ( +
+ No users assigned +
+ )} +
+ } + > + Users +
+
+ )} + {showRoleSelector && ( +
+ setShowRoles(!showRoles)} + dropdown={ + + setAdminRoleIds([...adminRoleIds, id]) + } + onRemove={(id: number) => + setAdminRoleIds( + adminRoleIds.filter((v) => v !== id), + ) + } + isOpen={showRoles} + onToggle={() => setShowRoles(!showRoles)} + /> + } + content={ + + {selectedRoles.map((r) => ( + + setAdminRoleIds( + adminRoleIds.filter( + (id) => id !== r.id, + ), + ) + } + className='chip mr-2' + > + {r.name} + + + + + ))} + {!selectedRoles.length && ( +
+ No roles assigned +
+ )} +
+ } + > + Roles +
+
+ )} +
+
+ )} +
+ {Utils.getPlansPermission('METADATA') && + envContentType?.id && ( + + + + } + /> + + + )} + {error && ( + + )} - {error && ( - +
+ +
- )} - -
- -
-
-
-

- Not seeing an environment? Check that your project - administrator has invited you to it. -

-
- )} +
+

+ Not seeing an environment? Check that your project + administrator has invited you to it. +

+ + ) + }}
) }}