diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-26 15:07:29 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-07-26 15:07:29 +0300 |
commit | 597d5ed08988cb00681eaf252d04ebae4bd24731 (patch) | |
tree | fa6c90ecda00858be51b790dad9e4d9098d29fdb /app | |
parent | e2cf652edb5e9d9fa9a081952070074c07bf651e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
31 files changed, 365 insertions, 143 deletions
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js index 1c2be99b393..b427820144d 100644 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ b/app/assets/javascripts/security_configuration/components/constants.js @@ -57,7 +57,7 @@ export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { anchor: 'enable-automatic-dast-run', }); -export const DAST_BADGE_TEXT = __('Available on-demand'); +export const DAST_BADGE_TEXT = __('Available on demand'); export const DAST_BADGE_TOOLTIP = __( 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', ); diff --git a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue index 9e77a1d44f4..e723505f01f 100644 --- a/app/assets/javascripts/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/service_desk/components/service_desk_list_app.vue @@ -16,8 +16,8 @@ import { TYPENAME_USER } from '~/graphql_shared/constants'; import searchUsersQuery from '~/issues/list/queries/search_users.query.graphql'; import searchLabelsQuery from '~/issues/list/queries/search_labels.query.graphql'; import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql'; -import getServiceDeskIssuesQuery from '../queries/get_service_desk_issues.query.graphql'; -import getServiceDeskIssuesCounts from '../queries/get_service_desk_issues_counts.query.graphql'; +import getServiceDeskIssuesQuery from 'ee_else_ce/service_desk/queries/get_service_desk_issues.query.graphql'; +import getServiceDeskIssuesCounts from 'ee_else_ce/service_desk/queries/get_service_desk_issues_counts.query.graphql'; import { errorFetchingCounts, errorFetchingIssues, @@ -59,6 +59,8 @@ export default { 'releasesPath', 'autocompleteAwardEmojisPath', 'hasIterationsFeature', + 'hasIssueWeightsFeature', + 'hasIssuableHealthStatusFeature', 'groupPath', 'emptyStateSvgPath', 'isProject', @@ -67,6 +69,13 @@ export default { 'isServiceDeskSupported', 'hasAnyIssues', ], + props: { + eeSearchTokens: { + type: Array, + required: false, + default: () => [], + }, + }, data() { return { serviceDeskIssues: [], @@ -201,6 +210,10 @@ export default { }); } + if (this.eeSearchTokens.length) { + tokens.push(...this.eeSearchTokens); + } + tokens.sort((a, b) => a.title.localeCompare(b.title)); return tokens; diff --git a/app/assets/javascripts/service_desk/index.js b/app/assets/javascripts/service_desk/index.js index bcb5093e401..440f7748461 100644 --- a/app/assets/javascripts/service_desk/index.js +++ b/app/assets/javascripts/service_desk/index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; +import ServiceDeskListApp from 'ee_else_ce/service_desk/components/service_desk_list_app.vue'; import { gqlClient } from './graphql'; -import ServiceDeskListApp from './components/service_desk_list_app.vue'; export async function mountServiceDeskListApp() { const el = document.querySelector('.js-service-desk-list'); diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue index efd93e88fa9..28e50dceb48 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue @@ -36,7 +36,7 @@ export default { <style scoped> .fake-input { - top: 12px; - left: 33px; + top: 18px; + left: 39px; } </style> diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index bec8c191b31..a91e41585a8 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -290,10 +290,10 @@ export default { <form role="search" :aria-label="searchPlaceholder" - class="gl-relative gl-rounded-base gl-w-full" + class="gl-relative gl-rounded-base gl-w-full gl-pb-0" data-testid="global-search-form" > - <div class="gl-p-1 gl-relative"> + <div class="gl-relative gl-bg-white gl-border-b gl-mb-n1 gl-p-3"> <gl-search-box-by-type id="search" ref="searchInput" @@ -347,7 +347,7 @@ export default { <div ref="resultsList" data-testid="global-search-results" - class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-2" + class="global-search-results gl-overflow-y-auto gl-w-full gl-pb-3" @keydown="onKeydown" > <command-palette-items diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue index cd623200b03..2686d86732e 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue @@ -23,9 +23,6 @@ export default { computed: { ...mapState(['search', 'loading', 'autocompleteError']), ...mapGetters(['autocompleteGroupedSearchOptions', 'scopedSearchOptions']), - isPrecededByScopedOptions() { - return this.scopedSearchOptions.length > 1; - }, }, methods: { highlightedName(val) { @@ -40,9 +37,9 @@ export default { <div> <ul v-if="!loading" class="gl-m-0 gl-p-0 gl-list-style-none"> <gl-disclosure-dropdown-group - v-for="group in autocompleteGroupedSearchOptions" + v-for="(group, index) in autocompleteGroupedSearchOptions" :key="group.name" - :class="{ 'gl-mt-0!': !isPrecededByScopedOptions }" + :class="{ 'gl-mt-0!': index === 0 }" :group="group" bordered > diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index 37df45a72a4..a4d50466f8f 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -5,6 +5,7 @@ import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import Tracking from '~/tracking'; +import axios from '~/lib/utils/axios_utils'; import { EVENT_ACTION, EVENT_LABEL_VIEWER, @@ -53,6 +54,11 @@ export default { }; }, computed: { + isLfsBlob() { + const { storedExternally, externalStorage, simpleViewer } = this.blob; + + return storedExternally && externalStorage === 'lfs' && simpleViewer?.fileType === 'text'; + }, splitContent() { return this.content.split(/\r?\n/); }, @@ -83,6 +89,15 @@ export default { }, }, async created() { + if (this.isLfsBlob) { + await axios + .get(this.blob.externalStorageUrl || this.blob.rawPath) + .then((result) => { + this.content = result.data; + }) + .catch(() => this.$emit('error')); + } + addBlobLinksTracking(); this.trackEvent(EVENT_LABEL_VIEWER); diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 12801b272e8..2586f544d94 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -294,8 +294,8 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; } .search-scope-help { - top: 0.625rem; - right: 2.5rem; + top: 1rem; + right: 3rem; } .gl-search-box-by-type-input-borderless { @@ -304,5 +304,14 @@ $super-sidebar-transition-hint-duration: $super-sidebar-transition-duration / 4; .global-search-results { max-height: 30rem; + + .gl-new-dropdown-item { + @include gl-px-3; + } + + // Target groups + [id*='gl-disclosure-dropdown-group'] { + @include gl-px-5; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 005fbc8b058..2722893d04c 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -222,6 +222,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .discussion-reply-holder { border: 1px solid $border-color; + background-color: $white; } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 06516a555ef..6aca2b5646c 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -87,7 +87,7 @@ } .settings-section, -.settings-section-no-bottom + .settings-section { +.settings-section-no-bottom ~ .settings-section { @include gl-pt-0; } @@ -95,7 +95,7 @@ @include gl-pt-6; } -.settings-section:not(.settings-section-no-bottom) + .settings-section { +.settings-section:not(.settings-section-no-bottom) ~ .settings-section { @include gl-border-t; } diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb index 53dd06ce638..bd6f4ea84ca 100644 --- a/app/controllers/concerns/integrations/params.rb +++ b/app/controllers/concerns/integrations/params.rb @@ -102,10 +102,14 @@ module Integrations param_values = return_value[:integration] if param_values.is_a?(ActionController::Parameters) - if %w[update test].include?(action_name) && integration.chat? && - param_values['webhook'] == BaseChatNotification::SECRET_MASK + if %w[update test].include?(action_name) && integration.chat? + param_values.delete('webhook') if param_values['webhook'] == BaseChatNotification::SECRET_MASK - param_values.delete('webhook') + if integration.try(:mask_configurable_channels?) + integration.event_channel_names.each do |channel| + param_values.delete(channel) if param_values[channel] == BaseChatNotification::SECRET_MASK + end + end end integration.secret_fields.each do |param| diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index c955032308c..e6e8cfc4020 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -60,7 +60,7 @@ class GraphqlController < ApplicationController urgency :low, [:execute] def execute - result = if Feature.enabled?(:cache_introspection_query) && introspection_query? + result = if introspection_query? execute_introspection_query else multiplex? ? execute_multiplex : execute_query diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb index 7213bd074fc..af0f1bd6808 100644 --- a/app/helpers/emails_helper.rb +++ b/app/helpers/emails_helper.rb @@ -2,6 +2,7 @@ module EmailsHelper include AppearancesHelper + include SafeFormatHelper # Google Actions # https://developers.google.com/gmail/markup/reference/go-to-action @@ -236,6 +237,44 @@ module EmailsHelper end end + def member_about_to_expire_text(member_source, days_to_expire, format: nil) + days_formatted = pluralize(days_to_expire, 'day') + + case member_source + when Project + url = project_url(member_source) + when Group + url = group_url(member_source) + end + + case format + when :html + link_to = generate_link(member_source.human_name, url).html_safe + safe_format(_("Your membership in %{link_to} %{project_or_group_name} will expire in %{days_formatted}."), link_to: link_to, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted) + else + _("Your membership in %{project_or_group} %{project_or_group_name} will expire in %{days_formatted}.") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular, days_formatted: days_formatted } + end + end + + def member_about_to_expire_link(member, member_source, format: nil) + project_or_group = member_source.human_name + + case member_source + when Project + url = project_project_members_url(member_source, search: member.user.username) + when Group + url = group_group_members_url(member_source, search: member.user.username) + end + + case format + when :html + link_to = generate_link("#{member_source.class.name.downcase} membership", url).html_safe + safe_format(_('For additional information, review your %{link_to} or contact your %{project_or_group} owner.'), link_to: link_to, project_or_group: project_or_group) + else + _('For additional information, review your %{project_or_group} membership: %{url} or contact your %{project_or_group} owner.') % { project_or_group: project_or_group, url: url } + end + end + def group_membership_expiration_changed_text(member, group) if member.expires? days = (member.expires_at - Date.today).to_i diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 33c955f94ee..221d359c8c6 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -133,6 +133,22 @@ module Emails subject: subject(subject)) end + def member_about_to_expire_email(member_source_type, member_id) + @member_source_type = member_source_type + @member_id = member_id + + return unless member_exists? + return unless member.expires_at + + @days_to_expire = (member.expires_at - Date.today).to_i + + return if @days_to_expire <= 0 + + email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject(s_("Your membership will expire in %{days_to_expire} days") % { days_to_expire: @days_to_expire })) + end + # rubocop: disable CodeReuse/ActiveRecord def member @member ||= Member.find_by(id: @member_id) diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 93d4625c344..4c6ae930cc5 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -166,6 +166,13 @@ class NotifyPreview < ActionMailer::Preview Notify.member_invited_email('project', member.id, '1234').message end + def member_about_to_expire_email + cleanup do + member = project.add_member(user, Gitlab::Access::GUEST, expires_at: 7.days.from_now.to_date) + Notify.member_about_to_expire_email('project', member.id).message + end + end + def pages_domain_enabled_email cleanup do pages_domain = PagesDomain.new(domain: 'my.example.com', project: project, verified_at: Time.now, enabled_until: 1.week.from_now) diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index c9de4d2b3bb..7140e57961f 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -23,7 +23,6 @@ module Integrations ].freeze SECRET_MASK = '************' - CHANNEL_LIMIT_PER_EVENT = 10 attribute :category, default: 'chat' @@ -186,6 +185,14 @@ module Integrations true end + def channel_limit_per_event + 10 + end + + def mask_configurable_channels? + false + end + private def should_execute?(object_kind) @@ -314,13 +321,13 @@ module Integrations def validate_channel_limit supported_events.each do |event| count = channels_for_event(event).count - next unless count > CHANNEL_LIMIT_PER_EVENT + next unless count > channel_limit_per_event errors.add( event_channel_name(event).to_sym, format( s_('SlackIntegration|cannot have more than %{limit} channels'), - limit: CHANNEL_LIMIT_PER_EVENT + limit: channel_limit_per_event ) ) end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 061c491034d..99072179c8c 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -10,7 +10,7 @@ module Integrations field :webhook, section: SECTION_TYPE_CONNECTION, - help: 'e.g. https://discordapp.com/api/webhooks/…', + help: 'e.g. https://discord.com/api/webhooks/…', required: true field :notify_only_broken_pipelines, @@ -45,7 +45,7 @@ module Integrations end def default_channel_placeholder - # No-op. + s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)') end def self.supported_events @@ -72,10 +72,23 @@ module Integrations ] end + def configurable_channels? + true + end + + def channel_limit_per_event + 1 + end + + def mask_configurable_channels? + true + end + private def notify(message, opts) - client = Discordrb::Webhooks::Client.new(url: webhook) + webhook_url = opts[:channel]&.first || webhook + client = Discordrb::Webhooks::Client.new(url: webhook_url) client.execute do |builder| builder.add_embed do |embed| diff --git a/app/models/member.rb b/app/models/member.rb index f164ea244b4..cdf40eaa8f5 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -153,6 +153,7 @@ class Member < ApplicationRecord scope :not_accepted_invitations, -> { invite.where(invite_accepted_at: nil) } scope :not_accepted_invitations_by_user, -> (user) { not_accepted_invitations.where(created_by: user) } scope :not_expired, -> (today = Date.current) { where(arel_table[:expires_at].gt(today).or(arel_table[:expires_at].eq(nil))) } + scope :expiring_and_not_notified, ->(date) { where("expiry_notified_at is null AND expires_at >= ? AND expires_at <= ?", Date.current, date) } scope :created_today, -> do now = Date.current diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 4bf37e228ab..6d0e7c35865 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -5,7 +5,7 @@ module Ml validates :project, :model, presence: true validates :version, - format: Gitlab::Regex.ml_model_version_regex, + format: Gitlab::Regex.semver_regex, uniqueness: { scope: [:project, :model_id] }, presence: true, length: { maximum: 255 } diff --git a/app/serializers/integrations/event_entity.rb b/app/serializers/integrations/event_entity.rb index 1cbd6114581..f7cac23f30c 100644 --- a/app/serializers/integrations/event_entity.rb +++ b/app/serializers/integrations/event_entity.rb @@ -23,7 +23,10 @@ module Integrations integration.event_channel_name(event) end expose :value do |event| - integration.event_channel_value(event) + value = integration.event_channel_value(event) + next BaseChatNotification::SECRET_MASK if value.present? && integration.mask_configurable_channels? + + value end expose :placeholder do |_event| integration.default_channel_placeholder diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb index b2c0fffc12d..3a3d0e53aae 100644 --- a/app/services/members/update_service.rb +++ b/app/services/members/update_service.rb @@ -36,6 +36,7 @@ module Members member.attributes = params return unless member.changed? + member.expiry_notified_at = nil if member.expires_at_changed? member.tap(&:save!) end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d305c8c03cf..ceafebddfcf 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -529,6 +529,12 @@ class NotificationService mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end + def member_about_to_expire(member) + return true unless member.notifiable?(:mention) + + mailer.member_about_to_expire_email(member.real_source_type, member.id).deliver_later + end + # Group invite def invite_group_member(group_member, token) mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml index 472ba2f84a0..4979f7e28e7 100644 --- a/app/views/admin/users/_access_levels.html.haml +++ b/app/views/admin/users/_access_levels.html.haml @@ -1,49 +1,49 @@ -.gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = s_('AdminUsers|Access') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :projects_limit, class: 'gl-display-block col-form-label' - = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') - = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile') - - %fieldset.form-group.gl-form-group - %legend.col-form-label.col-form-label - = s_('AdminUsers|Access level') - - editing_current_user = (current_user == @user) - - = f.gitlab_ui_radio_component :access_level, :regular, - s_('AdminUsers|Regular'), - radio_options: { disabled: editing_current_user }, - help_text: s_('AdminUsers|Regular users have access to their groups and projects.') - - = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user - - - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') - - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user - = f.gitlab_ui_radio_component :access_level, :admin, - s_('AdminUsers|Administrator'), - radio_options: { disabled: editing_current_user }, - help_text: help_text - - .form-group.gl-form-group{ role: 'group' } - = f.gitlab_ui_checkbox_component :external, - s_('AdminUsers|External'), - help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') - .hidden{ data: user_internal_regex_data } - .gl-display-flex.gl-align-items-baseline - %row.hidden#warning_external_automatically_set - = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning - - .form-group.gl-form-group{ role: 'group' } - - @user.credit_card_validation || @user.build_credit_card_validation - = f.fields_for :credit_card_validation do |ff| - = ff.gitlab_ui_checkbox_component :credit_card_validated_at, - s_('AdminUsers|Validate user account'), - help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), - checkbox_options: { checked: @user.credit_card_validated_at.present? } + + .form-group.gl-form-group{ role: 'group' } + = f.label :projects_limit, class: 'gl-display-block col-form-label' + = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control gl-form-input gl-form-input-sm' + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :can_create_group, s_('AdminUsers|Can create group') + = f.gitlab_ui_checkbox_component :private_profile, s_('AdminUsers|Private profile') + + %fieldset.form-group.gl-form-group + %legend.col-form-label.col-form-label + = s_('AdminUsers|Access level') + - editing_current_user = (current_user == @user) + + = f.gitlab_ui_radio_component :access_level, :regular, + s_('AdminUsers|Regular'), + radio_options: { disabled: editing_current_user }, + help_text: s_('AdminUsers|Regular users have access to their groups and projects.') + + = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user + + - help_text = s_('AdminUsers|The user has unlimited access to all groups, projects, users, and features.') + - help_text += ' ' + s_('AdminUsers|You cannot remove your own administrator access.') if editing_current_user + = f.gitlab_ui_radio_component :access_level, :admin, + s_('AdminUsers|Administrator'), + radio_options: { disabled: editing_current_user }, + help_text: help_text + + .form-group.gl-form-group{ role: 'group' } + = f.gitlab_ui_checkbox_component :external, + s_('AdminUsers|External'), + help_text: s_('AdminUsers|External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects, groups, or personal snippets.') + .hidden{ data: user_internal_regex_data } + .gl-display-flex.gl-align-items-baseline + %row.hidden#warning_external_automatically_set + = gl_badge_tag s_('AdminUsers|Automatically marked as default internal user'), variant: :warning + + .form-group.gl-form-group{ role: 'group' } + - @user.credit_card_validation || @user.build_credit_card_validation + = f.fields_for :credit_card_validation do |ff| + = ff.gitlab_ui_checkbox_component :credit_card_validated_at, + s_('AdminUsers|Validate user account'), + help_text: s_('AdminUsers|A user can validate themselves by inputting a credit/debit card, or an admin can manually validate a user. Validated users can use free CI minutes on shared runners.'), + checkbox_options: { checked: @user.credit_card_validated_at.present? } diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index dce008afb26..85796246c83 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -1,9 +1,9 @@ -.gl-mb-3 - .row - .col-lg-4 - %h4.gl-mt-0 +.settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Admin notes') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :note, s_('Admin|Note') - = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' + + .form-group.gl-form-group{ role: 'group' } + = f.label :note, s_('Admin|Note') + = f.text_area :note, class: 'form-control gl-form-input gl-form-textarea' diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 8822d52c3c0..ffe7e128d60 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -2,42 +2,42 @@ = gitlab_ui_form_for [:admin, @user], html: { class: 'fieldset-form' } do |f| = form_errors(@user) - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Account') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :name, _('Name'), class: 'gl-display-block col-form-label' - = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :username, _('Username'), class: 'gl-display-block col-form-label' - = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input' - - .form-group.gl-form-group{ role: 'group' } - = f.label :email, _('Email'), class: 'gl-display-block col-form-label' - = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input' - - .gl-border-b.gl-pb-3.gl-mb-6 - .row - .col-lg-4 - %h4.gl-mt-0 + + .form-group.gl-form-group{ role: 'group' } + = f.label :name, _('Name'), class: 'gl-display-block col-form-label' + = f.text_field :name, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :username, _('Username'), class: 'gl-display-block col-form-label' + = f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control gl-form-input gl-form-input-lg' + + .form-group.gl-form-group{ role: 'group' } + = f.label :email, _('Email'), class: 'gl-display-block col-form-label' + = f.text_field :email, required: true, autocomplete: 'off', class: 'form-control gl-form-input gl-form-input-lg' + + .settings-section + .settings-sticky-header + .settings-sticky-header-inner + %h4.gl-my-0 = _('Password') - .col-lg-8 - - if @user.new_record? - = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| - - c.with_body do - = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') - - else - .form-group.gl-form-group{ role: 'group' } - = f.label :password, _('Password'), class: 'gl-display-block col-form-label' - = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation' - = render_if_exists 'shared/password_requirements_list' - .form-group.gl-form-group{ role: 'group' } - = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' - = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input' + + - if @user.new_record? + = render Pajamas::AlertComponent.new(variant: :info, dismissible: false, alert_options: { class: 'gl-mb-5' }) do |c| + - c.with_body do + = s_('AdminUsers|Reset link will be generated and sent to the user. User will be forced to set the password on first sign in.') + - else + .form-group.gl-form-group{ role: 'group' } + = f.label :password, _('Password'), class: 'gl-display-block col-form-label' + = f.password_field :password, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input js-password-complexity-validation gl-form-input-lg' + = render_if_exists 'shared/password_requirements_list' + .form-group.gl-form-group{ role: 'group' } + = f.label :password_confirmation, _('Password confirmation'), class: 'gl-display-block col-form-label' + = f.password_field :password_confirmation, disabled: f.object.force_random_password, autocomplete: 'new-password', class: 'form-control gl-form-input gl-form-input-lg' = render partial: 'access_levels', locals: { f: f } @@ -45,42 +45,42 @@ = render_if_exists 'admin/users/limits', f: f - .gl-border-b.gl-pb-6.gl-mb-6 - .row - .col-lg-4 + .settings-section + .settings-sticky-header + .settings-sticky-header-inner %h4.gl-mt-0 = _('Profile') - .col-lg-8 - .form-group.gl-form-group{ role: 'group' } - = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label' - = f.file_field :avatar - .form-group.gl-form-group{ role: 'group' } - = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' - = f.text_field :skype, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :avatar, s_('AdminUsers|Avatar'), class: 'gl-display-block col-form-label gl-form-input-lg' + = f.file_field :avatar + + .form-group.gl-form-group{ role: 'group' } + = f.label :skype, s_('AdminUsers|Skype'), class: 'gl-display-block col-form-label' + = f.text_field :skype, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' - = f.text_field :linkedin, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :linkedin, s_('AdminUsers|Linkedin'), class: 'gl-display-block col-form-label' + = f.text_field :linkedin, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' - = f.text_field :twitter, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :twitter, _('Twitter'), class: 'gl-display-block col-form-label' + = f.text_field :twitter, class: 'form-control gl-form-input gl-form-input-lg' - .form-group.gl-form-group{ role: 'group' } - = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' - = f.text_field :website_url, class: 'form-control gl-form-input' + .form-group.gl-form-group{ role: 'group' } + = f.label :website_url, s_('AdminUsers|Website URL'), class: 'gl-display-block col-form-label' + = f.text_field :website_url, class: 'form-control gl-form-input gl-form-input-lg' = render_if_exists 'admin/users/custom_attributes', f: f = render 'admin/users/admin_notes', f: f - %div + .settings-sticky-footer - if @user.new_record? - = f.submit _('Create user'), pajamas_button: true + = f.submit _('Create user'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_users_path) do = _('Cancel') - else - = f.submit _('Save changes'), pajamas_button: true + = f.submit _('Save changes'), pajamas_button: true, class: 'gl-mr-3' = render Pajamas::ButtonComponent.new(href: admin_user_path(@user)) do = _('Cancel') diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 4825f192d4d..345a1cc0225 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -21,7 +21,8 @@ = recaptcha_tags nonce: content_security_policy_nonce - if remember_me_enabled? - = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } + .form-group + = f.gitlab_ui_checkbox_component :remember_me, _('Remember me'), checkbox_options: { autocomplete: 'off' } = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { class: "js-sign-in-button #{'js-no-auto-disable' if Feature.enabled?(:arkose_labs_login_challenge)}", data: { qa_selector: 'sign_in_button', testid: 'sign-in-button' } }) do = _('Sign in') diff --git a/app/views/notify/member_about_to_expire_email.html.haml b/app/views/notify/member_about_to_expire_email.html.haml new file mode 100644 index 00000000000..a9f92d90ae6 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.html.haml @@ -0,0 +1,6 @@ += email_default_heading(say_hi(@member.user)) + +%p + = member_about_to_expire_text(@member_source, @days_to_expire, format: :html) +%p + = member_about_to_expire_link(@member, @member_source, format: :html) diff --git a/app/views/notify/member_about_to_expire_email.text.erb b/app/views/notify/member_about_to_expire_email.text.erb new file mode 100644 index 00000000000..0c6e78bf501 --- /dev/null +++ b/app/views/notify/member_about_to_expire_email.text.erb @@ -0,0 +1,5 @@ +<%= say_hi(@member.user) %> + +<%= member_about_to_expire_text(@member_source, @days_to_expire) %> + +<%= member_about_to_expire_link(@member, @member_source) %> diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 4f2d101239b..e2d199b9e51 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -552,6 +552,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: cronjob:members_expiring + :worker_name: Members::ExpiringWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: false + :tags: [] - :name: cronjob:metrics_global_metrics_update :worker_name: Metrics::GlobalMetricsUpdateWorker :feature_category: :metrics @@ -2955,6 +2964,15 @@ :weight: 2 :idempotent: :tags: [] +- :name: members_expiring_email_notification + :worker_name: Members::ExpiringEmailNotificationWorker + :feature_category: :system_access + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: merge :worker_name: MergeWorker :feature_category: :source_code_management diff --git a/app/workers/members/expiring_email_notification_worker.rb b/app/workers/members/expiring_email_notification_worker.rb new file mode 100644 index 00000000000..1d0a6eb254a --- /dev/null +++ b/app/workers/members/expiring_email_notification_worker.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Members + class ExpiringEmailNotificationWorker # rubocop:disable Scalability/CronWorkerContext + include ApplicationWorker + + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency + feature_category :system_access + urgency :low + idempotent! + + def perform(member_id) + notification_service = NotificationService.new + member = ::Member.find_by_id(member_id) + + return unless member + return unless Feature.enabled?(:member_expiring_email_notification, member.source.root_ancestor) + return if member.expiry_notified_at.present? + + with_context(user: member.user) do + notification_service.member_about_to_expire(member) + Gitlab::AppLogger.info(message: "Notifying user about expiring membership", member_id: member.id) + + member.update(expiry_notified_at: Time.current) + end + end + end +end diff --git a/app/workers/members/expiring_worker.rb b/app/workers/members/expiring_worker.rb new file mode 100644 index 00000000000..0d631af3a7c --- /dev/null +++ b/app/workers/members/expiring_worker.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Members + class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + # rubocop:disable Scalability/CronWorkerContext + # This worker does not perform work scoped to a context + include CronjobQueue + # rubocop:enable Scalability/CronWorkerContext + + data_consistency :sticky + feature_category :system_access + urgency :low + + BATCH_LIMIT = 500 + + def perform + return unless Feature.enabled?(:member_expiring_email_notification) + + limit_date = Member::DAYS_TO_EXPIRE.days.from_now.to_date + + expiring_members = Member.active.where(users: { user_type: :human }).expiring_and_not_notified(limit_date) # rubocop: disable CodeReuse/ActiveRecord + + expiring_members.each_batch(of: BATCH_LIMIT) do |members| + members.pluck_primary_key.each do |member_id| + Members::ExpiringEmailNotificationWorker.perform_async(member_id) + end + end + end + end +end |