Skip to content

Commit

Permalink
Add dialog to delete user from UI (indico#6652)
Browse files Browse the repository at this point in the history
Co-authored-by: Dominic Hollis <[email protected]>
  • Loading branch information
SegiNyn and GovernmentPlates committed Jan 31, 2025
1 parent 2afe660 commit d22585c
Show file tree
Hide file tree
Showing 10 changed files with 316 additions and 10 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
^^^^^^^^
Expand Down
12 changes: 12 additions & 0 deletions docs/source/config/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <installation-plugins>` to enable.
Expand Down
1 change: 1 addition & 0 deletions indico/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion indico/modules/users/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,6 +77,7 @@
_bp.add_url_rule('/emails/<email>', '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'))
Expand Down
231 changes: 231 additions & 0 deletions indico/modules/users/client/js/UserDelete.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Modal.Header>
<Translate>
Delete <Param name="first_name" value={firstName} />{' '}
<Param name="last_name" value={lastName} />?
</Translate>
</Modal.Header>
<Modal.Content>
<Message negative icon>
<Icon name="warning sign" />
<Message.Content>
<Message.Header>
<Translate>This action is irreversible</Translate>
</Message.Header>
<Translate>Deleted user accounts cannot be restored.</Translate>
</Message.Content>
</Message>
<Translate as="p">Once deleted, the following will happen:</Translate>
<List style={{marginTop: 0}}>
<List.Item>
<List.Icon name="minus circle" />
<List.Content>
<Translate>
<Param name="first_name" value={firstName} /> will no longer be able to access
Indico.
</Translate>
</List.Content>
</List.Item>
<List.Item>
<List.Icon name="times circle outline" />
<List.Content>
<Translate>
<Param name="first_name" value={firstName} /> 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.
</Translate>
</List.Content>
</List.Item>
<List.Item>
<List.Icon name="trash alternate outline" />
<List.Content>
<Translate>
Where it is not possible to delete <Param name="first_name" value={firstName} />,
they will be anonymized and all personal data associated with the user will be
removed from Indico.
</Translate>
</List.Content>
</List.Item>
</List>
<Translate as="p">
Are you sure you want to delete{' '}
<Param wrapper={<strong />} name="first_name" value={firstName} />{' '}
<Param wrapper={<strong />} name="last_name" value={lastName} />?
</Translate>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose} disabled={disabled} content={Translate.string("No, I don't")} />
{isButtonDisabled ? (
<Button color="red" disabled>
<Translate>
Yes, I want to delete <Param name="first_name" value={firstName} /> (
<Param name="countdown_seconds" value={countdown} />)
</Translate>
</Button>
) : (
<Button color="red" onClick={onDelete} disabled={disabled} loading={inProgress}>
<Translate>
Yes, I want to delete <Param name="first_name" value={firstName} />
</Translate>
</Button>
)}
</Modal.Actions>
</>
);
}

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 (
<Popup
trigger={
<span>
<Button size="small" color="red" disabled>
<Translate>Delete User</Translate>
</Button>
</span>
}
size="small"
wide
content={Translate.string('You cannot delete an admin account')}
position="bottom center"
/>
);
}

return (
<div>
{isSameUser ? (
<Popup
trigger={
<span>
<Button size="small" color="red" disabled>
<Translate>Delete User</Translate>
</Button>
</span>
}
size="small"
wide
content={Translate.string('You cannot delete your own account')}
position="bottom center"
/>
) : (
<>
<Button size="small" color="red" onClick={() => setIsDialogOpen(true)}>
<Translate>Delete User</Translate>
</Button>
<Modal
size="small"
open={isDialogOpen}
onClose={handleCloseDialog}
closeIcon={!deleting}
closeOnEscape={!deleting}
closeOnDimmerClick={!deleting}
>
<UserDeleteDialogBody
firstName={firstName}
lastName={lastName}
disabled={deleting}
inProgress={deleting}
onDelete={handleDelete}
onClose={handleCloseDialog}
/>
</Modal>
</>
)}
</div>
);
}

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(
<UserDelete
userId={JSON.parse(this.getAttribute('user-id'))}
isAdmin={JSON.parse(this.getAttribute('user-is-admin'))}
firstName={this.getAttribute('user-first-name')}
lastName={this.getAttribute('user-last-name')}
/>,
this
);
}
}
);
50 changes: 45 additions & 5 deletions indico/modules/users/controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion indico/modules/users/module.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading

0 comments on commit d22585c

Please sign in to comment.