invite: Add option to invite user as an organization owner.

We can now invite new users as realm owners. We restrict only
owners to invite new users as owners both for single invite
and multiuse invite link. Also, only owners can revoke or resend
owner invitations.
This commit is contained in:
sahil839 2020-06-18 16:33:06 +05:30 committed by Tim Abbott
parent a052d24231
commit 63389b3bd3
10 changed files with 134 additions and 20 deletions

View File

@ -19,6 +19,7 @@ exports.invited_as_values = new Map([
[1, i18n.t("Member")], [1, i18n.t("Member")],
[2, i18n.t("Organization administrator")], [2, i18n.t("Organization administrator")],
[3, i18n.t("Guest")], [3, i18n.t("Guest")],
[4, i18n.t("Organization owner")],
]); ]);
function add_invited_as_text(invites) { function add_invited_as_text(invites) {
@ -51,6 +52,7 @@ function populate_invites(invites_data) {
modifier: function (item) { modifier: function (item) {
item.invited_absolute_time = timerender.absolute_time(item.invited * 1000); item.invited_absolute_time = timerender.absolute_time(item.invited * 1000);
item.is_admin = page_params.is_admin; item.is_admin = page_params.is_admin;
item.disable_buttons = item.invited_as === 4 && !page_params.is_owner;
return render_admin_invites_list({ invite: item }); return render_admin_invites_list({ invite: item });
}, },
filter: { filter: {

View File

@ -23,11 +23,11 @@
<span>{{invited_as_text}}</span> <span>{{invited_as_text}}</span>
</td> </td>
<td class="actions"> <td class="actions">
<button class="button rounded small revoke btn-danger" data-invite-id="{{id}}" data-is-multiuse="{{is_multiuse}}"> <button class="button rounded small revoke btn-danger" {{#if disable_buttons}}disabled="disabled"{{/if}} data-invite-id="{{id}}" data-is-multiuse="{{is_multiuse}}">
{{t "Revoke" }} {{t "Revoke" }}
</button> </button>
{{#unless is_multiuse}} {{#unless is_multiuse}}
<button class="button rounded small resend btn-warning" data-invite-id="{{id}}"> <button class="button rounded small resend btn-warning" {{#if disable_buttons}}disabled="disabled"{{/if}} data-invite-id="{{id}}">
{{t "Resend" }} {{t "Resend" }}
</button> </button>
{{/unless}} {{/unless}}

View File

@ -10,6 +10,11 @@ below features are supported.
## Changes in Zulip 2.2 ## Changes in Zulip 2.2
**Feature level 20**
* Added support for inviting users as organization owners to the
invitation endpoints.
**Feature level 19** **Feature level 19**
* [`GET /events`](/api/get-events): `subscriptions` event with * [`GET /events`](/api/get-events): `subscriptions` event with

View File

@ -38,6 +38,9 @@
<option name="invite_as" value="{{ invite_as.REALM_ADMIN }}">{{ _('Organization administrators') }}</option> <option name="invite_as" value="{{ invite_as.REALM_ADMIN }}">{{ _('Organization administrators') }}</option>
{% endif %} {% endif %}
<option name="invite_as" value="{{ invite_as.GUEST_USER }}">{{ _('Guests') }}</option> <option name="invite_as" value="{{ invite_as.GUEST_USER }}">{{ _('Guests') }}</option>
{% if is_owner %}
<option name="invite_as" value="{{ invite_as.REALM_OWNER }}">{{ _('Organization owners') }}</option>
{% endif %}
</select> </select>
</div> </div>
</div> </div>

View File

@ -12,7 +12,7 @@ the article below describes each in more detail.
* Share a **reusable invitation link**. * Share a **reusable invitation link**.
The last two, invite-based, techniques also allow you to control the The last two, invite-based, techniques also allow you to control the
[role (admin, member, or guest)](/help/roles-and-permissions) that the [role (owner, admin, member, or guest)](/help/roles-and-permissions) that the
invited people will have. invited people will have.
You can also manage access by You can also manage access by
@ -133,8 +133,9 @@ restrict invites to admins only.
## Manage pending invitations ## Manage pending invitations
Organization administrators can revoke or resend any invitation or reusable Organization owners can revoke or resend any invitation or reusable
invitation link. invitation link. Organization administrators can can do the same
except for invitations for the organization owners role.
{start_tabs} {start_tabs}

View File

@ -29,7 +29,7 @@ DESKTOP_WARNING_VERSION = "5.2.0"
# #
# 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. # new level means in templates/zerver/api/changelog.md.
API_FEATURE_LEVEL = 18 API_FEATURE_LEVEL = 20
# 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

@ -45,7 +45,7 @@ def get_display_email_address(user_profile: UserProfile, realm: Realm) -> str:
return user_profile.delivery_email return user_profile.delivery_email
def get_role_for_new_user(invited_as: int, realm_creation: bool=False) -> int: def get_role_for_new_user(invited_as: int, realm_creation: bool=False) -> int:
if realm_creation: if realm_creation or invited_as == PreregistrationUser.INVITE_AS['REALM_OWNER']:
return UserProfile.ROLE_REALM_OWNER return UserProfile.ROLE_REALM_OWNER
elif invited_as == PreregistrationUser.INVITE_AS['REALM_ADMIN']: elif invited_as == PreregistrationUser.INVITE_AS['REALM_ADMIN']:
return UserProfile.ROLE_REALM_ADMINISTRATOR return UserProfile.ROLE_REALM_ADMINISTRATOR

View File

@ -1355,6 +1355,7 @@ class PreregistrationUser(models.Model):
MEMBER = 1, MEMBER = 1,
REALM_ADMIN = 2, REALM_ADMIN = 2,
GUEST_USER = 3, GUEST_USER = 3,
REALM_OWNER = 4,
) )
invited_as: int = models.PositiveSmallIntegerField(default=INVITE_AS['MEMBER']) invited_as: int = models.PositiveSmallIntegerField(default=INVITE_AS['MEMBER'])

View File

@ -942,11 +942,27 @@ class InviteUserTest(InviteUserBase):
inviter.email, inviter.email,
) )
def test_successful_invite_user_as_owner_from_owner_account(self) -> None:
self.login('desdemona')
invitee = self.nonreg_email('alice')
result = self.invite(invitee, ["Denmark"],
invite_as=PreregistrationUser.INVITE_AS['REALM_OWNER'])
self.assert_json_success(result)
self.assertTrue(find_key_by_email(invitee))
self.submit_reg_form_for_user(invitee, "password")
invitee_profile = self.nonreg_user('alice')
self.assertTrue(invitee_profile.is_realm_owner)
self.assertFalse(invitee_profile.is_guest)
def test_invite_user_as_owner_from_admin_account(self) -> None:
self.login('iago')
invitee = self.nonreg_email('alice')
response = self.invite(invitee, ["Denmark"],
invite_as=PreregistrationUser.INVITE_AS['REALM_OWNER'])
self.assert_json_error(response, "Must be an organization owner")
def test_successful_invite_user_as_admin_from_admin_account(self) -> None: def test_successful_invite_user_as_admin_from_admin_account(self) -> None:
"""
Test that a new user invited to a stream receives some initial
history but only from public streams.
"""
self.login('iago') self.login('iago')
invitee = self.nonreg_email('alice') invitee = self.nonreg_email('alice')
result = self.invite(invitee, ["Denmark"], result = self.invite(invitee, ["Denmark"],
@ -957,13 +973,10 @@ class InviteUserTest(InviteUserBase):
self.submit_reg_form_for_user(invitee, "password") self.submit_reg_form_for_user(invitee, "password")
invitee_profile = self.nonreg_user('alice') invitee_profile = self.nonreg_user('alice')
self.assertTrue(invitee_profile.is_realm_admin) self.assertTrue(invitee_profile.is_realm_admin)
self.assertFalse(invitee_profile.is_realm_owner)
self.assertFalse(invitee_profile.is_guest) self.assertFalse(invitee_profile.is_guest)
def test_invite_user_as_admin_from_normal_account(self) -> None: def test_invite_user_as_admin_from_normal_account(self) -> None:
"""
Test that a new user invited to a stream receives some initial
history but only from public streams.
"""
self.login('hamlet') self.login('hamlet')
invitee = self.nonreg_email('alice') invitee = self.nonreg_email('alice')
response = self.invite(invitee, ["Denmark"], response = self.invite(invitee, ["Denmark"],
@ -1721,6 +1734,26 @@ class InvitationsTestCase(InviteUserBase):
lambda: ScheduledEmail.objects.get(address__iexact=invitee, lambda: ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER)) type=ScheduledEmail.INVITATION_REMINDER))
def test_delete_owner_invitation(self) -> None:
self.login('desdemona')
owner = self.example_user('desdemona')
invitee = "DeleteMe@zulip.com"
self.assert_json_success(self.invite(invitee, ['Denmark'],
invite_as=PreregistrationUser.INVITE_AS['REALM_OWNER']))
prereg_user = PreregistrationUser.objects.get(email=invitee)
result = self.api_delete(self.example_user('iago'),
'/api/v1/invites/' + str(prereg_user.id))
self.assert_json_error(result, "Must be an organization owner")
result = self.api_delete(owner, '/api/v1/invites/' + str(prereg_user.id))
self.assert_json_success(result)
result = self.api_delete(owner, '/api/v1/invites/' + str(prereg_user.id))
self.assert_json_error(result, "No such invitation")
self.assertRaises(ScheduledEmail.DoesNotExist,
lambda: ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER))
def test_delete_multiuse_invite(self) -> None: def test_delete_multiuse_invite(self) -> None:
""" """
A DELETE call to /json/invites/multiuse<ID> should delete the A DELETE call to /json/invites/multiuse<ID> should delete the
@ -1738,6 +1771,18 @@ class InvitationsTestCase(InviteUserBase):
error_result = self.client_delete('/json/invites/multiuse/' + str(multiuse_invite.id)) error_result = self.client_delete('/json/invites/multiuse/' + str(multiuse_invite.id))
self.assert_json_error(error_result, "No such invitation") self.assert_json_error(error_result, "No such invitation")
# Test deleting owner mutiuse_invite.
multiuse_invite = MultiuseInvite.objects.create(referred_by=self.example_user("desdemona"), realm=zulip_realm,
invited_as=PreregistrationUser.INVITE_AS['REALM_OWNER'])
create_confirmation_link(multiuse_invite, Confirmation.MULTIUSE_INVITE)
error_result = self.client_delete('/json/invites/multiuse/' + str(multiuse_invite.id))
self.assert_json_error(error_result, 'Must be an organization owner')
self.login('desdemona')
result = self.client_delete('/json/invites/multiuse/' + str(multiuse_invite.id))
self.assert_json_success(result)
self.assertIsNone(MultiuseInvite.objects.filter(id=multiuse_invite.id).first())
# Test deleting multiuse invite from another realm # Test deleting multiuse invite from another realm
mit_realm = get_realm("zephyr") mit_realm = get_realm("zephyr")
multiuse_invite_in_mit = MultiuseInvite.objects.create(referred_by=self.mit_user("sipbtest"), realm=mit_realm) multiuse_invite_in_mit = MultiuseInvite.objects.create(referred_by=self.mit_user("sipbtest"), realm=mit_realm)
@ -1833,6 +1878,36 @@ class InvitationsTestCase(InviteUserBase):
error_result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend') error_result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend')
self.assert_json_error(error_result, "Must be an organization administrator") self.assert_json_error(error_result, "Must be an organization administrator")
def test_resend_owner_invitation(self) -> None:
self.login("desdemona")
invitee = "resend_owner@zulip.com"
self.assert_json_success(self.invite(invitee, ['Denmark'],
invite_as=PreregistrationUser.INVITE_AS['REALM_OWNER']))
self.check_sent_emails([invitee], custom_from_name="Zulip")
scheduledemail_filter = ScheduledEmail.objects.filter(
address__iexact=invitee, type=ScheduledEmail.INVITATION_REMINDER)
self.assertEqual(scheduledemail_filter.count(), 1)
original_timestamp = scheduledemail_filter.values_list('scheduled_timestamp', flat=True)
# Test only organization owners can resend owner invitation.
self.login('iago')
prereg_user = PreregistrationUser.objects.get(email=invitee)
error_result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend')
self.assert_json_error(error_result, "Must be an organization owner")
self.login('desdemona')
result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend')
self.assert_json_success(result)
self.assertEqual(ScheduledEmail.objects.filter(
address__iexact=invitee, type=ScheduledEmail.INVITATION_REMINDER).count(), 1)
# Check that we have exactly one scheduled email, and that it is different
self.assertEqual(scheduledemail_filter.count(), 1)
self.assertNotEqual(original_timestamp,
scheduledemail_filter.values_list('scheduled_timestamp', flat=True))
def test_accessing_invites_in_another_realm(self) -> None: def test_accessing_invites_in_another_realm(self) -> None:
inviter = UserProfile.objects.exclude(realm=get_realm('zulip')).first() inviter = UserProfile.objects.exclude(realm=get_realm('zulip')).first()
prereg_user = PreregistrationUser.objects.create( prereg_user = PreregistrationUser.objects.create(
@ -2042,6 +2117,20 @@ class MultiuseInviteTest(ZulipTestCase):
result = self.client_post('/json/invites/multiuse') result = self.client_post('/json/invites/multiuse')
self.assert_json_error(result, "Must be an organization administrator") self.assert_json_error(result, "Must be an organization administrator")
def test_multiuse_link_for_inviting_as_owner(self) -> None:
self.login('iago')
result = self.client_post('/json/invites/multiuse',
{"invite_as": ujson.dumps(PreregistrationUser.INVITE_AS['REALM_OWNER'])})
self.assert_json_error(result, "Must be an organization owner")
self.login('desdemona')
result = self.client_post('/json/invites/multiuse',
{"invite_as": ujson.dumps(PreregistrationUser.INVITE_AS['REALM_OWNER'])})
self.assert_json_success(result)
invite_link = result.json()["invite_link"]
self.check_user_able_to_register(self.nonreg_email("test"), invite_link)
def test_create_multiuse_link_invalid_stream_api_call(self) -> None: def test_create_multiuse_link_invalid_stream_api_call(self) -> None:
self.login('iago') self.login('iago')
result = self.client_post('/json/invites/multiuse', result = self.client_post('/json/invites/multiuse',

View File

@ -13,7 +13,7 @@ from zerver.lib.actions import (
do_revoke_multi_use_invite, do_revoke_multi_use_invite,
do_revoke_user_invite, do_revoke_user_invite,
) )
from zerver.lib.exceptions import OrganizationAdministratorRequired from zerver.lib.exceptions import OrganizationAdministratorRequired, OrganizationOwnerRequired
from zerver.lib.request import REQ, JsonableError, has_request_variables from zerver.lib.request import REQ, JsonableError, has_request_variables
from zerver.lib.response import json_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.streams import access_stream_by_id from zerver.lib.streams import access_stream_by_id
@ -21,6 +21,10 @@ from zerver.lib.validator import check_int, check_list
from zerver.models import MultiuseInvite, PreregistrationUser, Stream, UserProfile from zerver.models import MultiuseInvite, PreregistrationUser, Stream, UserProfile
def check_if_owner_required(invited_as: int, user_profile: UserProfile) -> None:
if invited_as == PreregistrationUser.INVITE_AS['REALM_OWNER'] and not user_profile.is_realm_owner:
raise OrganizationOwnerRequired()
@require_member_or_admin @require_member_or_admin
@has_request_variables @has_request_variables
def invite_users_backend(request: HttpRequest, user_profile: UserProfile, def invite_users_backend(request: HttpRequest, user_profile: UserProfile,
@ -34,6 +38,7 @@ def invite_users_backend(request: HttpRequest, user_profile: UserProfile,
raise OrganizationAdministratorRequired() raise OrganizationAdministratorRequired()
if invite_as not in PreregistrationUser.INVITE_AS.values(): if invite_as not in PreregistrationUser.INVITE_AS.values():
return json_error(_("Must be invited as an valid type of user")) return json_error(_("Must be invited as an valid type of user"))
check_if_owner_required(invite_as, user_profile)
if invite_as == PreregistrationUser.INVITE_AS['REALM_ADMIN'] and not user_profile.is_realm_admin: if invite_as == PreregistrationUser.INVITE_AS['REALM_ADMIN'] and not user_profile.is_realm_admin:
return json_error(_("Must be an organization administrator")) return json_error(_("Must be an organization administrator"))
if not invitee_emails_raw: if not invitee_emails_raw:
@ -82,8 +87,10 @@ def revoke_user_invite(request: HttpRequest, user_profile: UserProfile,
if prereg_user.referred_by.realm != user_profile.realm: if prereg_user.referred_by.realm != user_profile.realm:
raise JsonableError(_("No such invitation")) raise JsonableError(_("No such invitation"))
if prereg_user.referred_by_id != user_profile.id and not user_profile.is_realm_admin: if prereg_user.referred_by_id != user_profile.id:
raise JsonableError(_("Must be an organization administrator")) check_if_owner_required(prereg_user.invited_as, user_profile)
if not user_profile.is_realm_admin:
raise JsonableError(_("Must be an organization administrator"))
do_revoke_user_invite(prereg_user) do_revoke_user_invite(prereg_user)
return json_success() return json_success()
@ -101,6 +108,8 @@ def revoke_multiuse_invite(request: HttpRequest, user_profile: UserProfile,
if invite.realm != user_profile.realm: if invite.realm != user_profile.realm:
raise JsonableError(_("No such invitation")) raise JsonableError(_("No such invitation"))
check_if_owner_required(invite.invited_as, user_profile)
do_revoke_multi_use_invite(invite) do_revoke_multi_use_invite(invite)
return json_success() return json_success()
@ -118,8 +127,10 @@ def resend_user_invite_email(request: HttpRequest, user_profile: UserProfile,
if prereg_user.referred_by is None or prereg_user.referred_by.realm != user_profile.realm: if prereg_user.referred_by is None or prereg_user.referred_by.realm != user_profile.realm:
raise JsonableError(_("No such invitation")) raise JsonableError(_("No such invitation"))
if prereg_user.referred_by_id != user_profile.id and not user_profile.is_realm_admin: if prereg_user.referred_by_id != user_profile.id:
raise JsonableError(_("Must be an organization administrator")) check_if_owner_required(prereg_user.invited_as, user_profile)
if not user_profile.is_realm_admin:
raise JsonableError(_("Must be an organization administrator"))
timestamp = do_resend_user_invite_email(prereg_user) timestamp = do_resend_user_invite_email(prereg_user)
return json_success({'timestamp': timestamp}) return json_success({'timestamp': timestamp})
@ -130,6 +141,8 @@ def generate_multiuse_invite_backend(
request: HttpRequest, user_profile: UserProfile, request: HttpRequest, user_profile: UserProfile,
invite_as: int=REQ(validator=check_int, default=PreregistrationUser.INVITE_AS['MEMBER']), invite_as: int=REQ(validator=check_int, default=PreregistrationUser.INVITE_AS['MEMBER']),
stream_ids: Sequence[int]=REQ(validator=check_list(check_int), default=[])) -> HttpResponse: stream_ids: Sequence[int]=REQ(validator=check_list(check_int), default=[])) -> HttpResponse:
check_if_owner_required(invite_as, user_profile)
streams = [] streams = []
for stream_id in stream_ids: for stream_id in stream_ids:
try: try: