settings: Send email after deactivating user.

This adds a feature where an admin can choose to send an email
with custom content to an user after they deactivated them.

Fixes #18943.
This commit is contained in:
Julia Bichler 2021-11-27 15:26:09 +01:00 committed by Tim Abbott
parent b8a760b14e
commit 0a278c39d2
16 changed files with 238 additions and 6 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -118,6 +118,10 @@ export function launch(conf) {
} }
const $submit_button = $dialog.find(".dialog_submit_button"); 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 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 // 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); 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", { overlays.open_modal("dialog_widget_modal", {
autoremove: true, autoremove: true,
on_show: () => { on_show: () => {

View File

@ -21,6 +21,7 @@ import * as settings_bots from "./settings_bots";
import * as settings_config from "./settings_config"; import * as settings_config from "./settings_config";
import * as settings_data from "./settings_data"; import * as settings_data from "./settings_data";
import * as settings_panel_menu from "./settings_panel_menu"; import * as settings_panel_menu from "./settings_panel_menu";
import * as settings_ui from "./settings_ui";
import * as timerender from "./timerender"; import * as timerender from "./timerender";
import * as ui from "./ui"; import * as ui from "./ui";
import * as user_pill from "./user_pill"; 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 bots_owned_by_user = bot_data.get_all_bots_owned_by_user(user_id);
const user = people.get_by_user_id(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 = { const opts = {
username: user.full_name, username: user.full_name,
email: settings_data.email_for_user_settings(user), email: settings_data.email_for_user_settings(user),
bots_owned_by_user, bots_owned_by_user,
number_of_invites_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); const html_body = render_settings_deactivation_user_modal(opts);
@ -479,6 +485,17 @@ function handle_deactivation($tbody) {
function handle_confirm() { function handle_confirm() {
const url = "/json/users/" + encodeURIComponent(user_id); const url = "/json/users/" + encodeURIComponent(user_id);
dialog_widget.submit_api_request(channel.del, url); 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); confirm_deactivation(user_id, handle_confirm, true);

View File

@ -124,6 +124,25 @@
margin-bottom: 10px; 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 { @keyframes mmfadeIn {
from { from {
opacity: 0; opacity: 0;

View File

@ -18,3 +18,30 @@
</ul> </ul>
{{/if}} {{/if}}
</p> </p>
<label class="checkbox">
<input type="checkbox" class="send_email" data-key="{{ key }}" />
{{#tr}}
Notify this user by email?
{{/tr}}
<span></span>
{{> ../help_link_widget link="/help/deactivate-or-reactivate-a-user#notify-users-of-their-deactivation" }}
</label>
<div class="email_field">
<p class="border-top">
<strong>{{t "Subject" }}:</strong>
{{#tr}}
Notification of account deactivation on {realm_name}
{{/tr}}
</p>
<div class="email-body">
<p>
{{#tr}}
Your Zulip account on <z-link></z-link> has been deactivated,
and you will no longer be able to log in.
{{#*inline "z-link"}}<a href="{{realm_uri}}">{{realm_uri}}</a>{{/inline}}
{{/tr}}
</p>
<p>{{t "The administrators provided the following comment:" }}</p>
<textarea class="email_field_textarea" rows="8" maxlength="2000"></textarea>
</div>
</div>

View File

@ -20,6 +20,12 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 6.0 ## 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** **Feature level 134**
* [`GET /events`](/api/get-events): Added `user_topic` event type * [`GET /events`](/api/get-events): Added `user_topic` event type

View File

@ -0,0 +1,20 @@
{% extends "zerver/emails/compiled/email_base_default.html" %}
{% block illustration %}
<img src="{{ email_images_base_uri }}/email_logo.png" alt=""/>
{% endblock %}
{% block content %}
{% trans %}
Your Zulip account on <a href="{{ realm_uri }}">{{ realm_uri }}</a> has been deactivated, and you will no longer be able to log in.
{% endtrans %}
<br/><br/>
{% if deactivation_notification_comment %}
{{ _("The administrators provided the following comment:") }}
<pre class="deactivated-user-text">{{ deactivation_notification_comment }}</pre>
{% endif %}
{% endblock %}

View File

@ -0,0 +1 @@
{% trans %}Notification of account deactivation on {{ realm_name }}{% endtrans %}

View File

@ -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 %}

View File

@ -21,6 +21,11 @@ body {
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
pre {
font-family: sans-serif;
font-size: 14px;
}
table { table {
border-collapse: separate; border-collapse: separate;
mso-table-lspace: 0; mso-table-lspace: 0;
@ -323,6 +328,13 @@ a.button:hover {
color: #15c; 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) { @media only screen and (max-width: 620px) {
table[class="body"] h1 { table[class="body"] h1 {
font-size: 28px !important; font-size: 28px !important;

View File

@ -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 create a new Zulip account in your organization using their deactivated
email address. 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:
<img src="/static/images/help/deactivate-user-email.png" alt="view-of-admin" width="800"/>
## Reactivate a user ## Reactivate a user
Organization administrators can reactivate a deactivated user. The reactivated Organization administrators can reactivate a deactivated user. The reactivated

View File

@ -230,7 +230,7 @@ python_rules = RuleList(
rules=[ rules=[
{ {
"pattern": "subject|SUBJECT", "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", "description": "avoid subject as a var",
"good_lines": ["topic_name"], "good_lines": ["topic_name"],
"bad_lines": ['subject="foo"', " MAX_SUBJECT_LEN"], "bad_lines": ['subject="foo"', " MAX_SUBJECT_LEN"],

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.4.3"
# Changes should be accompanied by documentation explaining what the # Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md, as well as # new level means in templates/zerver/api/changelog.md, as well as
# "**Changes**" entries in the endpoint's documentation in `zulip.yaml`. # "**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 # 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 # only when going from an old version of the code to a newer version. Bump

View File

@ -8801,6 +8801,21 @@ paths:
given their user ID. given their user ID.
parameters: parameters:
- $ref: "#/components/parameters/UserId" - $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: responses:
"200": "200":
$ref: "#/components/responses/SimpleSuccess" $ref: "#/components/responses/SimpleSuccess"

View File

@ -1399,6 +1399,42 @@ class ActivateTest(ZulipTestCase):
user = self.example_user("hamlet") user = self.example_user("hamlet")
self.assertTrue(user.is_active) 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: def test_api_with_nonexistent_user(self) -> None:
self.login("iago") self.login("iago")

View File

@ -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.rate_limiter import rate_limit_spectator_attachment_access_by_file
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success 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.streams import access_stream_by_id, access_stream_by_name, subscribed_to_stream
from zerver.lib.types import ProfileDataElementUpdateDict, ProfileDataElementValue, Validator from zerver.lib.types import ProfileDataElementUpdateDict, ProfileDataElementValue, Validator
from zerver.lib.upload import upload_avatar_image 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.utils import generate_api_key
from zerver.lib.validator import ( from zerver.lib.validator import (
check_bool, check_bool,
check_capped_string,
check_dict, check_dict,
check_dict_only, check_dict_only,
check_int, 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 return user_profile.is_realm_owner and not user_profile.is_bot and len(owners) == 1
@has_request_variables
def deactivate_user_backend( 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: ) -> HttpResponse:
target = access_user_by_id(user_profile, user_id, for_admin=True) target = access_user_by_id(user_profile, user_id, for_admin=True)
if target.is_realm_owner and not user_profile.is_realm_owner: if target.is_realm_owner and not user_profile.is_realm_owner:
raise OrganizationOwnerRequired() raise OrganizationOwnerRequired()
if check_last_owner(target): if check_last_owner(target):
raise JsonableError(_("Cannot deactivate the only organization owner")) 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: 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 request: HttpRequest, user_profile: UserProfile, bot_id: int
) -> HttpResponse: ) -> HttpResponse:
target = access_bot_by_id(user_profile, bot_id) 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( 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: ) -> HttpResponse:
do_deactivate_user(target, acting_user=user_profile) 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) return json_success(request)