diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb index 3e3e424e9c94a88845b27412c7c09b45b90ad3ba..2552407fa4cda8f4d95edb1ac05af12b422b06df 100644 --- a/app/models/users/group_callout.rb +++ b/app/models/users/group_callout.rb @@ -23,7 +23,8 @@ class GroupCallout < ApplicationRecord namespace_storage_limit_banner_alert_threshold: 12, # EE-only namespace_storage_limit_banner_error_threshold: 13, # EE-only usage_quota_trial_alert: 14, # EE-only - preview_usage_quota_free_plan_alert: 15 # EE-only + preview_usage_quota_free_plan_alert: 15, # EE-only + enforcement_at_limit_alert: 16 # EE-only } validates :group, presence: true diff --git a/ee/app/components/namespaces/free_user_cap/enforcement_at_limit_alert_component.rb b/ee/app/components/namespaces/free_user_cap/enforcement_at_limit_alert_component.rb new file mode 100644 index 0000000000000000000000000000000000000000..f55ba9cf15c5f231b99f66b7203d088c6b7b5e95 --- /dev/null +++ b/ee/app/components/namespaces/free_user_cap/enforcement_at_limit_alert_component.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Namespaces + module FreeUserCap + class EnforcementAtLimitAlertComponent < BaseAlertComponent + private + + ENFORCEMENT_AT_LIMIT_ALERT = 'enforcement_at_limit_alert' + + def breached_cap_limit? + Shared.enforcement_at_limit?(namespace) + end + + def feature_name + ENFORCEMENT_AT_LIMIT_ALERT + end + + def alert_attributes + { + # see issue with ViewComponent overriding Kernel version + # https://github.com/github/view_component/issues/156#issuecomment-737469885 + title: Kernel.format( + _("Your namespace %{namespace_name} has reached the %{free_limit} user limit"), + free_limit: free_user_limit, + namespace_name: namespace.name + ).html_safe, + body: Kernel.format( + n_("To invite more users, you can reduce the number of users in your " \ + "namespace to %{free_limit} user or less. You can also upgrade to " \ + "a paid tier which do not have user limits. If you need additional " \ + "time, you can start a free 30-day trial which includes unlimited users.", + "To invite more users, you can reduce the number of users in your " \ + "namespace to %{free_limit} users or less. You can also upgrade to " \ + "a paid tier which do not have user limits. If you need additional " \ + "time, you can start a free 30-day trial which includes unlimited users.", + free_user_limit + ), + free_limit: free_user_limit + ).html_safe, + primary_cta: namespace_primary_cta, + secondary_cta: namespace_secondary_cta + } + end + end + end +end diff --git a/ee/app/components/namespaces/free_user_cap/shared.rb b/ee/app/components/namespaces/free_user_cap/shared.rb index 7b1178c1f643c114bec35c6eaac5250a95971233..0fa501819c39411a2b1dded8bf6fa396ae3a47f0 100644 --- a/ee/app/components/namespaces/free_user_cap/shared.rb +++ b/ee/app/components/namespaces/free_user_cap/shared.rb @@ -33,6 +33,10 @@ def self.close_button_data } end + def self.enforcement_at_limit?(namespace) + ::Namespaces::FreeUserCap::Enforcement.new(namespace).at_limit? + end + def self.enforcement_over_limit?(namespace) ::Namespaces::FreeUserCap::Enforcement.new(namespace).over_limit? end diff --git a/ee/app/models/namespaces/free_user_cap/enforcement.rb b/ee/app/models/namespaces/free_user_cap/enforcement.rb index 9904eac0dcdc7d6f28d4ac1f3e1a3b8dfa6692d2..05dbbec45d3010fd7138d59eecb9645dcaa670fc 100644 --- a/ee/app/models/namespaces/free_user_cap/enforcement.rb +++ b/ee/app/models/namespaces/free_user_cap/enforcement.rb @@ -20,6 +20,13 @@ def reached_limit? users_count >= limit end + def at_limit? + return false unless enforce_cap? + return false unless new_namespace_enforcement? + + users_count == limit + end + def seat_available?(user) return true unless enforce_cap? return true if member_with_user_already_exists?(user) diff --git a/ee/app/views/shared/_free_user_cap_alert.html.haml b/ee/app/views/shared/_free_user_cap_alert.html.haml index 8d0fd9ec7286346a697c56203b7ec727606589fc..67c53a1b0ba7f0fec798dae1abd2733520fba2ee 100644 --- a/ee/app/views/shared/_free_user_cap_alert.html.haml +++ b/ee/app/views/shared/_free_user_cap_alert.html.haml @@ -3,6 +3,10 @@ user: current_user, content_class: full_content_class) + = render Namespaces::FreeUserCap::EnforcementAtLimitAlertComponent.new(namespace: source.root_ancestor, + user: current_user, + content_class: full_content_class) + = render Namespaces::FreeUserCap::NonOwnerAlertComponent.new(namespace: source.root_ancestor, user: current_user, content_class: full_content_class) diff --git a/ee/spec/components/namespaces/free_user_cap/enforcement_at_limit_alert_component_spec.rb b/ee/spec/components/namespaces/free_user_cap/enforcement_at_limit_alert_component_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..aa3ffe1b3c073da140ca4af7f585cf02b18677e5 --- /dev/null +++ b/ee/spec/components/namespaces/free_user_cap/enforcement_at_limit_alert_component_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true +require "spec_helper" + +RSpec.describe Namespaces::FreeUserCap::EnforcementAtLimitAlertComponent, + :saas, :aggregate_failures, type: :component do + let_it_be(:namespace) { build_stubbed(:group) } + let_it_be(:content_class) { '_content_class_' } + + let(:user) { build_stubbed(:user) } + let(:free_user_cap_at_limit?) { true } + let(:owner_access?) { true } + + let_it_be(:title) do + "Your namespace #{namespace.name} has reached the " \ + "#{::Namespaces::FreeUserCap.dashboard_limit} user limit" + end + + subject(:component) do + described_class.new(namespace: namespace, user: user, content_class: content_class) + end + + before do + allow_next_instance_of(::Namespaces::FreeUserCap::Enforcement) do |free_user_cap| + allow(free_user_cap).to receive(:at_limit?).and_return(free_user_cap_at_limit?) + end + + allow(Ability).to receive(:allowed?) + .with(user, :owner_access, namespace) + .and_return(owner_access?) + end + + context 'when user is authorized to see alert' do + context 'when at the limit' do + it 'has content for the alert' do + render_inline(component) + + expect(page).to have_content(title) + expect(page).to have_css('.gl-alert-actions') + expect(page).to have_link('Manage members', href: group_usage_quotas_path(namespace)) + + expect(page).to have_link( + 'Explore paid plans', + href: group_billings_path(namespace, source: 'user-limit-alert-enforcement') + ) + + expect(page).to have_css(".gl-overflow-auto.#{content_class}") + + expect(page) + .to have_css("[data-testid='user-over-limit-free-plan-alert']" \ + "[data-dismiss-endpoint='#{group_callouts_path}']" \ + "[data-feature-id='#{described_class::ENFORCEMENT_AT_LIMIT_ALERT}']" \ + "[data-group-id='#{namespace.id}']") + end + + it 'renders all the expected tracking items' do + render_inline(component) + + expect(page).to have_css('.js-user-over-limit-free-plan-alert[data-track-action="render"]' \ + '[data-track-label="user_limit_banner"]') + expect(page).to have_css('[data-testid="user-over-limit-primary-cta"]' \ + '[data-track-action="click_button"]' \ + '[data-track-label="manage_members"]') + expect(page).to have_css('[data-testid="user-over-limit-secondary-cta"]' \ + '[data-track-action="click_button"]' \ + '[data-track-label="explore_paid_plans"]') + end + + context 'when alert has been dismissed' do + before do + allow(user).to receive(:dismissed_callout_for_group?).with( + feature_name: described_class::ENFORCEMENT_AT_LIMIT_ALERT, + group: namespace, + ignore_dismissal_earlier_than: nil + ).and_return(true) + end + + it 'does not render the alert' do + render_inline(component) + + expect(page).not_to have_content(title) + end + end + end + + context 'when limit has not been reached' do + let(:free_user_cap_at_limit?) { false } + + it 'does not render the alert' do + render_inline(component) + + expect(page).not_to have_content(title) + end + end + end + + context 'when user is not authorized to see alert' do + let(:owner_access?) { false } + + it 'does not render the alert' do + render_inline(component) + + expect(page).not_to have_content(title) + end + end + + context 'when user does not exist' do + let(:user) { nil } + + it 'does not render the alert' do + render_inline(component) + + expect(page).not_to have_content(title) + end + end +end diff --git a/ee/spec/models/namespaces/free_user_cap/enforcement_spec.rb b/ee/spec/models/namespaces/free_user_cap/enforcement_spec.rb index dfb74ae2048873521ce675e1996481df23860aef..e217474f7f472dd9f27fc96541d3ceecc6b36f63 100644 --- a/ee/spec/models/namespaces/free_user_cap/enforcement_spec.rb +++ b/ee/spec/models/namespaces/free_user_cap/enforcement_spec.rb @@ -484,6 +484,77 @@ end end + describe '#at_limit?' do + let(:free_plan_members_count) { Namespaces::FreeUserCap.dashboard_limit + 1 } + + subject(:at_limit?) { described_class.new(namespace).at_limit? } + + before do + allow(::Namespaces::FreeUserCap::UsersFinder).to receive(:count).and_return(free_plan_members_count) + end + + context 'when :free_user_cap is disabled' do + before do + stub_feature_flags(free_user_cap: false) + end + + it { is_expected.to be false } + end + + context 'when :free_user_cap is enabled' do + let(:free_plan_members_count) { Namespaces::FreeUserCap.dashboard_limit } + + it { is_expected.to be false } + + context 'with a net new namespace' do + include_context 'with net new namespace' + + context 'when enforcement date is populated' do + before do + stub_ee_application_setting(dashboard_limit_new_namespace_creation_enforcement_date: enforcement_date) + end + + context 'when :free_user_cap_new_namespaces is enabled' do + before do + stub_ee_application_setting(dashboard_limit: 3) + stub_feature_flags(free_user_cap_new_namespaces: true) + end + + context 'when under the dashboard_limit' do + let(:free_plan_members_count) { 2 } + + it { is_expected.to be false } + end + + context 'when at the dashboard_limit' do + let(:free_plan_members_count) { 3 } + + it { is_expected.to be true } + end + + context 'when over the dashboard_limit' do + let(:free_plan_members_count) { 4 } + + it { is_expected.to be false } + end + end + + context 'when :free_user_cap_new_namespaces is disabled it honors existing namespace logic' do + before do + stub_feature_flags(free_user_cap_new_namespaces: false) + end + + it { is_expected.to be false } + end + end + + context 'when enforcement date is not populated it honors existing namespace logic' do + it { is_expected.to be false } + end + end + end + end + describe '#seat_available?' do let(:free_plan_members_count) { Namespaces::FreeUserCap.dashboard_limit + 1 } let_it_be(:user) { create(:user) } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index def7f2d018aff29d62a886dfa4c1ecf6721aaff9..a0cfd270916892d38805ef48d6875f2578d3265e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -43034,6 +43034,11 @@ msgstr "" msgid "To import an SVN repository, check out %{svn_link}." msgstr "" +msgid "To invite more users, you can reduce the number of users in your namespace to %{free_limit} user or less. You can also upgrade to a paid tier which do not have user limits. If you need additional time, you can start a free 30-day trial which includes unlimited users." +msgid_plural "To invite more users, you can reduce the number of users in your namespace to %{free_limit} users or less. You can also upgrade to a paid tier which do not have user limits. If you need additional time, you can start a free 30-day trial which includes unlimited users." +msgstr[0] "" +msgstr[1] "" + msgid "To keep this project going, create a new issue" msgstr "" @@ -47997,6 +48002,9 @@ msgstr "" msgid "Your name" msgstr "" +msgid "Your namespace %{namespace_name} has reached the %{free_limit} user limit" +msgstr "" + msgid "Your namespace %{namespace_name} is over the %{free_limit} user limit and has been placed in a read-only state." msgstr ""