Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI option to subscribe existing accounts during bulk invites. #16041

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions static/js/invite.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,54 @@ function beforeSend() {
return true;
}

function subscribe_existing_accounts(subscriber_stream_names, subscriber_emails) {
const invite_status = $("#invite_status");
const subscriber_user_ids = [];
subscriber_emails.forEach((email) => {
const person = people.get_by_email(email);
subscriber_user_ids.push(person.user_id);
});

function success(data) {
// At this point one of these three things could have happen.
// 1. All the users were newly subscribed to the selected streams.
// 2. All the users were already part of selected streams.
// 3. Some users got newly subscribed, while some others were already subscribed.
const all_newly_subscribed = !Object.entries(data.already_subscribed).length;
const all_already_subscribed = !Object.entries(data.subscribed).length;
if (all_newly_subscribed) {
ui_report.success(i18n.t("User(s) subscribed successfully."), invite_status);
} else if (all_already_subscribed) {
ui_report.success(
i18n.t("User(s) already subscribed to the selected stream(s)."),
invite_status,
);
} else {
ui_report.success(
i18n.t(
"Some of those addresses were already subscribed to the selected stream(s). We subscribed everyone else!",
),
invite_status,
);
}
}

function failure(xhr) {
// Some fatal error occured.
ui_report.error("", xhr, invite_status);
}

channel.post({
url: "/json/users/me/subscriptions",
data: {
subscriptions: JSON.stringify(subscriber_stream_names),
principals: JSON.stringify(subscriber_user_ids),
},
success,
error: failure,
});
}

function submit_invitation_form() {
const invite_status = $("#invite_status");
const invitee_emails = $("#invitee_emails");
Expand Down Expand Up @@ -72,29 +120,45 @@ function submit_invitation_form() {
} else {
// Some users were not invited.
const invitee_emails_errored = [];
const subscriber_emails_errored = [];
const subscriber_stream_names = [];
const subscriber_stream_ids = JSON.parse(data.stream_ids);
const error_list = [];
let is_invitee_deactivated = false;
arr.errors.forEach((value) => {
const [email, error_message, deactivated] = value;
const [email, error_message, deactivated, maybe_anonymous_email] = value;
error_list.push(`${email}: ${error_message}`);
if (deactivated) {
is_invitee_deactivated = true;
} else {
// If they aren't deactivated, they can still be subscribed.
subscriber_emails_errored.push(maybe_anonymous_email);
}
invitee_emails_errored.push(email);
});

subscriber_stream_ids.forEach((stream_id) => {
subscriber_stream_names.push({name: stream_data.get_sub_by_id(stream_id).name});
});
const error_response = render_invitation_failed_error({
error_message: arr.msg,
error_list,
is_admin: page_params.is_admin,
is_invitee_deactivated,
show_subscription:
arr.show_subscription && subscriber_emails_errored.length > 0,
subscriber_emails_errored,
subscriber_stream_names,
});
ui_report.message(error_response, invite_status, "alert-warning");
invitee_emails_group.addClass("warning");

if (arr.sent_invitations) {
invitee_emails.val(invitee_emails_errored.join("\n"));
}

$("#subscribe_existing_accounts").on("click", () => {
subscribe_existing_accounts(subscriber_stream_names, subscriber_emails_errored);
});
}
},
complete() {
Expand Down
10 changes: 10 additions & 0 deletions static/styles/zulip.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2193,6 +2193,16 @@ div.topic_edit_spinner .loading_indicator_spinner {
}
}

#subscribe_existing_accounts {
position: relative;
left: 270px;
background-color: hsl(50, 81%, 94%);

&:hover {
background-color: hsl(50, 36%, 80%);
}
}

