mirror of https://github.com/zulip/zulip.git
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:
parent
b8a760b14e
commit
0a278c39d2
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
|
@ -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: () => {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
|
@ -0,0 +1 @@
|
||||||
|
{% trans %}Notification of account deactivation on {{ realm_name }}{% endtrans %}
|
|
@ -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 %}
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue