diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index d655777224170a9596b751e8403a0a9d39e1f938..b7299df1bc137aa47ef8f1f4a68b437b091a7eb1 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -16,6 +16,7 @@
= render "shared/service_ping_consent"
= render_two_factor_auth_recovery_settings_check
= render_if_exists "layouts/header/ee_subscribable_banner"
+ = render_if_exists "layouts/header/seats_count_alert"
= render_if_exists "shared/namespace_storage_limit_alert"
= render_if_exists "shared/namespace_user_cap_reached_alert"
= render_if_exists "shared/new_user_signups_cap_reached_alert"
diff --git a/ee/app/helpers/seats_count_alert_helper.rb b/ee/app/helpers/seats_count_alert_helper.rb
new file mode 100644
index 0000000000000000000000000000000000000000..33b42eeb7d67d2ae90f45696ddfb96a3ce724c75
--- /dev/null
+++ b/ee/app/helpers/seats_count_alert_helper.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module SeatsCountAlertHelper
+ def display_seats_count_alert!
+ @display_seats_count_alert = true
+ end
+
+ def learn_more_link
+ link_to _('Learn more.'), help_page_path('subscriptions/quarterly_reconciliation'), target: '_blank', rel: 'noopener noreferrer'
+ end
+
+ def group_name
+ root_namespace&.name
+ end
+
+ def remaining_seats_count
+ return unless total_seats_count && seats_in_use
+
+ total_seats_count - seats_in_use
+ end
+
+ def seats_usage_link
+ return unless root_namespace
+
+ link_to _('View seat usage'), current_usage_quotas_path, class: 'btn gl-alert-action btn-info btn-md gl-button'
+ end
+
+ def show_seats_count_alert?
+ return false unless root_namespace&.group_namespace?
+ return false unless root_namespace&.has_owner?(current_user)
+ return false unless current_subscription
+
+ !!@display_seats_count_alert
+ end
+
+ def total_seats_count
+ current_subscription&.seats
+ end
+
+ private
+
+ def root_namespace
+ @project&.root_ancestor || @group&.root_ancestor
+ end
+
+ def current_subscription
+ root_namespace&.gitlab_subscription
+ end
+
+ def seats_in_use
+ current_subscription&.seats_in_use
+ end
+
+ def current_usage_quotas_path
+ usage_quotas_path(root_namespace, anchor: 'seats-quota-tab')
+ end
+end
diff --git a/ee/app/views/layouts/header/_seats_count_alert.html.haml b/ee/app/views/layouts/header/_seats_count_alert.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..3bd36b43a296d16a5e8e49e418d35df78db52816
--- /dev/null
+++ b/ee/app/views/layouts/header/_seats_count_alert.html.haml
@@ -0,0 +1,12 @@
+- return unless show_seats_count_alert?
+.container.container-limited.pt-3
+ .gl-alert.gl-alert-info{ role: 'alert' }
+ = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .gl-alert-body
+ %h4.gl-alert-title= _('%{group_name} is approaching the limit of available seats') % { group_name: group_name }
+ = _('Your subscription has %{remaining_seats_count} out of %{total_seats_count} seats remaining. Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage.') % { remaining_seats_count: remaining_seats_count, total_seats_count: total_seats_count }
+ = learn_more_link
+ .gl-alert-actions
+ = seats_usage_link
diff --git a/ee/spec/helpers/seats_count_alert_helper_spec.rb b/ee/spec/helpers/seats_count_alert_helper_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..009918a254182f7e9c3a5446ecefb390b5e180af
--- /dev/null
+++ b/ee/spec/helpers/seats_count_alert_helper_spec.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe SeatsCountAlertHelper, :saas do
+ include Devise::Test::ControllerHelpers
+
+ let_it_be(:group) { nil }
+ let_it_be(:project) { nil }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ assign(:project, project)
+ assign(:group, group)
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ shared_examples 'learn more link is built' do
+ it 'builds the correct link' do
+ expect(helper.learn_more_link).to match %r{.+}m
+ end
+ end
+
+ shared_examples 'seats info are not populated' do
+ it 'sets remaining seats count to nil' do
+ expect(helper.remaining_seats_count).to be_nil
+ end
+
+ it 'sets total seats count to nil' do
+ expect(helper.total_seats_count).to be_nil
+ end
+ end
+
+ shared_examples 'seats info are populated' do
+ it 'sets remaining seats count to the correct number' do
+ expect(helper.remaining_seats_count).to eq(14)
+ end
+
+ it 'sets total seats count to the correct number' do
+ expect(helper.total_seats_count).to eq(15)
+ end
+ end
+
+ shared_examples 'group info are populated' do
+ it 'builds the correct link' do
+ expect(helper.seats_usage_link).to match %r{.+}m
+ end
+
+ it 'has a group name' do
+ expect(helper.group_name).to eq(context.name)
+ end
+ end
+
+ shared_examples 'group info are not populated' do
+ it 'does not build the correct link' do
+ expect(helper.seats_usage_link).to be_nil
+ end
+
+ it 'does not have a group name' do
+ expect(helper.group_name).to be_nil
+ end
+ end
+
+ shared_examples 'alert is not displayed while some info are' do
+ it_behaves_like 'learn more link is built'
+
+ it_behaves_like 'seats info are not populated'
+
+ it_behaves_like 'group info are not populated'
+
+ it 'does not show the alert' do
+ expect(helper.show_seats_count_alert?).to be false
+ end
+ end
+
+ shared_examples 'alert is displayed' do
+ include_examples 'learn more link is built'
+
+ include_examples 'seats info are populated'
+
+ include_examples 'group info are populated'
+
+ it 'does show the alert' do
+ expect(helper.show_seats_count_alert?).to be true
+ end
+ end
+
+ shared_examples 'alert is not displayed' do
+ include_examples 'learn more link is built'
+
+ include_examples 'seats info are populated'
+
+ include_examples 'group info are populated'
+
+ it 'does not show the alert' do
+ expect(helper.show_seats_count_alert?).to be false
+ end
+ end
+
+ shared_examples 'common cases for users' do
+ let_it_be(:gitlab_subscription) do
+ create(:gitlab_subscription, namespace: context, plan_code: Plan::ULTIMATE, seats: 15, seats_in_use: 1)
+ end
+
+ describe 'without a owner' do
+ before do
+ context.add_user(user, GroupMember::DEVELOPER)
+ helper.display_seats_count_alert!
+ end
+
+ include_examples 'alert is not displayed'
+ end
+
+ describe 'with a owner' do
+ before do
+ context.add_owner(user)
+ end
+
+ context 'without display seats count' do
+ include_examples 'alert is not displayed'
+ end
+
+ context 'with display seats count' do
+ before do
+ helper.display_seats_count_alert!
+ end
+
+ include_examples 'alert is displayed'
+ end
+ end
+ end
+
+ it 'sets @display_seats_count_alert to true' do
+ expect(helper.instance_variable_get(:@display_seats_count_alert)).to be nil
+
+ helper.display_seats_count_alert!
+
+ expect(helper.instance_variable_get(:@display_seats_count_alert)).to be true
+ end
+
+ describe 'with no subscription' do
+ include_examples 'alert is not displayed while some info are'
+ end
+
+ describe 'outside a group or project context' do
+ before do
+ helper.display_seats_count_alert!
+ end
+
+ include_examples 'alert is not displayed while some info are'
+ end
+
+ describe 'within a group context' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:context) { group }
+ let_it_be(:project) { nil }
+
+ include_examples 'common cases for users'
+ end
+
+ describe 'within a subgroup context' do
+ let_it_be(:context) { create(:group) }
+ let_it_be(:group) { create(:group, parent: context) }
+ let_it_be(:project) { nil }
+
+ include_examples 'common cases for users'
+ end
+
+ describe 'within a project context' do
+ let_it_be(:group) { nil }
+ let_it_be(:context) { create(:group) }
+ let_it_be(:project) { create(:project, namespace: context) }
+
+ include_examples 'common cases for users'
+ end
+
+ describe 'within a user namespace context' do
+ let_it_be(:project) { create(:project) }
+
+ before do
+ helper.display_seats_count_alert!
+ end
+
+ it 'does show the alert' do
+ expect(helper.show_seats_count_alert?).to be false
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 08e634d28024e08e21383bf3f82427b23e6d7d70..612f673d5f463272fa4a6f100a1700561701f49d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -632,6 +632,9 @@ msgstr ""
msgid "%{group_name} group members"
msgstr ""
+msgid "%{group_name} is approaching the limit of available seats"
+msgstr ""
+
msgid "%{group_name} uses group managed accounts. You need to create a new GitLab account which will be managed by %{group_name}."
msgstr ""
@@ -39529,6 +39532,9 @@ msgstr[1] ""
msgid "View replaced file @ "
msgstr ""
+msgid "View seat usage"
+msgstr ""
+
msgid "View setting"
msgstr ""
@@ -41588,6 +41594,9 @@ msgstr ""
msgid "Your subscription expired!"
msgstr ""
+msgid "Your subscription has %{remaining_seats_count} out of %{total_seats_count} seats remaining. Even if you reach the number of seats in your subscription, you can continue to add users, and GitLab will bill you for the overage."
+msgstr ""
+
msgid "Your subscription is now expired. To renew, export your license usage file and email it to %{renewal_service_email}. A new license will be emailed to the email address registered in the %{customers_dot}. You can upload this license to your instance. To use Free tier, remove your current license."
msgstr ""