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
{{t "The administrators provided the following comment:" }}
+ +
+{% endblock %}
+
+{% block content %}
+{% trans %}
+Your Zulip account on {{ realm_uri }} has been deactivated, and you will no longer be able to log in.
+{% endtrans %}
+
+{{ 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:
+
+
+
## 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)