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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-10-10 03:06:44 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2019-10-10 03:06:44 +0300
commit308146dc398fd4c13453048105498018459e0985 (patch)
treed843eb63c1672e4b18c483907e2cd4aa7fca708e /app
parent4b28d5ae770c6bd332283a3f13ceae06329c409b (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue113
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue77
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue70
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js75
-rw-r--r--app/controllers/admin/users_controller.rb16
-rw-r--r--app/controllers/application_controller.rb9
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb3
-rw-r--r--app/controllers/sessions_controller.rb10
-rw-r--r--app/finders/user_finder.rb6
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb3
-rw-r--r--app/models/merge_request.rb9
-rw-r--r--app/models/note.rb6
-rw-r--r--app/models/user.rb29
-rw-r--r--app/policies/base_policy.rb4
-rw-r--r--app/policies/global_policy.rb6
-rw-r--r--app/views/admin/users/_head.html.haml2
-rw-r--r--app/views/admin/users/_modals.html.haml30
-rw-r--r--app/views/admin/users/_user.html.haml35
-rw-r--r--app/views/admin/users/_user_activation_effects.html.haml6
-rw-r--r--app/views/admin/users/_user_block_effects.html.haml11
-rw-r--r--app/views/admin/users/_user_deactivation_effects.html.haml16
-rw-r--r--app/views/admin/users/index.html.haml7
-rw-r--r--app/views/admin/users/show.html.haml48
24 files changed, 463 insertions, 133 deletions
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index e8905b479ee..78aaa9df0ec 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -1,37 +1,46 @@
<script>
import _ from 'underscore';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
components: {
- DeprecatedModal,
+ GlModal,
+ GlButton,
+ GlFormInput,
},
props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ secondaryAction: {
+ type: String,
+ required: true,
+ },
deleteUserUrl: {
type: String,
- required: false,
- default: '',
+ required: true,
},
blockUserUrl: {
type: String,
- required: false,
- default: '',
- },
- deleteContributions: {
- type: Boolean,
- required: false,
- default: false,
+ required: true,
},
username: {
type: String,
- required: false,
- default: '',
+ required: true,
},
csrfToken: {
type: String,
- required: false,
- default: '',
+ required: true,
},
},
data() {
@@ -40,32 +49,12 @@ export default {
};
},
computed: {
- title() {
- const keepContributionsTitle = s__('AdminUsers|Delete User %{username}?');
- const deleteContributionsTitle = s__('AdminUsers|Delete User %{username} and contributions?');
-
- return sprintf(
- this.deleteContributions ? deleteContributionsTitle : keepContributionsTitle,
- {
- username: `'${_.escape(this.username)}'`,
- },
- false,
- );
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
},
text() {
- const keepContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- Issues, merge requests, and groups linked to them will be transferred to a system-wide "Ghost-user".
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
-
- const deleteContributionsText = s__(`AdminArea|
- You are about to permanently delete the user %{username}.
- This will delete all of the issues, merge requests, and groups linked to them.
- To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead.
- Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered.`);
return sprintf(
- this.deleteContributions ? deleteContributionsText : keepContributionsText,
+ this.content,
{
username: `<strong>${_.escape(this.username)}</strong>`,
strong_start: '<strong>',
@@ -83,12 +72,7 @@ export default {
false,
);
},
- primaryButtonLabel() {
- const keepContributionsLabel = s__('AdminUsers|Delete user');
- const deleteContributionsLabel = s__('AdminUsers|Delete user and contributions');
- return this.deleteContributions ? deleteContributionsLabel : keepContributionsLabel;
- },
secondaryButtonLabel() {
return s__('AdminUsers|Block user');
},
@@ -97,8 +81,12 @@ export default {
},
},
methods: {
+ show() {
+ this.$refs.modal.show();
+ },
onCancel() {
this.enteredUsername = '';
+ this.$refs.modal.hide();
},
onSecondaryAction() {
const { form } = this.$refs;
@@ -117,43 +105,28 @@ export default {
</script>
<template>
- <deprecated-modal
- id="delete-user-modal"
- :title="title"
- :text="text"
- :primary-button-label="primaryButtonLabel"
- :secondary-button-label="secondaryButtonLabel"
- :submit-disabled="!canSubmit"
- kind="danger"
- @submit="onSubmit"
- @cancel="onCancel"
- >
- <template slot="body" slot-scope="props">
- <p v-html="props.text"></p>
+ <gl-modal ref="modal" modal-id="delete-user-modal" :title="modalTitle" kind="danger">
+ <template>
+ <p v-html="text"></p>
<p v-html="confirmationTextLabel"></p>
<form ref="form" :action="deleteUserUrl" method="post">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
- <input
+ <gl-form-input
v-model="enteredUsername"
+ autofocus
type="text"
name="username"
- class="form-control"
- aria-labelledby="input-label"
autocomplete="off"
/>
</form>
</template>
- <template slot="secondary-button">
- <button
- :disabled="!canSubmit"
- type="button"
- class="btn js-secondary-button btn-warning"
- data-dismiss="modal"
- @click="onSecondaryAction"
- >
- {{ secondaryButtonLabel }}
- </button>
+ <template slot="modal-footer">
+ <gl-button variant="secondary" @click="onCancel">{{ s__('Cancel') }}</gl-button>
+ <gl-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction">
+ {{ secondaryAction }}
+ </gl-button>
+ <gl-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ action }}</gl-button>
</template>
- </deprecated-modal>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
new file mode 100644
index 00000000000..a08d32028c3
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
@@ -0,0 +1,77 @@
+<script>
+export default {
+ props: {
+ modalConfiguration: {
+ required: true,
+ type: Object,
+ },
+ actionModals: {
+ required: true,
+ type: Object,
+ },
+ csrfToken: {
+ required: true,
+ type: String,
+ },
+ },
+ data() {
+ return {
+ currentModalData: null,
+ };
+ },
+ computed: {
+ activeModal() {
+ if (!this.currentModalData) return null;
+ const { glModalAction: action } = this.currentModalData;
+
+ return this.actionModals[action];
+ },
+
+ modalProps() {
+ const { glModalAction: requestedAction } = this.currentModalData;
+ return {
+ ...this.modalConfiguration[requestedAction],
+ ...this.currentModalData,
+ csrfToken: this.csrfToken,
+ };
+ },
+ },
+
+ mounted() {
+ document.addEventListener('click', this.handleClick);
+ },
+
+ beforeDestroy() {
+ document.removeEventListener('click', this.handleClick);
+ },
+
+ methods: {
+ handleClick(e) {
+ const { glModalAction: action } = e.target.dataset;
+ if (!action) return;
+
+ this.show(e.target.dataset);
+ e.preventDefault();
+ },
+
+ show(modalData) {
+ const { glModalAction: requestedAction } = modalData;
+ if (!this.actionModals[requestedAction]) {
+ throw new Error(`Requested non-existing modal action ${requestedAction}`);
+ }
+ if (!this.modalConfiguration[requestedAction]) {
+ throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
+ }
+
+ this.currentModalData = modalData;
+
+ return this.$nextTick().then(() => {
+ this.$refs.modal.show();
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" />
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
new file mode 100644
index 00000000000..4c335cfb018
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ content: {
+ type: String,
+ required: true,
+ },
+ action: {
+ type: String,
+ required: true,
+ },
+ url: {
+ type: String,
+ required: true,
+ },
+ username: {
+ type: String,
+ required: true,
+ },
+ csrfToken: {
+ type: String,
+ required: true,
+ },
+ method: {
+ type: String,
+ required: false,
+ default: 'put',
+ },
+ },
+ computed: {
+ modalTitle() {
+ return sprintf(this.title, { username: this.username });
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.modal.show();
+ },
+ submit() {
+ this.$refs.form.submit();
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ref="modal"
+ modal-id="user-operation-modal"
+ :title="modalTitle"
+ ok-variant="warning"
+ :ok-title="action"
+ @ok="submit"
+ >
+ <form ref="form" :action="url" method="post">
+ <span v-html="content"></span>
+ <input ref="method" type="hidden" name="_method" :value="method" />
+ <input :value="csrfToken" type="hidden" name="authenticity_token" />
+ </form>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 45046688b57..bc96e88351b 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -1,46 +1,65 @@
-import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
+import ModalManager from './components/user_modal_manager.vue';
+import DeleteUserModal from './components/delete_user_modal.vue';
+import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue';
import csrf from '~/lib/utils/csrf';
-import deleteUserModal from './components/delete_user_modal.vue';
+const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts';
+const MODAL_MANAGER_SELECTOR = '#user-modal';
+const ACTION_MODALS = {
+ deactivate: UserOperationConfirmationModal,
+ block: UserOperationConfirmationModal,
+ delete: DeleteUserModal,
+ 'delete-with-contributions': DeleteUserModal,
+};
+
+function loadModalsConfigurationFromHtml(modalsElement) {
+ const modalsConfiguration = {};
+
+ if (!modalsElement) {
+ /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
+ throw new Error('Modals content element not found!');
+ }
+
+ Array.from(modalsElement.children).forEach(node => {
+ const { modal, ...config } = node.dataset;
+ modalsConfiguration[modal] = {
+ title: node.dataset.title,
+ ...config,
+ content: node.innerHTML,
+ };
+ });
+
+ return modalsConfiguration;
+}
document.addEventListener('DOMContentLoaded', () => {
Vue.use(Translate);
- const deleteUserModalEl = document.getElementById('delete-user-modal');
+ const modalConfiguration = loadModalsConfigurationFromHtml(
+ document.querySelector(MODAL_TEXTS_CONTAINER_SELECTOR),
+ );
- const deleteModal = new Vue({
- el: deleteUserModalEl,
- data: {
- deleteUserUrl: '',
- blockUserUrl: '',
- deleteContributions: '',
- username: '',
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: MODAL_MANAGER_SELECTOR,
+ functional: true,
+ methods: {
+ show(...args) {
+ this.$refs.manager.show(...args);
+ },
},
- render(createElement) {
- return createElement(deleteUserModal, {
+ render(h) {
+ return h(ModalManager, {
+ ref: 'manager',
props: {
- deleteUserUrl: this.deleteUserUrl,
- blockUserUrl: this.blockUserUrl,
- deleteContributions: this.deleteContributions,
- username: this.username,
+ modalConfiguration,
+ actionModals: ACTION_MODALS,
csrfToken: csrf.token,
},
});
},
});
-
- $(document).on('shown.bs.modal', event => {
- if (event.relatedTarget.classList.contains('delete-user-button')) {
- const buttonProps = event.relatedTarget.dataset;
- deleteModal.deleteUserUrl = buttonProps.deleteUserUrl;
- deleteModal.blockUserUrl = buttonProps.blockUserUrl;
- deleteModal.deleteContributions = event.relatedTarget.hasAttribute(
- 'data-delete-contributions',
- );
- deleteModal.username = buttonProps.username;
- }
- });
});
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 61d36d1efc2..4c1ac8f206a 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -58,6 +58,22 @@ class Admin::UsersController < Admin::ApplicationController
end
end
+ def activate
+ return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?
+
+ user.activate
+ redirect_back_or_admin_user(notice: _("Successfully activated"))
+ end
+
+ def deactivate
+ return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user cannot be deactivated")) if user.blocked?
+ return redirect_back_or_admin_user(notice: _("Successfully deactivated")) if user.deactivated?
+ return redirect_back_or_admin_user(notice: _("The user you are trying to deactivate has been active in the past %{minimum_inactive_days} days and cannot be deactivated") % { minimum_inactive_days: ::User::MINIMUM_INACTIVE_DAYS }) unless user.can_be_deactivated?
+
+ user.deactivate
+ redirect_back_or_admin_user(notice: _("Successfully deactivated"))
+ end
+
def block
if update_user { |user| user.block }
redirect_back_or_admin_user(notice: _("Successfully blocked"))
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 01d80d77080..f5939b61948 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -26,6 +26,7 @@ class ApplicationController < ActionController::Base
before_action :add_gon_variables, unless: [:peek_request?, :json_request?]
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :require_email, unless: :devise_controller?
+ before_action :active_user_check, unless: :devise_controller?
before_action :set_usage_stats_consent_flag
before_action :check_impersonation_availability
@@ -294,6 +295,14 @@ class ApplicationController < ActionController::Base
end
end
+ def active_user_check
+ return unless current_user && current_user.deactivated?
+
+ sign_out current_user
+ flash[:alert] = _("Your account has been deactivated by your administrator. Please log back in to reactivate your account.")
+ redirect_to new_user_session_path
+ end
+
def ldap_security_check
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 755ce3463c4..b992972dfb8 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -148,6 +148,11 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
if user.two_factor_enabled? && !auth_user.bypass_two_factor?
prompt_for_two_factor(user)
else
+ if user.deactivated?
+ user.activate
+ flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
+ end
+
sign_in_and_redirect(user, event: :authentication)
end
else
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 23ef9157363..7b694dcdd77 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -25,6 +25,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
return render_404 unless diffable
diffs = diffable.diffs_in_batch(params[:page], params[:per_page], diff_options: diff_options)
+ positions = @merge_request.note_positions_for_paths(diffs.diff_file_paths, current_user)
+
+ diffs.unfold_diff_files(positions.unfoldable)
options = {
merge_request: @merge_request,
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index f8da152e3d2..1c506065b56 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -57,8 +57,14 @@ class SessionsController < Devise::SessionsController
reset_password_sent_at: nil)
end
- # hide the signed-in notification
- flash[:notice] = nil
+ if resource.deactivated?
+ resource.activate
+ flash[:notice] = _('Welcome back! Your account had been deactivated due to inactivity but is now reactivated.')
+ else
+ # hide the default signed-in notification
+ flash[:notice] = nil
+ end
+
log_audit_event(current_user, resource, with: authentication_method)
log_user_activity(current_user)
end
diff --git a/app/finders/user_finder.rb b/app/finders/user_finder.rb
index 556be4c4338..1dd1a27437e 100644
--- a/app/finders/user_finder.rb
+++ b/app/finders/user_finder.rb
@@ -52,6 +52,12 @@ class UserFinder
end
end
+ def find_by_ssh_key_id
+ return unless input_is_id?
+
+ User.find_by_ssh_key_id(@username_or_id)
+ end
+
def input_is_id?
@username_or_id.is_a?(Numeric) || @username_or_id =~ /^\d+$/
end
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
index dbc5ed1bc9a..76e0cbc7dff 100644
--- a/app/models/concerns/ci/pipeline_delegator.rb
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -15,7 +15,8 @@ module Ci
:merge_request_ref?,
:source_ref,
:source_ref_slug,
- :legacy_detached_merge_request_pipeline?, to: :pipeline
+ :legacy_detached_merge_request_pipeline?,
+ :merge_train_pipeline?, to: :pipeline
end
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 2b6934d4c83..50efe5d6aa0 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -454,6 +454,15 @@ class MergeRequest < ApplicationRecord
merge_request_diffs.where.not(id: merge_request_diff.id)
end
+ # Overwritten in EE
+ def note_positions_for_paths(paths, _user = nil)
+ positions = notes.new_diff_notes.joins(:note_diff_file)
+ .where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths)
+ .positions
+
+ Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
+ end
+
def preloads_discussion_diff_highlighting?
true
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 03a38346731..edc4a332581 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -193,6 +193,12 @@ class Note < ApplicationRecord
groups
end
+ def positions
+ where.not(position: nil)
+ .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting.
+ .map(&:position)
+ end
+
def count_for_collection(ids, type)
user.select('noteable_id', 'COUNT(*) as count')
.group(:noteable_id)
diff --git a/app/models/user.rb b/app/models/user.rb
index ad56d7a32c5..c4075f06dff 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -59,6 +59,8 @@ class User < ApplicationRecord
# Removed in GitLab 12.3. Keep until after 2019-09-22.
self.ignored_columns += %i[support_bot]
+ MINIMUM_INACTIVE_DAYS = 14
+
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
# rubocop: disable CodeReuse/ServiceClass
@@ -242,18 +244,25 @@ class User < ApplicationRecord
state_machine :state, initial: :active do
event :block do
transition active: :blocked
+ transition deactivated: :blocked
transition ldap_blocked: :blocked
end
event :ldap_block do
transition active: :ldap_blocked
+ transition deactivated: :ldap_blocked
end
event :activate do
+ transition deactivated: :active
transition blocked: :active
transition ldap_blocked: :active
end
+ event :deactivate do
+ transition active: :deactivated
+ end
+
state :blocked, :ldap_blocked do
def blocked?
true
@@ -284,6 +293,7 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
+ scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
@@ -431,6 +441,8 @@ class User < ApplicationRecord
without_projects
when 'external'
external
+ when 'deactivated'
+ deactivated
else
active
end
@@ -521,7 +533,7 @@ class User < ApplicationRecord
# Returns a user for the given SSH key.
def find_by_ssh_key_id(key_id)
- Key.find_by(id: key_id)&.user
+ find_by('EXISTS (?)', Key.select(1).where('keys.user_id = users.id').where(id: key_id))
end
def find_by_full_path(path, follow_redirects: false)
@@ -1534,6 +1546,17 @@ class User < ApplicationRecord
!!(password_expires_at && password_expires_at < Time.now)
end
+ def can_be_deactivated?
+ active? && no_recent_activity?
+ end
+
+ def last_active_at
+ last_activity = last_activity_on&.to_time&.in_time_zone
+ last_sign_in = current_sign_in_at
+
+ [last_activity, last_sign_in].compact.max
+ end
+
# @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
@@ -1683,6 +1706,10 @@ class User < ApplicationRecord
::Group.where(id: developer_groups_hierarchy.select(:id),
project_creation_level: project_creation_levels)
end
+
+ def no_recent_activity?
+ last_active_at.to_i <= MINIMUM_INACTIVE_DAYS.days.ago.to_i
+ end
end
User.prepend_if_ee('EE::User')
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 2305c55033f..18c23cbd13a 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -17,6 +17,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:blocked) { @user&.blocked? }
+ desc "User is deactivated"
+ with_options scope: :user, score: 0
+ condition(:deactivated) { @user&.deactivated? }
+
desc "User has access to all private groups & projects"
with_options scope: :user, score: 0
condition(:full_private_access) { @user&.full_private_access? }
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 659fde574fc..4d66a9e7d67 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -44,6 +44,12 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands
end
+ rule { deactivated }.policy do
+ prevent :access_git
+ prevent :access_api
+ prevent :receive_notifications
+ end
+
rule { required_terms_not_accepted }.policy do
prevent :access_api
prevent :access_git
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index b53b2019a97..a218885a00e 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -6,6 +6,8 @@
%span.cred (Internal)
- if @user.admin
%span.cred (Admin)
+ - if @user.deactivated?
+ %span.cred (Deactivated)
= render_if_exists 'admin/users/audtior_user_badge'
.float-right
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
new file mode 100644
index 00000000000..eaec6d69f5a
--- /dev/null
+++ b/app/views/admin/users/_modals.html.haml
@@ -0,0 +1,30 @@
+#user-modal
+#modal-texts.hidden{ "hidden": true, "aria-hidden": true }
+ %div{ data: { modal: "deactivate",
+ title: s_("AdminUsers|Deactivate User %{username}?"),
+ action: s_("AdminUsers|Deactivate") } }
+ = render partial: 'admin/users/user_deactivation_effects'
+
+ %div{ data: { modal: "block",
+ title: s_("AdminUsers|Block user %{username}?"),
+ action: s_("AdminUsers|Block") } }
+ = render partial: 'admin/users/user_block_effects'
+
+ %div{ data: { modal: "delete",
+ title: s_("AdminUsers|Delete User %{username}?"),
+ action: s_('AdminUsers|Delete user'),
+ 'secondary-action': s_('AdminUsers|Block user') } }
+ = s_('AdminUsers|You are about to permanently delete the user %{username}. Issues, merge requests,
+ and groups linked to them will be transferred to a system-wide "Ghost-user". To avoid data loss,
+ consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end},
+ it cannot be undone or recovered.')
+
+ %div{ data: { modal: "delete-with-contributions",
+ title: s_("AdminUsers|Delete User %{username} and contributions?"),
+ action: s_('AdminUsers|Delete user and contributions') ,
+ 'secondary-action': s_('AdminUsers|Block user') } }
+ = s_('AdminUsers|You are about to permanently delete the user %{username}. This will delete all of the issues,
+ merge requests, and groups linked to them. To avoid data loss,
+ consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end},
+ it cannot be undone or recovered.')
+
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 90a056dfe30..ca5109614fc 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -31,7 +31,19 @@
- elsif user.blocked?
= link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put
+ %button.btn{ data: { 'gl-modal-action': 'block',
+ url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Block')
+ - if user.can_be_deactivated?
+ %li
+ %button.btn{ data: { 'gl-modal-action': 'deactivate',
+ url: deactivate_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Deactivate')
+ - elsif user.deactivated?
+ %li
+ = link_to _('Activate'), activate_admin_user_path(user), method: :put
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
@@ -39,19 +51,14 @@
%li.divider
- if user.can_be_removed?
%li
- %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
- username: sanitize_name(user.name),
- delete_contributions: false }, type: 'button' }
+ username: sanitize_name(user.name) } }
= s_('AdminUsers|Delete user')
-
- %li
- %button.delete-user-button.btn.text-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
- delete_user_url: admin_user_path(user, hard_delete: true),
- block_user_url: block_admin_user_path(user),
- username: sanitize_name(user.name),
- delete_contributions: true }, type: 'button' }
- = s_('AdminUsers|Delete user and contributions')
+ %li
+ %button.delete-user-button.btn.text-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ delete_user_url: admin_user_path(user, hard_delete: true),
+ block_user_url: block_admin_user_path(user),
+ username: sanitize_name(user.name) } }
+ = s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/_user_activation_effects.html.haml b/app/views/admin/users/_user_activation_effects.html.haml
new file mode 100644
index 00000000000..244836dac11
--- /dev/null
+++ b/app/views/admin/users/_user_activation_effects.html.haml
@@ -0,0 +1,6 @@
+%p
+ = s_('AdminUsers|Reactivating a user will:')
+%ul
+ %li
+ = s_('AdminUsers|Restore user access to the account, including web, Git and API.')
+ = render_if_exists 'admin/users/user_activation_effects_on_seats'
diff --git a/app/views/admin/users/_user_block_effects.html.haml b/app/views/admin/users/_user_block_effects.html.haml
new file mode 100644
index 00000000000..8ffbe145169
--- /dev/null
+++ b/app/views/admin/users/_user_block_effects.html.haml
@@ -0,0 +1,11 @@
+%p
+ = s_('AdminUsers|Blocking user has the following effects:')
+%ul
+ %li
+ = s_('AdminUsers|User will not be able to login')
+ %li
+ = s_('AdminUsers|User will not be able to access git repositories')
+ %li
+ = s_('AdminUsers|Personal projects will be left')
+ %li
+ = s_('AdminUsers|Owned groups will be left')
diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml
new file mode 100644
index 00000000000..6cc47214d77
--- /dev/null
+++ b/app/views/admin/users/_user_deactivation_effects.html.haml
@@ -0,0 +1,16 @@
+%p
+ = s_('AdminUsers|Deactivating a user has the following effects:')
+%ul
+ %li
+ = s_('AdminUsers|The user will be logged out')
+ %li
+ = s_('AdminUsers|The user will not be able to access git repositories')
+ %li
+ = s_('AdminUsers|The user will not be able to access the API')
+ %li
+ = s_('AdminUsers|The user will not receive any notifications')
+ %li
+ = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account')
+ %li
+ = s_('AdminUsers|Personal projects, group and user history will be left intact')
+ = render_if_exists 'admin/users/user_deactivation_effects_on_seats'
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 9ce16749945..3c6ad899d1e 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -30,6 +30,10 @@
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
+ = link_to admin_users_path(filter: "deactivated") do
+ = s_('AdminUsers|Deactivated')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
@@ -50,6 +54,7 @@
= icon("search", class: "search-icon")
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
+ = label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
@@ -74,4 +79,4 @@
= paginate @users, theme: "gitlab"
-#delete-user-modal
+= render partial: 'admin/users/modals'
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index a988f746ced..706fa033c51 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -156,6 +156,27 @@
= render_if_exists 'admin/users/user_detail_note'
+ - if @user.deactivated?
+ .card.border-info
+ .card-header.bg-info.text-white
+ Reactivate this user
+ .card-body
+ = render partial: 'admin/users/user_activation_effects'
+ %br
+ = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
+ - elsif @user.can_be_deactivated?
+ .card.border-warning
+ .card-header.bg-warning.text-white
+ Deactivate this user
+ .card-body
+ = render partial: 'admin/users/user_deactivation_effects'
+ %br
+ %button.btn.btn-warning{ data: { 'gl-modal-action': 'deactivate',
+ content: 'You can always re-activate their account, their data will remain intact.',
+ url: deactivate_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Deactivate user')
+
- if @user.blocked?
.card.border-info
.card-header.bg-info.text-white
@@ -172,14 +193,13 @@
.card-header.bg-warning.text-white
Block this user
.card-body
- %p Blocking user has the following effects:
- %ul
- %li User will not be able to login
- %li User will not be able to access git repositories
- %li Personal projects will be left
- %li Owned groups will be left
+ = render partial: 'admin/users/user_block_effects'
%br
- = link_to 'Block user', block_admin_user_path(@user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put, class: "btn btn-warning"
+ %button.btn.btn-warning{ data: { 'gl-modal-action': 'block',
+ content: 'You can always unblock their account, their data will remain intact.',
+ url: block_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Block user')
- if @user.access_locked?
.card.border-info
.card-header.bg-info.text-white
@@ -197,12 +217,10 @@
%p Deleting a user has the following effects:
= render 'users/deletion_guidance', user: @user
%br
- %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete',
delete_user_url: admin_user_path(@user),
block_user_url: block_admin_user_path(@user),
- username: @user.name,
- delete_contributions: false }, type: 'button' }
+ username: sanitize_name(@user.name) } }
= s_('AdminUsers|Delete user')
- else
- if @user.solo_owned_groups.present?
@@ -229,15 +247,13 @@
the user, and projects in them, will also be removed. Commits
to other projects are unaffected.
%br
- %button.delete-user-button.btn.btn-danger{ data: { toggle: 'modal',
- target: '#delete-user-modal',
+ %button.delete-user-button.btn.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
delete_user_url: admin_user_path(@user, hard_delete: true),
block_user_url: block_admin_user_path(@user),
- username: @user.name,
- delete_contributions: true }, type: 'button' }
+ username: @user.name } }
= s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
- #delete-user-modal
+= render partial: 'admin/users/modals'