diff --git a/static/images/help/deactivate-user-email.png b/static/images/help/deactivate-user-email.png new file mode 100644 index 0000000000..378b119b59 Binary files /dev/null and b/static/images/help/deactivate-user-email.png differ diff --git a/static/js/dialog_widget.js b/static/js/dialog_widget.js index 750e3fae6a..85d153138e 100644 --- a/static/js/dialog_widget.js +++ b/static/js/dialog_widget.js @@ -118,6 +118,10 @@ export function launch(conf) { } const $submit_button = $dialog.find(".dialog_submit_button"); + const $send_email_checkbox = $dialog.find(".send_email"); + const $email_field = $dialog.find(".email_field"); + + $email_field.hide(); // This is used to link the submit button with the form, if present, in the modal. // This makes it so that submitting this form by pressing Enter on an input element @@ -140,6 +144,14 @@ export function launch(conf) { conf.on_click(e); }); + $($send_email_checkbox).on("change", () => { + if ($($send_email_checkbox).is(":checked")) { + $email_field.show(); + } else { + $email_field.hide(); + } + }); + overlays.open_modal("dialog_widget_modal", { autoremove: true, on_show: () => { diff --git a/static/js/settings_users.js b/static/js/settings_users.js index 81bb930905..11c46470ea 100644 --- a/static/js/settings_users.js +++ b/static/js/settings_users.js @@ -21,6 +21,7 @@ import * as settings_bots from "./settings_bots"; import * as settings_config from "./settings_config"; import * as settings_data from "./settings_data"; import * as settings_panel_menu from "./settings_panel_menu"; +import * as settings_ui from "./settings_ui"; import * as timerender from "./timerender"; import * as ui from "./ui"; import * as user_pill from "./user_pill"; @@ -442,11 +443,16 @@ export function confirm_deactivation(user_id, handle_confirm, loading_spinner) { const bots_owned_by_user = bot_data.get_all_bots_owned_by_user(user_id); const user = people.get_by_user_id(user_id); + const realm_uri = page_params.realm_uri; + const realm_name = page_params.realm_name; const opts = { username: user.full_name, email: settings_data.email_for_user_settings(user), bots_owned_by_user, number_of_invites_by_user, + admin_email: people.my_current_email(), + realm_uri, + realm_name, }; const html_body = render_settings_deactivation_user_modal(opts); @@ -479,6 +485,17 @@ function handle_deactivation($tbody) { function handle_confirm() { const url = "/json/users/" + encodeURIComponent(user_id); dialog_widget.submit_api_request(channel.del, url); + + let data = {}; + if ($(".send_email").is(":checked")) { + data = { + deactivation_notification_comment: $(".email_field_textarea").val(), + }; + } + + const $status_field = $("#admin-user-list .alert-notification"); + + settings_ui.do_settings_change(channel.del, url, data, $status_field); } confirm_deactivation(user_id, handle_confirm, true); diff --git a/static/styles/modal.css b/static/styles/modal.css index 2db3145164..4d64b6af12 100644 --- a/static/styles/modal.css +++ b/static/styles/modal.css @@ -124,6 +124,25 @@ margin-bottom: 10px; } +.email_field { + margin-top: 10px; + + .email_field_textarea { + width: 97%; + resize: vertical; + } + + .border-top { + border-top: 1px solid hsla(300, 2%, 11%, 0.3); + padding-top: 10px; + } + + .email-body { + margin-left: 20px; + margin-top: 20px; + } +} + @keyframes mmfadeIn { from { opacity: 0; diff --git a/static/templates/confirm_dialog/confirm_deactivate_user.hbs b/static/templates/confirm_dialog/confirm_deactivate_user.hbs index c019fb35b6..7b0930892a 100644 --- a/static/templates/confirm_dialog/confirm_deactivate_user.hbs +++ b/static/templates/confirm_dialog/confirm_deactivate_user.hbs @@ -18,3 +18,30 @@ {{/if}}

+ +
+