#invite_status {
display: none;
}
Expand Down
11 changes: 11 additions & 0 deletions static/templates/invitation_failed_error.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,14 @@
<p id="invitation_non_admin_message">{{t "Organization administrators can reactivate deactivated users." }}</p>
{{/if}}
{{/if}}
{{#if show_subscription}}
<p id="subscribe_anyway_message">{{t "To subscribe the existing user(s) to the following stream(s), click on
the button below." }}</p>
<ul>
{{#each subscriber_stream_names}}
<li>#{{this.name}}</li>
{{/each}}
</ul>
<button id="subscribe_existing_accounts" class="button small round" type="button">
{{t "Subscribe existing accounts" }}</button>
{{/if}}
6 changes: 6 additions & 0 deletions templates/zerver/api/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ below features are supported.

## Changes in Zulip 4.0

**Feature level 33**

* `POST /invites`: Now returns `show_subscription` as well as the
anonymous email (4th parameter of the `errors` tuple) when raising
an InvitationError.

**Feature level 32**

* [`GET /events`](/api/get-events): Added `op` field to
Expand Down
2 changes: 1 addition & 1 deletion version.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
#
# Changes should be accompanied by documentation explaining what the
# new level means in templates/zerver/api/changelog.md.
API_FEATURE_LEVEL = 32
API_FEATURE_LEVEL = 33

# 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
Expand Down
23 changes: 12 additions & 11 deletions zerver/lib/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5147,13 +5147,14 @@ def email_not_system_bot(email: str) -> None:

class InvitationError(JsonableError):
code = ErrorCode.INVITATION_FAILED
data_fields = ['errors', 'sent_invitations']
data_fields = ['errors', 'sent_invitations', 'show_subscription']

def __init__(self, msg: str, errors: List[Tuple[str, str, bool]],
sent_invitations: bool) -> None:
def __init__(self, msg: str, errors: List[Tuple[str, str, bool, str]],
sent_invitations: bool, show_subscription: bool=False) -> None:
self._msg: str = msg
self.errors: List[Tuple[str, str, bool]] = errors
self.errors: List[Tuple[str, str, bool, str]] = errors
self.sent_invitations: bool = sent_invitations
self.show_subscription: bool = show_subscription

def estimate_recent_invites(realms: Iterable[Realm], *, days: int) -> int:
'''An upper bound on the number of invites sent in the last `days` days'''
Expand Down Expand Up @@ -5220,7 +5221,7 @@ def do_invite_users(user_profile: UserProfile,
[], sent_invitations=False)

good_emails: Set[str] = set()
errors: List[Tuple[str, str, bool]] = []
errors: List[Tuple[str, str, bool, str]] = []
validate_email_allowed_in_realm = get_realm_email_validator(user_profile.realm)
for email in invitee_emails:
if email == '':
Expand All @@ -5231,7 +5232,7 @@ def do_invite_users(user_profile: UserProfile,
)

if email_error:
errors.append((email, email_error, False))
errors.append((email, email_error, False, ''))
else:
good_emails.add(email)

Expand All @@ -5242,10 +5243,10 @@ def do_invite_users(user_profile: UserProfile,
'''
error_dict = get_existing_user_errors(user_profile.realm, good_emails)

skipped: List[Tuple[str, str, bool]] = []
skipped: List[Tuple[str, str, bool, str]] = []
for email in error_dict:
msg, deactivated = error_dict[email]
skipped.append((email, msg, deactivated))
msg, deactivated, maybe_anonymous_email = error_dict[email]
skipped.append((email, msg, deactivated, maybe_anonymous_email))
good_emails.remove(email)

validated_emails = list(good_emails)
Expand All @@ -5258,7 +5259,7 @@ def do_invite_users(user_profile: UserProfile,
if skipped and len(skipped) == len(invitee_emails):
# All e-mails were skipped, so we didn't actually invite anyone.
raise InvitationError(_("We weren't able to invite anyone."),
skipped, sent_invitations=False)
skipped, sent_invitations=False, show_subscription=True)

# We do this here rather than in the invite queue processor since this
# is used for rate limiting invitations, rather than keeping track of
Expand All @@ -5284,7 +5285,7 @@ def do_invite_users(user_profile: UserProfile,
raise InvitationError(_("Some of those addresses are already using Zulip, "
"so we didn't send them an invitation. We did send "
"invitations to everyone else!"),
skipped, sent_invitations=True)
skipped, sent_invitations=True, show_subscription=True)
notify_invites_changed(user_profile)

def do_get_user_invites(user_profile: UserProfile) -> List[Dict[str, Any]]:
Expand Down
15 changes: 10 additions & 5 deletions zerver/lib/email_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def get_existing_user_errors(
target_realm: Realm,
emails: Set[str],
verbose: bool=False,
) -> Dict[str, Tuple[str, bool]]:
) -> Dict[str, Tuple[str, bool, str]]:
'''
We use this function even for a list of one emails.

Expand All @@ -128,7 +128,7 @@ def get_existing_user_errors(
to cross-realm bots and mirror dummies too.
'''

errors: Dict[str, Tuple[str, bool]] = {}
errors: Dict[str, Tuple[str, bool, str]] = {}

users = get_users_by_delivery_email(emails, target_realm).only(
'delivery_email',
Expand All @@ -153,7 +153,7 @@ def process_email(email: str) -> None:
else:
msg = _('Reserved for system bots.')
deactivated = False
errors[email] = (msg, deactivated)
errors[email] = (msg, deactivated, email)
return

existing_user_profile = user_dict.get(email.lower())
Expand All @@ -180,7 +180,12 @@ def process_email(email: str) -> None:
else:
msg = _("Account has been deactivated.")

errors[email] = (msg, deactivated)
'''
The existing_user_profile.email sends back the anonymous emails (different from
delivery_email depending on EMAIL_ADDRESS_VISIBILITY_EVERYONE) so that the clients
can continue to subscribe these existing users if they wish to.
'''
errors[email] = (msg, deactivated, existing_user_profile.email)

for email in emails:
process_email(email)
Expand All @@ -204,5 +209,5 @@ def validate_email_not_already_in_realm(target_realm: Realm,
# Loop through errors, the only key should be our email.
for key, error_info in error_dict.items():
assert key == email
msg, deactivated = error_info
msg, deactivated, _ = error_info
raise ValidationError(msg)
16 changes: 14 additions & 2 deletions zerver/tests/test_auth_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -4998,18 +4998,30 @@ def test_validate_email(self) -> None:
self.assertIn('containing + are not allowed', error)

cordelia_email = cordelia.delivery_email
cordelia_anonymous_email = cordelia.email
errors = get_existing_user_errors(realm, {cordelia_email})
error, is_deactivated = errors[cordelia_email]
error, is_deactivated, maybe_anonymous_email = errors[cordelia_email]
self.assertEqual(False, is_deactivated)
self.assertEqual(error, 'Already has an account.')
self.assertEqual(maybe_anonymous_email, cordelia_anonymous_email)

do_set_realm_property(realm, 'email_address_visibility', Realm.EMAIL_ADDRESS_VISIBILITY_EVERYONE)
cordelia.refresh_from_db()

errors = get_existing_user_errors(realm, {cordelia_email})
error, is_deactivated, maybe_anonymous_email = errors[cordelia_email]
self.assertEqual(False, is_deactivated)
self.assertEqual(error, 'Already has an account.')
self.assertEqual(maybe_anonymous_email, cordelia_email)

cordelia.is_active = False
cordelia.save()

errors = get_existing_user_errors(realm, {cordelia_email})
error, is_deactivated = errors[cordelia_email]
error, is_deactivated, maybe_anonymous_email = errors[cordelia_email]
self.assertEqual(True, is_deactivated)
self.assertEqual(error, 'Account has been deactivated.')
self.assertEqual(maybe_anonymous_email, cordelia_email)

errors = get_existing_user_errors(realm, {'fred-is-fine@zulip.com'})
self.assertEqual(errors, {})
Expand Down