Commit 65381105 authored by Ash McKenzie's avatar Ash McKenzie

Merge branch '22257-deactivate-a-user-with-self-service-reactivation' into 'master'

Resolve "Deactivate a user (with self-service reactivation)"

See merge request gitlab-org/gitlab!17037
parents 82bf296b e52711e3
<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>
<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 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;
}
});
});
......@@ -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"))
......
......@@ -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
......
......@@ -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
......
......@@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController
reset_password_sent_at: nil)
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
end
log_audit_event(current_user, resource, with: authentication_method)
log_user_activity(current_user)
end
......
......@@ -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
......@@ -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')
......@@ -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? }
......
......@@ -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
......
......@@ -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
......
#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 @@
- 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',
%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),
delete_contributions: true }, type: 'button' }
username: sanitize_name(user.name) } }
= 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 @@
= 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'
......@@ -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'
---
title: Deactivate a user (with self-service reactivation)
merge_request: 17037
author:
type: added
......@@ -13,6 +13,8 @@ namespace :admin do
get :keys
put :block
put :unblock
put :deactivate
put :activate
put :unlock
put :confirm
post :impersonate
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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 = []
......
......@@ -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
......
......@@ -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 ""
......
......@@ -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 }
......
......@@ -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
......
......@@ -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' }
......
......@@ -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)
......
......@@ -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
......
// 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
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
......@@ -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
......
......@@ -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
......
......@@ -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])
......@@ -2042,6 +2072,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) }
......
......@@ -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
......
......@@ -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
......@@ -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')
......
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