Commit e52711e3 authored by Manoj MJ's avatar Manoj MJ Committed by Ash McKenzie

Deactivate a user (with self-service reactivation)

This change adds the ability to
deactivate a user that has no recent activity i
in the last 14 days.
A deactivated user can still login
and this will reactivate the user.
parent 82bf296b
<script> <script>
import _ from 'underscore'; import _ from 'underscore';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
export default { export default {
components: { components: {
DeprecatedModal, GlModal,
GlButton,
GlFormInput,
}, },
props: { props: {
title: {
type: String,
required: true,
},
content: {
type: String,
required: true,
},
action: {
type: String,
required: true,
},
secondaryAction: {
type: String,
required: true,
},
deleteUserUrl: { deleteUserUrl: {
type: String, type: String,
required: false, required: true,
default: '',
}, },
blockUserUrl: { blockUserUrl: {
type: String, type: String,
required: false, required: true,
default: '',
},
deleteContributions: {
type: Boolean,
required: false,
default: false,
}, },
username: { username: {
type: String, type: String,
required: false, required: true,
default: '',
}, },
csrfToken: { csrfToken: {
type: String, type: String,
required: false, required: true,
default: '',
}, },
}, },
data() { data() {
...@@ -40,32 +49,12 @@ export default { ...@@ -40,32 +49,12 @@ export default {
}; };
}, },
computed: { computed: {
title() { modalTitle() {
const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?'); return sprintf(this.title, { username: this.username });
const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
return sprintf(
this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle,
{
username: `'${_.escape(this.username)}'`,
},
false,
);
}, },
text() { 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( return sprintf(
this.deleteContributions ? deleteContributionsText : keepContributionsText, this.content,
{ {
username: `<strong>${_.escape(this.username)}</strong>`, username: `<strong>${_.escape(this.username)}</strong>`,
strong_start: '<strong>', strong_start: '<strong>',
...@@ -83,12 +72,7 @@ export default { ...@@ -83,12 +72,7 @@ export default {
false, false,
); );
}, },
primaryButtonLabel() {
const keepContributionsLabel = s__('AdminUsers|Delete user');
const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
},
secondaryButtonLabel() { secondaryButtonLabel() {
return s__('AdminUsers|Block user'); return s__('AdminUsers|Block user');
}, },
...@@ -97,8 +81,12 @@ export default { ...@@ -97,8 +81,12 @@ export default {
}, },
}, },
methods: { methods: {
show() {
this.$refs.modal.show();
},
onCancel() { onCancel() {
this.enteredUsername = ''; this.enteredUsername = '';
this.$refs.modal.hide();
}, },
onSecondaryAction() { onSecondaryAction() {
const { form } = this.$refs; const { form } = this.$refs;
...@@ -117,43 +105,28 @@ export default { ...@@ -117,43 +105,28 @@ export default {
</script> </script>
<template> <template>
<deprecated-modal <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
id="delete-user-modal" <template>
:title="title" <p v-html="text"></p>
: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>
<p v-html="confirmationTextLabel"></p> <p v-html="confirmationTextLabel"></p>
<form ref="form" :action="deleteUserUrl" method="post"> <form ref="form" :action="deleteUserUrl" method="post">
<input ref="method" type="hidden" name="_method" value="delete" /> <input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" /> <input :value="csrfToken" type="hidden" name="authenticity_token" />
<input <gl-form-input
v-model="enteredUsername" v-model="enteredUsername"
autofocus
type="text" type="text"
name="username" name="username"
class="form-control"
aria-labelledby="input-label"
autocomplete="off" autocomplete="off"
/> />
</form> </form>
</template> </template>
<template slot="secondary-button"> <template slot="modal-footer">
<button <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button>
:disabled="!canSubmit" <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction">
type="button" {{ secondaryAction }}
class="btn js-secondary-button btn-warning" </gl-button>
data-dismiss="modal" <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button>
@click="onSecondaryAction"
>
{{ secondaryButtonLabel }}
</button>
</template> </template>
</deprecated-modal> </gl-modal>
</template> </template>
<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>
<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>
import $ from 'jquery';
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; 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 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', () => { document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate); Vue.use(Translate);
const deleteUserModalEl = document.getElementById('delete-user-modal'); const modalConfiguration = loadModalsConfigurationFromHtml(
document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
);
const deleteModal = new Vue({ // eslint-disable-next-line no-new
el: deleteUserModalEl, new Vue({
data: { el: MODAL_MANAGER_SELECTOR,
deleteUserUrl: '', functional: true,
blockUserUrl: '', methods: {
deleteContributions: '', show(...args) {
username: '', this.$refs.manager.show(...args);
},
}, },
render(createElement) { render(h) {
return createElement(deleteUserModal, { return h(ModalManager, {
ref: 'manager',
props: { props: {
deleteUserUrl: this.deleteUserUrl, modalConfiguration,
blockUserUrl: this.blockUserUrl, actionModals: ACTION_MODALS,
deleteContributions: this.deleteContributions,
username: this.username,
csrfToken: csrf.token, 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;
}
});
}); });
...@@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController ...@@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController
end end
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 def block
if update_user { |user| user.block } if update_user { |user| user.block }
redirect_back_or_admin_user(notice: _("Successfully blocked")) redirect_back_or_admin_user(notice: _("Successfully blocked"))
......
...@@ -26,6 +26,7 @@ class ApplicationController < ActionController::Base ...@@ -26,6 +26,7 @@ class ApplicationController < ActionController::Base
before_action :add_gon_variables, unless: [:peek_request?, :json_request?] before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller? before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :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 :set_usage_stats_consent_flag
before_action :check_impersonation_availability before_action :check_impersonation_availability
...@@ -294,6 +295,14 @@ class ApplicationController < ActionController::Base ...@@ -294,6 +295,14 @@ class ApplicationController < ActionController::Base
end end
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 def ldap_security_check
if current_user && current_user.requires_ldap_check? if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease return unless current_user.try_obtain_ldap_lease
......
...@@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController ...@@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if user.two_factor_enabled? && !auth_user.bypass_two_factor? if user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(user) prompt_for_two_factor(user)
else 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) sign_in_and_redirect(user, event: :authentication)
end end
else else
......
...@@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController ...@@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController
reset_password_sent_at: nil) reset_password_sent_at: nil)
end end
# hide the signed-in notification 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 flash[:notice] = nil
end
log_audit_event(current_user, resource, with: authentication_method) log_audit_event(current_user, resource, with: authentication_method)
log_user_activity(current_user) log_user_activity(current_user)
end end
......
...@@ -59,6 +59,8 @@ class User < ApplicationRecord ...@@ -59,6 +59,8 @@ class User < ApplicationRecord
# Removed in GitLab 12.3. Keep until after 2019-09-22. # Removed in GitLab 12.3. Keep until after 2019-09-22.
self.ignored_columns += %i[support_bot] self.ignored_columns += %i[support_bot]
MINIMUM_INACTIVE_DAYS = 14
# Override Devise::Models::Trackable#update_tracked_fields! # Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour # to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass
...@@ -242,18 +244,25 @@ class User < ApplicationRecord ...@@ -242,18 +244,25 @@ class User < ApplicationRecord
state_machine :state, initial: :active do state_machine :state, initial: :active do
event :block do event :block do
transition active: :blocked transition active: :blocked
transition deactivated: :blocked
transition ldap_blocked: :blocked transition ldap_blocked: :blocked
end end
event :ldap_block do event :ldap_block do
transition active: :ldap_blocked transition active: :ldap_blocked
transition deactivated: :ldap_blocked
end end
event :activate do event :activate do
transition deactivated: :active
transition blocked: :active transition blocked: :active
transition ldap_blocked: :active transition ldap_blocked: :active
end end
event :deactivate do
transition active: :deactivated
end
state :blocked, :ldap_blocked do state :blocked, :ldap_blocked do
def blocked? def blocked?
true true
...@@ -284,6 +293,7 @@ class User < ApplicationRecord ...@@ -284,6 +293,7 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) } scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal } 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 :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_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')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
...@@ -431,6 +441,8 @@ class User < ApplicationRecord ...@@ -431,6 +441,8 @@ class User < ApplicationRecord
without_projects without_projects
when 'external' when 'external'
external external
when 'deactivated'
deactivated
else else
active active
end end
...@@ -1534,6 +1546,17 @@ class User < ApplicationRecord ...@@ -1534,6 +1546,17 @@ class User < ApplicationRecord
!!(password_expires_at && password_expires_at < Time.now) !!(password_expires_at && password_expires_at < Time.now)
end 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 # @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
...@@ -1683,6 +1706,10 @@ class User < ApplicationRecord ...@@ -1683,6 +1706,10 @@ class User < ApplicationRecord
::Group.where(id: developer_groups_hierarchy.select(:id), ::Group.where(id: developer_groups_hierarchy.select(:id),
project_creation_level: project_creation_levels) project_creation_level: project_creation_levels)
end end
def no_recent_activity?
last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i
end
end end
User.prepend_if_ee('EE::User') User.prepend_if_ee('EE::User')
...@@ -17,6 +17,10 @@ class BasePolicy < DeclarativePolicy::Base ...@@ -17,6 +17,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0 with_options scope: :user, score: 0
condition(:blocked) { @user&.blocked? } 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" desc "User has access to all private groups & projects"
with_options scope: :user, score: 0 with_options scope: :user, score: 0
condition(:full_private_access) { @user&.full_private_access? } condition(:full_private_access) { @user&.full_private_access? }
......
...@@ -44,6 +44,12 @@ class GlobalPolicy < BasePolicy ...@@ -44,6 +44,12 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands prevent :use_slash_commands
end end
rule { deactivated }.policy do
prevent :access_git
prevent :access_api
prevent :receive_notifications
end
rule { required_terms_not_accepted }.policy do rule { required_terms_not_accepted }.policy do
prevent :access_api prevent :access_api
prevent :access_git prevent :access_git
......
...@@ -6,6 +6,8 @@ ...@@ -6,6 +6,8 @@
%span.cred (Internal) %span.cred (Internal)
- if @user.admin - if @user.admin
%span.cred (Admin) %span.cred (Admin)
- if @user.deactivated?
%span.cred (Deactivated)
= render_if_exists 'admin/users/audtior_user_badge' = render_if_exists 'admin/users/audtior_user_badge'
.float-right .float-right
......
#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.')
...@@ -31,7 +31,19 @@ ...@@ -31,7 +31,19 @@
- elsif user.blocked? - elsif user.blocked?
= link_to _('Unblock'), unblock_admin_user_path(user), method: :put = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else - 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? - if user.access_locked?
%li %li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') } = link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
...@@ -39,19 +51,14 @@ ...@@ -39,19 +51,14 @@
%li.divider %li.divider
- if user.can_be_removed? - if user.can_be_removed?
%li %li
%button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete',
target: '#delete-user-modal',
delete_user_url: admin_user_path(user), delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user), block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name), username: sanitize_name(user.name) } }
delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user') = s_('AdminUsers|Delete user')
%li %li
%button.delete-user-button.btn.text-danger{ data: { toggle: 'modal', %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
target: '#delete-user-modal',
delete_user_url: admin_user_path(user, hard_delete: true), delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user), block_user_url: block_admin_user_path(user),
username: sanitize_name(user.name), username: sanitize_name(user.name) } }
delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions') = s_('AdminUsers|Delete user and contributions')
%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'
%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')
%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'
...@@ -30,6 +30,10 @@ ...@@ -30,6 +30,10 @@
= link_to admin_users_path(filter: "blocked") do = link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked') = s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.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 = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do = link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects') = s_('AdminUsers|Without projects')
...@@ -50,6 +54,7 @@ ...@@ -50,6 +54,7 @@
= icon("search", class: "search-icon") = icon("search", class: "search-icon")
= button_tag s_('AdminUsers|Search users') if Rails.env.test? = button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown .dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name - toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' }) = dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right %ul.dropdown-menu.dropdown-menu-right
...@@ -74,4 +79,4 @@ ...@@ -74,4 +79,4 @@
= paginate @users, theme: "gitlab" = paginate @users, theme: "gitlab"
#delete-user-modal = render partial: 'admin/users/modals'
...@@ -156,6 +156,27 @@ ...@@ -156,6 +156,27 @@
= render_if_exists 'admin/users/user_detail_note' = 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? - if @user.blocked?
.card.border-info .card.border-info
.card-header.bg-info.text-white .card-header.bg-info.text-white
...@@ -172,14 +193,13 @@ ...@@ -172,14 +193,13 @@
.card-header.bg-warning.text-white .card-header.bg-warning.text-white
Block this user Block this user
.card-body .card-body
%p Blocking user has the following effects: = render partial: 'admin/users/user_block_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
%br %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? - if @user.access_locked?
.card.border-info .card.border-info
.card-header.bg-info.text-white .card-header.bg-info.text-white
...@@ -197,12 +217,10 @@ ...@@ -197,12 +217,10 @@
%p Deleting a user has the following effects: %p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user = render 'users/deletion_guidance', user: @user
%br %br
%button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete',
target: '#delete-user-modal',
delete_user_url: admin_user_path(@user), delete_user_url: admin_user_path(@user),
block_user_url: block_admin_user_path(@user), block_user_url: block_admin_user_path(@user),
username: @user.name, username: sanitize_name(@user.name) } }
delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user') = s_('AdminUsers|Delete user')
- else - else
- if @user.solo_owned_groups.present? - if @user.solo_owned_groups.present?
...@@ -229,15 +247,13 @@ ...@@ -229,15 +247,13 @@
the user, and projects in them, will also be removed. Commits the user, and projects in them, will also be removed. Commits
to other projects are unaffected. to other projects are unaffected.
%br %br
%button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal', %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
target: '#delete-user-modal',
delete_user_url: admin_user_path(@user, hard_delete: true), delete_user_url: admin_user_path(@user, hard_delete: true),
block_user_url: block_admin_user_path(@user), block_user_url: block_admin_user_path(@user),
username: @user.name, username: @user.name } }
delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions') = s_('AdminUsers|Delete user and contributions')
- else - else
%p %p
You don't have access to delete this user. You don't have access to delete this user.
#delete-user-modal = render partial: 'admin/users/modals'
---
title: Deactivate a user (with self-service reactivation)
merge_request: 17037
author:
type: added
...@@ -13,6 +13,8 @@ namespace :admin do ...@@ -13,6 +13,8 @@ namespace :admin do
get :keys get :keys
put :block put :block
put :unblock put :unblock
put :deactivate
put :activate
put :unlock put :unlock
put :confirm put :confirm
post :impersonate post :impersonate
......
...@@ -1152,6 +1152,48 @@ Parameters: ...@@ -1152,6 +1152,48 @@ Parameters:
Will return `201 OK` on success, `404 User Not Found` is user cannot be found or 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. `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 ### Get user contribution events
Please refer to the [Events API documentation](events.md#get-user-contribution-events) Please refer to the [Events API documentation](events.md#get-user-contribution-events)
......
...@@ -105,8 +105,16 @@ You can administer all users in the GitLab instance from the Admin Area's Users ...@@ -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**. To access the Users page, go to **Admin Area > Overview > Users**.
Click the **Active**, **Admins**, **2FA Enabled**, or **2FA Disabled**, **External**, or To list users matching a specific criteria, click on one of the following tabs on the **Users** page:
**Without projects** tab to list only users of that criteria.
- **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 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 created and the date of last activity. To edit a user, click the **Edit** button in that user's
......
...@@ -42,6 +42,52 @@ a user can be blocked directly from the Admin area. To do this: ...@@ -42,6 +42,52 @@ a user can be blocked directly from the Admin area. To do this:
1. Selecting a user. 1. Selecting a user.
1. Under the **Account** tab, click **Block 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 ## Associated Records
> - Introduced for issues in > - Introduced for issues in
......
...@@ -459,6 +459,42 @@ module API ...@@ -459,6 +459,42 @@ module API
end end
# rubocop: enable CodeReuse/ActiveRecord # 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.' desc 'Block a user. Available only for admins.'
params do params do
requires :id, type: Integer, desc: 'The ID of the user' requires :id, type: Integer, desc: 'The ID of the user'
...@@ -489,6 +525,8 @@ module API ...@@ -489,6 +525,8 @@ module API
if user.ldap_blocked? if user.ldap_blocked?
forbidden!('LDAP blocked users cannot be unblocked by the API') forbidden!('LDAP blocked users cannot be unblocked by the API')
elsif user.deactivated?
forbidden!('Deactivated users cannot be unblocked by the API')
else else
user.activate user.activate
end end
......
...@@ -69,7 +69,7 @@ module Gitlab ...@@ -69,7 +69,7 @@ module Gitlab
Gitlab::Auth::UniqueIpsLimiter.limit_user! do Gitlab::Auth::UniqueIpsLimiter.limit_user! do
user = User.by_login(login) user = User.by_login(login)
break if user && !user.active? break if user && !user.can?(:log_in)
authenticators = [] authenticators = []
......
...@@ -14,6 +14,9 @@ module Gitlab ...@@ -14,6 +14,9 @@ module Gitlab
when :terms_not_accepted when :terms_not_accepted
"You (#{@user.to_reference}) must accept the Terms of Service in order to perform this action. "\ "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." "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 else
"Your account has been blocked." "Your account has been blocked."
end end
...@@ -26,6 +29,8 @@ module Gitlab ...@@ -26,6 +29,8 @@ module Gitlab
:internal :internal
elsif @user.required_terms_not_accepted? elsif @user.required_terms_not_accepted?
:terms_not_accepted :terms_not_accepted
elsif @user.deactivated?
:deactivated
else else
:blocked :blocked
end end
......
...@@ -809,6 +809,9 @@ msgstr "" ...@@ -809,6 +809,9 @@ msgstr ""
msgid "Action to take when receiving an alert." msgid "Action to take when receiving an alert."
msgstr "" msgstr ""
msgid "Activate"
msgstr ""
msgid "Activate Service Desk" msgid "Activate Service Desk"
msgstr "" msgstr ""
...@@ -1051,12 +1054,6 @@ msgstr "" ...@@ -1051,12 +1054,6 @@ msgstr ""
msgid "Admin notes" msgid "Admin notes"
msgstr "" 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" msgid "AdminArea|Stop all jobs"
msgstr "" msgstr ""
...@@ -1162,15 +1159,39 @@ msgstr "" ...@@ -1162,15 +1159,39 @@ msgstr ""
msgid "AdminUsers|Admins" msgid "AdminUsers|Admins"
msgstr "" msgstr ""
msgid "AdminUsers|Block"
msgstr ""
msgid "AdminUsers|Block user" msgid "AdminUsers|Block user"
msgstr "" msgstr ""
msgid "AdminUsers|Block user %{username}?"
msgstr ""
msgid "AdminUsers|Blocked" msgid "AdminUsers|Blocked"
msgstr "" msgstr ""
msgid "AdminUsers|Blocking user has the following effects:"
msgstr ""
msgid "AdminUsers|Cannot unblock LDAP blocked users" msgid "AdminUsers|Cannot unblock LDAP blocked users"
msgstr "" 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?" msgid "AdminUsers|Delete User %{username} and contributions?"
msgstr "" msgstr ""
...@@ -1195,6 +1216,21 @@ msgstr "" ...@@ -1195,6 +1216,21 @@ msgstr ""
msgid "AdminUsers|No users found" msgid "AdminUsers|No users found"
msgstr "" 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" msgid "AdminUsers|Search by name, email or username"
msgstr "" msgstr ""
...@@ -1207,18 +1243,42 @@ msgstr "" ...@@ -1207,18 +1243,42 @@ msgstr ""
msgid "AdminUsers|Sort by" msgid "AdminUsers|Sort by"
msgstr "" 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}" msgid "AdminUsers|To confirm, type %{projectName}"
msgstr "" msgstr ""
msgid "AdminUsers|To confirm, type %{username}" msgid "AdminUsers|To confirm, type %{username}"
msgstr "" 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 "" msgstr ""
msgid "AdminUsers|Without projects" msgid "AdminUsers|Without projects"
msgstr "" 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" msgid "Advanced"
msgstr "" msgstr ""
...@@ -1796,9 +1856,6 @@ 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>" 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 "" msgstr ""
msgid "Are you sure"
msgstr ""
msgid "Are you sure that you want to archive this project?" msgid "Are you sure that you want to archive this project?"
msgstr "" msgstr ""
...@@ -2367,9 +2424,6 @@ msgstr "" ...@@ -2367,9 +2424,6 @@ msgstr ""
msgid "Bitbucket import" msgid "Bitbucket import"
msgstr "" msgstr ""
msgid "Block"
msgstr ""
msgid "Blocked" msgid "Blocked"
msgstr "" msgstr ""
...@@ -6273,6 +6327,12 @@ msgstr "" ...@@ -6273,6 +6327,12 @@ msgstr ""
msgid "Error occurred while updating the issue weight" msgid "Error occurred while updating the issue weight"
msgstr "" 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" msgid "Error occurred. User was not blocked"
msgstr "" msgstr ""
...@@ -15526,12 +15586,18 @@ msgstr "" ...@@ -15526,12 +15586,18 @@ msgstr ""
msgid "Subtracts" msgid "Subtracts"
msgstr "" msgstr ""
msgid "Successfully activated"
msgstr ""
msgid "Successfully blocked" msgid "Successfully blocked"
msgstr "" msgstr ""
msgid "Successfully confirmed" msgid "Successfully confirmed"
msgstr "" msgstr ""
msgid "Successfully deactivated"
msgstr ""
msgid "Successfully deleted U2F device." msgid "Successfully deleted U2F device."
msgstr "" msgstr ""
...@@ -16128,6 +16194,9 @@ 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." 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 "" 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" msgid "The user-facing URL of the Geo node"
msgstr "" msgstr ""
...@@ -18175,6 +18244,9 @@ msgstr "" ...@@ -18175,6 +18244,9 @@ msgstr ""
msgid "Weight %{weight}" msgid "Weight %{weight}"
msgstr "" msgstr ""
msgid "Welcome back! Your account had been deactivated due to inactivity but is now reactivated."
msgstr ""
msgid "Welcome to GitLab" msgid "Welcome to GitLab"
msgstr "" msgstr ""
...@@ -18792,6 +18864,9 @@ msgstr "" ...@@ -18792,6 +18864,9 @@ msgstr ""
msgid "Your access request to the %{source_type} has been withdrawn." msgid "Your access request to the %{source_type} has been withdrawn."
msgstr "" 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." msgid "Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO."
msgstr "" msgstr ""
......
...@@ -60,6 +60,96 @@ describe Admin::UsersController do ...@@ -60,6 +60,96 @@ describe Admin::UsersController do
end end
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 describe 'PUT block/:id' do
it 'blocks user' do it 'blocks user' do
put :block, params: { id: user.username } put :block, params: { id: user.username }
......
...@@ -460,6 +460,25 @@ describe ApplicationController do ...@@ -460,6 +460,25 @@ describe ApplicationController do
end end
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 context 'terms' do
controller(described_class) do controller(described_class) do
def index def index
......
...@@ -18,6 +18,28 @@ describe OmniauthCallbacksController, type: :controller do ...@@ -18,6 +18,28 @@ describe OmniauthCallbacksController, type: :controller do
Rails.application.env_config['omniauth.auth'] = @original_env_config_omniauth_auth Rails.application.env_config['omniauth.auth'] = @original_env_config_omniauth_auth
end 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 context 'when the user is on the last sign in attempt' do
let(:extern_uid) { 'my-uid' } let(:extern_uid) { 'my-uid' }
......
...@@ -61,6 +61,25 @@ describe SessionsController do ...@@ -61,6 +61,25 @@ describe SessionsController do
expect(subject.current_user).to eq user expect(subject.current_user).to eq user
end 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 context 'with password authentication disabled' do
before do before do
stub_application_setting(password_authentication_enabled_for_web: false) stub_application_setting(password_authentication_enabled_for_web: false)
......
...@@ -31,7 +31,8 @@ describe "Admin::Users" do ...@@ -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(current_user.last_activity_on.strftime("%e %b, %Y"))
expect(page).to have_content(user.email) expect(page).to have_content(user.email)
expect(page).to have_content(user.name) 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')
expect(page).to have_button('Delete user and contributions') expect(page).to have_button('Delete user and contributions')
end end
...@@ -277,7 +278,8 @@ describe "Admin::Users" do ...@@ -277,7 +278,8 @@ describe "Admin::Users" do
expect(page).to have_content(user.email) expect(page).to have_content(user.email)
expect(page).to have_content(user.name) expect(page).to have_content(user.name)
expect(page).to have_content(user.id) 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')
expect(page).to have_button('Delete user and contributions') expect(page).to have_button('Delete user and contributions')
end end
......
// 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>
`;
// 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>
`;
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();
});
});
});
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;
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();
});
});
});
});
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'),
);
});
});
});
...@@ -33,5 +33,13 @@ describe Gitlab::Auth::UserAccessDeniedReason do ...@@ -33,5 +33,13 @@ describe Gitlab::Auth::UserAccessDeniedReason do
it { is_expected.to match /This action cannot be performed by internal users/ } it { is_expected.to match /This action cannot be performed by internal users/ }
end 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
end end
...@@ -520,6 +520,12 @@ describe Gitlab::Auth do ...@@ -520,6 +520,12 @@ describe Gitlab::Auth do
end end
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 it "does not find user in blocked state" do
user.block user.block
......
...@@ -541,6 +541,13 @@ describe Gitlab::GitAccess do ...@@ -541,6 +541,13 @@ describe Gitlab::GitAccess do
expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.') expect { pull_access_check }.to raise_unauthorized('Your account has been blocked.')
end 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 context 'when the project repository does not exist' do
it 'returns not found' do it 'returns not found' do
project.add_guest(user) project.add_guest(user)
...@@ -925,6 +932,12 @@ describe Gitlab::GitAccess do ...@@ -925,6 +932,12 @@ describe Gitlab::GitAccess do
project.add_developer(user) project.add_developer(user)
end 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 it 'cleans up the files' do
expect(project.repository).to receive(:clean_stale_repository_files).and_call_original expect(project.repository).to receive(:clean_stale_repository_files).and_call_original
expect { push_access_check }.not_to raise_error expect { push_access_check }.not_to raise_error
......
...@@ -1120,6 +1120,30 @@ describe User do ...@@ -1120,6 +1120,30 @@ describe User do
end end
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 describe '.filter_items' do
let(:user) { double } let(:user) { double }
...@@ -1141,6 +1165,12 @@ describe User do ...@@ -1141,6 +1165,12 @@ describe User do
expect(described_class.filter_items('blocked')).to include user expect(described_class.filter_items('blocked')).to include user
end 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 it 'filters by two_factor_disabled' do
expect(described_class).to receive(:without_two_factor).and_return([user]) expect(described_class).to receive(:without_two_factor).and_return([user])
...@@ -2042,6 +2072,95 @@ describe User do ...@@ -2042,6 +2072,95 @@ describe User do
end end
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 describe "#contributed_projects" do
subject { create(:user) } subject { create(:user) }
let!(:project1) { create(:project) } let!(:project1) { create(:project) }
......
...@@ -141,6 +141,40 @@ describe GlobalPolicy do ...@@ -141,6 +141,40 @@ describe GlobalPolicy do
end end
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 'git access' do
describe 'regular user' do describe 'regular user' do
it { is_expected.to be_allowed(:access_git) } it { is_expected.to be_allowed(:access_git) }
...@@ -158,6 +192,14 @@ describe GlobalPolicy do ...@@ -158,6 +192,14 @@ describe GlobalPolicy do
it { is_expected.to be_allowed(:access_git) } it { is_expected.to be_allowed(:access_git) }
end 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 context 'when terms are enforced' do
before do before do
enforce_terms enforce_terms
......
...@@ -38,21 +38,35 @@ describe 'doorkeeper access' do ...@@ -38,21 +38,35 @@ describe 'doorkeeper access' do
end end
end end
describe "when user is blocked" do shared_examples 'forbidden request' do
it "returns authorization error" do it 'returns 403 response' do
user.block
get api("/user"), params: { access_token: token.token } get api("/user"), params: { access_token: token.token }
expect(response).to have_gitlab_http_status(403) expect(response).to have_gitlab_http_status(403)
end end
end end
describe "when user is ldap_blocked" do context "when user is blocked" do
it "returns authorization error" do before do
user.block
end
it_behaves_like 'forbidden request'
end
context "when user is ldap_blocked" do
before do
user.ldap_block 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 end
context "when user is deactivated" do
before do
user.deactivate
end
it_behaves_like 'forbidden request'
end end
end end
...@@ -1846,6 +1846,182 @@ describe API::Users do ...@@ -1846,6 +1846,182 @@ describe API::Users do
end end
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 describe 'POST /users/:id/block' do
before do before do
admin admin
...@@ -1878,6 +2054,7 @@ describe API::Users do ...@@ -1878,6 +2054,7 @@ describe API::Users do
describe 'POST /users/:id/unblock' do describe 'POST /users/:id/unblock' do
let(:blocked_user) { create(:user, state: 'blocked') } let(:blocked_user) { create(:user, state: 'blocked') }
let(:deactivated_user) { create(:user, state: 'deactivated') }
before do before do
admin admin
...@@ -1901,7 +2078,13 @@ describe API::Users do ...@@ -1901,7 +2078,13 @@ describe API::Users do
expect(ldap_blocked_user.reload.state).to eq('ldap_blocked') expect(ldap_blocked_user.reload.state).to eq('ldap_blocked')
end 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) post api("/users/#{user.id}/unblock", user)
expect(response).to have_gitlab_http_status(403) expect(response).to have_gitlab_http_status(403)
expect(user.reload.state).to eq('active') expect(user.reload.state).to eq('active')
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment