Commit b840e66a authored by Mike Greiling's avatar Mike Greiling

Merge branch...

Merge branch '346278-eng-when-pipeline-fails-due-to-cc-verification-failure-create-a-persistent-header-in-the-ui' into 'master'

Display a verification reminder when a pipeline fails due to missing cc verification

See merge request gitlab-org/gitlab!75123
parents 443e1f2e 62a3ce00
...@@ -37,7 +37,8 @@ class UserCallout < ApplicationRecord ...@@ -37,7 +37,8 @@ class UserCallout < ApplicationRecord
security_configuration_devops_alert: 36, # EE-only security_configuration_devops_alert: 36, # EE-only
profile_personal_access_token_expiry: 37, # EE-only profile_personal_access_token_expiry: 37, # EE-only
terraform_notification_dismissed: 38, terraform_notification_dismissed: 38,
security_newsletter_callout: 39 security_newsletter_callout: 39,
verification_reminder: 40 # EE-only
} }
validates :feature_name, validates :feature_name,
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
= render "layouts/nav/sidebar/#{nav}" = render "layouts/nav/sidebar/#{nav}"
.content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" } .content-wrapper.content-wrapper-margin{ class: "#{@content_wrapper_class}" }
.mobile-overlay .mobile-overlay
= render_if_exists 'layouts/header/verification_reminder'
= yield :group_invite_members_banner = yield :group_invite_members_banner
.alert-wrapper.gl-force-block-formatting-context .alert-wrapper.gl-force-block-formatting-context
= render 'shared/outdated_browser' = render 'shared/outdated_browser'
......
...@@ -17119,6 +17119,7 @@ Name of the feature that the callout is for. ...@@ -17119,6 +17119,7 @@ Name of the feature that the callout is for.
| <a id="usercalloutfeaturenameenumtwo_factor_auth_recovery_settings_check"></a>`TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` | Callout feature name for two_factor_auth_recovery_settings_check. | | <a id="usercalloutfeaturenameenumtwo_factor_auth_recovery_settings_check"></a>`TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK` | Callout feature name for two_factor_auth_recovery_settings_check. |
| <a id="usercalloutfeaturenameenumultimate_trial"></a>`ULTIMATE_TRIAL` | Callout feature name for ultimate_trial. | | <a id="usercalloutfeaturenameenumultimate_trial"></a>`ULTIMATE_TRIAL` | Callout feature name for ultimate_trial. |
| <a id="usercalloutfeaturenameenumunfinished_tag_cleanup_callout"></a>`UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. | | <a id="usercalloutfeaturenameenumunfinished_tag_cleanup_callout"></a>`UNFINISHED_TAG_CLEANUP_CALLOUT` | Callout feature name for unfinished_tag_cleanup_callout. |
| <a id="usercalloutfeaturenameenumverification_reminder"></a>`VERIFICATION_REMINDER` | Callout feature name for verification_reminder. |
| <a id="usercalloutfeaturenameenumweb_ide_alert_dismissed"></a>`WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. | | <a id="usercalloutfeaturenameenumweb_ide_alert_dismissed"></a>`WEB_IDE_ALERT_DISMISSED` | Callout feature name for web_ide_alert_dismissed. |
| <a id="usercalloutfeaturenameenumweb_ide_ci_environments_guidance"></a>`WEB_IDE_CI_ENVIRONMENTS_GUIDANCE` | Callout feature name for web_ide_ci_environments_guidance. | | <a id="usercalloutfeaturenameenumweb_ide_ci_environments_guidance"></a>`WEB_IDE_CI_ENVIRONMENTS_GUIDANCE` | Callout feature name for web_ide_ci_environments_guidance. |
<script>
import { GlAlert, GlSprintf, GlButton } from '@gitlab/ui';
import Tracking from '~/tracking';
import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
import AccountVerificationModal from 'ee/billings/components/account_verification_modal.vue';
import {
FEATURE_NAME,
DOCS_LINK,
I18N,
EVENT_LABEL,
MOUNTED_EVENT,
DISMISS_EVENT,
OPEN_DOCS_EVENT,
START_VERIFICATION_EVENT,
SUCCESSFUL_VERIFICATION_EVENT,
} from '../constants';
export default {
components: {
UserCalloutDismisser,
AccountVerificationModal,
GlAlert,
GlSprintf,
GlButton,
},
mixins: [Tracking.mixin({ label: EVENT_LABEL })],
data() {
return {
shouldRenderSuccess: false,
};
},
computed: {
iframeUrl() {
return gon.payment_form_url;
},
allowedOrigin() {
return gon.subscriptions_url;
},
},
mounted() {
this.track(MOUNTED_EVENT);
},
methods: {
handleDismiss() {
this.track(DISMISS_EVENT);
this.$refs.calloutDismisser.dismiss();
},
showModal() {
this.track(START_VERIFICATION_EVENT);
this.$refs.modal.show();
},
clickOpenDocs() {
this.track(OPEN_DOCS_EVENT);
},
handleSuccessfulVerification() {
this.track(SUCCESSFUL_VERIFICATION_EVENT);
this.$refs.modal.hide();
this.$refs.calloutDismisser.dismiss();
this.shouldRenderSuccess = true;
},
},
i18n: I18N,
featureName: FEATURE_NAME,
docsLink: DOCS_LINK,
};
</script>
<template>
<div>
<user-callout-dismisser ref="calloutDismisser" :feature-name="$options.featureName" skip-query>
<template #default="{ shouldShowCallout }">
<gl-alert
v-if="shouldShowCallout"
ref="warningAlert"
:title="$options.i18n.warningAlert.title"
variant="warning"
@dismiss="handleDismiss"
>
<gl-sprintf :message="$options.i18n.warningAlert.message">
<template #validateLink="{ content }">
<gl-button ref="validateLink" variant="link" @click="showModal">
{{ content }}
</gl-button>
</template>
<template #docsLink="{ content }">
<gl-button
ref="docsLink"
variant="link"
:href="$options.docsLink"
target="_blank"
@click="clickOpenDocs"
>
{{ content }}
</gl-button>
</template>
</gl-sprintf>
</gl-alert>
</template>
</user-callout-dismisser>
<account-verification-modal
ref="modal"
:iframe-url="iframeUrl"
:allowed-origin="allowedOrigin"
@success="handleSuccessfulVerification"
/>
<gl-alert
v-if="shouldRenderSuccess"
ref="successAlert"
variant="success"
:title="$options.i18n.successAlert.title"
@dismiss="shouldRenderSuccess = false"
>
{{ $options.i18n.successAlert.message }}
</gl-alert>
</div>
</template>
import { s__ } from '~/locale';
export const FEATURE_NAME = 'verification_reminder';
export const DOCS_LINK = 'https://docs.gitlab.com/runner/install/';
export const EVENT_LABEL = 'verification_reminder';
export const MOUNTED_EVENT = 'shown';
export const DISMISS_EVENT = 'dismissed';
export const OPEN_DOCS_EVENT = 'clicked_docs_link';
export const START_VERIFICATION_EVENT = 'start_verification';
export const SUCCESSFUL_VERIFICATION_EVENT = 'successful_verification';
export const I18N = {
warningAlert: {
title: s__(
'VerificationReminder|Pipeline failing? To keep GitLab spam and abuse free we ask that you verify your identity with a valid payment method.',
),
message: s__(
'VerificationReminder|Until then, free pipeline minutes on shared runners are unavailable. %{validateLinkStart}Validate your account%{validateLinkEnd} or %{docsLinkStart}use your own runners%{docsLinkEnd}.',
),
},
successAlert: {
title: s__('VerificationReminder|Your account has been validated.'),
message: s__(
'VerificationReminder|You’ll now be able to take advantage of free pipeline minutes on shared runners.',
),
},
};
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import VerificationReminder from './components/verification_reminder.vue';
Vue.use(VueApollo);
export default () => {
const el = document.querySelector('.js-verification-reminder');
if (!el) {
return false;
}
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(VerificationReminder);
},
});
};
...@@ -4,6 +4,13 @@ import trackNavbarEvents from 'ee/event_tracking/navbar'; ...@@ -4,6 +4,13 @@ import trackNavbarEvents from 'ee/event_tracking/navbar';
import initNamespaceStorageLimitAlert from 'ee/namespace_storage_limit_alert'; import initNamespaceStorageLimitAlert from 'ee/namespace_storage_limit_alert';
import initNamespaceUserCapReachedAlert from 'ee/namespace_user_cap_reached_alert'; import initNamespaceUserCapReachedAlert from 'ee/namespace_user_cap_reached_alert';
if (document.querySelector('.js-verification-reminder') !== null) {
// eslint-disable-next-line promise/catch-or-return
import('ee/billings/verification_reminder').then(({ default: initVerificationReminder }) => {
initVerificationReminder();
});
}
// EE specific calls // EE specific calls
initEETrialBanner(); initEETrialBanner();
initNamespaceStorageLimitAlert(); initNamespaceStorageLimitAlert();
......
...@@ -102,6 +102,16 @@ module EE ...@@ -102,6 +102,16 @@ module EE
).execute ).execute
end end
def show_verification_reminder?
return false unless ::Gitlab.dev_env_or_com?
return false unless ::Feature.enabled?(:verification_reminder, default_enabled: :yaml)
return false unless current_user
return false if current_user.has_valid_credit_card?
failed_pipeline = current_user.pipelines.user_not_verified.last
failed_pipeline.present? && !user_dismissed?('verification_reminder', failed_pipeline.created_at)
end
private private
def eoa_bronze_plan_end_date def eoa_bronze_plan_end_date
......
...@@ -430,6 +430,10 @@ module EE ...@@ -430,6 +430,10 @@ module EE
::Gitlab.config.omniauth.block_auto_created_users && identities.any? ::Gitlab.config.omniauth.block_auto_created_users && identities.any?
end end
def has_valid_credit_card?
credit_card_validated_at.present?
end
protected protected
override :password_required? override :password_required?
...@@ -446,10 +450,6 @@ module EE ...@@ -446,10 +450,6 @@ module EE
::Feature.enabled?(:ci_require_credit_card_for_old_users, project, default_enabled: :yaml) ::Feature.enabled?(:ci_require_credit_card_for_old_users, project, default_enabled: :yaml)
end end
def has_valid_credit_card?
credit_card_validated_at.present?
end
def requires_credit_card_to_run_pipelines?(project) def requires_credit_card_to_run_pipelines?(project)
return false unless project.shared_runners_enabled return false unless project.shared_runners_enabled
......
- if show_verification_reminder?
.js-verification-reminder
---
name: verification_reminder
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75123
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/346828
milestone: "14.6"
type: development
group: group::activation
default_enabled: false
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import VerificationReminder from 'ee/billings/verification_reminder/components/verification_reminder.vue';
import { TEST_HOST } from 'helpers/test_constants';
import {
EVENT_LABEL,
MOUNTED_EVENT,
DISMISS_EVENT,
OPEN_DOCS_EVENT,
START_VERIFICATION_EVENT,
SUCCESSFUL_VERIFICATION_EVENT,
} from 'ee/billings/verification_reminder/constants';
describe('VerificationReminder', () => {
let wrapper;
let trackingSpy;
const createComponent = ({ shouldShowCallout = true } = {}, data = {}) => {
wrapper = shallowMount(VerificationReminder, {
data() {
return data;
},
stubs: {
GlSprintf,
UserCalloutDismisser: makeMockUserCalloutDismisser({
shouldShowCallout,
}),
},
});
};
const findVerificationModal = () => wrapper.find({ ref: 'modal' });
const calloutDismisser = () => wrapper.find({ ref: 'calloutDismisser' });
const findWarningAlert = () => wrapper.find({ ref: 'warningAlert' });
const findSuccessAlert = () => wrapper.find({ ref: 'successAlert' });
const findValidateLink = () => wrapper.find({ ref: 'validateLink' });
const findDocsLink = () => wrapper.find({ ref: 'docsLink' });
beforeEach(() => {
window.gon = {
subscriptions_url: TEST_HOST,
payment_form_url: TEST_HOST,
};
trackingSpy = mockTracking(undefined, undefined, jest.spyOn);
createComponent();
findVerificationModal().vm.show = jest.fn();
findVerificationModal().vm.hide = jest.fn();
calloutDismisser().vm.dismiss = jest.fn();
});
afterEach(() => {
window.gon = {};
unmockTracking();
wrapper.destroy();
});
describe('when the component is mounted', () => {
it('sends the mounted event', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, MOUNTED_EVENT, {
label: EVENT_LABEL,
});
});
it('renders the warning alert', () => {
expect(findWarningAlert().exists()).toBe(true);
});
});
describe('when dismissing the alert', () => {
beforeEach(() => {
findWarningAlert().vm.$emit('dismiss');
});
it('sends the dismiss event', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, DISMISS_EVENT, {
label: EVENT_LABEL,
});
});
it('calls the dismiss callback', () => {
expect(calloutDismisser().vm.dismiss).toHaveBeenCalled();
});
});
describe('when the alert has been dismissed', () => {
beforeEach(() => {
createComponent({
shouldShowCallout: false,
});
});
it('hides the warning alert', () => {
expect(findWarningAlert().exists()).toBe(false);
});
});
describe('when the validate link is clicked', () => {
beforeEach(() => {
findValidateLink().vm.$emit('click');
});
it('sends the start verification event', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, START_VERIFICATION_EVENT, {
label: EVENT_LABEL,
});
});
it('shows the verification modal', () => {
expect(findVerificationModal().vm.show).toHaveBeenCalled();
});
});
describe('when the docs link is clicked', () => {
beforeEach(() => {
findDocsLink().vm.$emit('click');
});
it('sends the open docs event', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, OPEN_DOCS_EVENT, {
label: EVENT_LABEL,
});
});
});
describe('when validation was successful', () => {
beforeEach(() => {
findVerificationModal().vm.$emit('success');
});
it('sends the successful verification event', () => {
expect(trackingSpy).toHaveBeenCalledWith(undefined, SUCCESSFUL_VERIFICATION_EVENT, {
label: EVENT_LABEL,
});
});
it('hides the modal', () => {
expect(findVerificationModal().vm.hide).toHaveBeenCalled();
});
it('calls the dismiss callback', () => {
expect(calloutDismisser().vm.dismiss).toHaveBeenCalled();
});
it('renders the success alert', () => {
expect(findSuccessAlert().exists()).toBe(true);
});
});
describe('when dismissing the success alert', () => {
beforeEach(() => {
createComponent(undefined, {
shouldRenderSuccess: true,
});
findSuccessAlert().vm.$emit('dismiss');
});
it('hides the success alert', () => {
expect(findSuccessAlert().exists()).toBe(false);
});
});
});
...@@ -412,4 +412,55 @@ RSpec.describe EE::UserCalloutsHelper do ...@@ -412,4 +412,55 @@ RSpec.describe EE::UserCalloutsHelper do
helper.dismiss_two_factor_auth_recovery_settings_check helper.dismiss_two_factor_auth_recovery_settings_check
end end
end end
describe '#show_verification_reminder?' do
subject { helper.show_verification_reminder? }
let_it_be(:user) { create(:user) }
let_it_be(:pipeline) { create(:ci_pipeline, user: user, failure_reason: :user_not_verified) }
where(:on_gitlab_com?, :logged_in?, :unverified?, :failed_pipeline?, :not_dismissed_callout?, :flag_enabled?, :result) do
true | true | true | true | true | true | true
false | true | true | true | true | true | false
true | false | true | true | true | true | false
true | true | false | true | true | true | false
true | true | true | false | true | true | false
true | true | true | true | false | true | false
true | true | true | true | true | false | false
end
with_them do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(on_gitlab_com?)
allow(helper).to receive(:current_user).and_return(logged_in? ? user : nil)
allow(user).to receive(:has_valid_credit_card?).and_return(!unverified?)
pipeline.update!(failure_reason: nil) unless failed_pipeline?
allow(user).to receive(:dismissed_callout?).and_return(!not_dismissed_callout?)
stub_feature_flags(verification_reminder: flag_enabled?)
end
it { is_expected.to eq(result) }
end
describe 'dismissing the alert timing' do
before do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
allow(helper).to receive(:current_user).and_return(user)
create(:user_callout, user: user, feature_name: :verification_reminder, dismissed_at: Time.current)
create(:ci_pipeline, user: user, failure_reason: :user_not_verified, created_at: pipeline_created_at)
end
context 'when failing a pipeline after dismissing the alert' do
let(:pipeline_created_at) { 2.days.from_now }
it { is_expected.to eq(true) }
end
context 'when dismissing the alert after failing a pipeline' do
let(:pipeline_created_at) { 2.days.ago }
it { is_expected.to eq(false) }
end
end
end
end end
...@@ -2000,6 +2000,28 @@ RSpec.describe User do ...@@ -2000,6 +2000,28 @@ RSpec.describe User do
end end
end end
describe '#has_valid_credit_card?' do
it 'returns true when a credit card validation is present' do
credit_card_validation = build(:credit_card_validation, credit_card_validated_at: Time.current)
user = build(:user, credit_card_validation: credit_card_validation)
expect(user.has_valid_credit_card?).to be_truthy
end
it 'returns false when a credit card validation is present, but the credit_card_validated_at attribute is blank' do
credit_card_validation = build(:credit_card_validation, credit_card_validated_at: nil)
user = build(:user, credit_card_validation: credit_card_validation)
expect(user.has_valid_credit_card?).to be_falsey
end
it 'returns false when a credit card validation is missing' do
user = build(:user, credit_card_validation: nil)
expect(user.has_valid_credit_card?).to be_falsey
end
end
describe '#activate_based_on_user_cap?' do describe '#activate_based_on_user_cap?' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
......
...@@ -38390,6 +38390,18 @@ msgstr "" ...@@ -38390,6 +38390,18 @@ msgstr ""
msgid "Verification status" msgid "Verification status"
msgstr "" msgstr ""
msgid "VerificationReminder|Pipeline failing? To keep GitLab spam and abuse free we ask that you verify your identity with a valid payment method."
msgstr ""
msgid "VerificationReminder|Until then, free pipeline minutes on shared runners are unavailable. %{validateLinkStart}Validate your account%{validateLinkEnd} or %{docsLinkStart}use your own runners%{docsLinkEnd}."
msgstr ""
msgid "VerificationReminder|Your account has been validated."
msgstr ""
msgid "VerificationReminder|You’ll now be able to take advantage of free pipeline minutes on shared runners."
msgstr ""
msgid "Verified" msgid "Verified"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment