From d22585c6a1c9b401e81cb63b3bf4b7f105484555 Mon Sep 17 00:00:00 2001 From: Segilola Mustapha <54508387+SegiNyn@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:01:15 +0100 Subject: [PATCH] Add dialog to delete user from UI (#6652) Co-authored-by: Dominic Hollis --- CHANGES.rst | 6 + docs/source/config/settings.rst | 12 + indico/core/config.py | 1 + indico/modules/users/blueprint.py | 3 +- indico/modules/users/client/js/UserDelete.jsx | 231 ++++++++++++++++++ indico/modules/users/controllers.py | 50 +++- indico/modules/users/module.json | 3 +- .../users/templates/personal_data.html | 11 +- indico/modules/users/views.py | 2 +- indico/web/client/styles/partials/_boxes.scss | 7 + 10 files changed, 316 insertions(+), 10 deletions(-) create mode 100644 indico/modules/users/client/js/UserDelete.jsx diff --git a/CHANGES.rst b/CHANGES.rst index 3f1f0e620f2..72768440d0e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -23,6 +23,12 @@ Improvements - Make the event export/import util much more flexible to support exporting whole category subtrees, add better support for dealing with files, and add various things that were not correctly exported before (:pr:`6446`) +- Add a setting to limit the information room booking users can see for bookings not + linked to them or their rooms (:pr:`6704`) +- Add shortcuts to the past and closest events in a category (:pr:`6710`) +- Improve the appearance of the date pickers (:issue:`6719`, :pr:`6720`, thanks :user:`foxbunny`) +- Add a new setting (:data:`ALLOW_ADMIN_USER_DELETION`) to let administrators permanently + delete Indico users from the user management UI (:pr:`6652`, thanks :user:`SegiNyn`) Bugfixes ^^^^^^^^ diff --git a/docs/source/config/settings.rst b/docs/source/config/settings.rst index eef85b7d9d3..ff9469d9896 100644 --- a/docs/source/config/settings.rst +++ b/docs/source/config/settings.rst @@ -916,6 +916,18 @@ System Default: ``False`` +.. data:: ALLOW_ADMIN_USER_DELETION + + Whether to allow administrators to permanently delete users from the + Indico UI. + + If enabled, any Indico administrator can permanently delete a user from Indico, + along with all their associated user data. If it is not possible to delete the user + (e.g. because they are listed as a speaker at an event), the user will be anonymized + instead. + + Default: ``False`` + .. data:: PLUGINS The list of :ref:`Indico plugins ` to enable. diff --git a/indico/core/config.py b/indico/core/config.py index bb5a9e6bf21..a5db66f9ff3 100644 --- a/indico/core/config.py +++ b/indico/core/config.py @@ -28,6 +28,7 @@ # Note: Whenever you add/change something here, you MUST update the docs (settings.rst) as well DEFAULTS = { + 'ALLOW_ADMIN_USER_DELETION': False, 'ATTACHMENT_STORAGE': 'default', 'AUTH_PROVIDERS': {}, 'BASE_URL': None, diff --git a/indico/modules/users/blueprint.py b/indico/modules/users/blueprint.py index e6c74eb68f5..56b8966ff26 100644 --- a/indico/modules/users/blueprint.py +++ b/indico/modules/users/blueprint.py @@ -14,7 +14,7 @@ RHRegistrationRequestList, RHRejectRegistrationRequest, RHSaveProfilePicture, RHSearchAffiliations, RHUserBlock, RHUserDashboard, RHUserDataExport, RHUserDataExportAPI, RHUserDataExportDownload, - RHUserEmails, RHUserEmailsDelete, RHUserEmailsSetPrimary, + RHUserDelete, RHUserEmails, RHUserEmailsDelete, RHUserEmailsSetPrimary, RHUserEmailsVerify, RHUserFavorites, RHUserFavoritesAPI, RHUserFavoritesCategoryAPI, RHUserFavoritesEventAPI, RHUserPreferences, RHUserPreferencesMarkdownAPI, RHUserPreferencesMastodonServer, @@ -77,6 +77,7 @@ _bp.add_url_rule('/emails/', 'user_emails_delete', RHUserEmailsDelete, methods=('DELETE',)) _bp.add_url_rule('/emails/make-primary', 'user_emails_set_primary', RHUserEmailsSetPrimary, methods=('POST',)) _bp.add_url_rule('/blocked', 'user_block', RHUserBlock, methods=('PUT', 'DELETE')) + _bp.add_url_rule('/delete', 'user_delete', RHUserDelete, methods=('DELETE',)) _bp.add_url_rule('/data-export', 'user_data_export', RHUserDataExport) _bp.add_url_rule('/data-export.zip', 'user_data_export_download', RHUserDataExportDownload) _bp.add_url_rule('/api/data-export', 'api_user_data_export', RHUserDataExportAPI, methods=('GET', 'POST')) diff --git a/indico/modules/users/client/js/UserDelete.jsx b/indico/modules/users/client/js/UserDelete.jsx new file mode 100644 index 00000000000..823676bae51 --- /dev/null +++ b/indico/modules/users/client/js/UserDelete.jsx @@ -0,0 +1,231 @@ +// This file is part of Indico. +// Copyright (C) 2002 - 2025 CERN +// +// Indico is free software; you can redistribute it and/or +// modify it under the terms of the MIT License; see the +// LICENSE file for more details. + +import userDeleteURL from 'indico-url:users.user_delete'; +import usersAdminURL from 'indico-url:users.users_admin'; + +import PropTypes from 'prop-types'; +import React, {useEffect, useState} from 'react'; +import ReactDOM from 'react-dom'; +import {Button, Message, Modal, Icon, List, Popup} from 'semantic-ui-react'; + +import {Translate, Param} from 'indico/react/i18n'; +import {indicoAxios, handleAxiosError} from 'indico/utils/axios'; + +function UserDeleteDialogBody({firstName, lastName, disabled, inProgress, onDelete, onClose}) { + const [countdown, setCountdown] = useState(10); + const [isButtonDisabled, setButtonDisabled] = useState(true); + + useEffect(() => { + setCountdown(10); + setButtonDisabled(true); + const timer = setInterval(() => { + setCountdown(prevCountdown => { + if (prevCountdown <= 1) { + clearInterval(timer); + setButtonDisabled(false); + return 0; + } + return prevCountdown - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, []); + + return ( + <> + + + Delete {' '} + ? + + + + + + + + This action is irreversible + + Deleted user accounts cannot be restored. + + + Once deleted, the following will happen: + + + + + + will no longer be able to access + Indico. + + + + + + + + will be removed from all areas of + Indico - this does not affect their presence in events as a speaker or other role; + they will still be listed in such capacities where applicable. + + + + + + + + Where it is not possible to delete , + they will be anonymized and all personal data associated with the user will be + removed from Indico. + + + + + + Are you sure you want to delete{' '} + } name="first_name" value={firstName} />{' '} + } name="last_name" value={lastName} />? + + + + + ) : ( + + )} + + + ); +} + +UserDeleteDialogBody.propTypes = { + firstName: PropTypes.string.isRequired, + lastName: PropTypes.string.isRequired, + disabled: PropTypes.bool.isRequired, + inProgress: PropTypes.bool.isRequired, + onDelete: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +function UserDelete({userId, isAdmin, firstName, lastName}) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + const isSameUser = userId === null; + + const handleCloseDialog = () => { + setIsDialogOpen(false); + }; + + const handleDelete = async () => { + setDeleting(true); + try { + await indicoAxios.delete(userDeleteURL({user_id: userId})); + } catch (err) { + setDeleting(false); + handleCloseDialog(); + handleAxiosError(err); + return; + } + location.href = usersAdminURL(); + }; + + if (isAdmin && !isSameUser) { + return ( + + + + } + size="small" + wide + content={Translate.string('You cannot delete an admin account')} + position="bottom center" + /> + ); + } + + return ( +
+ {isSameUser ? ( + + + + } + size="small" + wide + content={Translate.string('You cannot delete your own account')} + position="bottom center" + /> + ) : ( + <> + + + + + + )} +
+ ); +} + +UserDelete.propTypes = { + userId: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), + isAdmin: PropTypes.bool.isRequired, + firstName: PropTypes.string.isRequired, + lastName: PropTypes.string.isRequired, +}; + +customElements.define( + 'ind-user-delete-button', + class extends HTMLElement { + connectedCallback() { + ReactDOM.render( + , + this + ); + } + } +); diff --git a/indico/modules/users/controllers.py b/indico/modules/users/controllers.py index 5cb5047ebeb..cf81b17fbe5 100644 --- a/indico/modules/users/controllers.py +++ b/indico/modules/users/controllers.py @@ -17,6 +17,7 @@ from marshmallow import fields from marshmallow_enum import EnumField from PIL import Image +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload, load_only, subqueryload from sqlalchemy.orm.exc import StaleDataError from webargs import validate @@ -25,6 +26,7 @@ from indico.core import signals from indico.core.auth import multipass from indico.core.cache import make_scoped_cache +from indico.core.config import config from indico.core.db import db from indico.core.db.sqlalchemy.util.queries import get_n_matching from indico.core.errors import UserValueError @@ -49,10 +51,11 @@ from indico.modules.users.operations import create_user from indico.modules.users.schemas import (AffiliationSchema, BasicCategorySchema, FavoriteEventSchema, UserPersonalDataSchema) -from indico.modules.users.util import (get_avatar_url_from_name, get_gravatar_for_user, get_linked_events, - get_mastodon_server_name, get_related_categories, get_suggested_categories, - get_unlisted_events, get_user_by_email, get_user_titles, merge_users, - search_users, send_avatar, serialize_user, set_user_avatar) +from indico.modules.users.util import (anonymize_user, get_avatar_url_from_name, get_gravatar_for_user, + get_linked_events, get_mastodon_server_name, get_related_categories, + get_suggested_categories, get_unlisted_events, get_user_by_email, + get_user_titles, merge_users, search_users, send_avatar, serialize_user, + set_user_avatar) from indico.modules.users.views import (WPUser, WPUserDashboard, WPUserDataExport, WPUserFavorites, WPUserPersonalData, WPUserProfilePic, WPUsersAdmin) from indico.util.date_time import now_utc @@ -239,7 +242,8 @@ def _process(self): titles=titles, user_values=user_values, locked_fields=locked_fields, locked_field_message=multipass.locked_field_message, current_affiliation=current_affiliation, - has_predefined_affiliations=has_predefined_affiliations) + has_predefined_affiliations=has_predefined_affiliations, + allow_deletion=config.ALLOW_ADMIN_USER_DELETION) class RHUserDataExport(RHUserBase): @@ -1009,3 +1013,39 @@ def _process_DELETE(self): logger.info('User %s unblocked %s', session.user, self.user) flash(_('{name} has been unblocked.').format(name=self.user.name), 'success') return jsonify(success=True) + + +class RHUserDelete(RHUserBase): + """Delete a user. + + Deletes the user, and all their associated data. If it is not possible to delete the user, it will + instead fallback to anonymizing the user. + """ + + def _check_access(self): + RHUserBase._check_access(self) + if not session.user.is_admin or not config.ALLOW_ADMIN_USER_DELETION: + raise Forbidden + if self.user == session.user: + raise Forbidden('You cannot delete your own account.') + if self.user.is_admin: + raise Forbidden('You cannot delete an admin account.') + + def _process(self): + user_name = self.user.name + user_repr = repr(self.user) + signals.users.db_deleted.send(self.user, flushed=False) + try: + db.session.delete(self.user) + db.session.flush() + except IntegrityError as exc: + db.session.rollback() + logger.info('User %r could not be deleted %s', self.user, str(exc)) + anonymize_user(self.user) + logger.info('User %r anonymized %s', session.user, user_repr) + flash(_('{user_name} has been anonymized.').format(user_name=user_name), 'success') + else: + signals.users.db_deleted.send(self.user, flushed=True) + logger.info('User %r deleted %s', session.user, user_repr) + flash(_('{user_name} has been deleted.').format(user_name=user_name), 'success') + return '', 204 diff --git a/indico/modules/users/module.json b/indico/modules/users/module.json index dd83de57f73..89222cf0d04 100644 --- a/indico/modules/users/module.json +++ b/indico/modules/users/module.json @@ -5,6 +5,7 @@ "profile_picture": "./ProfilePicture.jsx", "personal_data": "./PersonalDataForm.jsx", "favorites": "./Favorites.jsx", - "data_export": "./DataExport.jsx" + "data_export": "./DataExport.jsx", + "user_delete": "./UserDelete.jsx" } } diff --git a/indico/modules/users/templates/personal_data.html b/indico/modules/users/templates/personal_data.html index 3f5742247ae..8baf7de816e 100644 --- a/indico/modules/users/templates/personal_data.html +++ b/indico/modules/users/templates/personal_data.html @@ -8,10 +8,10 @@ {%- trans %}Details{% endtrans -%} {% if session.user.is_admin %} -
+
{% if not user.is_blocked %}
-
{% endif %}
diff --git a/indico/modules/users/views.py b/indico/modules/users/views.py index 9fb6893353b..83b044dc9f3 100644 --- a/indico/modules/users/views.py +++ b/indico/modules/users/views.py @@ -57,7 +57,7 @@ class WPUserProfilePic(WPUser): class WPUserPersonalData(WPUser): - bundles = ('module_users.personal_data.js', 'module_users.personal_data.css') + bundles = ('module_users.personal_data.js', 'module_users.personal_data.css', 'module_users.user_delete.js') class WPUserFavorites(WPUser): diff --git a/indico/web/client/styles/partials/_boxes.scss b/indico/web/client/styles/partials/_boxes.scss index d7fff052363..f97f801df42 100644 --- a/indico/web/client/styles/partials/_boxes.scss +++ b/indico/web/client/styles/partials/_boxes.scss @@ -233,6 +233,13 @@ $i-box-padding: 10px; padding: 0; } + .i-box-align-buttons { + display: flex; + justify-content: space-between; + align-items: center; + gap: 5px; + } + .i-box-metadata { float: right; line-height: $toolbar-thin-height;