Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-07-26 15:07:29 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-07-26 15:07:29 +0300
commit597d5ed08988cb00681eaf252d04ebae4bd24731 (patch)
treefa6c90ecda00858be51b790dad9e4d9098d29fdb /app
parente2cf652edb5e9d9fa9a081952070074c07bf651e (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js2
-rw-r--r--app/assets/javascripts/service_desk/components/service_desk_list_app.vue17
-rw-r--r--app/assets/javascripts/service_desk/index.js2
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/command_palette/fake_search_input.vue4
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue6
-rw-r--r--app/assets/javascripts/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue15
-rw-r--r--app/assets/stylesheets/framework/super_sidebar.scss13
-rw-r--r--app/assets/stylesheets/pages/notes.scss1
-rw-r--r--app/assets/stylesheets/pages/settings.scss4
-rw-r--r--app/controllers/concerns/integrations/params.rb10
-rw-r--r--app/controllers/graphql_controller.rb2
-rw-r--r--app/helpers/emails_helper.rb39
-rw-r--r--app/mailers/emails/members.rb16
-rw-r--r--app/mailers/previews/notify_preview.rb7
-rw-r--r--app/models/integrations/base_chat_notification.rb13
-rw-r--r--app/models/integrations/discord.rb19
-rw-r--r--app/models/member.rb1
-rw-r--r--app/models/ml/model_version.rb2
-rw-r--r--app/serializers/integrations/event_entity.rb5
-rw-r--r--app/services/members/update_service.rb1
-rw-r--r--app/services/notification_service.rb6
-rw-r--r--app/views/admin/users/_access_levels.html.haml96
-rw-r--r--app/views/admin/users/_admin_notes.html.haml16
-rw-r--r--app/views/admin/users/_form.html.haml112
-rw-r--r--app/views/devise/sessions/_new_base.html.haml3
-rw-r--r--app/views/notify/member_about_to_expire_email.html.haml6
-rw-r--r--app/views/notify/member_about_to_expire_email.text.erb5
-rw-r--r--app/workers/all_queues.yml18
-rw-r--r--app/workers/members/expiring_email_notification_worker.rb28
-rw-r--r--app/workers/members/expiring_worker.rb32
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