+ {{t "Subject" }}: + {{#tr}} + Notification of account deactivation on {realm_name} + {{/tr}} +

+
+

+ {{#tr}} + Your Zulip account on has been deactivated, + and you will no longer be able to log in. + {{#*inline "z-link"}}{{realm_uri}}{{/inline}} + {{/tr}} +

+

{{t "The administrators provided the following comment:" }}

+ +
+
diff --git a/templates/zerver/api/changelog.md b/templates/zerver/api/changelog.md index 5e901ed895..399aa396d1 100644 --- a/templates/zerver/api/changelog.md +++ b/templates/zerver/api/changelog.md @@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with. ## Changes in Zulip 6.0 +**Feature level 135** + +* [`DELETE /user/{user_id}`](/api/deactivate-user): Added + `deactivation_notification_comment` field controlling whether the + user receives a notification email about their deactivation. + **Feature level 134** * [`GET /events`](/api/get-events): Added `user_topic` event type diff --git a/templates/zerver/emails/deactivate.source.html b/templates/zerver/emails/deactivate.source.html new file mode 100644 index 0000000000..87f29813ee --- /dev/null +++ b/templates/zerver/emails/deactivate.source.html @@ -0,0 +1,20 @@ +{% extends "zerver/emails/compiled/email_base_default.html" %} + +{% block illustration %} + +{% endblock %} + +{% block content %} +{% trans %} +Your Zulip account on {{ realm_uri }} has been deactivated, and you will no longer be able to log in. +{% endtrans %} + +

+ +{% if deactivation_notification_comment %} +{{ _("The administrators provided the following comment:") }} + +
{{ deactivation_notification_comment }}
+{% endif %} + +{% endblock %} diff --git a/templates/zerver/emails/deactivate.subject.txt b/templates/zerver/emails/deactivate.subject.txt new file mode 100644 index 0000000000..6f4e6dc440 --- /dev/null +++ b/templates/zerver/emails/deactivate.subject.txt @@ -0,0 +1 @@ +{% trans %}Notification of account deactivation on {{ realm_name }}{% endtrans %} diff --git a/templates/zerver/emails/deactivate.txt b/templates/zerver/emails/deactivate.txt new file mode 100644 index 0000000000..6c96153460 --- /dev/null +++ b/templates/zerver/emails/deactivate.txt @@ -0,0 +1,9 @@ +{% trans %} +Your Zulip account on {{ realm_uri }} has been deactivated, and you will no longer be able to log in. +{% endtrans %} + +{% if deactivation_notification_comment %} +{{ _("The administrators provided the following comment:") }} + +{{ deactivation_notification_comment }} +{% endif %} diff --git a/templates/zerver/emails/email.css b/templates/zerver/emails/email.css index 3cd0ed6e40..ef78febd8e 100644 --- a/templates/zerver/emails/email.css +++ b/templates/zerver/emails/email.css @@ -21,6 +21,11 @@ body { -webkit-text-size-adjust: 100%; } +pre { + font-family: sans-serif; + font-size: 14px; +} + table { border-collapse: separate; mso-table-lspace: 0; @@ -323,6 +328,13 @@ a.button:hover { color: #15c; } +.deactivated-user-text { + padding: 0 0 0 15px; + margin: 0 0 20px; + border-left: 5px solid #eee; + white-space: pre-line; +} + @media only screen and (max-width: 620px) { table[class="body"] h1 { font-size: 28px !important; diff --git a/templates/zerver/help/deactivate-or-reactivate-a-user.md b/templates/zerver/help/deactivate-or-reactivate-a-user.md index a2591fdd78..7533028fad 100644 --- a/templates/zerver/help/deactivate-or-reactivate-a-user.md +++ b/templates/zerver/help/deactivate-or-reactivate-a-user.md @@ -43,6 +43,29 @@ user's bots will also be deactivated. Lastly, the user will be unable to create a new Zulip account in your organization using their deactivated email address. +### Notify users of their deactivation + +Zulip can optionally send the user an email notification that their account was deactivated. + +{start_tabs} + +{settings_tab|user-list-admin} + + 2. Click the **Deactivate** button to the right of the user account that you +want to deactivate. + + 3. Check the checkbox labeled **"Notify this user by email?"**. + + 4. Optional: Enter a custom message for the user in the provided textbox. + + 3. Approve by clicking **Confirm**. + +{end_tabs} + +Here is a sample notification email: + +view-of-admin + ## Reactivate a user Organization administrators can reactivate a deactivated user. The reactivated diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index 110845d0db..a66f95ddaa 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -230,7 +230,7 @@ python_rules = RuleList( rules=[ { "pattern": "subject|SUBJECT", - "exclude_pattern": "subject to the|email|outbox", + "exclude_pattern": "subject to the|email|outbox|account deactivation", "description": "avoid subject as a var", "good_lines": ["topic_name"], "bad_lines": ['subject="foo"', " MAX_SUBJECT_LEN"], diff --git a/version.py b/version.py index e9e42ba49c..21965485ce 100644 --- a/version.py +++ b/version.py @@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3" # Changes should be accompanied by documentation explaining what the # new level means in templates/zerver/api/changelog.md, as well as # "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. -API_FEATURE_LEVEL = 134 +API_FEATURE_LEVEL = 135 # Bump the minor PROVISION_VERSION to indicate that folks should provision # only when going from an old version of the code to a newer version. Bump diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index 1cf2651fea..fdf69ad226 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -8801,6 +8801,21 @@ paths: given their user ID. parameters: - $ref: "#/components/parameters/UserId" + - name: deactivation_notification_comment + in: query + description: | + If not `null`, requests that the deactivated user receive + a notification email about their account deactivation. + + If not `""`, encodes custom text written by the administrator + to be included in the notification email. + + **Changes**: New in Zulip 5.0 (feature level 135). + schema: + type: string + example: | + Farewell! + required: false responses: "200": $ref: "#/components/responses/SimpleSuccess" diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index 0943271718..8849321d31 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -1399,6 +1399,42 @@ class ActivateTest(ZulipTestCase): user = self.example_user("hamlet") self.assertTrue(user.is_active) + def test_email_sent(self) -> None: + self.login("iago") + user = self.example_user("hamlet") + + # Verify no email sent by default. + result = self.client_delete(f"/json/users/{user.id}", dict()) + self.assert_json_success(result) + from django.core.mail import outbox + + self.assert_length(outbox, 0) + user.refresh_from_db() + self.assertFalse(user.is_active) + + # Reactivate user + do_reactivate_user(user, acting_user=None) + user.refresh_from_db() + self.assertTrue(user.is_active) + + # Verify no email sent by default. + result = self.client_delete( + f"/json/users/{user.id}", + dict( + deactivation_notification_comment="Dear Hamlet,\nyou just got deactivated.", + ), + ) + self.assert_json_success(result) + user.refresh_from_db() + self.assertFalse(user.is_active) + + self.assert_length(outbox, 1) + msg = outbox[0] + self.assertEqual(msg.subject, "Notification of account deactivation on Zulip Dev") + self.assert_length(msg.reply_to, 1) + self.assertEqual(msg.reply_to[0], "noreply@testserver") + self.assertIn("Dear Hamlet,", msg.body) + def test_api_with_nonexistent_user(self) -> None: self.login("iago") diff --git a/zerver/views/users.py b/zerver/views/users.py index 4e7beb396c..4d07b49173 100644 --- a/zerver/views/users.py +++ b/zerver/views/users.py @@ -48,6 +48,7 @@ from zerver.lib.integrations import EMBEDDED_BOTS from zerver.lib.rate_limiter import rate_limit_spectator_attachment_access_by_file from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_success +from zerver.lib.send_email import FromAddress, send_email from zerver.lib.streams import access_stream_by_id, access_stream_by_name, subscribed_to_stream from zerver.lib.types import ProfileDataElementUpdateDict, ProfileDataElementValue, Validator from zerver.lib.upload import upload_avatar_image @@ -71,6 +72,7 @@ from zerver.lib.users import ( from zerver.lib.utils import generate_api_key from zerver.lib.validator import ( check_bool, + check_capped_string, check_dict, check_dict_only, check_int, @@ -104,15 +106,28 @@ def check_last_owner(user_profile: UserProfile) -> bool: return user_profile.is_realm_owner and not user_profile.is_bot and len(owners) == 1 +@has_request_variables def deactivate_user_backend( - request: HttpRequest, user_profile: UserProfile, user_id: int + request: HttpRequest, + user_profile: UserProfile, + user_id: int, + deactivation_notification_comment: Optional[str] = REQ( + str_validator=check_capped_string(max_length=2000), default=None + ), ) -> HttpResponse: target = access_user_by_id(user_profile, user_id, for_admin=True) if target.is_realm_owner and not user_profile.is_realm_owner: raise OrganizationOwnerRequired() if check_last_owner(target): raise JsonableError(_("Cannot deactivate the only organization owner")) - return _deactivate_user_profile_backend(request, user_profile, target) + if deactivation_notification_comment is not None: + deactivation_notification_comment = deactivation_notification_comment.strip() + return _deactivate_user_profile_backend( + request, + user_profile, + target, + deactivation_notification_comment=deactivation_notification_comment, + ) def deactivate_user_own_backend(request: HttpRequest, user_profile: UserProfile) -> HttpResponse: @@ -129,13 +144,33 @@ def deactivate_bot_backend( request: HttpRequest, user_profile: UserProfile, bot_id: int ) -> HttpResponse: target = access_bot_by_id(user_profile, bot_id) - return _deactivate_user_profile_backend(request, user_profile, target) + return _deactivate_user_profile_backend( + request, user_profile, target, deactivation_notification_comment=None + ) def _deactivate_user_profile_backend( - request: HttpRequest, user_profile: UserProfile, target: UserProfile + request: HttpRequest, + user_profile: UserProfile, + target: UserProfile, + *, + deactivation_notification_comment: Optional[str], ) -> HttpResponse: do_deactivate_user(target, acting_user=user_profile) + + # It's important that we check for None explicitly here, since "" + # encodes sending an email without a custom administrator comment. + if deactivation_notification_comment is not None: + send_email( + "zerver/emails/deactivate", + to_user_ids=[target.id], + from_address=FromAddress.NOREPLY, + context={ + "deactivation_notification_comment": deactivation_notification_comment, + "realm_uri": target.realm.uri, + "realm_name": target.realm.name, + }, + ) return json_success(request)