diff --git a/app/assets/javascripts/environments/components/deployment.vue b/app/assets/javascripts/environments/components/deployment.vue
index f98edb6bb7de1b4773651a800794c4f2a3d8e8d5..19284b26d51348c4bcdeb90fc6094a843eb175a1 100644
--- a/app/assets/javascripts/environments/components/deployment.vue
+++ b/app/assets/javascripts/environments/components/deployment.vue
@@ -102,6 +102,9 @@ export default {
     refPath() {
       return this.ref?.refPath;
     },
+    needsApproval() {
+      return this.deployment.pendingApprovalCount > 0;
+    },
   },
   methods: {
     toggleCollapse() {
@@ -116,6 +119,7 @@ export default {
     showDetails: __('Show details'),
     hideDetails: __('Hide details'),
     triggerer: s__('Deployment|Triggerer'),
+    needsApproval: s__('Deployment|Needs Approval'),
     job: __('Job'),
     api: __('API'),
     branch: __('Branch'),
@@ -153,6 +157,9 @@ export default {
       <div :class="$options.headerDetailsClasses">
         <div :class="$options.deploymentStatusClasses">
           <deployment-status-badge v-if="status" :status="status" />
+          <gl-badge v-if="needsApproval" variant="warning">
+            {{ $options.i18n.needsApproval }}
+          </gl-badge>
           <gl-badge v-if="latest" variant="info">{{ $options.i18n.latestBadge }}</gl-badge>
         </div>
         <div class="gl-display-flex gl-align-items-center gl-gap-x-5">
@@ -199,6 +206,7 @@ export default {
       </gl-button>
     </div>
     <commit v-if="commit" :commit="commit" class="gl-mt-3" />
+    <div class="gl-mt-3"><slot name="approval"></slot></div>
     <gl-collapse :visible="visible">
       <div
         class="gl-display-flex gl-md-align-items-center gl-mt-5 gl-flex-direction-column gl-md-flex-direction-row gl-pr-4 gl-md-pr-0"
diff --git a/app/assets/javascripts/environments/components/new_environment_item.vue b/app/assets/javascripts/environments/components/new_environment_item.vue
index c039a663a68f9cf0f20c663525c57fb1778c4b1d..80d9b300d3fd873dd265d3702c07d7a8e8800573 100644
--- a/app/assets/javascripts/environments/components/new_environment_item.vue
+++ b/app/assets/javascripts/environments/components/new_environment_item.vue
@@ -41,6 +41,8 @@ export default {
     TimeAgoTooltip,
     Delete,
     EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
+    EnvironmentApproval: () =>
+      import('ee_component/environments/components/environment_approval.vue'),
   },
   directives: {
     GlTooltip,
@@ -305,7 +307,11 @@ export default {
             :deployment="upcomingDeployment"
             :class="{ 'gl-ml-7': inFolder }"
             class="gl-pl-4"
-          />
+          >
+            <template #approval>
+              <environment-approval :environment="environment" @change="$emit('change')" />
+            </template>
+          </deployment>
         </div>
       </template>
       <div v-else :class="$options.deploymentClasses">
diff --git a/app/assets/javascripts/environments/components/new_environments_app.vue b/app/assets/javascripts/environments/components/new_environments_app.vue
index 3699f39b611508aa1f331c919c1372c35169253b..67fd6ffd9754d219c13791101f6fd05164f4b504 100644
--- a/app/assets/javascripts/environments/components/new_environments_app.vue
+++ b/app/assets/javascripts/environments/components/new_environments_app.vue
@@ -175,11 +175,10 @@ export default {
     },
     resetPolling() {
       this.$apollo.queries.environmentApp.stopPolling();
+      this.$apollo.queries.environmentApp.refetch();
       this.$nextTick(() => {
         if (this.interval) {
           this.$apollo.queries.environmentApp.startPolling(this.interval);
-        } else {
-          this.$apollo.queries.environmentApp.refetch({ scope: this.scope, page: this.page });
         }
       });
     },
@@ -233,6 +232,7 @@ export default {
       :key="environment.name"
       class="gl-mb-3 gl-border-gray-100 gl-border-1 gl-border-b-solid"
       :environment="environment.latest"
+      @change="resetPolling"
     />
     <gl-pagination
       align="center"
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 3b1d35c1f22687a203f1e9ebbf8dc1ec122a3f55..5805fe6b1c0d3c0f0c31beba1ce8f141c3e55a61 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -22,6 +22,7 @@ export default (el) => {
       apolloProvider,
       provide: {
         projectPath: el.dataset.projectPath,
+        projectId: el.dataset.projectId,
         defaultBranchName: el.dataset.defaultBranchName,
       },
       data() {
diff --git a/app/assets/javascripts/environments/new_index.js b/app/assets/javascripts/environments/new_index.js
index dd5c709c75a4b8cc41cc9eee0274e5959f5e6bc5..d30ceb80f21b3e1d4b7ddb958d262ecb6caecd51 100644
--- a/app/assets/javascripts/environments/new_index.js
+++ b/app/assets/javascripts/environments/new_index.js
@@ -15,6 +15,7 @@ export default (el) => {
       helpPagePath,
       projectPath,
       defaultBranchName,
+      projectId,
     } = el.dataset;
 
     return new Vue({
@@ -26,6 +27,7 @@ export default (el) => {
         endpoint,
         newEnvironmentPath,
         helpPagePath,
+        projectId,
         canCreateEnvironment: parseBoolean(canCreateEnvironment),
       },
       render(h) {
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index 2b05ffe3eea0fe7d5d257fba1e6a4f104f516389..77b2fc25c9a0d662a0adc3806e4e443d1e5c3185 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -8,6 +8,7 @@
     "new-environment-path" => new_project_environment_path(@project),
     "help-page-path" => help_page_path("ci/environments/index.md"),
     "project-path" => @project.full_path,
+    "project-id" => @project.id,
     "default-branch-name" => @project.default_branch_or_main } }
 - else
   #environments-list-view{ data: { environments_data: environments_list_data,
@@ -16,4 +17,5 @@
     "new-environment-path" => new_project_environment_path(@project),
     "help-page-path" => help_page_path("ci/environments/index.md"),
     "project-path" => @project.full_path,
+    "project-id" => @project.id,
     "default-branch-name" => @project.default_branch_or_main } }
diff --git a/doc/ci/environments/deployment_approvals.md b/doc/ci/environments/deployment_approvals.md
index d8e6bb629c360258f066fa7f5d6e45eee1e20faa..b5cbc24aa99b28b80325f7b3cb1f42a51774f192 100644
--- a/doc/ci/environments/deployment_approvals.md
+++ b/doc/ci/environments/deployment_approvals.md
@@ -84,7 +84,12 @@ This functionality is currently only available through the API. UI is planned fo
 
 A blocked deployment is enqueued as soon as it receives the required number of approvals. A single rejection causes the deployment to fail. The creator of a deployment cannot approve it, even if they have permission to deploy.
 
-Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment.
+There are two ways to approve or reject a deployment to a protected environment:
+
+1. Using the [UI](index.md#view-environments-and-deployments):
+   1. Select **Approval options** (**{thumb-up}**)
+   1. Select **Approve** or **Reject**
+1. Using the [Deployments API](../../api/deployments.md#approve-or-reject-a-blocked-deployment), users who are allowed to deploy to the protected environment can approve or reject a blocked deployment.
 
 Example:
 
diff --git a/ee/app/assets/javascripts/api.js b/ee/app/assets/javascripts/api.js
index 9d015c86990eaf0c70f743a2bde7ba3137622c3e..ea19b584a44f44e983adf15b64c68928803aa4c7 100644
--- a/ee/app/assets/javascripts/api.js
+++ b/ee/app/assets/javascripts/api.js
@@ -43,6 +43,7 @@ export default {
   issueMetricImagesPath: '/api/:version/projects/:id/issues/:issue_iid/metric_images',
   issueMetricSingleImagePath:
     '/api/:version/projects/:id/issues/:issue_iid/metric_images/:image_id',
+  environmentApprovalPath: '/api/:version/projects/:id/deployments/:deployment_id/approval',
 
   userSubscription(namespaceId) {
     const url = Api.buildUrl(this.subscriptionPath).replace(':id', encodeURIComponent(namespaceId));
@@ -387,4 +388,19 @@ export default {
         return data;
       });
   },
+
+  deploymentApproval(id, deploymentId, approve) {
+    const url = Api.buildUrl(this.environmentApprovalPath)
+      .replace(':id', encodeURIComponent(id))
+      .replace(':deployment_id', encodeURIComponent(deploymentId));
+
+    return axios.post(url, { status: approve ? 'approved' : 'rejected' });
+  },
+
+  approveDeployment(id, deploymentId) {
+    return this.deploymentApproval(id, deploymentId, true);
+  },
+  rejectDeployment(id, deploymentId) {
+    return this.deploymentApproval(id, deploymentId, false);
+  },
 };
diff --git a/ee/app/assets/javascripts/environments/components/environment_approval.vue b/ee/app/assets/javascripts/environments/components/environment_approval.vue
new file mode 100644
index 0000000000000000000000000000000000000000..f1632d96a72f87c50e894ee695de0c4717c7461e
--- /dev/null
+++ b/ee/app/assets/javascripts/environments/components/environment_approval.vue
@@ -0,0 +1,175 @@
+<script>
+import { GlButton, GlButtonGroup, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import Api from 'ee/api';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { createAlert } from '~/flash';
+import { __, s__, sprintf } from '~/locale';
+
+export default {
+  components: {
+    GlButton,
+    GlButtonGroup,
+    GlLink,
+    GlPopover,
+    GlSprintf,
+    TimeAgoTooltip,
+  },
+  inject: ['projectId'],
+  props: {
+    environment: {
+      required: true,
+      type: Object,
+    },
+  },
+  data() {
+    return {
+      id: uniqueId('environment-approval'),
+      loading: false,
+      show: false,
+    };
+  },
+  computed: {
+    title() {
+      return sprintf(this.$options.i18n.title, {
+        deploymentIid: this.deploymentIid,
+      });
+    },
+    upcomingDeployment() {
+      return this.environment?.upcomingDeployment;
+    },
+    needsApproval() {
+      return this.upcomingDeployment.pendingApprovalCount > 0;
+    },
+    deploymentIid() {
+      return this.upcomingDeployment.iid;
+    },
+    totalApprovals() {
+      return this.environment.requiredApprovalCount;
+    },
+    currentApprovals() {
+      return this.totalApprovals - this.upcomingDeployment.pendingApprovalCount;
+    },
+    currentUserHasApproved() {
+      return this.upcomingDeployment?.approvals.find(
+        ({ user }) => user.username === gon.current_username,
+      );
+    },
+    canApproveDeployment() {
+      return this.upcomingDeployment.canApproveDeployment && !this.currentUserHasApproved;
+    },
+    deployableName() {
+      return this.upcomingDeployment.deployable?.name;
+    },
+  },
+  methods: {
+    showPopover() {
+      this.show = true;
+    },
+    approve() {
+      return this.actOnDeployment(Api.approveDeployment.bind(Api));
+    },
+    reject() {
+      return this.actOnDeployment(Api.rejectDeployment.bind(Api));
+    },
+    actOnDeployment(action) {
+      this.loading = true;
+      this.show = false;
+      action(this.projectId, this.upcomingDeployment.id)
+        .catch((err) => {
+          if (err.response) {
+            createAlert({ message: err.response.data.message });
+          }
+        })
+        .finally(() => {
+          this.loading = false;
+          this.$emit('change');
+        });
+    },
+    approvalText({ user }) {
+      if (user.username === gon.current_username) {
+        return this.$options.i18n.approvalByMe;
+      }
+
+      return this.$options.i18n.approval;
+    },
+  },
+  i18n: {
+    button: s__('DeploymentApproval|Approval options'),
+    title: s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'),
+    message: s__(
+      'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
+    ),
+    environment: s__('DeploymentApproval|Environment: %{environment}'),
+    tier: s__('DeploymentApproval|Deployment tier: %{tier}'),
+    job: s__('DeploymentApproval|Manual job: %{jobName}'),
+    current: s__('DeploymentApproval| Current approvals: %{current}'),
+    approval: s__('DeploymentApproval|Approved by %{user} %{time}'),
+    approvalByMe: s__('DeploymentApproval|Approved by you %{time}'),
+    approve: __('Approve'),
+    reject: __('Reject'),
+  },
+};
+</script>
+<template>
+  <gl-button-group v-if="needsApproval">
+    <gl-button :id="id" ref="button" :loading="loading" icon="thumb-up" @click="showPopover">
+      {{ $options.i18n.button }}
+    </gl-button>
+    <gl-popover :target="id" triggers="click blur" placement="top" :title="title" :show="show">
+      <p>
+        <gl-sprintf :message="$options.i18n.message">
+          <template #deploymentIid>{{ deploymentIid }}</template>
+        </gl-sprintf>
+      </p>
+
+      <div>
+        <gl-sprintf :message="$options.i18n.environment">
+          <template #environment>
+            <span class="gl-font-weight-bold">{{ environment.name }}</span>
+          </template>
+        </gl-sprintf>
+      </div>
+      <div v-if="environment.tier">
+        <gl-sprintf :message="$options.i18n.tier">
+          <template #tier>
+            <span class="gl-font-weight-bold">{{ environment.tier }}</span>
+          </template>
+        </gl-sprintf>
+      </div>
+      <div>
+        <gl-sprintf v-if="deployableName" :message="$options.i18n.job">
+          <template #jobName>
+            <span class="gl-font-weight-bold">
+              {{ deployableName }}
+            </span>
+          </template>
+        </gl-sprintf>
+      </div>
+
+      <div class="gl-mt-4 gl-pt-4">
+        <gl-sprintf :message="$options.i18n.current">
+          <template #current>
+            <span class="gl-font-weight-bold"> {{ currentApprovals }}/{{ totalApprovals }}</span>
+          </template>
+        </gl-sprintf>
+      </div>
+      <p v-for="(approval, index) in upcomingDeployment.approvals" :key="index">
+        <gl-sprintf :message="approvalText(approval)">
+          <template #user>
+            <gl-link :href="approval.user.webUrl">@{{ approval.user.username }}</gl-link>
+          </template>
+          <template #time><time-ago-tooltip :time="approval.createdAt" /></template>
+        </gl-sprintf>
+      </p>
+      <div v-if="canApproveDeployment" class="gl-mt-4 gl-pt-4">
+        <gl-button ref="approve" :loading="loading" variant="confirm" @click="approve">
+          {{ $options.i18n.approve }}
+        </gl-button>
+        <gl-button ref="reject" :loading="loading" @click="reject">
+          {{ $options.i18n.reject }}
+        </gl-button>
+      </div>
+    </gl-popover>
+  </gl-button-group>
+</template>
diff --git a/ee/app/serializers/ee/deployment_entity.rb b/ee/app/serializers/ee/deployment_entity.rb
index 84f8e4a3cd4b6c7ad7b388fea6035ddd394a802a..254b0d3c8ce69f4faa673234b3e89afdba995515 100644
--- a/ee/app/serializers/ee/deployment_entity.rb
+++ b/ee/app/serializers/ee/deployment_entity.rb
@@ -7,6 +7,10 @@ module DeploymentEntity
     prepended do
       expose :pending_approval_count
       expose :approvals, using: ::API::Entities::Deployments::Approval
+
+      expose :can_approve_deployment do |deployment|
+        can?(request.current_user, :update_deployment, deployment)
+      end
     end
   end
 end
diff --git a/ee/spec/frontend/api_spec.js b/ee/spec/frontend/api_spec.js
index 979b0dceaf95e6686dbc345ae780784b39f293d9..d7ca4952ff6393bbd8b2742e6958f01291856788 100644
--- a/ee/spec/frontend/api_spec.js
+++ b/ee/spec/frontend/api_spec.js
@@ -749,4 +749,28 @@ describe('Api', () => {
       });
     });
   });
+
+  describe('deployment approvals', () => {
+    const projectId = 1;
+    const deploymentId = 2;
+    const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/deployments/${deploymentId}/approval`;
+
+    it('sends an approval when approve is true', async () => {
+      mock.onPost(expectedUrl, { status: 'approved' }).replyOnce(httpStatus.OK);
+
+      await Api.deploymentApproval(projectId, deploymentId, true);
+
+      expect(mock.history.post.length).toBe(1);
+      expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'approved' }));
+    });
+
+    it('sends a rejection when approve is false', async () => {
+      mock.onPost(expectedUrl, { status: 'rejected' }).replyOnce(httpStatus.OK);
+
+      await Api.deploymentApproval(projectId, deploymentId, false);
+
+      expect(mock.history.post.length).toBe(1);
+      expect(mock.history.post[0].data).toBe(JSON.stringify({ status: 'rejected' }));
+    });
+  });
 });
diff --git a/ee/spec/frontend/environments/environment_approval_spec.js b/ee/spec/frontend/environments/environment_approval_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..44bbac6b2b12963fb90f4a6176dcf5773f224a36
--- /dev/null
+++ b/ee/spec/frontend/environments/environment_approval_spec.js
@@ -0,0 +1,177 @@
+import { GlButton, GlPopover } from '@gitlab/ui';
+import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
+import Api from 'ee/api';
+import { __, s__, sprintf } from '~/locale';
+import { createAlert } from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { environment as mockEnvironment } from './mock_data';
+
+jest.mock('ee/api.js');
+jest.mock('~/flash');
+
+describe('ee/environments/components/environment_approval.vue', () => {
+  let wrapper;
+
+  const environment = convertObjectPropsToCamelCase(mockEnvironment, { deep: true });
+
+  const createWrapper = ({ propsData = {} } = {}) =>
+    mountExtended(EnvironmentApproval, {
+      propsData: { environment, ...propsData },
+      provide: { projectId: '5' },
+    });
+
+  afterEach(() => {
+    wrapper.destroy();
+  });
+
+  const findPopover = () => extendedWrapper(wrapper.findComponent(GlPopover));
+  const findButton = () => extendedWrapper(wrapper.findComponent(GlButton));
+
+  it('should link the popover to the button', () => {
+    wrapper = createWrapper();
+    const popover = findPopover();
+    const button = findButton();
+
+    expect(popover.props('target')).toBe(button.attributes('id'));
+  });
+
+  describe('popover', () => {
+    let popover;
+
+    beforeEach(async () => {
+      wrapper = createWrapper();
+      await findButton().trigger('click');
+      popover = findPopover();
+    });
+
+    it('should set the popover title', () => {
+      expect(popover.props('title')).toBe(
+        sprintf(s__('DeploymentApproval|Approve or reject deployment #%{deploymentIid}'), {
+          deploymentIid: environment.upcomingDeployment.iid,
+        }),
+      );
+    });
+
+    it('should show the popover after clicking the button', () => {
+      expect(popover.attributes('show')).toBe('true');
+    });
+
+    it('should show which deployment this is approving', () => {
+      const main = sprintf(
+        s__(
+          'DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job.',
+        ),
+        {
+          deploymentIid: environment.upcomingDeployment.iid,
+        },
+      );
+      expect(popover.findByText(main).exists()).toBe(true);
+    });
+
+    describe('showing details about the environment', () => {
+      it.each`
+        detail                | text
+        ${'environment name'} | ${sprintf(s__('DeploymentApproval|Environment: %{environment}'), { environment: environment.name })}
+        ${'environment tier'} | ${sprintf(s__('DeploymentApproval|Deployment tier: %{tier}'), { tier: environment.tier })}
+        ${'job name'}         | ${sprintf(s__('DeploymentApproval|Manual job: %{jobName}'), { jobName: environment.upcomingDeployment.deployable.name })}
+      `('should show information on $detail', ({ text }) => {
+        expect(trimText(popover.text())).toContain(text);
+      });
+
+      it('shows the number of current approvals as well as the number of total approvals needed', () => {
+        expect(trimText(popover.text())).toContain(
+          sprintf(s__('DeploymentApproval| Current approvals: %{current}'), {
+            current: '5/10',
+          }),
+        );
+      });
+    });
+
+    describe('permissions', () => {
+      beforeAll(() => {
+        gon.current_username = 'root';
+      });
+
+      it.each`
+        scenario                                       | username  | approvals                                                  | canApproveDeployment | visible
+        ${'user can approve, no approvals'}            | ${'root'} | ${[]}                                                      | ${true}              | ${true}
+        ${'user cannot approve, no approvals'}         | ${'root'} | ${[]}                                                      | ${false}             | ${false}
+        ${'user can approve, has approved'}            | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${true}              | ${false}
+        ${'user can approve, someone else approved'}   | ${'root'} | ${[{ user: { username: 'foo' }, createdAt: Date.now() }]}  | ${true}              | ${true}
+        ${'user cannot approve, has already approved'} | ${'root'} | ${[{ user: { username: 'root' }, createdAt: Date.now() }]} | ${false}             | ${false}
+      `(
+        'should have buttons visible when $scenario: $visible',
+        ({ approvals, canApproveDeployment, visible }) => {
+          wrapper = createWrapper({
+            propsData: {
+              environment: {
+                ...environment,
+                upcomingDeployment: {
+                  ...environment.upcomingDeployment,
+                  approvals,
+                  canApproveDeployment,
+                },
+              },
+            },
+          });
+
+          expect(wrapper.findComponent({ ref: 'approve' }).exists()).toBe(visible);
+          expect(wrapper.findComponent({ ref: 'reject' }).exists()).toBe(visible);
+        },
+      );
+    });
+
+    describe.each`
+      ref          | api                      | text
+      ${'approve'} | ${Api.approveDeployment} | ${__('Approve')}
+      ${'reject'}  | ${Api.rejectDeployment}  | ${__('Reject')}
+    `('$ref', ({ ref, api, text }) => {
+      let button;
+
+      beforeEach(() => {
+        button = wrapper.findComponent({ ref });
+      });
+
+      it('should show the correct text', () => {
+        expect(button.text()).toBe(text);
+      });
+
+      it('should approve the deployment when Approve is clicked', async () => {
+        api.mockResolvedValue();
+
+        await button.trigger('click');
+
+        expect(api).toHaveBeenCalledWith('5', environment.upcomingDeployment.id);
+
+        await waitForPromises();
+
+        expect(wrapper.emitted('change')).toEqual([[]]);
+      });
+
+      it('should show an error on failure', async () => {
+        api.mockRejectedValue({ response: { data: { message: 'oops' } } });
+
+        await button.trigger('click');
+
+        expect(createAlert).toHaveBeenCalledWith({ message: 'oops' });
+      });
+
+      it('should set loading to true after click', async () => {
+        await button.trigger('click');
+
+        expect(button.props('loading')).toBe(true);
+      });
+
+      it('should stop showing the popover once resolved', async () => {
+        api.mockResolvedValue();
+
+        await button.trigger('click');
+
+        expect(popover.attributes('show')).toBeUndefined();
+      });
+    });
+  });
+});
diff --git a/ee/spec/frontend/environments/mock_data.js b/ee/spec/frontend/environments/mock_data.js
index f9b8024ec4d367ce14cda6720d3160436d6ca74f..a773fb535261ac24c88399baa218965d0ba0b0b6 100644
--- a/ee/spec/frontend/environments/mock_data.js
+++ b/ee/spec/frontend/environments/mock_data.js
@@ -58,6 +58,69 @@ export const environment = {
     ],
     deployed_at: '2016-11-29T18:11:58.430Z',
   },
+  upcoming_deployment: {
+    id: 66,
+    iid: 6,
+    sha: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+    ref: {
+      name: 'main',
+      ref_url: 'root/ci-folders/tree/main',
+    },
+    tag: true,
+    'last?': true,
+    user: {
+      name: 'Administrator',
+      username: 'root',
+      id: 1,
+      state: 'active',
+      avatar_url:
+        'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+      web_url: 'http://localhost:3000/root',
+    },
+    commit: {
+      id: '500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+      short_id: '500aabcb',
+      title: 'Update .gitlab-ci.yml',
+      author_name: 'Administrator',
+      author_email: 'admin@example.com',
+      created_at: '2016-11-07T18:28:13.000+00:00',
+      message: 'Update .gitlab-ci.yml',
+      author: {
+        name: 'Administrator',
+        username: 'root',
+        id: 1,
+        state: 'active',
+        avatar_url:
+          'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+        web_url: 'http://localhost:3000/root',
+      },
+      commit_path: '/root/ci-folders/tree/500aabcb17c97bdcf2d0c410b70cb8556f0362dd',
+    },
+    deployable: {
+      id: 1279,
+      name: 'deploy',
+      build_path: '/root/ci-folders/builds/1279',
+      retry_path: '/root/ci-folders/builds/1279/retry',
+      created_at: '2016-11-29T18:11:58.430Z',
+      updated_at: '2016-11-29T18:11:58.430Z',
+      status: {
+        text: 'success',
+        icon: 'status_success',
+      },
+    },
+    manual_actions: [
+      {
+        name: 'action',
+        play_path: '/play',
+      },
+    ],
+    approvals: [],
+    can_approve_deployment: true,
+    deployed_at: '2016-11-29T18:11:58.430Z',
+    pending_approval_count: 5,
+  },
+  required_approval_count: 10,
+  tier: 'production',
   has_stop_action: true,
   environment_path: 'root/ci-folders/environments/31',
   log_path: 'root/ci-folders/environments/31/logs',
diff --git a/ee/spec/frontend/environments/new_environment_item_spec.js b/ee/spec/frontend/environments/new_environment_item_spec.js
index 61a83e800e3447e767cb0c3027e20e80c0b56d22..b50b09a3bba3e66ab21d3e1fa2b346761590240d 100644
--- a/ee/spec/frontend/environments/new_environment_item_spec.js
+++ b/ee/spec/frontend/environments/new_environment_item_spec.js
@@ -5,6 +5,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper';
 import { stubTransition } from 'helpers/stub_transition';
 import EnvironmentItem from '~/environments/components/new_environment_item.vue';
 import EnvironmentAlert from 'ee/environments/components/environment_alert.vue';
+import EnvironmentApproval from 'ee/environments/components/environment_approval.vue';
 import alertQuery from 'ee/environments/graphql/queries/environment.query.graphql';
 import { resolvedEnvironment } from 'jest/environments/graphql/mock_data';
 
@@ -13,6 +14,7 @@ Vue.use(VueApollo);
 describe('~/environments/components/new_environment_item.vue', () => {
   let wrapper;
   let alert;
+  let approval;
 
   const createApolloProvider = () => {
     return createMockApollo([
@@ -43,14 +45,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
     wrapper = mountExtended(EnvironmentItem, {
       apolloProvider,
       propsData: { environment: resolvedEnvironment, ...propsData },
-      provide: { helpPagePath: '/help' },
+      provide: { helpPagePath: '/help', projectId: '1' },
       stubs: { transition: stubTransition() },
     });
 
     await nextTick();
 
     alert = wrapper.findComponent(EnvironmentAlert);
+    approval = wrapper.findComponent(EnvironmentApproval);
   };
+
   it('shows an alert if one is opened', async () => {
     const environment = { ...resolvedEnvironment, hasOpenedAlert: true };
     await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
@@ -62,7 +66,16 @@ describe('~/environments/components/new_environment_item.vue', () => {
   it('does not show an alert if one is opened', async () => {
     await createWrapper({ apolloProvider: createApolloProvider() });
 
-    alert = wrapper.findComponent(EnvironmentAlert);
     expect(alert.exists()).toBe(false);
   });
+
+  it('emits a change if approval changes', async () => {
+    const upcomingDeployment = resolvedEnvironment.lastDeployment;
+    const environment = { ...resolvedEnvironment, lastDeployment: null, upcomingDeployment };
+    await createWrapper({ propsData: { environment }, apolloProvider: createApolloProvider() });
+
+    approval.vm.$emit('change');
+
+    expect(wrapper.emitted('change')).toEqual([[]]);
+  });
 });
diff --git a/ee/spec/serializers/ee/deployment_entity_spec.rb b/ee/spec/serializers/ee/deployment_entity_spec.rb
index e2b91ea983a82acbb7d5038b2793eee300428f10..fa420909e07c428826ea3754e2698008d3a305f3 100644
--- a/ee/spec/serializers/ee/deployment_entity_spec.rb
+++ b/ee/spec/serializers/ee/deployment_entity_spec.rb
@@ -4,15 +4,17 @@
 
 RSpec.describe DeploymentEntity do
   let_it_be(:project) { create(:project, :repository) }
-  let_it_be(:environment) { create(:environment, project: project) }
-  let_it_be(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
-  let_it_be(:request) { EntityRequest.new(project: project, current_user: create(:user)) }
+  let_it_be(:current_user) { create(:user) }
+  let_it_be(:request) { EntityRequest.new(project: project, current_user: current_user) }
+
+  let(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
+  let(:environment) { create(:environment, project: project) }
+  let!(:protected_environment) { create(:protected_environment, name: environment.name, project: project, required_approval_count: 3) }
 
   subject { described_class.new(deployment, request: request).as_json }
 
   before do
     stub_licensed_features(protected_environments: true)
-    create(:protected_environment, name: environment.name, project: project, required_approval_count: 3)
     create(:deployment_approval, deployment: deployment)
   end
 
@@ -27,4 +29,23 @@
       expect(subject[:approvals].length).to eq(1)
     end
   end
+
+  describe '#can_approve_deployment' do
+    context 'when user has permission to update deployment' do
+      before do
+        project.add_maintainer(current_user)
+        create(:protected_environment_deploy_access_level, protected_environment: protected_environment, user: current_user)
+      end
+
+      it 'returns true' do
+        expect(subject[:can_approve_deployment]).to be(true)
+      end
+    end
+
+    context 'when user does not have permission to update deployment' do
+      it 'returns false' do
+        expect(subject[:can_approve_deployment]).to be(false)
+      end
+    end
+  end
 end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 134bcfc240d884b6565eaf0a2dc6ff2a6793b50a..b94e32447b52f7aaab1b5290779b6a2b575110c6 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -12227,6 +12227,33 @@ msgstr ""
 msgid "Deployment frequency"
 msgstr ""
 
+msgid "DeploymentApproval| Current approvals: %{current}"
+msgstr ""
+
+msgid "DeploymentApproval|Approval options"
+msgstr ""
+
+msgid "DeploymentApproval|Approve or reject deployment #%{deploymentIid}"
+msgstr ""
+
+msgid "DeploymentApproval|Approved by %{user} %{time}"
+msgstr ""
+
+msgid "DeploymentApproval|Approved by you %{time}"
+msgstr ""
+
+msgid "DeploymentApproval|Approving will run the manual job from deployment #%{deploymentIid}. Rejecting will fail the manual job."
+msgstr ""
+
+msgid "DeploymentApproval|Deployment tier: %{tier}"
+msgstr ""
+
+msgid "DeploymentApproval|Environment: %{environment}"
+msgstr ""
+
+msgid "DeploymentApproval|Manual job: %{jobName}"
+msgstr ""
+
 msgid "DeploymentTarget|GitLab Pages"
 msgstr ""
 
@@ -12289,6 +12316,9 @@ msgstr ""
 msgid "Deployment|Latest Deployed"
 msgstr ""
 
+msgid "Deployment|Needs Approval"
+msgstr ""
+
 msgid "Deployment|Running"
 msgstr ""
 
@@ -30352,6 +30382,9 @@ msgstr ""
 msgid "Reindexing Status: %{status} (Slice multiplier: %{multiplier}, Maximum running slices: %{max_slices})"
 msgstr ""
 
+msgid "Reject"
+msgstr ""
+
 msgid "Rejected (closed)"
 msgstr ""
 
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index db596688dad396494b908cd9564d23be708cd8f3..1d7a33fb95bbf67ecdedfda4bce8ac584b417e6c 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -24,7 +24,7 @@ describe('~/environments/components/new_environment_item.vue', () => {
     mountExtended(EnvironmentItem, {
       apolloProvider,
       propsData: { environment: resolvedEnvironment, ...propsData },
-      provide: { helpPagePath: '/help' },
+      provide: { helpPagePath: '/help', projectId: '1' },
       stubs: { transition: stubTransition() },
     });
 
diff --git a/spec/frontend/environments/new_environments_app_spec.js b/spec/frontend/environments/new_environments_app_spec.js
index 42e3608109b909258f8348506dd49176da28d1bc..2cc1c18d325ee65264b2d165c0ce1ac5548a6909 100644
--- a/spec/frontend/environments/new_environments_app_spec.js
+++ b/spec/frontend/environments/new_environments_app_spec.js
@@ -48,6 +48,7 @@ describe('~/environments/components/new_environments_app.vue', () => {
         canCreateEnvironment: true,
         defaultBranchName: 'main',
         helpPagePath: '/help',
+        projectId: '1',
         ...provide,
       },
       apolloProvider,