diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-10 00:06:44 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-10 00:06:44 +0000 |
commit | 308146dc398fd4c13453048105498018459e0985 (patch) | |
tree | d843eb63c1672e4b18c483907e2cd4aa7fca708e | |
parent | 4b28d5ae770c6bd332283a3f13ceae06329c409b (diff) | |
download | gitlab-ce-308146dc398fd4c13453048105498018459e0985.tar.gz |
Add latest changes from gitlab-org/gitlab@master
84 files changed, 1934 insertions, 239 deletions
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index e8905b479ee..78aaa9df0ec 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,37 +1,46 @@ <script> import _ from 'underscore'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { components: { - DeprecatedModal, + GlModal, + GlButton, + GlFormInput, }, props: { + title: { + type: String, + required: true, + }, + content: { + type: String, + required: true, + }, + action: { + type: String, + required: true, + }, + secondaryAction: { + type: String, + required: true, + }, deleteUserUrl: { type: String, - required: false, - default: '', + required: true, }, blockUserUrl: { type: String, - required: false, - default: '', - }, - deleteContributions: { - type: Boolean, - required: false, - default: false, + required: true, }, username: { type: String, - required: false, - default: '', + required: true, }, csrfToken: { type: String, - required: false, - default: '', + required: true, }, }, data() { @@ -40,32 +49,12 @@ export default { }; }, computed: { - title() { - const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?'); - const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?'); - - return sprintf( - this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle, - { - username: `'${_.escape(this.username)}'`, - }, - false, - ); + modalTitle() { + return sprintf(this.title, { username: this.username }); }, text() { - const keepContributionsText = s__(`AdminArea| - You are about to permanently delete the user %{username}. - Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user". - To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. - Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); - - const deleteContributionsText = s__(`AdminArea| - You are about to permanently delete the user %{username}. - This will delete all of the issues, merge requests, and groups linked to them. - To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. - Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`); return sprintf( - this.deleteContributions ? deleteContributionsText : keepContributionsText, + this.content, { username: `<strong>${_.escape(this.username)}</strong>`, strong_start: '<strong>', @@ -83,12 +72,7 @@ export default { false, ); }, - primaryButtonLabel() { - const keepContributionsLabel = s__('AdminUsers|Delete user'); - const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions'); - return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel; - }, secondaryButtonLabel() { return s__('AdminUsers|Block user'); }, @@ -97,8 +81,12 @@ export default { }, }, methods: { + show() { + this.$refs.modal.show(); + }, onCancel() { this.enteredUsername = ''; + this.$refs.modal.hide(); }, onSecondaryAction() { const { form } = this.$refs; @@ -117,43 +105,28 @@ export default { </script> <template> - <deprecated-modal - id="delete-user-modal" - :title="title" - :text="text" - :primary-button-label="primaryButtonLabel" - :secondary-button-label="secondaryButtonLabel" - :submit-disabled="!canSubmit" - kind="danger" - @submit="onSubmit" - @cancel="onCancel" - > - <template slot="body" slot-scope="props"> - <p v-html="props.text"></p> + <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger"> + <template> + <p v-html="text"></p> <p v-html="confirmationTextLabel"></p> <form ref="form" :action="deleteUserUrl" method="post"> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <input + <gl-form-input v-model="enteredUsername" + autofocus type="text" name="username" - class="form-control" - aria-labelledby="input-label" autocomplete="off" /> </form> </template> - <template slot="secondary-button"> - <button - :disabled="!canSubmit" - type="button" - class="btn js-secondary-button btn-warning" - data-dismiss="modal" - @click="onSecondaryAction" - > - {{ secondaryButtonLabel }} - </button> + <template slot="modal-footer"> + <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button> + <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction"> + {{ secondaryAction }} + </gl-button> + <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button> </template> - </deprecated-modal> + </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue new file mode 100644 index 00000000000..a08d32028c3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue @@ -0,0 +1,77 @@ +<script> +export default { + props: { + modalConfiguration: { + required: true, + type: Object, + }, + actionModals: { + required: true, + type: Object, + }, + csrfToken: { + required: true, + type: String, + }, + }, + data() { + return { + currentModalData: null, + }; + }, + computed: { + activeModal() { + if (!this.currentModalData) return null; + const { glModalAction: action } = this.currentModalData; + + return this.actionModals[action]; + }, + + modalProps() { + const { glModalAction: requestedAction } = this.currentModalData; + return { + ...this.modalConfiguration[requestedAction], + ...this.currentModalData, + csrfToken: this.csrfToken, + }; + }, + }, + + mounted() { + document.addEventListener('click', this.handleClick); + }, + + beforeDestroy() { + document.removeEventListener('click', this.handleClick); + }, + + methods: { + handleClick(e) { + const { glModalAction: action } = e.target.dataset; + if (!action) return; + + this.show(e.target.dataset); + e.preventDefault(); + }, + + show(modalData) { + const { glModalAction: requestedAction } = modalData; + if (!this.actionModals[requestedAction]) { + throw new Error(`Requested non-existing modal action ${requestedAction}`); + } + if (!this.modalConfiguration[requestedAction]) { + throw new Error(`Modal action ${requestedAction} has no configuration in HTML`); + } + + this.currentModalData = modalData; + + return this.$nextTick().then(() => { + this.$refs.modal.show(); + }); + }, + }, +}; +</script> +<template> + <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" /> +</template> diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue new file mode 100644 index 00000000000..4c335cfb018 --- /dev/null +++ b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue @@ -0,0 +1,70 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { sprintf } from '~/locale'; + +export default { + components: { + GlModal, + }, + props: { + title: { + type: String, + required: true, + }, + content: { + type: String, + required: true, + }, + action: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + username: { + type: String, + required: true, + }, + csrfToken: { + type: String, + required: true, + }, + method: { + type: String, + required: false, + default: 'put', + }, + }, + computed: { + modalTitle() { + return sprintf(this.title, { username: this.username }); + }, + }, + methods: { + show() { + this.$refs.modal.show(); + }, + submit() { + this.$refs.form.submit(); + }, + }, +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="user-operation-modal" + :title="modalTitle" + ok-variant="warning" + :ok-title="action" + @ok="submit" + > + <form ref="form" :action="url" method="post"> + <span v-html="content"></span> + <input ref="method" type="hidden" name="_method" :value="method" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 45046688b57..bc96e88351b 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,46 +1,65 @@ -import $ from 'jquery'; import Vue from 'vue'; import Translate from '~/vue_shared/translate'; +import ModalManager from './components/user_modal_manager.vue'; +import DeleteUserModal from './components/delete_user_modal.vue'; +import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue'; import csrf from '~/lib/utils/csrf'; -import deleteUserModal from './components/delete_user_modal.vue'; +const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts'; +const MODAL_MANAGER_SELECTOR = '#user-modal'; +const ACTION_MODALS = { + deactivate: UserOperationConfirmationModal, + block: UserOperationConfirmationModal, + delete: DeleteUserModal, + 'delete-with-contributions': DeleteUserModal, +}; + +function loadModalsConfigurationFromHtml(modalsElement) { + const modalsConfiguration = {}; + + if (!modalsElement) { + /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ + throw new Error('Modals content element not found!'); + } + + Array.from(modalsElement.children).forEach(node => { + const { modal, ...config } = node.dataset; + modalsConfiguration[modal] = { + title: node.dataset.title, + ...config, + content: node.innerHTML, + }; + }); + + return modalsConfiguration; +} document.addEventListener('DOMContentLoaded', () => { Vue.use(Translate); - const deleteUserModalEl = document.getElementById('delete-user-modal'); + const modalConfiguration = loadModalsConfigurationFromHtml( + document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR), + ); - const deleteModal = new Vue({ - el: deleteUserModalEl, - data: { - deleteUserUrl: '', - blockUserUrl: '', - deleteContributions: '', - username: '', + // eslint-disable-next-line no-new + new Vue({ + el: MODAL_MANAGER_SELECTOR, + functional: true, + methods: { + show(...args) { + this.$refs.manager.show(...args); + }, }, - render(createElement) { - return createElement(deleteUserModal, { + render(h) { + return h(ModalManager, { + ref: 'manager', props: { - deleteUserUrl: this.deleteUserUrl, - blockUserUrl: this.blockUserUrl, - deleteContributions: this.deleteContributions, - username: this.username, + modalConfiguration, + actionModals: ACTION_MODALS, csrfToken: csrf.token, }, }); }, }); - - $(document).on('shown.bs.modal', event => { - if (event.relatedTarget.classList.contains('delete-user-button')) { - const buttonProps = event.relatedTarget.dataset; - deleteModal.deleteUserUrl = buttonProps.deleteUserUrl; - deleteModal.blockUserUrl = buttonProps.blockUserUrl; - deleteModal.deleteContributions = event.relatedTarget.hasAttribute( - 'data-delete-contributions', - ); - deleteModal.username = buttonProps.username; - } - }); }); diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 61d36d1efc2..4c1ac8f206a 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController end end + def activate + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked? + + user.activate + redirect_back_or_admin_user(notice: _("Successfully activated")) + end + + def deactivate + return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked? + return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated? + return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated? + + user.deactivate + redirect_back_or_admin_user(notice: _("Successfully deactivated")) + end + def block if update_user { |user| user.block } redirect_back_or_admin_user(notice: _("Successfully blocked")) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 01d80d77080..f5939b61948 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -26,6 +26,7 @@ class ApplicationController < ActionController::Base before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :configure_permitted_parameters, if: :devise_controller? before_action :require_email, unless: :devise_controller? + before_action :active_user_check, unless: :devise_controller? before_action :set_usage_stats_consent_flag before_action :check_impersonation_availability @@ -294,6 +295,14 @@ class ApplicationController < ActionController::Base end end + def active_user_check + return unless current_user && current_user.deactivated? + + sign_out current_user + flash[:alert] = _("Your account has been deactivated by your administrator. Please log back in to reactivate your account.") + redirect_to new_user_session_path + end + def ldap_security_check if current_user && current_user.requires_ldap_check? return unless current_user.try_obtain_ldap_lease diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 755ce3463c4..b992972dfb8 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if user.two_factor_enabled? && !auth_user.bypass_two_factor? prompt_for_two_factor(user) else + if user.deactivated? + user.activate + flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + end + sign_in_and_redirect(user, event: :authentication) end else diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 23ef9157363..7b694dcdd77 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -25,6 +25,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic return render_404 unless diffable diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options) + positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user) + + diffs.unfold_diff_files(positions.unfoldable) options = { merge_request: @merge_request, diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f8da152e3d2..1c506065b56 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController reset_password_sent_at: nil) end - # hide the signed-in notification - flash[:notice] = nil + if resource.deactivated? + resource.activate + flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + else + # hide the default signed-in notification + flash[:notice] = nil + end + log_audit_event(current_user, resource, with: authentication_method) log_user_activity(current_user) end diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb index 556be4c4338..1dd1a27437e 100644 --- a/app/finders/user_finder.rb +++ b/app/finders/user_finder.rb @@ -52,6 +52,12 @@ class UserFinder end end + def find_by_ssh_key_id + return unless input_is_id? + + User.find_by_ssh_key_id(@username_or_id) + end + def input_is_id? @username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/ end diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb index dbc5ed1bc9a..76e0cbc7dff 100644 --- a/app/models/concerns/ci/pipeline_delegator.rb +++ b/app/models/concerns/ci/pipeline_delegator.rb @@ -15,7 +15,8 @@ module Ci :merge_request_ref?, :source_ref, :source_ref_slug, - :legacy_detached_merge_request_pipeline?, to: :pipeline + :legacy_detached_merge_request_pipeline?, + :merge_train_pipeline?, to: :pipeline end end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2b6934d4c83..50efe5d6aa0 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -454,6 +454,15 @@ class MergeRequest < ApplicationRecord merge_request_diffs.where.not(id: merge_request_diff.id) end + # Overwritten in EE + def note_positions_for_paths(paths, _user = nil) + positions = notes.new_diff_notes.joins(:note_diff_file) + .where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths) + .positions + + Gitlab::Diff::PositionCollection.new(positions, diff_head_sha) + end + def preloads_discussion_diff_highlighting? true end diff --git a/app/models/note.rb b/app/models/note.rb index 03a38346731..edc4a332581 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -193,6 +193,12 @@ class Note < ApplicationRecord groups end + def positions + where.not(position: nil) + .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting. + .map(&:position) + end + def count_for_collection(ids, type) user.select('noteable_id', 'COUNT(*) as count') .group(:noteable_id) diff --git a/app/models/user.rb b/app/models/user.rb index ad56d7a32c5..c4075f06dff 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,8 @@ class User < ApplicationRecord # Removed in GitLab 12.3. Keep until after 2019-09-22. self.ignored_columns += %i[support_bot] + MINIMUM_INACTIVE_DAYS = 14 + # Override Devise::Models::Trackable#update_tracked_fields! # to limit database writes to at most once every hour # rubocop: disable CodeReuse/ServiceClass @@ -242,18 +244,25 @@ class User < ApplicationRecord state_machine :state, initial: :active do event :block do transition active: :blocked + transition deactivated: :blocked transition ldap_blocked: :blocked end event :ldap_block do transition active: :ldap_blocked + transition deactivated: :ldap_blocked end event :activate do + transition deactivated: :active transition blocked: :active transition ldap_blocked: :active end + event :deactivate do + transition active: :deactivated + end + state :blocked, :ldap_blocked do def blocked? true @@ -284,6 +293,7 @@ class User < ApplicationRecord scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } + scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } @@ -431,6 +441,8 @@ class User < ApplicationRecord without_projects when 'external' external + when 'deactivated' + deactivated else active end @@ -521,7 +533,7 @@ class User < ApplicationRecord # Returns a user for the given SSH key. def find_by_ssh_key_id(key_id) - Key.find_by(id: key_id)&.user + find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').where(id: key_id)) end def find_by_full_path(path, follow_redirects: false) @@ -1534,6 +1546,17 @@ class User < ApplicationRecord !!(password_expires_at && password_expires_at < Time.now) end + def can_be_deactivated? + active? && no_recent_activity? + end + + def last_active_at + last_activity = last_activity_on&.to_time&.in_time_zone + last_sign_in = current_sign_in_at + + [last_activity, last_sign_in].compact.max + end + # @deprecated alias_method :owned_or_masters_groups, :owned_or_maintainers_groups @@ -1683,6 +1706,10 @@ class User < ApplicationRecord ::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels) end + + def no_recent_activity? + last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i + end end User.prepend_if_ee('EE::User') diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 2305c55033f..18c23cbd13a 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -17,6 +17,10 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:blocked) { @user&.blocked? } + desc "User is deactivated" + with_options scope: :user, score: 0 + condition(:deactivated) { @user&.deactivated? } + desc "User has access to all private groups & projects" with_options scope: :user, score: 0 condition(:full_private_access) { @user&.full_private_access? } diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 659fde574fc..4d66a9e7d67 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -44,6 +44,12 @@ class GlobalPolicy < BasePolicy prevent :use_slash_commands end + rule { deactivated }.policy do + prevent :access_git + prevent :access_api + prevent :receive_notifications + end + rule { required_terms_not_accepted }.policy do prevent :access_api prevent :access_git diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index b53b2019a97..a218885a00e 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -6,6 +6,8 @@ %span.cred (Internal) - if @user.admin %span.cred (Admin) + - if @user.deactivated? + %span.cred (Deactivated) = render_if_exists 'admin/users/audtior_user_badge' .float-right diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml new file mode 100644 index 00000000000..eaec6d69f5a --- /dev/null +++ b/app/views/admin/users/_modals.html.haml @@ -0,0 +1,30 @@ +#user-modal +#modal-texts.hidden{ "hidden": true, "aria-hidden": true } + %div{ data: { modal: "deactivate", + title: s_("AdminUsers|Deactivate User %{username}?"), + action: s_("AdminUsers|Deactivate") } } + = render partial: 'admin/users/user_deactivation_effects' + + %div{ data: { modal: "block", + title: s_("AdminUsers|Block user %{username}?"), + action: s_("AdminUsers|Block") } } + = render partial: 'admin/users/user_block_effects' + + %div{ data: { modal: "delete", + title: s_("AdminUsers|Delete User %{username}?"), + action: s_('AdminUsers|Delete user'), + 'secondary-action': s_('AdminUsers|Block user') } } + = s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests, + and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss, + consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, + it cannot be undone or recovered.') + + %div{ data: { modal: "delete-with-contributions", + title: s_("AdminUsers|Delete User %{username} and contributions?"), + action: s_('AdminUsers|Delete user and contributions') , + 'secondary-action': s_('AdminUsers|Block user') } } + = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues, + merge requests, and groups linked to them. To avoid data loss, + consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, + it cannot be undone or recovered.') + diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 90a056dfe30..ca5109614fc 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -31,7 +31,19 @@ - elsif user.blocked? = link_to _('Unblock'), unblock_admin_user_path(user), method: :put - else - = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put + %button.btn{ data: { 'gl-modal-action': 'block', + url: block_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Block') + - if user.can_be_deactivated? + %li + %button.btn{ data: { 'gl-modal-action': 'deactivate', + url: deactivate_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Deactivate') + - elsif user.deactivated? + %li + = link_to _('Activate'), activate_admin_user_path(user), method: :put - if user.access_locked? %li = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } @@ -39,19 +51,14 @@ %li.divider - if user.can_be_removed? %li - %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete', delete_user_url: admin_user_path(user), block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name), - delete_contributions: false }, type: 'button' } + username: sanitize_name(user.name) } } = s_('AdminUsers|Delete user') - - %li - %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', - delete_user_url: admin_user_path(user, hard_delete: true), - block_user_url: block_admin_user_path(user), - username: sanitize_name(user.name), - delete_contributions: true }, type: 'button' } - = s_('AdminUsers|Delete user and contributions') + %li + %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions', + delete_user_url: admin_user_path(user, hard_delete: true), + block_user_url: block_admin_user_path(user), + username: sanitize_name(user.name) } } + = s_('AdminUsers|Delete user and contributions') diff --git a/app/views/admin/users/_user_activation_effects.html.haml b/app/views/admin/users/_user_activation_effects.html.haml new file mode 100644 index 00000000000..244836dac11 --- /dev/null +++ b/app/views/admin/users/_user_activation_effects.html.haml @@ -0,0 +1,6 @@ +%p + = s_('AdminUsers|Reactivating a user will:') +%ul + %li + = s_('AdminUsers|Restore user access to the account, including web, Git and API.') + = render_if_exists 'admin/users/user_activation_effects_on_seats' diff --git a/app/views/admin/users/_user_block_effects.html.haml b/app/views/admin/users/_user_block_effects.html.haml new file mode 100644 index 00000000000..8ffbe145169 --- /dev/null +++ b/app/views/admin/users/_user_block_effects.html.haml @@ -0,0 +1,11 @@ +%p + = s_('AdminUsers|Blocking user has the following effects:') +%ul + %li + = s_('AdminUsers|User will not be able to login') + %li + = s_('AdminUsers|User will not be able to access git repositories') + %li + = s_('AdminUsers|Personal projects will be left') + %li + = s_('AdminUsers|Owned groups will be left') diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml new file mode 100644 index 00000000000..6cc47214d77 --- /dev/null +++ b/app/views/admin/users/_user_deactivation_effects.html.haml @@ -0,0 +1,16 @@ +%p + = s_('AdminUsers|Deactivating a user has the following effects:') +%ul + %li + = s_('AdminUsers|The user will be logged out') + %li + = s_('AdminUsers|The user will not be able to access git repositories') + %li + = s_('AdminUsers|The user will not be able to access the API') + %li + = s_('AdminUsers|The user will not receive any notifications') + %li + = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account') + %li + = s_('AdminUsers|Personal projects, group and user history will be left intact') + = render_if_exists 'admin/users/user_deactivation_effects_on_seats' diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 9ce16749945..3c6ad899d1e 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -30,6 +30,10 @@ = link_to admin_users_path(filter: "blocked") do = s_('AdminUsers|Blocked') %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked) + = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do + = link_to admin_users_path(filter: "deactivated") do + = s_('AdminUsers|Deactivated') + %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated) = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do = link_to admin_users_path(filter: "wop") do = s_('AdminUsers|Without projects') @@ -50,6 +54,7 @@ = icon("search", class: "search-icon") = button_tag s_('AdminUsers|Search users') if Rails.env.test? .dropdown.user-sort-dropdown + = label_tag 'Sort by', nil, class: 'label-bold' - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name = dropdown_toggle(toggle_text, { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right @@ -74,4 +79,4 @@ = paginate @users, theme: "gitlab" -#delete-user-modal += render partial: 'admin/users/modals' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index a988f746ced..706fa033c51 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -156,6 +156,27 @@ = render_if_exists 'admin/users/user_detail_note' + - if @user.deactivated? + .card.border-info + .card-header.bg-info.text-white + Reactivate this user + .card-body + = render partial: 'admin/users/user_activation_effects' + %br + = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + - elsif @user.can_be_deactivated? + .card.border-warning + .card-header.bg-warning.text-white + Deactivate this user + .card-body + = render partial: 'admin/users/user_deactivation_effects' + %br + %button.btn.btn-warning{ data: { 'gl-modal-action': 'deactivate', + content: 'You can always re-activate their account, their data will remain intact.', + url: deactivate_admin_user_path(@user), + username: sanitize_name(@user.name) } } + = s_('AdminUsers|Deactivate user') + - if @user.blocked? .card.border-info .card-header.bg-info.text-white @@ -172,14 +193,13 @@ .card-header.bg-warning.text-white Block this user .card-body - %p Blocking user has the following effects: - %ul - %li User will not be able to login - %li User will not be able to access git repositories - %li Personal projects will be left - %li Owned groups will be left + = render partial: 'admin/users/user_block_effects' %br - = link_to 'Block user', block_admin_user_path(@user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put, class: "btn btn-warning" + %button.btn.btn-warning{ data: { 'gl-modal-action': 'block', + content: 'You can always unblock their account, their data will remain intact.', + url: block_admin_user_path(@user), + username: sanitize_name(@user.name) } } + = s_('AdminUsers|Block user') - if @user.access_locked? .card.border-info .card-header.bg-info.text-white @@ -197,12 +217,10 @@ %p Deleting a user has the following effects: = render 'users/deletion_guidance', user: @user %br - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete', delete_user_url: admin_user_path(@user), block_user_url: block_admin_user_path(@user), - username: @user.name, - delete_contributions: false }, type: 'button' } + username: sanitize_name(@user.name) } } = s_('AdminUsers|Delete user') - else - if @user.solo_owned_groups.present? @@ -229,15 +247,13 @@ the user, and projects in them, will also be removed. Commits to other projects are unaffected. %br - %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', - target: '#delete-user-modal', + %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions', delete_user_url: admin_user_path(@user, hard_delete: true), block_user_url: block_admin_user_path(@user), - username: @user.name, - delete_contributions: true }, type: 'button' } + username: @user.name } } = s_('AdminUsers|Delete user and contributions') - else %p You don't have access to delete this user. - #delete-user-modal += render partial: 'admin/users/modals' diff --git a/changelogs/unreleased/63921-deactivate-a-user-with-self-service-reactivation.yml b/changelogs/unreleased/63921-deactivate-a-user-with-self-service-reactivation.yml new file mode 100644 index 00000000000..5f4d9e41e04 --- /dev/null +++ b/changelogs/unreleased/63921-deactivate-a-user-with-self-service-reactivation.yml @@ -0,0 +1,5 @@ +--- +title: Deactivate a user (with self-service reactivation) +merge_request: 17037 +author: +type: added diff --git a/config/routes/admin.rb b/config/routes/admin.rb index c6aa51b50ef..9238eae3a8e 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -13,6 +13,8 @@ namespace :admin do get :keys put :block put :unblock + put :deactivate + put :activate put :unlock put :confirm post :impersonate diff --git a/doc/administration/incoming_email.md b/doc/administration/incoming_email.md index 45634d50b91..88cf702cf0e 100644 --- a/doc/administration/incoming_email.md +++ b/doc/administration/incoming_email.md @@ -58,7 +58,7 @@ this method only supports replies, and not the other features of [incoming email ## Set it up If you want to use Gmail / Google Apps for incoming emails, make sure you have -[IMAP access enabled](https://support.google.com/mail/troubleshooter/1668960?hl=en#ts=1665018) +[IMAP access enabled](https://support.google.com/mail/answer/7126229) and [allowed less secure apps to access the account](https://support.google.com/accounts/answer/6010255) or [turn-on 2-step validation](https://support.google.com/accounts/answer/185839) and use [an application password](https://support.google.com/mail/answer/185833). diff --git a/doc/administration/integration/terminal.md b/doc/administration/integration/terminal.md index dbc61c82061..1af15648b97 100644 --- a/doc/administration/integration/terminal.md +++ b/doc/administration/integration/terminal.md @@ -60,8 +60,8 @@ guides document the necessary steps for a selection of popular reverse proxies: - [Apache](https://httpd.apache.org/docs/2.4/mod/mod_proxy_wstunnel.html) - [NGINX](https://www.nginx.com/blog/websocket-nginx/) -- [HAProxy](http://blog.haproxy.com/2012/11/07/websockets-load-balancing-with-haproxy/) -- [Varnish](https://www.varnish-cache.org/docs/4.1/users-guide/vcl-example-websockets.html) +- [HAProxy](https://www.haproxy.com/blog/websockets-load-balancing-with-haproxy/) +- [Varnish](https://varnish-cache.org/docs/4.1/users-guide/vcl-example-websockets.html) Workhorse won't let WebSocket requests through to non-WebSocket endpoints, so it's safe to enable support for these headers globally. If you'd rather had a diff --git a/doc/api/users.md b/doc/api/users.md index 1a1f45a259b..33b6efa7a02 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1152,6 +1152,48 @@ Parameters: Will return `201 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to unblock a user blocked by LDAP synchronization. +## Deactivate user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +Deactivates the specified user. Available only for admin. + +``` +POST /users/:id/deactivate +``` + +Parameters: + +- `id` (required) - id of specified user + +Returns: + +- `201 OK` on success. +- `404 User Not Found` if user cannot be found. +- `403 Forbidden` when trying to deactivate a user: + - Blocked by admin or by LDAP synchronization. + - That has any activity in past 14 days. These cannot be deactivated. + +## Activate user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +Activates the specified user. Available only for admin. + +``` +POST /users/:id/activate +``` + +Parameters: + +- `id` (required) - id of specified user + +Returns: + +- `201 OK` on success. +- `404 User Not Found` if user cannot be found. +- `403 Forbidden` when trying to activate a user blocked by admin or by LDAP synchronization. + ### Get user contribution events Please refer to the [Events API documentation](events.md#get-user-contribution-events) diff --git a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md index 767058376ca..f2a7902c9ca 100644 --- a/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md +++ b/doc/ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md @@ -122,6 +122,12 @@ is unavailable when Follow [this issue](https://gitlab.com/gitlab-org/gitlab/issues/12267) to track progress on this issue. +### Merge Train Pipeline cannot be retried + +A Merge Train pipeline cannot be retried because the merge request is dropped from the merge train upon failure. For this reason, the retry button does not appear next to the pipeline icon. + +In the case of pipeline failure, you should [re-enqueue](#how-to-add-a-merge-request-to-a-merge-train) the merge request to the merge train, which will then initiate a new pipeline. + ### Merge Train disturbs your workflow First of all, please check if [merge immediately](#immediately-merge-a-merge-request-with-a-merge-train) diff --git a/doc/development/internal_api.md b/doc/development/internal_api.md index 63c441f2505..db61fca3939 100644 --- a/doc/development/internal_api.md +++ b/doc/development/internal_api.md @@ -159,7 +159,6 @@ discovers the user associated with an SSH key. |:----------|:-------|:---------|:------------| | `key_id` | integer | no | The id of the SSH key used as found in the authorized-keys file or through the `/authorized_keys` check | | `username` | string | no | Username of the user being looked up, used by GitLab-shell when authenticating using a certificate | -| `user_id` | integer | no | **Deprecated** User_id of the user being looked up | ``` GET /internal/discover diff --git a/doc/subscriptions/index.md b/doc/subscriptions/index.md index e35d1d9c51b..cae83d6186f 100644 --- a/doc/subscriptions/index.md +++ b/doc/subscriptions/index.md @@ -148,7 +148,7 @@ For more information, please see our: - [Subscription FAQ](https://about.gitlab.com/pricing/licensing-faq/). - [Pricing page](https://about.gitlab.com/pricing/), which includes information - on our [true-up pricing policy](https://about.gitlab.com/handbook/product/pricing/#true-up-pricing) + on our [true-up pricing policy](https://about.gitlab.com/handbook/ceo/pricing/#true-up-pricing) when adding more users other than at the time of purchase. NOTE: **Note:** diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index e70be6401ec..c8cc5b356c0 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -77,7 +77,7 @@ As Auto DevOps relies on many different components, it's good to have a basic knowledge of the following: - [Kubernetes](https://kubernetes.io/docs/home/) -- [Helm](https://docs.helm.sh/) +- [Helm](https://helm.sh/docs/) - [Docker](https://docs.docker.com) - [GitLab Runner](https://docs.gitlab.com/runner/) - [Prometheus](https://prometheus.io/docs/introduction/overview/) @@ -124,7 +124,7 @@ To make full use of Auto DevOps, you will need: - A [Kubernetes cluster][kubernetes-clusters] for the project. - A load balancer. You can use NGINX Ingress by deploying it to your Kubernetes cluster by either: - - Using the [`nginx-ingress`](https://github.com/kubernetes/charts/tree/master/stable/nginx-ingress) Helm chart. + - Using the [`nginx-ingress`](https://github.com/helm/charts/tree/master/stable/nginx-ingress) Helm chart. - Installing the Ingress [GitLab Managed App](../../user/clusters/applications.md#ingress). - **Prometheus** (for Auto Monitoring) @@ -172,7 +172,7 @@ and `1.2.3.4` is the IP address of your load balancer; generally NGINX ([see requirements](#requirements)). How to set up the DNS record is beyond the scope of this document; you should check with your DNS provider. -Alternatively you can use free public services like [nip.io](http://nip.io) +Alternatively you can use free public services like [nip.io](https://nip.io) which provide automatic wildcard DNS without any configuration. Just set the Auto DevOps base domain to `1.2.3.4.nip.io`. diff --git a/doc/topics/autodevops/quick_start_guide.md b/doc/topics/autodevops/quick_start_guide.md index d0ff149cf31..11051c78af8 100644 --- a/doc/topics/autodevops/quick_start_guide.md +++ b/doc/topics/autodevops/quick_start_guide.md @@ -25,7 +25,7 @@ Google account (for example, one that you use to access Gmail, Drive, etc.) or c TIP: **Tip:** Every new Google Cloud Platform (GCP) account receives [$300 in credit](https://console.cloud.google.com/freetrial), and in partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab's -Google Kubernetes Engine Integration. All you have to do is [follow this link](https://goo.gl/AaJzRW) and apply for credit. +Google Kubernetes Engine Integration. All you have to do is [follow this link](https://cloud.google.com/partners/partnercredit/?PCN=a0n60000006Vpz4AAC) and apply for credit. ## Creating a new project from a template diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md index 043345093f5..5fd9f4252d4 100644 --- a/doc/user/admin_area/index.md +++ b/doc/user/admin_area/index.md @@ -105,8 +105,16 @@ You can administer all users in the GitLab instance from the Admin Area's Users To access the Users page, go to **Admin Area > Overview > Users**. -Click the **Active**, **Admins**, **2FA Enabled**, or **2FA Disabled**, **External**, or -**Without projects** tab to list only users of that criteria. +To list users matching a specific criteria, click on one of the following tabs on the **Users** page: + +- **Active** +- **Admins** +- **2FA Enabled** +- **2FA Disabled** +- **External** +- **Blocked** +- **Deactivated** +- **Without projects** For each user, their username, email address, are listed, also the date their account was created and the date of last activity. To edit a user, click the **Edit** button in that user's diff --git a/doc/user/analytics/cycle_analytics.md b/doc/user/analytics/cycle_analytics.md index 743b0c217d7..b4b053ded42 100644 --- a/doc/user/analytics/cycle_analytics.md +++ b/doc/user/analytics/cycle_analytics.md @@ -169,7 +169,7 @@ For Cycle Analytics functionality introduced in GitLab 12.3 and later: Learn more about Cycle Analytics in the following resources: -- [Cycle Analytics feature page](https://about.gitlab.com/features/cycle-analytics/) +- [Cycle Analytics feature page](https://about.gitlab.com/product/cycle-analytics/) - [Cycle Analytics feature preview](https://about.gitlab.com/2016/09/16/feature-preview-introducing-cycle-analytics/) - [Cycle Analytics feature highlight](https://about.gitlab.com/2016/09/21/cycle-analytics-feature-highlight/) diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index c525bf04d99..db832a4accf 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -62,7 +62,7 @@ can lead to confusion during deployments. > - Introduced in GitLab 10.2 for project-level clusters. > - Introduced in GitLab 11.6 for group-level clusters. -[Helm](https://docs.helm.sh/) is a package manager for Kubernetes and is +[Helm](https://helm.sh/docs/) is a package manager for Kubernetes and is required to install all the other applications. It is installed in its own pod inside the cluster which can run the `helm` CLI in a safe environment. @@ -174,7 +174,7 @@ higher](../permissions.md) access to the associated project or group. We use a [custom Jupyter image](https://gitlab.com/gitlab-org/jupyterhub-user-image/blob/master/Dockerfile) that installs additional useful packages on top of the base Jupyter. You -will also see ready-to-use DevOps Runbooks built with Nurtch's [Rubix library](https://github.com/amit1rrr/rubix). +will also see ready-to-use DevOps Runbooks built with Nurtch's [Rubix library](https://github.com/Nurtch/rubix). More information on creating executable runbooks can be found in [our Runbooks @@ -221,7 +221,7 @@ You can clone repositories from the files tab in Jupyter: > - Introduced in GitLab 11.5 for project-level clusters. > - Introduced in GitLab 12.3 for group- and instance-level clusters. -[Knative](https://cloud.google.com/knative) provides a platform to +[Knative](https://cloud.google.com/knative/) provides a platform to create, deploy, and manage serverless workloads from a Kubernetes cluster. It is used in conjunction with, and includes [Istio](https://istio.io) to provide an external IP address for all diff --git a/doc/user/gitlab_com/index.md b/doc/user/gitlab_com/index.md index cfb561481d6..035ec15b453 100644 --- a/doc/user/gitlab_com/index.md +++ b/doc/user/gitlab_com/index.md @@ -41,7 +41,7 @@ Host gitlab.com ## GitLab Pages -Below are the settings for [GitLab Pages]. +Below are the settings for [GitLab Pages](https://about.gitlab.com/product/pages/). | Setting | GitLab.com | Default | | --------------------------- | ---------------- | ------------- | @@ -103,13 +103,11 @@ Below are the shared Runners settings. | Setting | GitLab.com | Default | | ----------- | ----------------- | ---------- | -| [GitLab Runner] | [Runner versions dashboard][ci_version_dashboard] | - | +| [GitLab Runner] | [Runner versions dashboard](https://dashboards.gitlab.com/d/000000159/ci?from=now-1h&to=now&refresh=5m&orgId=1&panelId=12&fullscreen&theme=light) | - | | Executor | `docker+machine` | - | | Default Docker image | `ruby:2.5` | - | | `privileged` (run [Docker in Docker]) | `true` | `false` | -[ci_version_dashboard]: https://dashboards.gitlab.com/dashboard/db/ci?from=now-1h&to=now&refresh=5m&orgId=1&panelId=12&fullscreen&theme=light - ### `config.toml` The full contents of our `config.toml` are: @@ -174,7 +172,7 @@ sentry_dsn = "X" ## Sidekiq -GitLab.com runs [Sidekiq][sidekiq] with arguments `--timeout=4 --concurrency=4` +GitLab.com runs [Sidekiq](https://sidekiq.org) with arguments `--timeout=4 --concurrency=4` and the following environment variables: | Setting | GitLab.com | Default | @@ -275,7 +273,7 @@ released depending on the type of block, as described below. If you receive a `403 Forbidden` error for all requests to GitLab.com, please check for any automated processes that may be triggering a block. For -assistance, contact [GitLab Support](https://support.gitlab.com) +assistance, contact [GitLab Support](https://support.gitlab.com/hc/en-us) with details, such as the affected IP address. ### HAProxy API throttle @@ -390,10 +388,8 @@ High Performance TCP/HTTP Load Balancer: [runners-post]: https://about.gitlab.com/2016/04/05/shared-runners/ "Shared Runners on GitLab.com" [GitLab Runner]: https://gitlab.com/gitlab-org/gitlab-runner [altssh]: https://about.gitlab.com/2016/02/18/gitlab-dot-com-now-supports-an-alternate-git-plus-ssh-port/ "GitLab.com now supports an alternate git+ssh port" -[GitLab Pages]: https://about.gitlab.com/features/pages "GitLab Pages" [docker in docker]: https://hub.docker.com/_/docker/ "Docker in Docker at DockerHub" [mailgun]: https://www.mailgun.com/ "Mailgun website" -[sidekiq]: http://sidekiq.org/ "Sidekiq website" [unicorn-worker-killer]: https://rubygems.org/gems/unicorn-worker-killer "unicorn-worker-killer" [4010]: https://gitlab.com/gitlab-com/infrastructure/issues/4010 "Find a good value for maximum timeout for Shared Runners" [4070]: https://gitlab.com/gitlab-com/infrastructure/issues/4070 "Configure per-runner timeout for shared-runners-manager-X on GitLab.com" diff --git a/doc/user/group/epics/index.md b/doc/user/group/epics/index.md index d04ecedc7a2..51e779cce6a 100644 --- a/doc/user/group/epics/index.md +++ b/doc/user/group/epics/index.md @@ -85,7 +85,7 @@ the re-calculation will happen immediately to set a new dynamic date. ## Roadmap in epics -> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing) 11.10. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/7327) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.10. If your epic contains one or more [child epics](#multi-level-child-epics) which have a [start or due date](#start-date-and-due-date), then you can see a diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index fecf543af5f..d40ddb00390 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -105,13 +105,13 @@ NOTE: **Note:** GitLab is unable to provide support for IdPs that are not listed | Provider | Documentation | |----------|---------------| | ADFS (Active Directory Federation Services) | [Create a Relying Party Trust](https://docs.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust) | -| Azure | [Configuring single sign-on to applications](https://docs.microsoft.com/en-us/azure/active-directory/active-directory-saas-custom-apps) | +| Azure | [Configuring single sign-on to applications](https://docs.microsoft.com/en-us/azure/active-directory/manage-apps/configure-single-sign-on-non-gallery-applications) | | Auth0 | [Auth0 as Identity Provider](https://auth0.com/docs/protocols/saml/saml-idp-generic) | | G Suite | [Set up your own custom SAML application](https://support.google.com/a/answer/6087519?hl=en) | | JumpCloud | [Single Sign On (SSO) with GitLab](https://support.jumpcloud.com/customer/en/portal/articles/2810701-single-sign-on-sso-with-gitlab) | -| Okta | [Setting up a SAML application in Okta](https://developer.okta.com/standards/SAML/setting_up_a_saml_application_in_okta) | +| Okta | [Setting up a SAML application in Okta](https://developer.okta.com/docs/guides/saml-application-setup/overview/) | | OneLogin | [Use the OneLogin SAML Test Connector](https://onelogin.service-now.com/support?id=kb_article&sys_id=93f95543db109700d5505eea4b96198f) | -| Ping Identity | [Add and configure a new SAML application](https://docs.pingidentity.com/bundle/p1_enterpriseConfigSsoSaml_cas/page/enableAppWithoutURL.html) | +| Ping Identity | [Add and configure a new SAML application](https://support.pingidentity.com/s/document-item?bundleId=pingone&topicId=xsh1564020480660-1.html) | ## Linking SAML to your existing GitLab.com account diff --git a/doc/user/markdown.md b/doc/user/markdown.md index 65ff176df27..0b4bb43b4bf 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -14,7 +14,7 @@ NOTE: **Note:** We encourage you to view this document as [rendered by GitLab it GitLab uses "GitLab Flavored Markdown" (GFM). It extends the [CommonMark specification](https://spec.commonmark.org/current/) (which is based on standard Markdown) in several ways to add additional useful functionality. -It was inspired by [GitHub Flavored Markdown](https://help.github.com/articles/basic-writing-and-formatting-syntax/). +It was inspired by [GitHub Flavored Markdown](https://help.github.com/en/articles/basic-writing-and-formatting-syntax). You can use GFM in the following areas: @@ -352,7 +352,7 @@ However the wrapping tags cannot be mixed: > If this is not rendered correctly, [view it in GitLab itself](https://gitlab.com/gitlab-org/gitlab/blob/master/doc/user/markdown.md#math). -It is possible to have math written with LaTeX syntax rendered using [KaTeX](https://github.com/Khan/KaTeX). +It is possible to have math written with LaTeX syntax rendered using [KaTeX](https://github.com/KaTeX/KaTeX). Math written between dollar signs `$` will be rendered inline with the text. Math written inside a [code block](#code-spans-and-blocks) with the language declared as `math`, will be rendered @@ -379,7 +379,7 @@ a^2+b^2=c^2 _Be advised that KaTeX only supports a [subset](https://katex.org/docs/supported.html) of LaTeX._ NOTE: **Note:** This also works for the asciidoctor `:stem: latexmath`. For details see -the [asciidoctor user manual](http://asciidoctor.org/docs/user-manual/#activating-stem-support). +the [asciidoctor user manual](https://asciidoctor.org/docs/user-manual/#activating-stem-support). ### Special GitLab references @@ -641,7 +641,7 @@ Tildes are OK too. GitLab uses the [Rouge Ruby library](http://rouge.jneen.net/) for more colorful syntax highlighting in code blocks. For a list of supported languages visit the -[Rouge project wiki](https://github.com/jneen/rouge/wiki/List-of-supported-languages-and-lexers). +[Rouge project wiki](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers). Syntax highlighting is only supported in code blocks, it is not possible to highlight code when it is inline. @@ -922,7 +922,7 @@ Here's a sample audio clip: You can also use raw HTML in your Markdown, and it'll usually work pretty well. -See the documentation for HTML::Pipeline's [SanitizationFilter](http://www.rubydoc.info/gems/html-pipeline/1.11.0/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) +See the documentation for HTML::Pipeline's [SanitizationFilter](https://www.rubydoc.info/gems/html-pipeline/1.11.0/HTML/Pipeline/SanitizationFilter#WHITELIST-constant) class for the list of allowed HTML tags and attributes. In addition to the default `SanitizationFilter` whitelist, GitLab allows `span`, `abbr`, `details` and `summary` elements. @@ -1126,8 +1126,8 @@ Using references: Some text to show that the reference links can follow later. -[arbitrary case-insensitive reference text]: https://www.mozilla.org -[1]: http://slashdot.org +[arbitrary case-insensitive reference text]: https://www.mozilla.org/en-US/ +[1]: https://slashdot.org [link text itself]: https://www.reddit.com ``` @@ -1149,8 +1149,8 @@ Using references: Some text to show that the reference links can follow later. -[arbitrary case-insensitive reference text]: https://www.mozilla.org -[1]: http://slashdot.org +[arbitrary case-insensitive reference text]: https://www.mozilla.org/en-US/ +[1]: https://slashdot.org [link text itself]: https://www.reddit.com NOTE: **Note:** Relative links do not allow the referencing of project files in a wiki @@ -1164,7 +1164,7 @@ GFM will autolink almost any URL you put into your text: ```markdown - https://www.google.com -- https://google.com/ +- https://www.google.com - ftp://ftp.us.debian.org/debian/ - smb://foo/bar/baz - irc://irc.freenode.net/ @@ -1172,7 +1172,7 @@ GFM will autolink almost any URL you put into your text: ``` - <https://www.google.com> -- <https://google.com/> +- <https://www.google.com> - <ftp://ftp.us.debian.org/debian/> - <smb://foo/bar/baz> - <irc://irc.freenode.net/> diff --git a/doc/user/permissions.md b/doc/user/permissions.md index 2bf7f24bbab..85930365708 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -24,7 +24,7 @@ For information on eligible approvers for Merge Requests, see ## Principles behind permissions -See our [product handbook on permissions](https://about.gitlab.com/handbook/product#permissions-in-gitlab) +See our [product handbook on permissions](https://about.gitlab.com/handbook/product/#permissions-in-gitlab) ## Instance-wide user permissions diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index 9b72956a55e..fda0936355e 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -42,6 +42,52 @@ a user can be blocked directly from the Admin area. To do this: 1. Selecting a user. 1. Under the **Account** tab, click **Block user**. +### Deactivating a user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +A user can be deactivated from the Admin area. Deactivating a user is functionally identical to blocking a user, with the following differences: + +- It does not prohibit the user from logging back in via the UI. +- Once a deactivated user logs back into the GitLab UI, their account is set to active. + +A deactivated user: + +- Cannot access Git repositories or the API. +- Will not receive any notifications from GitLab. + +Personal projects, group and user history of the deactivated user will be left intact. + +NOTE: **Note:** +A deactivated user does not consume a [seat](../../../subscriptions/index.md#managing-subscriptions). + +To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Select a user. +1. Under the **Account** tab, click **Deactivate user**. + +Please note that for the deactivation option to be visible to an admin, the user: + +- Must be currently active. +- Should not have any activity in the last 14 days. + +### Activating a user + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/63921) in GitLab 12.4. + +A deactivated user can be activated from the Admin area. Activating a user sets their account to active state. + +To do this: + +1. Navigate to **Admin Area > Overview > Users**. +1. Click on the **Deactivated** tab. +1. Select a user. +1. Under the **Account** tab, click **Activate user**. + +TIP: **Tip:** +A deactivated user can also activate their account by themselves by simply logging back via the UI. + ## Associated Records > - Introduced for issues in diff --git a/doc/user/project/clusters/eks_and_gitlab/index.md b/doc/user/project/clusters/eks_and_gitlab/index.md index 28f3420de35..22576b84926 100644 --- a/doc/user/project/clusters/eks_and_gitlab/index.md +++ b/doc/user/project/clusters/eks_and_gitlab/index.md @@ -192,7 +192,7 @@ deployment of the other applications. Next, if you would like the deployed app to be reachable on the internet, deploy the Ingress. Note that this will also cause an -[Elastic Load Balancer](https://aws.amazon.com/documentation/elastic-load-balancing/) +[Elastic Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/) to be created, which will incur additional AWS costs. Once installed, you may see a `?` for "Ingress IP Address". This is because the diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 9d61cba8e7e..9ecb785d6fe 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -112,7 +112,7 @@ There are two options when adding a new cluster to your project: TIP: **Tip:** Every new Google Cloud Platform (GCP) account receives [$300 in credit upon sign up](https://console.cloud.google.com/freetrial), and in partnership with Google, GitLab is able to offer an additional $200 for new GCP accounts to get started with GitLab's -Google Kubernetes Engine Integration. All you have to do is [follow this link](https://goo.gl/AaJzRW) and apply for credit. +Google Kubernetes Engine Integration. All you have to do is [follow this link](https://cloud.google.com/partners/partnercredit/?PCN=a0n60000006Vpz4AAC) and apply for credit. NOTE: **Note:** The [Google authentication integration](../../../integration/google.md) must @@ -390,8 +390,8 @@ you can either: When creating a cluster in GitLab, you will be asked if you would like to create either: -- An [Attribute-based access control (ABAC)](https://kubernetes.io/docs/admin/authorization/abac/) cluster. -- A [Role-based access control (RBAC)](https://kubernetes.io/docs/admin/authorization/rbac/) cluster. +- An [Attribute-based access control (ABAC)](https://kubernetes.io/docs/reference/access-authn-authz/abac/) cluster. +- A [Role-based access control (RBAC)](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) cluster. NOTE: **Note:** [RBAC](#rbac-cluster-resources) is recommended and the GitLab default. diff --git a/doc/user/project/clusters/runbooks/index.md b/doc/user/project/clusters/runbooks/index.md index 26ce4820174..7b17ec68234 100644 --- a/doc/user/project/clusters/runbooks/index.md +++ b/doc/user/project/clusters/runbooks/index.md @@ -50,7 +50,7 @@ To create an executable runbook, you will need: Nurtch is the company behind the [Rubix library](https://github.com/Nurtch/rubix). Rubix is an open-source Python library that makes it easy to perform common DevOps tasks inside Jupyter Notebooks. Tasks such as plotting Cloudwatch metrics and rolling your ECS/Kubernetes app are simplified -down to a couple of lines of code. See the [Nurtch Documentation](http://docs.nurtch.com/en/latest) +down to a couple of lines of code. See the [Nurtch Documentation](http://docs.nurtch.com/en/latest/) for more information. ## Configure an executable runbook with GitLab diff --git a/doc/user/project/deploy_boards.md b/doc/user/project/deploy_boards.md index a1a4e589c84..b14d7f821bb 100644 --- a/doc/user/project/deploy_boards.md +++ b/doc/user/project/deploy_boards.md @@ -29,9 +29,9 @@ to the latest release. Since Deploy Boards are tightly coupled with Kubernetes, there is some required knowledge. In particular you should be familiar with: -- [Kubernetes pods](https://kubernetes.io/docs/user-guide/pods) +- [Kubernetes pods](https://kubernetes.io/docs/concepts/workloads/pods/pod/) - [Kubernetes labels](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/) -- [Kubernetes namespaces](https://kubernetes.io/docs/user-guide/namespaces/) +- [Kubernetes namespaces](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) - [Kubernetes canary deployments](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/#canary-deployments) ## Use cases diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 3f3536838e7..a2094c05635 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -50,7 +50,7 @@ users will only see the thread through email. > **Note:** Service Desk is enabled on GitLab.com. If you're a -[Silver subscriber](https://about.gitlab.com/gitlab-com/), +[Silver subscriber](https://about.gitlab.com/pricing/#gitlab-com), you can skip the step 1 below; you only need to enable it per project. 1. [Set up incoming email](../../administration/incoming_email.md#set-it-up) for the GitLab instance. This must diff --git a/doc/workflow/file_finder.md b/doc/workflow/file_finder.md index 324989c173b..8eb705b5363 100644 --- a/doc/workflow/file_finder.md +++ b/doc/workflow/file_finder.md @@ -23,7 +23,7 @@ and go back to **Files**. ## How it works -The File finder feature is powered by the [Fuzzy filter] library. +The File finder feature is powered by the [Fuzzy filter](https://github.com/jeancroy/fuzz-aldrin-plus) library. It implements a fuzzy search with highlight, and tries to provide intuitive results by recognizing patterns that people use while searching. @@ -38,5 +38,4 @@ Using fuzzy search, we start by typing letters that get us closer to the file. ![Find file button](img/file_finder_find_file.png) [gh-9889]: https://github.com/gitlabhq/gitlabhq/pull/9889 "File finder pull request" -[fuzzy filter]: https://github.com/jeancroy/fuzzaldrin-plus "fuzzaldrin-plus on GitHub" [ce]: https://gitlab.com/gitlab-org/gitlab-foss/tree/master "GitLab CE repository" diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md index cfa846a9c12..6d1a5913789 100644 --- a/doc/workflow/repository_mirroring.md +++ b/doc/workflow/repository_mirroring.md @@ -79,7 +79,7 @@ mirror. To set up a mirror from GitLab to GitHub, you need to follow these steps: -1. Create a [GitHub personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with the `public_repo` box checked. +1. Create a [GitHub personal access token](https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line) with the `public_repo` box checked. 1. Fill in the **Git repository URL** field using this format: `https://<your_github_username>@github.com/<your_github_group>/<your_github_project>.git`. 1. Fill in **Password** field with your GitHub personal access token. 1. Click the **Mirror repository** button. @@ -197,10 +197,10 @@ Assuming you used the former, you now need to verify that the fingerprints are those you expect. GitLab.com and other code hosting sites publish their fingerprints in the open for you to check: -- [AWS CodeCommit](http://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) -- [Bitbucket](https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints) -- [GitHub](https://help.github.com/articles/github-s-ssh-key-fingerprints/) -- [GitLab.com](https://about.gitlab.com/gitlab-com/settings/#ssh-host-keys-fingerprints) +- [AWS CodeCommit](https://docs.aws.amazon.com/codecommit/latest/userguide/regions.html#regions-fingerprints) +- [Bitbucket](https://confluence.atlassian.com/bitbucket/ssh-keys-935365775.html) +- [GitHub](https://help.github.com/en/articles/githubs-ssh-key-fingerprints) +- [GitLab.com](../user/gitlab_com/index.md#ssh-host-keys-fingerprints) - [Launchpad](https://help.launchpad.net/SSHFingerprints) - [Savannah](http://savannah.gnu.org/maintenance/SshAccess/) - [SourceForge](https://sourceforge.net/p/forge/documentation/SSH%20Key%20Fingerprints/) @@ -398,7 +398,7 @@ CAUTION: **Warning:** Bidirectional mirroring should not be used as a permanent configuration. Refer to [Migrating from Perforce Helix](../user/project/import/perforce.md) for alternative migration approaches. -[Git Fusion](https://www.perforce.com/video-tutorials/git-fusion-overview) provides a Git interface +[Git Fusion](https://www.perforce.com/manuals/git-fusion/#Git-Fusion/section_avy_hyc_gl.html) provides a Git interface to [Perforce Helix](https://www.perforce.com/products) which can be used by GitLab to bidirectionally mirror projects with GitLab. This may be useful in some situations when migrating from Perforce Helix to GitLab where overlapping Perforce Helix workspaces cannot be migrated simultaneously to GitLab. @@ -415,7 +415,7 @@ settings are recommended: - `unknown_git` user will be used as the commit author if the GitLab user does not exist in Perforce Helix. -Read about [Git Fusion settings on Perforce.com](https://www.perforce.com/perforce/doc.current/manuals/git-fusion/Content/Git-Fusion/section_vss_bdw_w3.html#section_zdp_zz1_3l). +Read about [Git Fusion settings on Perforce.com](https://www.perforce.com/manuals/git-fusion/Content/Git-Fusion/section_vss_bdw_w3.html#section_zdp_zz1_3l). ## Troubleshooting @@ -423,4 +423,4 @@ Should an error occur during a push, GitLab will display an "Error" highlight fo ### 13:Received RST_STREAM with error code 2 with GitHub -If you receive an "13:Received RST_STREAM with error code 2" while mirroring to a GitHub repository, your GitHub settings might be set to block pushes that expose your email address used in commits. Either set your email address on GitHub to be public, or disable the [Block command line pushes that expose my email](http://github.com/settings/emails) setting. +If you receive an "13:Received RST_STREAM with error code 2" while mirroring to a GitHub repository, your GitHub settings might be set to block pushes that expose your email address used in commits. Either set your email address on GitHub to be public, or disable the [Block command line pushes that expose my email](https://github.com/settings/emails) setting. diff --git a/lib/api/internal/base.rb b/lib/api/internal/base.rb index 7963adfd7f4..1fe884eea13 100644 --- a/lib/api/internal/base.rb +++ b/lib/api/internal/base.rb @@ -129,20 +129,15 @@ module API # # Discover user by ssh key, user id or username # - # rubocop: disable CodeReuse/ActiveRecord - get "/discover" do + get '/discover' do if params[:key_id] - key = Key.find(params[:key_id]) - user = key.user - elsif params[:user_id] - user = User.find_by(id: params[:user_id]) + user = UserFinder.new(params[:key_id]).find_by_ssh_key_id elsif params[:username] user = UserFinder.new(params[:username]).find_by_username end present user, with: Entities::UserSafe end - # rubocop: enable CodeReuse/ActiveRecord get "/check" do { diff --git a/lib/api/users.rb b/lib/api/users.rb index ff8b82e1898..ff0b1e87b03 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -459,6 +459,42 @@ module API end # rubocop: enable CodeReuse/ActiveRecord + desc 'Activate a deactivated user. Available only for admins.' + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + # rubocop: disable CodeReuse/ActiveRecord + post ':id/activate' do + authenticated_as_admin! + + user = User.find_by(id: params[:id]) + not_found!('User') unless user + forbidden!('A blocked user must be unblocked to be activated') if user.blocked? + + user.activate + end + # rubocop: enable CodeReuse/ActiveRecord + desc 'Deactivate an active user. Available only for admins.' + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + # rubocop: disable CodeReuse/ActiveRecord + post ':id/deactivate' do + authenticated_as_admin! + user = User.find_by(id: params[:id]) + not_found!('User') unless user + + break if user.deactivated? + + unless user.can_be_deactivated? + forbidden!('A blocked user cannot be deactivated by the API') if user.blocked? + forbidden!("The user you are trying to deactivate has been active in the past #{::User::MINIMUM_INACTIVE_DAYS} days and cannot be deactivated") + end + + user.deactivate + end + # rubocop: enable CodeReuse/ActiveRecord + desc 'Block a user. Available only for admins.' params do requires :id, type: Integer, desc: 'The ID of the user' @@ -489,6 +525,8 @@ module API if user.ldap_blocked? forbidden!('LDAP blocked users cannot be unblocked by the API') + elsif user.deactivated? + forbidden!('Deactivated users cannot be unblocked by the API') else user.activate end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index ecba0ffbc46..4217859f9fb 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -69,7 +69,7 @@ module Gitlab Gitlab::Auth::UniqueIpsLimiter.limit_user! do user = User.by_login(login) - break if user && !user.active? + break if user && !user.can?(:log_in) authenticators = [] diff --git a/lib/gitlab/auth/user_access_denied_reason.rb b/lib/gitlab/auth/user_access_denied_reason.rb index fd09fe76c02..e73f6ca808c 100644 --- a/lib/gitlab/auth/user_access_denied_reason.rb +++ b/lib/gitlab/auth/user_access_denied_reason.rb @@ -14,6 +14,9 @@ module Gitlab when :terms_not_accepted "You (#{@user.to_reference}) must accept the Terms of Service in order to perform this action. "\ "Please access GitLab from a web browser to accept these terms." + when :deactivated + "Your account has been deactivated by your administrator. "\ + "Please log back in from a web browser to reactivate your account at #{Gitlab.config.gitlab.url}" else "Your account has been blocked." end @@ -26,6 +29,8 @@ module Gitlab :internal elsif @user.required_terms_not_accepted? :terms_not_accepted + elsif @user.deactivated? + :deactivated else :blocked end diff --git a/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml index 7f9a7df2f31..f058468ed8e 100644 --- a/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/Container-Scanning.gitlab-ci.yml @@ -1,9 +1,12 @@ # Read more about this feature here: https://docs.gitlab.com/ee/user/application_security/container_scanning/ +variables: + CS_MAJOR_VERSION: 1 + container_scanning: stage: test image: - name: registry.gitlab.com/gitlab-org/security-products/analyzers/klar:$CI_SERVER_VERSION_MAJOR-$CI_SERVER_VERSION_MINOR-stable + name: registry.gitlab.com/gitlab-org/security-products/analyzers/klar:$CS_MAJOR_VERSION entrypoint: [] variables: # By default, use the latest clair vulnerabilities database, however, allow it to be overridden here diff --git a/lib/gitlab/diff/file_collection/merge_request_diff_batch.rb b/lib/gitlab/diff/file_collection/merge_request_diff_batch.rb index c6d1e0b93a7..663326e01d5 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff_batch.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff_batch.rb @@ -29,6 +29,10 @@ module Gitlab } end + def diff_file_paths + diff_files.map(&:file_path) + end + override :diffs def diffs strong_memoize(:diffs) do diff --git a/lib/gitlab/diff/lines_unfolder.rb b/lib/gitlab/diff/lines_unfolder.rb index 0bd18fe9622..6def3a074a3 100644 --- a/lib/gitlab/diff/lines_unfolder.rb +++ b/lib/gitlab/diff/lines_unfolder.rb @@ -54,7 +54,7 @@ module Gitlab def unfold_required? strong_memoize(:unfold_required) do next false unless @diff_file.text? - next false unless @position.on_text? && @position.unchanged? + next false unless @position.unfoldable? next false if @diff_file.new_file? || @diff_file.deleted_file? next false unless @position.old_line # Invalid position (MR import scenario) diff --git a/lib/gitlab/diff/position.rb b/lib/gitlab/diff/position.rb index 5fe06b9c5e6..8b99fd5cd42 100644 --- a/lib/gitlab/diff/position.rb +++ b/lib/gitlab/diff/position.rb @@ -79,6 +79,10 @@ module Gitlab formatter.line_age end + def unfoldable? + on_text? && unchanged? + end + def unchanged? type.nil? end diff --git a/lib/gitlab/diff/position_collection.rb b/lib/gitlab/diff/position_collection.rb new file mode 100644 index 00000000000..59c60f77aaa --- /dev/null +++ b/lib/gitlab/diff/position_collection.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Diff + class PositionCollection + include Enumerable + + # collection - An array of Gitlab::Diff::Position + def initialize(collection, diff_head_sha) + @collection = collection + @diff_head_sha = diff_head_sha + end + + def each(&block) + @collection.each(&block) + end + + def concat(positions) + tap { @collection.concat(positions) } + end + + # Doing a lightweight filter in-memory given we're not prepared for querying + # positions (https://gitlab.com/gitlab-org/gitlab/issues/33271). + def unfoldable + select do |position| + position.unfoldable? && position.head_sha == @diff_head_sha + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7fb5a1559ce..cbeae82e16a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -809,6 +809,9 @@ msgstr "" msgid "Action to take when receiving an alert." msgstr "" +msgid "Activate" +msgstr "" + msgid "Activate Service Desk" msgstr "" @@ -1051,12 +1054,6 @@ msgstr "" msgid "Admin notes" msgstr "" -msgid "AdminArea| You are about to permanently delete the user %{username}. Issues, merge requests, and groups linked to them will be transferred to a system-wide \"Ghost-user\". To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered." -msgstr "" - -msgid "AdminArea| You are about to permanently delete the user %{username}. This will delete all of the issues, merge requests, and groups linked to them. To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered." -msgstr "" - msgid "AdminArea|Stop all jobs" msgstr "" @@ -1162,15 +1159,39 @@ msgstr "" msgid "AdminUsers|Admins" msgstr "" +msgid "AdminUsers|Block" +msgstr "" + msgid "AdminUsers|Block user" msgstr "" +msgid "AdminUsers|Block user %{username}?" +msgstr "" + msgid "AdminUsers|Blocked" msgstr "" +msgid "AdminUsers|Blocking user has the following effects:" +msgstr "" + msgid "AdminUsers|Cannot unblock LDAP blocked users" msgstr "" +msgid "AdminUsers|Deactivate" +msgstr "" + +msgid "AdminUsers|Deactivate User %{username}?" +msgstr "" + +msgid "AdminUsers|Deactivate user" +msgstr "" + +msgid "AdminUsers|Deactivated" +msgstr "" + +msgid "AdminUsers|Deactivating a user has the following effects:" +msgstr "" + msgid "AdminUsers|Delete User %{username} and contributions?" msgstr "" @@ -1195,6 +1216,21 @@ msgstr "" msgid "AdminUsers|No users found" msgstr "" +msgid "AdminUsers|Owned groups will be left" +msgstr "" + +msgid "AdminUsers|Personal projects will be left" +msgstr "" + +msgid "AdminUsers|Personal projects, group and user history will be left intact" +msgstr "" + +msgid "AdminUsers|Reactivating a user will:" +msgstr "" + +msgid "AdminUsers|Restore user access to the account, including web, Git and API." +msgstr "" + msgid "AdminUsers|Search by name, email or username" msgstr "" @@ -1207,18 +1243,42 @@ msgstr "" msgid "AdminUsers|Sort by" msgstr "" +msgid "AdminUsers|The user will be logged out" +msgstr "" + +msgid "AdminUsers|The user will not be able to access git repositories" +msgstr "" + +msgid "AdminUsers|The user will not be able to access the API" +msgstr "" + +msgid "AdminUsers|The user will not receive any notifications" +msgstr "" + msgid "AdminUsers|To confirm, type %{projectName}" msgstr "" msgid "AdminUsers|To confirm, type %{username}" msgstr "" -msgid "AdminUsers|User will be blocked" +msgid "AdminUsers|User will not be able to access git repositories" +msgstr "" + +msgid "AdminUsers|User will not be able to login" +msgstr "" + +msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account" msgstr "" msgid "AdminUsers|Without projects" msgstr "" +msgid "AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests, and groups linked to them will be transferred to a system-wide \"Ghost-user\". To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered." +msgstr "" + +msgid "AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues, merge requests, and groups linked to them. To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered." +msgstr "" + msgid "Advanced" msgstr "" @@ -1796,9 +1856,6 @@ msgstr "" msgid "Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>" msgstr "" -msgid "Are you sure" -msgstr "" - msgid "Are you sure that you want to archive this project?" msgstr "" @@ -2367,9 +2424,6 @@ msgstr "" msgid "Bitbucket import" msgstr "" -msgid "Block" -msgstr "" - msgid "Blocked" msgstr "" @@ -6273,6 +6327,12 @@ msgstr "" msgid "Error occurred while updating the issue weight" msgstr "" +msgid "Error occurred. A blocked user cannot be deactivated" +msgstr "" + +msgid "Error occurred. A blocked user must be unblocked to be activated" +msgstr "" + msgid "Error occurred. User was not blocked" msgstr "" @@ -15526,12 +15586,18 @@ msgstr "" msgid "Subtracts" msgstr "" +msgid "Successfully activated" +msgstr "" + msgid "Successfully blocked" msgstr "" msgid "Successfully confirmed" msgstr "" +msgid "Successfully deactivated" +msgstr "" + msgid "Successfully deleted U2F device." msgstr "" @@ -16128,6 +16194,9 @@ msgstr "" msgid "The user map is a mapping of the FogBugz users that participated on your projects to the way their email address and usernames will be imported into GitLab. You can change this by populating the table below." msgstr "" +msgid "The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated" +msgstr "" + msgid "The user-facing URL of the Geo node" msgstr "" @@ -18175,6 +18244,9 @@ msgstr "" msgid "Weight %{weight}" msgstr "" +msgid "Welcome back! Your account had been deactivated due to inactivity but is now reactivated." +msgstr "" + msgid "Welcome to GitLab" msgstr "" @@ -18792,6 +18864,9 @@ msgstr "" msgid "Your access request to the %{source_type} has been withdrawn." msgstr "" +msgid "Your account has been deactivated by your administrator. Please log back in to reactivate your account." +msgstr "" + msgid "Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO." msgstr "" diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb index 28d53a7f830..1d1653e67e3 100644 --- a/spec/controllers/admin/users_controller_spec.rb +++ b/spec/controllers/admin/users_controller_spec.rb @@ -60,6 +60,96 @@ describe Admin::UsersController do end end + describe 'PUT #activate' do + shared_examples 'a request that activates the user' do + it 'activates the user' do + put :activate, params: { id: user.username } + user.reload + expect(user.active?).to be_truthy + expect(flash[:notice]).to eq('Successfully activated') + end + end + + context 'for a deactivated user' do + before do + user.deactivate + end + + it_behaves_like 'a request that activates the user' + end + + context 'for an active user' do + it_behaves_like 'a request that activates the user' + end + + context 'for a blocked user' do + before do + user.block + end + + it 'does not activate the user' do + put :activate, params: { id: user.username } + user.reload + expect(user.active?).to be_falsey + expect(flash[:notice]).to eq('Error occurred. A blocked user must be unblocked to be activated') + end + end + end + + describe 'PUT #deactivate' do + shared_examples 'a request that deactivates the user' do + it 'deactivates the user' do + put :deactivate, params: { id: user.username } + user.reload + expect(user.deactivated?).to be_truthy + expect(flash[:notice]).to eq('Successfully deactivated') + end + end + + context 'for an active user' do + let(:activity) { {} } + let(:user) { create(:user, **activity) } + + context 'with no recent activity' do + let(:activity) { { last_activity_on: ::User::MINIMUM_INACTIVE_DAYS.next.days.ago } } + + it_behaves_like 'a request that deactivates the user' + end + + context 'with recent activity' do + let(:activity) { { last_activity_on: ::User::MINIMUM_INACTIVE_DAYS.pred.days.ago } } + + it 'does not deactivate the user' do + put :deactivate, params: { id: user.username } + user.reload + expect(user.deactivated?).to be_falsey + expect(flash[:notice]).to eq("The user you are trying to deactivate has been active in the past 14 days and cannot be deactivated") + end + end + end + + context 'for a deactivated user' do + before do + user.deactivate + end + + it_behaves_like 'a request that deactivates the user' + end + + context 'for a blocked user' do + before do + user.block + end + + it 'does not deactivate the user' do + put :deactivate, params: { id: user.username } + user.reload + expect(user.deactivated?).to be_falsey + expect(flash[:notice]).to eq('Error occurred. A blocked user cannot be deactivated') + end + end + end + describe 'PUT block/:id' do it 'blocks user' do put :block, params: { id: user.username } diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index e87c5e59b3b..5e33421854b 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -460,6 +460,25 @@ describe ApplicationController do end end + context 'deactivated user' do + controller(described_class) do + def index + render html: 'authenticated' + end + end + + before do + sign_in user + user.deactivate + end + + it 'signs out a deactivated user' do + get :index + expect(response).to redirect_to(new_user_session_path) + expect(flash[:alert]).to eq('Your account has been deactivated by your administrator. Please log back in to reactivate your account.') + end + end + context 'terms' do controller(described_class) do def index diff --git a/spec/controllers/omniauth_callbacks_controller_spec.rb b/spec/controllers/omniauth_callbacks_controller_spec.rb index 9371c434f49..521dbe7ee23 100644 --- a/spec/controllers/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/omniauth_callbacks_controller_spec.rb @@ -18,6 +18,28 @@ describe OmniauthCallbacksController, type: :controller do Rails.application.env_config['omniauth.auth'] = @original_env_config_omniauth_auth end + context 'a deactivated user' do + let(:provider) { :github } + let(:extern_uid) { 'my-uid' } + + before do + user.deactivate! + post provider + end + + it 'allows sign in' do + expect(request.env['warden']).to be_authenticated + end + + it 'activates the user' do + expect(user.reload.active?).to be_truthy + end + + it 'shows reactivation flash message after logging in' do + expect(flash[:notice]).to eq('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + end + end + context 'when the user is on the last sign in attempt' do let(:extern_uid) { 'my-uid' } diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index ca3c802777b..302de3246c2 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -258,5 +258,26 @@ describe Projects::MergeRequests::DiffsController do it_behaves_like 'forked project with submodules' it_behaves_like 'persisted preferred diff view cookie' + + context 'diff unfolding' do + let!(:unfoldable_diff_note) do + create(:diff_note_on_merge_request, :folded_position, project: project, noteable: merge_request) + end + + let!(:diff_note) do + create(:diff_note_on_merge_request, project: project, noteable: merge_request) + end + + it 'unfolds correct diff file positions' do + expect_next_instance_of(Gitlab::Diff::FileCollection::MergeRequestDiffBatch) do |instance| + expect(instance) + .to receive(:unfold_diff_files) + .with([unfoldable_diff_note.position]) + .and_call_original + end + + go + end + end end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 68b7bf61231..2108cf1c8ae 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -61,6 +61,25 @@ describe SessionsController do expect(subject.current_user).to eq user end + context 'a deactivated user' do + before do + user.deactivate! + post(:create, params: { user: user_params }) + end + + it 'is allowed to login' do + expect(subject.current_user).to eq user + end + + it 'activates the user' do + expect(subject.current_user.active?).to be_truthy + end + + it 'shows reactivation flash message after logging in' do + expect(flash[:notice]).to eq('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.') + end + end + context 'with password authentication disabled' do before do stub_application_setting(password_authentication_enabled_for_web: false) diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 8304b718136..2f02acca794 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -62,6 +62,18 @@ FactoryBot.define do ) end + trait :folded_position do + position do + Gitlab::Diff::Position.new( + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: 1, + new_line: 1, + diff_refs: diff_refs + ) + end + end + trait :resolved do resolved_at { Time.now } resolved_by { create(:user) } diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index ebf71d8c9da..29f29e58917 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -31,7 +31,8 @@ describe "Admin::Users" do expect(page).to have_content(current_user.last_activity_on.strftime("%e %b, %Y")) expect(page).to have_content(user.email) expect(page).to have_content(user.name) - expect(page).to have_link('Block', href: block_admin_user_path(user)) + expect(page).to have_button('Block') + expect(page).to have_button('Deactivate') expect(page).to have_button('Delete user') expect(page).to have_button('Delete user and contributions') end @@ -277,7 +278,8 @@ describe "Admin::Users" do expect(page).to have_content(user.email) expect(page).to have_content(user.name) expect(page).to have_content(user.id) - expect(page).to have_link('Block user', href: block_admin_user_path(user)) + expect(page).to have_button('Deactivate user') + expect(page).to have_button('Block user') expect(page).to have_button('Delete user') expect(page).to have_button('Delete user and contributions') end diff --git a/spec/finders/user_finder_spec.rb b/spec/finders/user_finder_spec.rb index 4771b878b8e..c20d7850d68 100644 --- a/spec/finders/user_finder_spec.rb +++ b/spec/finders/user_finder_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe UserFinder do - set(:user) { create(:user) } + let_it_be(:user) { create(:user) } describe '#find_by_id' do context 'when the user exists' do @@ -24,7 +24,7 @@ describe UserFinder do context 'when the user does not exist' do it 'returns nil' do - found = described_class.new(1).find_by_id + found = described_class.new(-1).find_by_id expect(found).to be_nil end @@ -84,7 +84,7 @@ describe UserFinder do context 'when the user does not exist' do it 'returns nil' do - found = described_class.new(1).find_by_id_or_username + found = described_class.new(-1).find_by_id_or_username expect(found).to be_nil end @@ -110,7 +110,7 @@ describe UserFinder do context 'when the user does not exist' do it 'raises ActiveRecord::RecordNotFound' do - finder = described_class.new(1) + finder = described_class.new(-1) expect { finder.find_by_id! }.to raise_error(ActiveRecord::RecordNotFound) end @@ -170,10 +170,32 @@ describe UserFinder do context 'when the user does not exist' do it 'raises ActiveRecord::RecordNotFound' do - finder = described_class.new(1) + finder = described_class.new(-1) expect { finder.find_by_id_or_username! }.to raise_error(ActiveRecord::RecordNotFound) end end end + + describe '#find_by_ssh_key_id' do + let_it_be(:ssh_key) { create(:key, user: user) } + + it 'returns the user when passing the ssh key id' do + found = described_class.new(ssh_key.id).find_by_ssh_key_id + + expect(found).to eq(user) + end + + it 'returns the user when passing the ssh key id (string)' do + found = described_class.new(ssh_key.id.to_s).find_by_ssh_key_id + + expect(found).to eq(user) + end + + it 'returns nil when the id does not exist' do + found = described_class.new(-1).find_by_ssh_key_id + + expect(found).to be_nil + end + end end diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap new file mode 100644 index 00000000000..78a736a9060 --- /dev/null +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User Operation confirmation modal renders modal with form included 1`] = ` +<div> + <p> + content + </p> + + <p> + To confirm, type + <code> + username + </code> + </p> + + <form + action="delete-url" + method="post" + > + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + value="csrf" + /> + + <glforminput-stub + autocomplete="off" + autofocus="" + name="username" + type="text" + value="" + /> + </form> + + <glbutton-stub + variant="secondary" + > + Cancel + </glbutton-stub> + + <glbutton-stub + disabled="true" + variant="warning" + > + + secondaryAction + + </glbutton-stub> + + <glbutton-stub + disabled="true" + variant="danger" + > + action + </glbutton-stub> +</div> +`; diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap new file mode 100644 index 00000000000..4a3989f5192 --- /dev/null +++ b/spec/frontend/pages/admin/users/components/__snapshots__/user_operation_confirmation_modal_spec.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`User Operation confirmation modal renders modal with form included 1`] = ` +<glmodal-stub + modalclass="" + modalid="user-operation-modal" + ok-title="action" + ok-variant="warning" + title="title" + titletag="h4" +> + <form + action="/url" + method="post" + > + <span> + content + </span> + + <input + name="_method" + type="hidden" + value="method" + /> + + <input + name="authenticity_token" + type="hidden" + value="csrf" + /> + </form> +</glmodal-stub> +`; diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js new file mode 100644 index 00000000000..57802a41bb5 --- /dev/null +++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlFormInput } from '@gitlab/ui'; +import DeleteUserModal from '~/pages/admin/users/components/delete_user_modal.vue'; +import ModalStub from './stubs/modal_stub'; + +describe('User Operation confirmation modal', () => { + let wrapper; + + const findButton = variant => + wrapper + .findAll(GlButton) + .filter(w => w.attributes('variant') === variant) + .at(0); + + const createComponent = (props = {}) => { + wrapper = shallowMount(DeleteUserModal, { + propsData: { + title: 'title', + content: 'content', + action: 'action', + secondaryAction: 'secondaryAction', + deleteUserUrl: 'delete-url', + blockUserUrl: 'block-url', + username: 'username', + csrfToken: 'csrf', + ...props, + }, + stubs: { + GlModal: ModalStub, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders modal with form included', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it.each` + variant | prop | action + ${'danger'} | ${'deleteUserUrl'} | ${'delete'} + ${'warning'} | ${'blockUserUrl'} | ${'block'} + `('closing modal with $variant button triggers $action', ({ variant, prop }) => { + createComponent(); + const form = wrapper.find('form'); + jest.spyOn(form.element, 'submit').mockReturnValue(); + const modalButton = findButton(variant); + modalButton.vm.$emit('click'); + return wrapper.vm.$nextTick().then(() => { + expect(form.element.submit).toHaveBeenCalled(); + expect(form.element.action).toContain(wrapper.props(prop)); + expect(new FormData(form.element).get('authenticity_token')).toEqual( + wrapper.props('csrfToken'), + ); + }); + }); + + it('disables buttons by default', () => { + createComponent(); + const blockButton = findButton('warning'); + const deleteButton = findButton('danger'); + expect(blockButton.attributes().disabled).toBeTruthy(); + expect(deleteButton.attributes().disabled).toBeTruthy(); + }); + + it('enables button when username is typed', () => { + createComponent({ + username: 'some-username', + }); + wrapper.find(GlFormInput).vm.$emit('input', 'some-username'); + const blockButton = findButton('warning'); + const deleteButton = findButton('danger'); + + return wrapper.vm.$nextTick().then(() => { + expect(blockButton.attributes().disabled).toBeFalsy(); + expect(deleteButton.attributes().disabled).toBeFalsy(); + }); + }); +}); diff --git a/spec/frontend/pages/admin/users/components/stubs/modal_stub.js b/spec/frontend/pages/admin/users/components/stubs/modal_stub.js new file mode 100644 index 00000000000..4dc55e909a0 --- /dev/null +++ b/spec/frontend/pages/admin/users/components/stubs/modal_stub.js @@ -0,0 +1,23 @@ +const ModalStub = { + inheritAttrs: false, + name: 'glmodal-stub', + data() { + return { + showWasCalled: false, + }; + }, + methods: { + show() { + this.showWasCalled = true; + }, + hide() {}, + }, + render(h) { + const children = [this.$slots.default, this.$slots['modal-footer']] + .filter(Boolean) + .reduce((acc, nodes) => acc.concat(nodes), []); + return h('div', children); + }, +}; + +export default ModalStub; diff --git a/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js new file mode 100644 index 00000000000..7653fffc502 --- /dev/null +++ b/spec/frontend/pages/admin/users/components/user_modal_manager_spec.js @@ -0,0 +1,148 @@ +import { shallowMount } from '@vue/test-utils'; +import UserModalManager from '~/pages/admin/users/components/user_modal_manager.vue'; +import ModalStub from './stubs/modal_stub'; + +describe('Users admin page Modal Manager', () => { + const modalConfiguration = { + action1: { + title: 'action1', + content: 'Action Modal 1', + }, + action2: { + title: 'action2', + content: 'Action Modal 2', + }, + }; + + const actionModals = { + action1: ModalStub, + action2: ModalStub, + }; + + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UserModalManager, { + propsData: { + actionModals, + modalConfiguration, + csrfToken: 'dummyCSRF', + ...props, + }, + stubs: { + dummyComponent1: true, + dummyComponent2: true, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('render behavior', () => { + it('does not renders modal when initialized', () => { + createComponent(); + expect(wrapper.find({ ref: 'modal' }).exists()).toBeFalsy(); + }); + + it('throws if non-existing action is requested', () => { + createComponent(); + expect(() => wrapper.vm.show({ glModalAction: 'non-existing' })).toThrow(); + }); + + it('throws if action has no proper configuration', () => { + createComponent({ + modalConfiguration: {}, + }); + expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow(); + }); + + it('renders modal with expected props when valid configuration is passed', () => { + createComponent(); + wrapper.vm.show({ + glModalAction: 'action1', + extraProp: 'extraPropValue', + }); + + return wrapper.vm.$nextTick().then(() => { + const modal = wrapper.find({ ref: 'modal' }); + expect(modal.exists()).toBeTruthy(); + expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF'); + expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue'); + expect(modal.vm.showWasCalled).toBeTruthy(); + }); + }); + }); + + describe('global listener', () => { + beforeEach(() => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(document, 'removeEventListener'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('registers global listener on mount', () => { + createComponent(); + expect(document.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('removes global listener on destroy', () => { + createComponent(); + wrapper.destroy(); + expect(document.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); + }); + }); + + describe('click handling', () => { + let node; + + beforeEach(() => { + node = document.createElement('div'); + document.body.appendChild(node); + }); + + afterEach(() => { + node.remove(); + node = null; + }); + + it('ignores wrong clicks', () => { + createComponent(); + const event = new window.MouseEvent('click', { + bubbles: true, + cancellable: true, + }); + jest.spyOn(event, 'preventDefault'); + node.dispatchEvent(event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('captures click with glModalAction', () => { + createComponent(); + node.dataset.glModalAction = 'action1'; + const event = new window.MouseEvent('click', { + bubbles: true, + cancellable: true, + }); + jest.spyOn(event, 'preventDefault'); + node.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + return wrapper.vm.$nextTick().then(() => { + const modal = wrapper.find({ ref: 'modal' }); + expect(modal.exists()).toBeTruthy(); + expect(modal.vm.showWasCalled).toBeTruthy(); + }); + }); + }); +}); diff --git a/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js new file mode 100644 index 00000000000..0ecdae2618c --- /dev/null +++ b/spec/frontend/pages/admin/users/components/user_operation_confirmation_modal_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import UserOperationConfirmationModal from '~/pages/admin/users/components/user_operation_confirmation_modal.vue'; + +describe('User Operation confirmation modal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UserOperationConfirmationModal, { + propsData: { + title: 'title', + content: 'content', + action: 'action', + url: '/url', + username: 'username', + csrfToken: 'csrf', + method: 'method', + ...props, + }, + sync: false, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders modal with form included', () => { + createComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('closing modal with ok button triggers form submit', () => { + createComponent(); + const form = wrapper.find('form'); + jest.spyOn(form.element, 'submit').mockReturnValue(); + wrapper.find(GlModal).vm.$emit('ok'); + return wrapper.vm.$nextTick().then(() => { + expect(form.element.submit).toHaveBeenCalled(); + expect(form.element.action).toContain(wrapper.props('url')); + expect(new FormData(form.element).get('authenticity_token')).toEqual( + wrapper.props('csrfToken'), + ); + }); + }); +}); diff --git a/spec/lib/gitlab/auth/user_access_denied_reason_spec.rb b/spec/lib/gitlab/auth/user_access_denied_reason_spec.rb index 8ec19c454d8..7045105a2c7 100644 --- a/spec/lib/gitlab/auth/user_access_denied_reason_spec.rb +++ b/spec/lib/gitlab/auth/user_access_denied_reason_spec.rb @@ -33,5 +33,13 @@ describe Gitlab::Auth::UserAccessDeniedReason do it { is_expected.to match /This action cannot be performed by internal users/ } end + + context 'when the user is deactivated' do + before do + user.deactivate! + end + + it { is_expected.to eq "Your account has been deactivated by your administrator. Please log back in from a web browser to reactivate your account at #{Gitlab.config.gitlab.url}" } + end end end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 3fc45bfc920..dc4b0b5b1b6 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -520,6 +520,12 @@ describe Gitlab::Auth do end end + it 'finds the user in deactivated state' do + user.deactivate! + + expect( gl_auth.find_with_user_password(username, password) ).to eql user + end + it "does not find user in blocked state" do user.block diff --git a/spec/lib/gitlab/diff/position_collection_spec.rb b/spec/lib/gitlab/diff/position_collection_spec.rb new file mode 100644 index 00000000000..de0e631ab03 --- /dev/null +++ b/spec/lib/gitlab/diff/position_collection_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Diff::PositionCollection do + let(:merge_request) { build(:merge_request) } + + def build_text_position(attrs = {}) + attributes = { + old_path: "files/ruby/popen.rb", + new_path: "files/ruby/popen.rb", + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + }.merge(attrs) + + Gitlab::Diff::Position.new(attributes) + end + + def build_image_position(attrs = {}) + attributes = { + old_path: "files/images/any_image.png", + new_path: "files/images/any_image.png", + width: 10, + height: 10, + x: 1, + y: 1, + diff_refs: merge_request.diff_refs, + position_type: "image" + }.merge(attrs) + + Gitlab::Diff::Position.new(attributes) + end + + let(:text_position) { build_text_position } + let(:folded_text_position) { build_text_position(old_line: 1, new_line: 1) } + let(:image_position) { build_image_position } + let(:head_sha) { merge_request.diff_head_sha } + + let(:collection) do + described_class.new([text_position, folded_text_position, image_position], head_sha) + end + + describe '#to_a' do + it 'returns all positions' do + expect(collection.to_a).to eq([text_position, folded_text_position, image_position]) + end + end + + describe '#unfoldable' do + it 'returns unfoldable diff positions' do + expect(collection.unfoldable).to eq([folded_text_position]) + end + + context 'when given head_sha does not match with positions head_sha' do + let(:head_sha) { 'unknown' } + + it 'returns no position' do + expect(collection.unfoldable).to be_empty + end + end + end + + describe '#concat' do + let(:new_text_position) { build_text_position(old_line: 1, new_line: 1) } + + it 'returns a Gitlab::Diff::Position' do + expect(collection.concat([new_text_position])).to be_a(described_class) + end + + it 'concatenates the new position to the collection' do + collection.concat([new_text_position]) + + expect(collection.to_a).to eq([text_position, folded_text_position, image_position, new_text_position]) + end + end +end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index d584cdbe280..81dc96b538a 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -541,6 +541,13 @@ describe Gitlab::GitAccess do expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') end + it 'disallows deactivated users to pull' do + project.add_maintainer(user) + user.deactivate! + + expect { pull_access_check }.to raise_unauthorized("Your account has been deactivated by your administrator. Please log back in from a web browser to reactivate your account at #{Gitlab.config.gitlab.url}") + end + context 'when the project repository does not exist' do it 'returns not found' do project.add_guest(user) @@ -925,6 +932,12 @@ describe Gitlab::GitAccess do project.add_developer(user) end + it 'does not allow deactivated users to push' do + user.deactivate! + + expect { push_access_check }.to raise_unauthorized("Your account has been deactivated by your administrator. Please log back in from a web browser to reactivate your account at #{Gitlab.config.gitlab.url}") + end + it 'cleans up the files' do expect(project.repository).to receive(:clean_stale_repository_files).and_call_original expect { push_access_check }.not_to raise_error diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 58a6c62cdcf..b142942a0a7 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -650,6 +650,46 @@ describe MergeRequest do end end + describe '#note_positions_for_paths' do + let(:merge_request) { create(:merge_request, :with_diffs) } + let(:project) { merge_request.project } + let!(:diff_note) do + create(:diff_note_on_merge_request, project: project, noteable: merge_request) + end + + let(:file_paths) { merge_request.diffs.diff_files.map(&:file_path) } + + subject do + merge_request.note_positions_for_paths(file_paths) + end + + it 'returns a Gitlab::Diff::PositionCollection' do + expect(subject).to be_a(Gitlab::Diff::PositionCollection) + end + + context 'within all diff files' do + it 'returns correct positions' do + expect(subject).to match_array([diff_note.position]) + end + end + + context 'within specific diff file' do + let(:file_paths) { [diff_note.position.file_path] } + + it 'returns correct positions' do + expect(subject).to match_array([diff_note.position]) + end + end + + context 'within no diff files' do + let(:file_paths) { [] } + + it 'returns no positions' do + expect(subject.to_a).to be_empty + end + end + end + describe '#discussions_diffs' do let(:merge_request) { create(:merge_request) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 25e17f3bec4..12292dad142 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1120,6 +1120,30 @@ describe User do end end + describe 'deactivating a user' do + let(:user) { create(:user, name: 'John Smith') } + + context "an active user" do + it "can be deactivated" do + user.deactivate + + expect(user.deactivated?).to be_truthy + end + end + + context "a user who is blocked" do + before do + user.block + end + + it "cannot be deactivated" do + user.deactivate + + expect(user.reload.deactivated?).to be_falsy + end + end + end + describe '.filter_items' do let(:user) { double } @@ -1141,6 +1165,12 @@ describe User do expect(described_class.filter_items('blocked')).to include user end + it 'filters by deactivated' do + expect(described_class).to receive(:deactivated).and_return([user]) + + expect(described_class.filter_items('deactivated')).to include user + end + it 'filters by two_factor_disabled' do expect(described_class).to receive(:without_two_factor).and_return([user]) @@ -1524,15 +1554,22 @@ describe User do end describe '.find_by_ssh_key_id' do - context 'using an existing SSH key ID' do - let(:user) { create(:user) } - let(:key) { create(:key, user: user) } + let_it_be(:user) { create(:user) } + let_it_be(:key) { create(:key, user: user) } + context 'using an existing SSH key ID' do it 'returns the corresponding User' do expect(described_class.find_by_ssh_key_id(key.id)).to eq(user) end end + it 'only performs a single query' do + key # Don't count the queries for creating the key and user + + expect { described_class.find_by_ssh_key_id(key.id) } + .not_to exceed_query_limit(1) + end + context 'using an invalid SSH key ID' do it 'returns nil' do expect(described_class.find_by_ssh_key_id(-1)).to be_nil @@ -2042,6 +2079,95 @@ describe User do end end + describe "#last_active_at" do + let(:last_activity_on) { 5.days.ago.to_date } + let(:current_sign_in_at) { 8.days.ago } + + context 'for a user that has `last_activity_on` set' do + let(:user) { create(:user, last_activity_on: last_activity_on) } + + it 'returns `last_activity_on` with current time zone' do + expect(user.last_active_at).to eq(last_activity_on.to_time.in_time_zone) + end + end + + context 'for a user that has `current_sign_in_at` set' do + let(:user) { create(:user, current_sign_in_at: current_sign_in_at) } + + it 'returns `current_sign_in_at`' do + expect(user.last_active_at).to eq(current_sign_in_at) + end + end + + context 'for a user that has both `current_sign_in_at` & ``last_activity_on`` set' do + let(:user) { create(:user, current_sign_in_at: current_sign_in_at, last_activity_on: last_activity_on) } + + it 'returns the latest among `current_sign_in_at` & `last_activity_on`' do + latest_event = [current_sign_in_at, last_activity_on.to_time.in_time_zone].max + expect(user.last_active_at).to eq(latest_event) + end + end + + context 'for a user that does not have both `current_sign_in_at` & `last_activity_on` set' do + let(:user) { create(:user, current_sign_in_at: nil, last_activity_on: nil) } + + it 'returns nil' do + expect(user.last_active_at).to eq(nil) + end + end + end + + describe "#can_be_deactivated?" do + let(:activity) { {} } + let(:user) { create(:user, name: 'John Smith', **activity) } + let(:day_within_minium_inactive_days_threshold) { User::MINIMUM_INACTIVE_DAYS.pred.days.ago } + let(:day_outside_minium_inactive_days_threshold) { User::MINIMUM_INACTIVE_DAYS.next.days.ago } + + shared_examples 'not eligible for deactivation' do + it 'returns false' do + expect(user.can_be_deactivated?).to be_falsey + end + end + + shared_examples 'eligible for deactivation' do + it 'returns true' do + expect(user.can_be_deactivated?).to be_truthy + end + end + + context "a user who is not active" do + before do + user.block + end + + it_behaves_like 'not eligible for deactivation' + end + + context 'a user who has activity within the specified minimum inactive days' do + let(:activity) { { last_activity_on: day_within_minium_inactive_days_threshold } } + + it_behaves_like 'not eligible for deactivation' + end + + context 'a user who has signed in within the specified minimum inactive days' do + let(:activity) { { current_sign_in_at: day_within_minium_inactive_days_threshold } } + + it_behaves_like 'not eligible for deactivation' + end + + context 'a user who has no activity within the specified minimum inactive days' do + let(:activity) { { last_activity_on: day_outside_minium_inactive_days_threshold } } + + it_behaves_like 'eligible for deactivation' + end + + context 'a user who has not signed in within the specified minimum inactive days' do + let(:activity) { { current_sign_in_at: day_outside_minium_inactive_days_threshold } } + + it_behaves_like 'eligible for deactivation' + end + end + describe "#contributed_projects" do subject { create(:user) } let!(:project1) { create(:project) } diff --git a/spec/policies/global_policy_spec.rb b/spec/policies/global_policy_spec.rb index df6cc526eb0..48f3d485f4b 100644 --- a/spec/policies/global_policy_spec.rb +++ b/spec/policies/global_policy_spec.rb @@ -141,6 +141,40 @@ describe GlobalPolicy do end end + describe 'receive notifications' do + describe 'regular user' do + it { is_expected.to be_allowed(:receive_notifications) } + end + + describe 'admin' do + let(:current_user) { create(:admin) } + + it { is_expected.to be_allowed(:receive_notifications) } + end + + describe 'anonymous' do + let(:current_user) { nil } + + it { is_expected.not_to be_allowed(:receive_notifications) } + end + + describe 'blocked user' do + before do + current_user.block + end + + it { is_expected.not_to be_allowed(:receive_notifications) } + end + + describe 'deactivated user' do + before do + current_user.deactivate + end + + it { is_expected.not_to be_allowed(:receive_notifications) } + end + end + describe 'git access' do describe 'regular user' do it { is_expected.to be_allowed(:access_git) } @@ -158,6 +192,14 @@ describe GlobalPolicy do it { is_expected.to be_allowed(:access_git) } end + describe 'deactivated user' do + before do + current_user.deactivate + end + + it { is_expected.not_to be_allowed(:access_git) } + end + context 'when terms are enforced' do before do enforce_terms diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index d74484c8d29..cfee3f6c0f8 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -38,21 +38,35 @@ describe 'doorkeeper access' do end end - describe "when user is blocked" do - it "returns authorization error" do - user.block + shared_examples 'forbidden request' do + it 'returns 403 response' do get api("/user"), params: { access_token: token.token } expect(response).to have_gitlab_http_status(403) end end - describe "when user is ldap_blocked" do - it "returns authorization error" do + context "when user is blocked" do + before do + user.block + end + + it_behaves_like 'forbidden request' + end + + context "when user is ldap_blocked" do + before do user.ldap_block - get api("/user"), params: { access_token: token.token } + end - expect(response).to have_gitlab_http_status(403) + it_behaves_like 'forbidden request' + end + + context "when user is deactivated" do + before do + user.deactivate end + + it_behaves_like 'forbidden request' end end diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index 2280d8ca9d4..7161d6f0a10 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -237,14 +237,6 @@ describe API::Internal::Base do expect(json_response['name']).to eq(user.name) end - it "finds a user by user id" do - get(api("/internal/discover"), params: { user_id: user.id, secret_token: secret_token }) - - expect(response).to have_gitlab_http_status(200) - - expect(json_response['name']).to eq(user.name) - end - it "finds a user by username" do get(api("/internal/discover"), params: { username: user.username, secret_token: secret_token }) diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index df76b62b40e..0d190ae069e 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -1846,6 +1846,182 @@ describe API::Users do end end + context 'activate and deactivate' do + shared_examples '404' do + it 'returns 404' do + expect(response).to have_gitlab_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + end + + describe 'POST /users/:id/activate' do + context 'performed by a non-admin user' do + it 'is not authorized to perform the action' do + post api("/users/#{user.id}/activate", user) + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'performed by an admin user' do + context 'for a deactivated user' do + before do + user.deactivate + + post api("/users/#{user.id}/activate", admin) + end + + it 'activates a deactivated user' do + expect(response).to have_gitlab_http_status(201) + expect(user.reload.state).to eq('active') + end + end + + context 'for an active user' do + before do + user.activate + + post api("/users/#{user.id}/activate", admin) + end + + it 'returns 201' do + expect(response).to have_gitlab_http_status(201) + expect(user.reload.state).to eq('active') + end + end + + context 'for a blocked user' do + before do + user.block + + post api("/users/#{user.id}/activate", admin) + end + + it 'returns 403' do + expect(response).to have_gitlab_http_status(403) + expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated') + expect(user.reload.state).to eq('blocked') + end + end + + context 'for a ldap blocked user' do + before do + user.ldap_block + + post api("/users/#{user.id}/activate", admin) + end + + it 'returns 403' do + expect(response).to have_gitlab_http_status(403) + expect(json_response['message']).to eq('403 Forbidden - A blocked user must be unblocked to be activated') + expect(user.reload.state).to eq('ldap_blocked') + end + end + + context 'for a user that does not exist' do + before do + post api("/users/0/activate", admin) + end + + it_behaves_like '404' + end + end + end + + describe 'POST /users/:id/deactivate' do + context 'performed by a non-admin user' do + it 'is not authorized to perform the action' do + post api("/users/#{user.id}/deactivate", user) + + expect(response).to have_gitlab_http_status(403) + end + end + + context 'performed by an admin user' do + context 'for an active user' do + let(:activity) { {} } + let(:user) { create(:user, username: 'user.with.dot', **activity) } + + context 'with no recent activity' do + let(:activity) { { last_activity_on: ::User::MINIMUM_INACTIVE_DAYS.next.days.ago } } + + before do + post api("/users/#{user.id}/deactivate", admin) + end + + it 'deactivates an active user' do + expect(response).to have_gitlab_http_status(201) + expect(user.reload.state).to eq('deactivated') + end + end + + context 'with recent activity' do + let(:activity) { { last_activity_on: ::User::MINIMUM_INACTIVE_DAYS.pred.days.ago } } + + before do + post api("/users/#{user.id}/deactivate", admin) + end + + it 'does not deactivate an active user' do + expect(response).to have_gitlab_http_status(403) + expect(json_response['message']).to eq("403 Forbidden - The user you are trying to deactivate has been active in the past #{::User::MINIMUM_INACTIVE_DAYS} days and cannot be deactivated") + expect(user.reload.state).to eq('active') + end + end + end + + context 'for a deactivated user' do + before do + user.deactivate + + post api("/users/#{user.id}/deactivate", admin) + end + + it 'returns 201' do + expect(response).to have_gitlab_http_status(201) + expect(user.reload.state).to eq('deactivated') + end + end + + context 'for a blocked user' do + before do + user.block + + post api("/users/#{user.id}/deactivate", admin) + end + + it 'returns 403' do + expect(response).to have_gitlab_http_status(403) + expect(json_response['message']).to eq('403 Forbidden - A blocked user cannot be deactivated by the API') + expect(user.reload.state).to eq('blocked') + end + end + + context 'for a ldap blocked user' do + before do + user.ldap_block + + post api("/users/#{user.id}/deactivate", admin) + end + + it 'returns 403' do + expect(response).to have_gitlab_http_status(403) + expect(json_response['message']).to eq('403 Forbidden - A blocked user cannot be deactivated by the API') + expect(user.reload.state).to eq('ldap_blocked') + end + end + + context 'for a user that does not exist' do + before do + post api("/users/0/deactivate", admin) + end + + it_behaves_like '404' + end + end + end + end + describe 'POST /users/:id/block' do before do admin @@ -1878,6 +2054,7 @@ describe API::Users do describe 'POST /users/:id/unblock' do let(:blocked_user) { create(:user, state: 'blocked') } + let(:deactivated_user) { create(:user, state: 'deactivated') } before do admin @@ -1901,7 +2078,13 @@ describe API::Users do expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') end - it 'does not be available for non admin users' do + it 'does not unblock deactivated users' do + post api("/users/#{deactivated_user.id}/unblock", admin) + expect(response).to have_gitlab_http_status(403) + expect(deactivated_user.reload.state).to eq('deactivated') + end + + it 'is not available for non admin users' do post api("/users/#{user.id}/unblock", user) expect(response).to have_gitlab_http_status(403) expect(user.reload.state).to eq('active') |