diff --git a/app/__init__.py b/app/__init__.py
index ecf6aea52..2cd412af4 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -52,6 +52,7 @@ from app.notify_client.provider_client import ProviderClient
from app.notify_client.email_branding_client import EmailBrandingClient
from app.notify_client.models import AnonymousUser
from app.notify_client.organisations_api_client import OrganisationsClient
+from app.notify_client.org_invite_api_client import OrgInviteApiClient
from app.notify_client.letter_jobs_client import LetterJobsClient
from app.notify_client.inbound_number_client import InboundNumberClient
from app.notify_client.billing_api_client import BillingAPIClient
@@ -74,6 +75,7 @@ events_api_client = EventsApiClient()
provider_client = ProviderClient()
email_branding_client = EmailBrandingClient()
organisations_client = OrganisationsClient()
+org_invite_api_client = OrgInviteApiClient()
asset_fingerprinter = AssetFingerprinter()
statsd_client = StatsdClient()
deskpro_client = DeskproClient()
@@ -109,6 +111,7 @@ def create_app(application):
notification_api_client.init_app(application)
status_api_client.init_app(application)
invite_api_client.init_app(application)
+ org_invite_api_client.init_app(application)
template_statistics_client.init_app(application)
events_api_client.init_app(application)
provider_client.init_app(application)
diff --git a/app/config.py b/app/config.py
index fdb87bad0..c2b0185ea 100644
--- a/app/config.py
+++ b/app/config.py
@@ -48,7 +48,7 @@ class Config(object):
HTTP_PROTOCOL = 'http'
MAX_FAILED_LOGIN_COUNT = 10
NOTIFY_APP_NAME = 'admin'
- NOTIFY_LOG_LEVEL = 'DEBUG'
+ NOTIFY_LOG_LEVEL = 'ERROR'
PERMANENT_SESSION_LIFETIME = 20 * 60 * 60 # 20 hours
SEND_FILE_MAX_AGE_DEFAULT = 365 * 24 * 60 * 60 # 1 year
SESSION_COOKIE_HTTPONLY = True
diff --git a/app/main/forms.py b/app/main/forms.py
index 442809940..4d7d7a9e5 100644
--- a/app/main/forms.py
+++ b/app/main/forms.py
@@ -237,6 +237,25 @@ class RegisterUserFromInviteForm(StripWhitespaceForm):
raise ValidationError('Can’t be empty')
+class RegisterUserFromOrgInviteForm(StripWhitespaceForm):
+ def __init__(self, invited_org_user):
+ super().__init__(
+ organisation=invited_org_user['organisation'],
+ email_address=invited_org_user['email_address'],
+ )
+
+ name = StringField(
+ 'Full name',
+ validators=[DataRequired(message='Can’t be empty')]
+ )
+
+ mobile_number = InternationalPhoneNumber('Mobile number', validators=[DataRequired(message='Can’t be empty')])
+ password = password()
+ organisation = HiddenField('organisation')
+ email_address = HiddenField('email_address')
+ auth_type = HiddenField('auth_type', validators=[DataRequired()])
+
+
class PermissionsForm(StripWhitespaceForm):
send_messages = BooleanField("Send messages from existing templates")
manage_templates = BooleanField("Add and edit templates")
@@ -264,6 +283,18 @@ class InviteUserForm(PermissionsForm):
raise ValidationError("You can’t send an invitation to yourself")
+class InviteOrgUserForm(StripWhitespaceForm):
+ email_address = email_address(gov_user=False)
+
+ def __init__(self, invalid_email_address, *args, **kwargs):
+ super(InviteOrgUserForm, self).__init__(*args, **kwargs)
+ self.invalid_email_address = invalid_email_address.lower()
+
+ def validate_email_address(self, field):
+ if field.data.lower() == self.invalid_email_address:
+ raise ValidationError("You can’t send an invitation to yourself")
+
+
class TwoFactorForm(StripWhitespaceForm):
def __init__(self, validate_code_func, *args, **kwargs):
'''
@@ -419,7 +450,7 @@ class ChangeEmailForm(StripWhitespaceForm):
def validate_email_address(self, field):
is_valid = self.validate_email_func(field.data)
- if not is_valid:
+ if is_valid:
raise ValidationError("The email address is already in use")
diff --git a/app/main/views/invites.py b/app/main/views/invites.py
index 2da96a154..8575eb02e 100644
--- a/app/main/views/invites.py
+++ b/app/main/views/invites.py
@@ -4,38 +4,23 @@ from flask import (
session,
flash,
render_template,
- abort,
- current_app
+ abort
)
-from itsdangerous import SignatureExpired
from markupsafe import Markup
-from notifications_utils.url_safe_token import check_token
from flask_login import current_user
from app.main import main
from app import (
invite_api_client,
+ org_invite_api_client,
user_api_client,
+ organisations_client,
service_api_client
)
@main.route("/invitation/")
def accept_invite(token):
- try:
- check_token(
- token,
- current_app.config['SECRET_KEY'],
- current_app.config['DANGEROUS_SALT'],
- current_app.config['INVITATION_EXPIRY_SECONDS']
- )
- except SignatureExpired:
- errors = [
- 'Your invitation to GOV.UK Notify has expired. '
- 'Please ask the person that invited you to send you another one'
- ]
- return render_template("error/400.html", message=errors), 400
-
invited_user = invite_api_client.check_token(token)
if not current_user.is_anonymous and current_user.email_address.lower() != invited_user.email_address.lower():
@@ -88,3 +73,44 @@ def accept_invite(token):
return redirect(url_for('main.service_dashboard', service_id=invited_user.service))
else:
return redirect(url_for('main.register_from_invite'))
+
+
+@main.route("/organisation-invitation/")
+def accept_org_invite(token):
+ invited_org_user = org_invite_api_client.check_token(token)
+ if not current_user.is_anonymous and current_user.email_address.lower() != invited_org_user.email_address.lower():
+ message = Markup("""
+ You’re signed in as {}.
+ This invite is for another email address.
+ Sign out and click the link again to accept this invite.
+ """.format(
+ current_user.email_address,
+ url_for("main.sign_out", _external=True)))
+
+ flash(message=message)
+
+ abort(403)
+
+ if invited_org_user.status == 'cancelled':
+ invited_by = user_api_client.get_user(invited_org_user.invited_by)
+ organisation = organisations_client.get_organisation(invited_org_user.organisation)
+ return render_template('views/cancelled-invitation.html',
+ from_user=invited_by.name,
+ organisation_name=organisation['name'])
+
+ if invited_org_user.status == 'accepted':
+ session.pop('invited_org_user', None)
+ return redirect(url_for('main.organisation_dashboard', org_id=invited_org_user.organisation))
+
+ session['invited_org_user'] = invited_org_user.serialize()
+
+ existing_user = user_api_client.get_user_by_email_or_none(invited_org_user.email_address)
+ organisation_users = user_api_client.get_users_for_organisation(invited_org_user.organisation)
+
+ if existing_user:
+ org_invite_api_client.accept_invite(invited_org_user.organisation, invited_org_user.id)
+ if existing_user not in organisation_users:
+ user_api_client.add_user_to_organisation(invited_org_user.organisation, existing_user.id)
+ return redirect(url_for('main.organisation_dashboard', org_id=invited_org_user.organisation))
+ else:
+ return redirect(url_for('main.register_from_org_invite'))
diff --git a/app/main/views/organisations.py b/app/main/views/organisations.py
index 54ac461cb..c210b6639 100644
--- a/app/main/views/organisations.py
+++ b/app/main/views/organisations.py
@@ -1,11 +1,31 @@
-from flask import redirect, render_template, url_for
-from flask_login import login_required
+from flask import (
+ redirect,
+ render_template,
+ url_for,
+ flash,
+ request
+)
+from flask_login import (
+ login_required,
+ current_user
+)
-from app import organisations_client
+from app import (
+ organisations_client,
+ org_invite_api_client,
+ user_api_client,
+)
+from app.main.forms import (
+ SearchUsersForm,
+ InviteOrgUserForm,
+)
from app.main import main
from app.main.forms import CreateOrUpdateOrganisation
from app.utils import user_has_permissions
+from notifications_python_client.errors import HTTPError
+from werkzeug.exceptions import abort
+
@main.route("/organisations", methods=['GET'])
@login_required
@@ -38,7 +58,7 @@ def add_organisation():
)
-@main.route("/organisation/", methods=['GET'])
+@main.route("/organisations/", methods=['GET'])
@login_required
@user_has_permissions(admin_override=True)
def organisation_dashboard(org_id):
@@ -50,7 +70,7 @@ def organisation_dashboard(org_id):
)
-@main.route("/organisation//edit", methods=['GET', 'POST'])
+@main.route("/organisations//edit", methods=['GET', 'POST'])
@login_required
@user_has_permissions(admin_override=True)
def update_organisation(org_id):
@@ -75,11 +95,96 @@ def update_organisation(org_id):
)
-@main.route("/organisation//users", methods=['GET'])
+@main.route("/organisations//users", methods=['GET'])
@login_required
@user_has_permissions(admin_override=True)
-def organisation_users(org_id):
+def manage_org_users(org_id):
+ users = sorted(
+ user_api_client.get_users_for_organisation(org_id=org_id) + [
+ invite for invite in org_invite_api_client.get_invites_for_organisation(org_id=org_id)
+ if invite.status != 'accepted'
+ ],
+ key=lambda user: user.email_address,
+ )
return render_template(
'views/organisations/organisation/users/index.html',
+ users=users,
+ show_search_box=(len(users) > 7),
+ form=SearchUsersForm(),
)
+
+
+@main.route("/organisations//users/invite", methods=['GET', 'POST'])
+@login_required
+@user_has_permissions(admin_override=True)
+def invite_org_user(org_id):
+ form = InviteOrgUserForm(
+ invalid_email_address=current_user.email_address
+ )
+ if form.validate_on_submit():
+ email_address = form.email_address.data
+ invited_org_user = org_invite_api_client.create_invite(
+ current_user.id,
+ org_id,
+ email_address
+ )
+
+ flash('Invite sent to {}'.format(invited_org_user.email_address), 'default_with_tick')
+ return redirect(url_for('.manage_org_users', org_id=org_id))
+
+ return render_template(
+ 'views/organisations/organisation/users/invite-org-user.html',
+ form=form
+ )
+
+
+@main.route("/organisations//users/", methods=['GET', 'POST'])
+@login_required
+@user_has_permissions(admin_override=True)
+def edit_user_org_permissions(org_id, user_id):
+ user = user_api_client.get_user(user_id)
+
+ return render_template(
+ 'views/organisations/organisation/users/user/index.html',
+ user=user
+ )
+
+
+@main.route("/organisations//users//delete", methods=['GET', 'POST'])
+@login_required
+@user_has_permissions(admin_override=True)
+def remove_user_from_organisation(org_id, user_id):
+ user = user_api_client.get_user(user_id)
+ if request.method == 'POST':
+ try:
+ organisations_client.remove_user_from_organisation(org_id, user_id)
+ except HTTPError as e:
+ msg = "You cannot remove the only user for a service"
+ if e.status_code == 400 and msg in e.message:
+ flash(msg, 'info')
+ return redirect(url_for(
+ '.manage_org_users',
+ org_id=org_id))
+ else:
+ abort(500, e)
+
+ return redirect(url_for(
+ '.manage_org_users',
+ org_id=org_id
+ ))
+
+ flash('Are you sure you want to remove {}?'.format(user.name), 'remove')
+ return render_template(
+ 'views/organisations/organisation/users/user/index.html',
+ user=user,
+ )
+
+
+@main.route("/organisations//cancel-invited-user/", methods=['GET'])
+@login_required
+@user_has_permissions(admin_override=True)
+def cancel_invited_org_user(org_id, invited_user_id):
+ org_invite_api_client.cancel_invited_user(org_id=org_id, invited_user_id=invited_user_id)
+
+ return redirect(url_for('main.manage_org_users', org_id=org_id))
diff --git a/app/main/views/register.py b/app/main/views/register.py
index 0bdcf3ca4..c3e4754d4 100644
--- a/app/main/views/register.py
+++ b/app/main/views/register.py
@@ -17,13 +17,15 @@ from app.main import main
from app.main.forms import (
RegisterUserForm,
- RegisterUserFromInviteForm
+ RegisterUserFromInviteForm,
+ RegisterUserFromOrgInviteForm
)
from app.main.views.verify import activate_user
from app import (
user_api_client,
- invite_api_client
+ invite_api_client,
+ org_invite_api_client
)
@@ -65,20 +67,42 @@ def register_from_invite():
return render_template('views/register-from-invite.html', invited_user=invited_user, form=form)
-def _do_registration(form, send_sms=True, send_email=True):
- if user_api_client.is_email_unique(form.email_address.data):
+@main.route('/register-from-org-invite', methods=['GET', 'POST'])
+def register_from_org_invite():
+ invited_org_user = session.get('invited_org_user')
+ if not invited_org_user:
+ abort(404)
+
+ form = RegisterUserFromOrgInviteForm(
+ invited_org_user,
+ )
+ form.auth_type.data = 'sms_auth'
+
+ if form.validate_on_submit():
+ if (form.organisation.data != invited_org_user['organisation'] or
+ form.email_address.data != invited_org_user['email_address']):
+ abort(400)
+ _do_registration(form, send_email=False, send_sms=True, organisation_id=invited_org_user['organisation'])
+ org_invite_api_client.accept_invite(invited_org_user['organisation'], invited_org_user['id'])
+ user_api_client.add_user_to_organisation(invited_org_user['organisation'], session['user_details']['id'])
+
+ return redirect(url_for('main.verify'))
+ return render_template('views/register-from-org-invite.html', invited_org_user=invited_org_user, form=form)
+
+
+def _do_registration(form, send_sms=True, send_email=True, organisation_id=None):
+ if user_api_client.is_email_already_in_use(form.email_address.data):
+ user = user_api_client.get_user_by_email(form.email_address.data)
+ if send_email:
+ user_api_client.send_already_registered_email(user.id, user.email_address)
+ session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1))
+ session['user_details'] = {"email": user.email_address, "id": user.id}
+ else:
user = user_api_client.register_user(form.name.data,
form.email_address.data,
form.mobile_number.data or None,
form.password.data,
form.auth_type.data)
-
- # TODO possibly there should be some exception handling
- # for sending sms and email codes.
- # How do we report to the user there is a problem with
- # sending codes apart from service unavailable?
- # at the moment i believe http 500 is fine.
-
if send_email:
user_api_client.send_verify_email(user.id, user.email_address)
@@ -86,12 +110,8 @@ def _do_registration(form, send_sms=True, send_email=True):
user_api_client.send_verify_code(user.id, 'sms', user.mobile_number)
session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1))
session['user_details'] = {"email": user.email_address, "id": user.id}
- else:
- user = user_api_client.get_user_by_email(form.email_address.data)
- if send_email:
- user_api_client.send_already_registered_email(user.id, user.email_address)
- session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1))
- session['user_details'] = {"email": user.email_address, "id": user.id}
+ if organisation_id:
+ session['organisation_id'] = organisation_id
@main.route('/registration-continue')
diff --git a/app/main/views/user_profile.py b/app/main/views/user_profile.py
index 0d583e4b2..b0c84b5fa 100644
--- a/app/main/views/user_profile.py
+++ b/app/main/views/user_profile.py
@@ -64,9 +64,9 @@ def user_profile_email():
if not is_gov_user(current_user.email_address):
abort(403)
- def _is_email_unique(email):
- return user_api_client.is_email_unique(email)
- form = ChangeEmailForm(_is_email_unique,
+ def _is_email_already_in_use(email):
+ return user_api_client.is_email_already_in_use(email)
+ form = ChangeEmailForm(_is_email_already_in_use,
email_address=current_user.email_address)
if form.validate_on_submit():
diff --git a/app/main/views/verify.py b/app/main/views/verify.py
index 1b3bd70fb..f656c061b 100644
--- a/app/main/views/verify.py
+++ b/app/main/views/verify.py
@@ -74,6 +74,10 @@ def activate_user(user_id):
user = user_api_client.get_user(user_id)
# the user will have a new current_session_id set by the API - store it in the cookie for future requests
session['current_session_id'] = user.current_session_id
+ organisation_id = session.get('organisation_id', None)
activated_user = user_api_client.activate_user(user)
login_user(activated_user)
- return redirect(url_for('main.add_service', first='first'))
+ if organisation_id:
+ return redirect(url_for('main.organisation_dashboard', org_id=organisation_id))
+ else:
+ return redirect(url_for('main.add_service', first='first'))
diff --git a/app/notify_client/models.py b/app/notify_client/models.py
index 394402154..4cc23f04b 100644
--- a/app/notify_client/models.py
+++ b/app/notify_client/models.py
@@ -27,6 +27,7 @@ class User(UserMixin):
self.max_failed_login_count = max_failed_login_count
self.platform_admin = fields.get('platform_admin')
self.current_session_id = fields.get('current_session_id')
+ self.organisations = fields.get('organisations', [])
def get_id(self):
return self.id
@@ -155,6 +156,7 @@ class User(UserMixin):
"state": self.state,
"failed_login_count": self.failed_login_count,
"permissions": [x for x in self._permissions],
+ "organisations": self.organisations,
"current_session_id": self.current_session_id
}
if hasattr(self, '_password'):
@@ -164,6 +166,14 @@ class User(UserMixin):
def set_password(self, pwd):
self._password = pwd
+ @property
+ def organisations(self):
+ return self._organisations
+
+ @organisations.setter
+ def organisations(self, organisations):
+ self._organisations = organisations
+
class InvitedUser(object):
@@ -217,6 +227,38 @@ class InvitedUser(object):
return data
+class InvitedOrgUser(object):
+
+ def __init__(self, id, organisation, invited_by, email_address, status, created_at):
+ self.id = id
+ self.organisation = str(organisation)
+ self.invited_by = invited_by
+ self.email_address = email_address
+ self.status = status
+ self.created_at = created_at
+
+ def __eq__(self, other):
+ return ((self.id,
+ self.organisation,
+ self.invited_by,
+ self.email_address,
+ self.status) == (other.id,
+ other.organisation,
+ other.invited_by,
+ other.email_address,
+ other.status))
+
+ def serialize(self, permissions_as_string=False):
+ data = {'id': self.id,
+ 'organisation': self.organisation,
+ 'invited_by': self.invited_by,
+ 'email_address': self.email_address,
+ 'status': self.status,
+ 'created_at': str(self.created_at)
+ }
+ return data
+
+
class AnonymousUser(AnonymousUserMixin):
# set the anonymous user so that if a new browser hits us we don't error http://stackoverflow.com/a/19275188
def logged_in_elsewhere(self):
diff --git a/app/notify_client/org_invite_api_client.py b/app/notify_client/org_invite_api_client.py
new file mode 100644
index 000000000..71f3f955b
--- /dev/null
+++ b/app/notify_client/org_invite_api_client.py
@@ -0,0 +1,52 @@
+from app.notify_client import _attach_current_user, NotifyAdminAPIClient
+from app.notify_client.models import InvitedOrgUser
+
+
+class OrgInviteApiClient(NotifyAdminAPIClient):
+ def __init__(self):
+ super().__init__("a" * 73, "b")
+
+ def init_app(self, app):
+ self.base_url = app.config['API_HOST_NAME']
+ self.admin_url = app.config['ADMIN_BASE_URL']
+ self.service_id = app.config['ADMIN_CLIENT_USER_NAME']
+ self.api_key = app.config['ADMIN_CLIENT_SECRET']
+
+ def create_invite(self, invite_from_id, org_id, email_address):
+ data = {
+ 'email_address': email_address,
+ 'invited_by': invite_from_id,
+ 'invite_link_host': self.admin_url,
+ }
+ data = _attach_current_user(data)
+ resp = self.post(url='/organisation/{}/invite'.format(org_id), data=data)
+ return InvitedOrgUser(**resp['data'])
+
+ def get_invites_for_organisation(self, org_id):
+ endpoint = '/organisation/{}/invite'.format(org_id)
+ resp = self.get(endpoint)
+ invites = resp['data']
+ invited_users = self._get_invited_org_users(invites)
+ return invited_users
+
+ def check_token(self, token):
+ resp = self.get(url='/organisation-invitation/{}'.format(token))
+ return InvitedOrgUser(**resp['data'])
+
+ def cancel_invited_user(self, org_id, invited_user_id):
+ data = {'status': 'cancelled'}
+ data = _attach_current_user(data)
+ self.post(url='/organisation/{0}/invite/{1}'.format(org_id, invited_user_id),
+ data=data)
+
+ def accept_invite(self, org_id, invited_user_id):
+ data = {'status': 'accepted'}
+ self.post(url='/organisation/{0}/invite/{1}'.format(org_id, invited_user_id),
+ data=data)
+
+ def _get_invited_org_users(self, invites):
+ invited_users = []
+ for invite in invites:
+ invited_user = InvitedOrgUser(**invite)
+ invited_users.append(invited_user)
+ return invited_users
diff --git a/app/notify_client/organisations_api_client.py b/app/notify_client/organisations_api_client.py
index 1d9d876c8..f6aeea383 100644
--- a/app/notify_client/organisations_api_client.py
+++ b/app/notify_client/organisations_api_client.py
@@ -1,4 +1,4 @@
-from app.notify_client import NotifyAdminAPIClient
+from app.notify_client import _attach_current_user, NotifyAdminAPIClient
class OrganisationsClient(NotifyAdminAPIClient):
@@ -43,3 +43,10 @@ class OrganisationsClient(NotifyAdminAPIClient):
def get_organisation_services(self, org_id):
return self.get(url="/organisations/{}/services".format(org_id))
+
+ def remove_user_from_organisation(self, org_id, user_id):
+ endpoint = '/organisations/{}/users/{}'.format(
+ org_id=org_id,
+ user_id=user_id)
+ data = _attach_current_user({})
+ return self.delete(endpoint, data)
diff --git a/app/notify_client/user_api_client.py b/app/notify_client/user_api_client.py
index 57d9d4260..342976ad0 100644
--- a/app/notify_client/user_api_client.py
+++ b/app/notify_client/user_api_client.py
@@ -131,12 +131,21 @@ class UserApiClient(NotifyAdminAPIClient):
resp = self.get(endpoint)
return [User(data) for data in resp['data']]
+ def get_users_for_organisation(self, org_id):
+ endpoint = '/organisations/{}/users'.format(org_id)
+ resp = self.get(endpoint)
+ return [User(data) for data in resp['data']]
+
def add_user_to_service(self, service_id, user_id, permissions):
endpoint = '/service/{}/users/{}'.format(service_id, user_id)
data = [{'permission': x} for x in permissions]
resp = self.post(endpoint, data=data)
return User(resp['data'], max_failed_login_count=self.max_failed_login_count)
+ def add_user_to_organisation(self, org_id, user_id):
+ resp = self.post('/organisations/{}/users/{}'.format(org_id, user_id), data={})
+ return User(resp['data'], max_failed_login_count=self.max_failed_login_count)
+
def set_user_permissions(self, user_id, service_id, permissions):
data = [{'permission': x} for x in permissions]
endpoint = '/user/{}/service/{}/permission'.format(user_id, service_id)
@@ -147,10 +156,10 @@ class UserApiClient(NotifyAdminAPIClient):
data = {'email': email_address}
self.post(endpoint, data=data)
- def is_email_unique(self, email_address):
+ def is_email_already_in_use(self, email_address):
if self.get_user_by_email_or_none(email_address):
- return False
- return True
+ return True
+ return False
def activate_user(self, user):
if user.state == 'pending':
diff --git a/app/templates/org_nav.html b/app/templates/org_nav.html
index d27081d09..a8df3392c 100644
--- a/app/templates/org_nav.html
+++ b/app/templates/org_nav.html
@@ -2,7 +2,7 @@
diff --git a/app/templates/views/cancelled-invitation.html b/app/templates/views/cancelled-invitation.html
index 9a9e82cc9..d817ecaa9 100644
--- a/app/templates/views/cancelled-invitation.html
+++ b/app/templates/views/cancelled-invitation.html
@@ -10,7 +10,7 @@
{{ from_user }} decided to cancel this invitation.
- If you need access to {{ service_name }}, you’ll have to ask them to invite you again.
+ If you need access to {{ service_name or organisation_name }}, you’ll have to ask them to invite you again.
diff --git a/app/templates/views/organisations/organisation/users/index.html b/app/templates/views/organisations/organisation/users/index.html
index ddfe159d8..d2edfbfee 100644
--- a/app/templates/views/organisations/organisation/users/index.html
+++ b/app/templates/views/organisations/organisation/users/index.html
@@ -1,4 +1,8 @@
{% extends "org_template.html" %}
+{% from "components/table.html" import list_table, row, field, hidden_field_heading %}
+{% from "components/page-footer.html" import page_footer %}
+{% from "components/tick-cross.html" import tick_cross %}
+{% from "components/textbox.html" import textbox %}
{% block org_page_title %}
Team members
@@ -12,6 +16,54 @@
Team members
+
+
+
+ {% if show_search_box %}
+
+
+ {{ textbox(
+ form.search,
+ width='1-1'
+ ) }}
+
+
+ {% endif %}
+
+
+ {% for user in users %}
+
+
+ {%- if user.name -%}
+ {{ user.name }}
+ {%- endif -%}
+
+ {%- if user.status == 'pending' -%}
+ {{ user.email_address }} (invited)
+ {%- elif user.status == 'cancelled' -%}
+ {{ user.email_address }} (cancelled invite)
+ {%- elif user.id == current_user.id -%}
+ (you)
+ {% else %}
+ {{ user.email_address }}
+ {% endif %}
+
+
+
+
+ {% endfor %}
{% endblock %}
+
+
diff --git a/app/templates/views/organisations/organisation/users/invite-org-user.html b/app/templates/views/organisations/organisation/users/invite-org-user.html
new file mode 100644
index 000000000..8e7ee3645
--- /dev/null
+++ b/app/templates/views/organisations/organisation/users/invite-org-user.html
@@ -0,0 +1,33 @@
+{% extends "org_template.html" %}
+
+{% from "components/textbox.html" import textbox %}
+{% from "components/page-footer.html" import page_footer %}
+
+{% block service_page_title %}
+ Invite a team member
+{% endblock %}
+
+{% block maincolumn_content %}
+
+
+ Invite a team member
+
+
+
+{% endblock %}
diff --git a/app/templates/views/organisations/organisation/users/user/index.html b/app/templates/views/organisations/organisation/users/user/index.html
new file mode 100644
index 000000000..2d7615141
--- /dev/null
+++ b/app/templates/views/organisations/organisation/users/user/index.html
@@ -0,0 +1,28 @@
+{% extends "withnav_template.html" %}
+{% from "components/textbox.html" import textbox %}
+{% from "components/page-footer.html" import page_footer %}
+
+{% block service_page_title %}
+ {{ user.name or user.email_localpart }}
+{% endblock %}
+
+{% block maincolumn_content %}
+
+
+ {{ user.name or user.email_localpart }}
+
+
+
+ {{ user.email_address }}
+
+
+
+{% endblock %}
diff --git a/app/templates/views/organisations/organisation/users/user/remove-org-user.html b/app/templates/views/organisations/organisation/users/user/remove-org-user.html
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/templates/views/register-from-org-invite.html b/app/templates/views/register-from-org-invite.html
new file mode 100644
index 000000000..717e19992
--- /dev/null
+++ b/app/templates/views/register-from-org-invite.html
@@ -0,0 +1,28 @@
+{% extends "withoutnav_template.html" %}
+{% from "components/textbox.html" import textbox %}
+{% from "components/page-footer.html" import page_footer %}
+
+{% block per_page_title %}
+Create an account
+{% endblock %}
+
+{% block maincolumn_content %}
+
+
+
+
Create an account
+
Your account will be created with this email: {{invited_org_user.email_address}}
+
+
+
+
+{% endblock %}
diff --git a/tests/__init__.py b/tests/__init__.py
index ad095fdc6..20ca78664 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -5,6 +5,9 @@ from datetime import datetime, timedelta, timezone
from flask.testing import FlaskClient
from flask import url_for
from flask_login import login_user
+from app.notify_client.models import (
+ InvitedOrgUser,
+)
class TestClient(FlaskClient):
@@ -37,6 +40,85 @@ def created_by_json(id_, name='', email_address=''):
return {'id': id_, 'name': name, 'email_address': email_address}
+def user_json(
+ id_='1234',
+ name='Test User',
+ email_address='test@gov.uk',
+ mobile_number='+447700900986',
+ password_changed_at=None,
+ permissions={generate_uuid(): [
+ 'send_texts',
+ 'send_emails',
+ 'send_letters',
+ 'manage_users',
+ 'manage_templates',
+ 'manage_settings',
+ 'manage_api_keys']
+ },
+ auth_type='sms_auth',
+ failed_login_count=0,
+ state='active',
+ max_failed_login_count=3,
+ platform_admin=False,
+ current_session_id='1234',
+ organisations=[],
+
+):
+ return {
+ 'id': id_,
+ 'name': name,
+ 'email_address': email_address,
+ 'mobile_number': mobile_number,
+ 'password_changed_at': password_changed_at,
+ 'permissions': permissions,
+ 'auth_type': auth_type,
+ 'failed_login_count': failed_login_count,
+ 'state': state,
+ 'max_failed_login_count': max_failed_login_count,
+ 'platform_admin': platform_admin,
+ 'current_session_id': current_session_id,
+ 'organisations': organisations
+ }
+
+
+def invited_user(
+ _id='1234',
+ service=None,
+ from_user='1234',
+ email_address='testinviteduser@gov.uk',
+ permissions=None,
+ status='pending',
+ created_at=datetime.utcnow(),
+ auth_type='sms_auth',
+ organisation=None
+):
+ org_user = organisation is not None
+ data = {
+ 'id': _id,
+ 'from_user': from_user,
+ 'email_address': email_address,
+ 'status': status,
+ 'created_at': created_at,
+ 'auth_type': auth_type,
+ }
+ if service:
+ data['service'] = service
+ if permissions:
+ data['permissions'] = permissions
+ if organisation:
+ data['organisation'] = organisation
+
+ if org_user:
+ return InvitedOrgUser(
+ data['id'],
+ data['organisation'],
+ data['from_user'],
+ data['email_address'],
+ data['status'],
+ data['created_at']
+ )
+
+
def service_json(
id_='1234',
name='Test Service',
@@ -88,6 +170,28 @@ def service_json(
}
+def organisation_json(
+ id_='1234',
+ name='Test Organisation',
+ users=None,
+ active=True,
+ created_at=None,
+ services=None
+):
+ if users is None:
+ users = []
+ if services is None:
+ services = []
+ return {
+ 'id': id_,
+ 'name': name,
+ 'active': active,
+ 'users': users,
+ 'services': services,
+ 'created_at': created_at or str(datetime.utcnow())
+ }
+
+
def template_json(service_id,
id_,
name="sample template",
@@ -166,6 +270,17 @@ def invite_json(id_, from_user, service_id, email_address, permissions, created_
}
+def org_invite_json(id_, invited_by, org_id, email_address, created_at, status):
+ return {
+ 'id': id_,
+ 'invited_by': invited_by,
+ 'organisation': org_id,
+ 'email_address': email_address,
+ 'status': status,
+ 'created_at': created_at,
+ }
+
+
TEST_USER_EMAIL = 'test@user.gov.uk'
diff --git a/tests/app/main/test_errorhandlers.py b/tests/app/main/test_errorhandlers.py
index e5c2f7487..202559cdd 100644
--- a/tests/app/main/test_errorhandlers.py
+++ b/tests/app/main/test_errorhandlers.py
@@ -26,7 +26,6 @@ def test_load_service_before_request_handles_404(client_request, mocker):
@pytest.mark.parametrize('url', [
- '/invitation/MALFORMED_TOKEN',
'/new-password/MALFORMED_TOKEN',
'/user-profile/email/confirm/MALFORMED_TOKEN',
'/verify-email/MALFORMED_TOKEN'
diff --git a/tests/app/main/views/test_accept_invite.py b/tests/app/main/views/test_accept_invite.py
index 0ff67f30b..277bef460 100644
--- a/tests/app/main/views/test_accept_invite.py
+++ b/tests/app/main/views/test_accept_invite.py
@@ -1,7 +1,6 @@
from flask import url_for
from bs4 import BeautifulSoup
from unittest.mock import ANY
-from itsdangerous import SignatureExpired
import app
from app.notify_client.models import InvitedUser
@@ -22,8 +21,6 @@ def test_existing_user_accept_invite_calls_api_and_redirects_to_dashboard(
mock_get_service,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
expected_service = service_one['id']
expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys']
@@ -50,8 +47,6 @@ def test_existing_user_with_no_permissions_accept_invite(
mock_add_user_to_service,
mock_get_service,
):
- mocker.patch('app.main.views.invites.check_token')
-
expected_service = service_one['id']
sample_invite['permissions'] = ''
expected_permissions = []
@@ -69,8 +64,6 @@ def test_if_existing_user_accepts_twice_they_redirect_to_sign_in(
sample_invite,
mock_get_service,
):
- mocker.patch('app.main.views.invites.check_token')
-
sample_invite['status'] = 'accepted'
invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=invite)
@@ -96,7 +89,6 @@ def test_existing_user_of_service_get_redirected_to_signin(
mock_get_user_by_email,
mock_accept_invite,
):
- mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address
invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=invite)
@@ -128,8 +120,6 @@ def test_existing_signed_out_user_accept_invite_redirects_to_sign_in(
mock_get_service,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
expected_service = service_one['id']
expected_permissions = ['send_messages', 'manage_service', 'manage_api_keys']
@@ -161,8 +151,6 @@ def test_new_user_accept_invite_calls_api_and_redirects_to_registration(
mock_get_service,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
expected_redirect_location = 'http://localhost/register-from-invite'
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
@@ -184,8 +172,6 @@ def test_new_user_accept_invite_calls_api_and_views_registration_page(
mock_get_service,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'), follow_redirects=True)
mock_check_invite_token.assert_called_with('thisisnotarealtoken')
@@ -219,7 +205,6 @@ def test_cancelled_invited_user_accepts_invited_redirect_to_cancelled_invitation
mock_get_user,
mock_get_service,
):
- mocker.patch('app.main.views.invites.check_token')
cancelled_invitation = create_sample_invite(mocker, service_one, status='cancelled')
mock_check_token_invite(mocker, cancelled_invitation)
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
@@ -237,7 +222,7 @@ def test_new_user_accept_invite_completes_new_registration_redirects_to_verify(
api_user_active,
mock_check_invite_token,
mock_dont_get_user_by_email,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_register_user,
mock_send_verify_code,
mock_accept_invite,
@@ -246,8 +231,6 @@ def test_new_user_accept_invite_completes_new_registration_redirects_to_verify(
mock_get_service,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
expected_service = service_one['id']
expected_email = sample_invite['email_address']
expected_from_user = service_one['users'][0]
@@ -297,7 +280,6 @@ def test_signed_in_existing_user_cannot_use_anothers_invite(
mock_accept_invite,
mock_get_service,
):
- mocker.patch('app.main.views.invites.check_token')
invite = InvitedUser(**sample_invite)
mocker.patch('app.invite_api_client.check_token', return_value=invite)
mocker.patch('app.user_api_client.get_users_for_service', return_value=[api_user_active])
@@ -324,8 +306,6 @@ def test_accept_invite_does_not_treat_email_addresses_as_case_sensitive(
mock_accept_invite,
mock_get_user_by_email
):
- mocker.patch('app.main.views.invites.check_token')
-
# the email address of api_user_active is 'test@user.gov.uk'
sample_invite['email_address'] = 'TEST@user.gov.uk'
invite = InvitedUser(**sample_invite)
@@ -345,7 +325,7 @@ def test_new_invited_user_verifies_and_added_to_service(
api_user_active,
mock_check_invite_token,
mock_dont_get_user_by_email,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_register_user,
mock_send_verify_code,
mock_check_verify_code,
@@ -363,8 +343,6 @@ def test_new_invited_user_verifies_and_added_to_service(
mock_get_usage,
mocker,
):
- mocker.patch('app.main.views.invites.check_token')
-
# visit accept token page
response = client.get(url_for('main.accept_invite', token='thisisnotarealtoken'))
assert response.status_code == 302
@@ -404,24 +382,6 @@ def test_new_invited_user_verifies_and_added_to_service(
assert page.find('h1').text == 'Dashboard'
-def test_gives_message_if_token_has_expired(
- app_,
- client,
- mock_check_invite_token,
- mocker,
-):
- check_token = mocker.patch('app.main.views.invites.check_token', side_effect=SignatureExpired('this is too old'))
-
- response = client.get(url_for('main.accept_invite', token='a really old token'))
- raw_html = response.data.decode('utf-8')
- page = BeautifulSoup(raw_html, 'html.parser')
-
- check_token.assert_called_once_with(ANY, ANY, ANY, 3600 * 24 * 2)
- assert response.status_code == 400
- assert 'Your invitation to GOV.UK Notify has expired' in page.find('h1').text
- assert not mock_check_invite_token.called
-
-
def test_existing_user_accepts_and_sets_email_auth(
client_request,
api_user_active,
@@ -434,7 +394,6 @@ def test_existing_user_accepts_and_sets_email_auth(
mock_add_user_to_service,
mocker
):
- mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth')
@@ -465,7 +424,6 @@ def test_existing_user_doesnt_get_auth_changed_by_service_without_permission(
mock_add_user_to_service,
mocker
):
- mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address
assert 'email_auth' not in service_one['permissions']
@@ -495,7 +453,6 @@ def test_existing_email_auth_user_without_phone_cannot_set_sms_auth(
mock_add_user_to_service,
mocker
):
- mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth')
@@ -529,7 +486,6 @@ def test_existing_email_auth_user_with_phone_can_set_sms_auth(
mock_add_user_to_service,
mocker
):
- mocker.patch('app.main.views.invites.check_token')
sample_invite['email_address'] = api_user_active.email_address
service_one['permissions'].append('email_auth')
diff --git a/tests/app/main/views/test_organisations.py b/tests/app/main/views/test_organisations.py
index 4a9383276..5a14fa2f8 100644
--- a/tests/app/main/views/test_organisations.py
+++ b/tests/app/main/views/test_organisations.py
@@ -1,9 +1,20 @@
+import pytest
+
from bs4 import BeautifulSoup
-from flask import url_for
+from flask import url_for, Response
+
+from datetime import (
+ datetime,
+ timedelta
+)
from tests.conftest import (
normalize_spaces
)
+from unittest.mock import ANY
+from app.notify_client.models import InvitedOrgUser
+
+from notifications_python_client.errors import HTTPError
def test_organisation_page_shows_all_organisations(
@@ -151,3 +162,376 @@ def test_organisation_services_show(
assert normalize_spaces(
page.select('.browse-list-item a')[i]['href']
) == '/services/{}'.format(service_id)
+
+
+def test_view_team_members(
+ logged_in_platform_admin_client,
+ mocker,
+ mock_get_organisation,
+ mock_get_users_for_organisation,
+ mock_get_invited_users_for_organisation,
+ fake_uuid
+):
+ response = logged_in_platform_admin_client.get(
+ url_for('.manage_org_users', org_id=fake_uuid),
+ )
+
+ assert response.status_code == 200
+ page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
+
+ for i in range(0, 2):
+ assert normalize_spaces(
+ page.select('.user-list-item .heading-small')[i].text
+ ) == 'Test User {}'.format(i + 1)
+
+ assert normalize_spaces(
+ page.select('.tick-cross-list-edit-link')[1].text
+ ) == 'Cancel invitation'
+
+
+def test_invite_org_user(
+ logged_in_platform_admin_client,
+ mocker,
+ mock_get_organisation,
+ sample_org_invite,
+ fake_uuid
+):
+
+ mock_invite_org_user = mocker.patch(
+ 'app.org_invite_api_client.create_invite',
+ return_value=InvitedOrgUser(**sample_org_invite)
+ )
+
+ logged_in_platform_admin_client.post(
+ url_for('.invite_org_user', org_id=mock_get_organisation['id']),
+ data={'email_address': 'test@example.gov.uk'}
+ )
+
+ mock_invite_org_user.assert_called_once_with(
+ sample_org_invite['invited_by'],
+ '{}'.format(mock_get_organisation['id']),
+ 'test@example.gov.uk',
+ )
+
+
+def test_invite_org_user_errors_when_same_email_as_inviter(
+ logged_in_platform_admin_client,
+ mocker,
+ mock_get_organisation,
+ sample_org_invite,
+ fake_uuid
+):
+ new_org_user_data = {
+ 'email_address': 'platform@admin.gov.uk',
+ }
+
+ mock_invite_org_user = mocker.patch(
+ 'app.org_invite_api_client.create_invite',
+ return_value=InvitedOrgUser(**sample_org_invite)
+ )
+
+ response = logged_in_platform_admin_client.post(
+ url_for('.invite_org_user', org_id=mock_get_organisation['id']),
+ data=new_org_user_data
+ )
+
+ assert response.status_code == 200
+ page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
+
+ assert mock_invite_org_user.called is False
+ assert normalize_spaces(page.select_one('.error-message').text) == 'You can’t send an invitation to yourself'
+
+# Broken
+
+
+def test_accept_invite_with_invalid_token(
+ client,
+ mocker
+):
+ mocker.patch(
+ 'app.org_invite_api_client.check_token',
+ side_effect=HTTPError(Response(status=400), {'result': 'error', 'message': 'default error message'})
+ )
+
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'))
+ assert response.status_code == 400
+
+
+def test_accepted_invite_when_user_already_logged_in(
+ logged_in_client,
+ mock_check_org_invite_token
+):
+ response = logged_in_client.get(
+ url_for('main.accept_org_invite', token='thisisnotarealtoken'),
+ follow_redirects=True
+ )
+
+ assert response.status_code == 403
+ page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
+
+ assert 'This invite is for another email address.' in normalize_spaces(page.select_one('.banner-dangerous').text)
+
+
+def test_cancelled_invite_opened_by_user(
+ client,
+ mock_check_org_cancelled_invite_token,
+ mock_get_organisation,
+ mock_get_user,
+ fake_uuid
+):
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'), follow_redirects=True)
+
+ assert response.status_code == 200
+ page = BeautifulSoup(response.data.decode('utf-8'), 'html.parser')
+
+ assert normalize_spaces(
+ page.select_one('h1').text
+ ) == 'The invitation you were sent has been cancelled'
+ assert normalize_spaces(
+ page.select('main p')[0].text
+ ) == 'Test User decided to cancel this invitation.'
+ assert normalize_spaces(
+ page.select('main p')[1].text
+ ) == 'If you need access to Org 1, you’ll have to ask them to invite you again.'
+
+ mock_get_user.assert_called_once_with(fake_uuid)
+ mock_get_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af')
+
+
+def test_user_invite_already_accepted(
+ client,
+ mock_check_org_accepted_invite_token
+):
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'))
+
+ assert response.status_code == 302
+ assert response.location == url_for(
+ 'main.organisation_dashboard',
+ org_id='596364a0-858e-42c8-9062-a8fe822260af',
+ _external=True
+ )
+
+
+def test_existing_user_invite_already_is_member_of_organisation(
+ client,
+ mock_check_org_invite_token,
+ mock_get_user_by_email,
+ mock_get_users_for_organisation,
+ mock_accept_org_invite,
+ fake_uuid
+):
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'))
+
+ assert response.status_code == 302
+ assert response.location == url_for(
+ 'main.organisation_dashboard',
+ org_id='596364a0-858e-42c8-9062-a8fe822260af',
+ _external=True
+ )
+
+ mock_accept_org_invite.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af', fake_uuid)
+ mock_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk')
+ mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af')
+
+
+def test_exisiting_user_invite_not_a_member_of_organisation(
+ client,
+ mock_check_org_invite_token,
+ mock_get_user_by_email,
+ mock_get_users_for_organisation,
+ mock_accept_org_invite,
+ mock_add_user_to_organisation,
+ fake_uuid
+):
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'))
+
+ assert response.status_code == 302
+ assert response.location == url_for(
+ 'main.organisation_dashboard',
+ org_id='596364a0-858e-42c8-9062-a8fe822260af',
+ _external=True
+ )
+
+ mock_accept_org_invite.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af', ANY)
+ mock_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk')
+ mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af')
+ mock_add_user_to_organisation.assert_called_once_with(
+ '596364a0-858e-42c8-9062-a8fe822260af',
+ '6ce466d0-fd6a-11e5-82f5-e0accb9d11a6'
+ )
+
+
+def test_user_accepts_invite(
+ client,
+ mock_check_org_invite_token,
+ mock_dont_get_user_by_email,
+ mock_get_users_for_organisation,
+):
+ response = client.get(url_for('main.accept_org_invite', token='thisisnotarealtoken'))
+
+ assert response.status_code == 302
+ assert response.location == url_for('main.register_from_org_invite', _external=True)
+
+ mock_check_org_invite_token.assert_called_once_with('thisisnotarealtoken')
+ mock_dont_get_user_by_email.assert_called_once_with('invited_user@test.gov.uk')
+ mock_get_users_for_organisation.assert_called_once_with('596364a0-858e-42c8-9062-a8fe822260af')
+
+
+def test_registration_from_org_invite_404s_if_user_not_in_session(
+ client,
+):
+ response = client.get(url_for('main.register_from_org_invite'))
+ assert response.status_code == 404
+
+
+@pytest.mark.parametrize('data, error', [
+ [{
+ 'name': 'Bad Mobile',
+ 'mobile_number': 'not good',
+ 'password': 'validPassword!'
+ }, 'Must not contain letters or symbols'],
+ [{
+ 'name': 'Bad Password',
+ 'mobile_number': '+44123412345',
+ 'password': 'password'
+ }, 'Choose a password that’s harder to guess'],
+])
+def test_registration_from_org_invite_has_bad_data(
+ client,
+ sample_org_invite,
+ data,
+ error
+):
+ invited_org_user = InvitedOrgUser(**sample_org_invite)
+ with client.session_transaction() as session:
+ session['invited_org_user'] = invited_org_user.serialize()
+
+ response = client.post(url_for('main.register_from_org_invite'), data=data)
+
+ assert response.status_code == 200
+ assert error in response.get_data(as_text=True)
+
+
+@pytest.mark.parametrize('diff_data', [
+ ['email_address'],
+ ['organisation'],
+ ['email_address', 'organisation']
+])
+def test_registration_from_org_invite_has_different_email_or_organisation(
+ client,
+ sample_org_invite,
+ diff_data
+):
+ invited_org_user = InvitedOrgUser(**sample_org_invite)
+ with client.session_transaction() as session:
+ session['invited_org_user'] = invited_org_user.serialize()
+
+ for data in diff_data:
+ session['invited_org_user'][data] = 'different'
+
+ response = client.post(url_for('main.register_from_org_invite'), data={
+ 'name': 'Test User',
+ 'mobile_number': '+4407700900460',
+ 'password': 'validPassword!',
+ 'email_address': session['invited_org_user']['email_address'],
+ 'organisation': session['invited_org_user']['organisation']
+ })
+
+ assert response.status_code == 400
+
+
+def test_org_user_registers_with_email_already_in_use(
+ client,
+ sample_org_invite,
+ mock_email_is_already_in_use,
+ mock_get_user_by_email,
+ mock_accept_org_invite,
+ mock_add_user_to_organisation,
+ mock_send_already_registered_email,
+ mock_register_user
+):
+ invited_org_user = InvitedOrgUser(**sample_org_invite)
+ with client.session_transaction() as session:
+ session['invited_org_user'] = invited_org_user.serialize()
+
+ response = client.post(url_for('main.register_from_org_invite'), data={
+ 'name': 'Test User',
+ 'mobile_number': '+4407700900460',
+ 'password': 'validPassword!',
+ 'email_address': session['invited_org_user']['email_address'],
+ 'organisation': session['invited_org_user']['organisation']
+ })
+
+ assert response.status_code == 302
+ assert response.location == url_for('main.verify', _external=True)
+
+ mock_get_user_by_email.assert_called_once_with(
+ session['invited_org_user']['email_address']
+ )
+ assert mock_register_user.called is False
+ assert mock_send_already_registered_email.called is False
+
+
+def test_org_user_registration(
+ client,
+ sample_org_invite,
+ mock_email_is_not_already_in_use,
+ mock_register_user,
+ mock_send_verify_code,
+ mock_get_user_by_email,
+ mock_send_verify_email,
+ mock_accept_org_invite,
+ mock_add_user_to_organisation,
+):
+ invited_org_user = InvitedOrgUser(**sample_org_invite)
+ with client.session_transaction() as session:
+ session['invited_org_user'] = invited_org_user.serialize()
+
+ response = client.post(url_for('main.register_from_org_invite'), data={
+ 'name': 'Test User',
+ 'email_address': session['invited_org_user']['email_address'],
+ 'mobile_number': '+4407700900460',
+ 'password': 'validPassword!',
+ 'organisation': session['invited_org_user']['organisation']
+ })
+
+ assert response.status_code == 302
+ assert response.location == url_for('main.verify', _external=True)
+
+ mock_get_user_by_email.called is False
+ mock_register_user.assert_called_once_with(
+ 'Test User',
+ session['invited_org_user']['email_address'],
+ '+4407700900460',
+ 'validPassword!',
+ 'sms_auth'
+ )
+ mock_send_verify_code.assert_called_once_with(
+ '6ce466d0-fd6a-11e5-82f5-e0accb9d11a6',
+ 'sms',
+ '+4407700900460',
+ )
+
+
+def test_verified_org_user_redirects_to_dashboard(
+ client,
+ sample_org_invite,
+ mock_check_verify_code,
+ mock_get_user,
+ mock_activate_user,
+ mock_login,
+):
+ invited_org_user = InvitedOrgUser(**sample_org_invite).serialize()
+ with client.session_transaction() as session:
+ session['expiry_date'] = str(datetime.utcnow() + timedelta(hours=1))
+ session['user_details'] = {"email": invited_org_user['email_address'], "id": invited_org_user['id']}
+ session['organisation_id'] = invited_org_user['organisation']
+
+ response = client.post(url_for('main.verify'), data={'sms_code': '12345'})
+
+ assert response.status_code == 302
+ assert response.location == url_for(
+ 'main.organisation_dashboard',
+ org_id=invited_org_user['organisation'],
+ _external=True
+ )
diff --git a/tests/app/main/views/test_register.py b/tests/app/main/views/test_register.py
index b5ba1d508..2cd9d7c43 100644
--- a/tests/app/main/views/test_register.py
+++ b/tests/app/main/views/test_register.py
@@ -48,7 +48,7 @@ def test_register_creates_new_user_and_redirects_to_continue_page(
mock_send_verify_code,
mock_register_user,
mock_get_user_by_email_not_found,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_send_verify_email,
mock_login,
phone_number_to_register_with,
@@ -122,7 +122,7 @@ def test_should_add_user_details_to_session(
mock_register_user,
mock_get_user,
mock_get_user_by_email_not_found,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_send_verify_email,
mock_login,
):
@@ -176,7 +176,7 @@ def test_register_with_existing_email_sends_emails(
def test_register_from_invite(
client,
fake_uuid,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_register_user,
mock_send_verify_code,
mock_accept_invite,
@@ -237,7 +237,7 @@ def test_register_from_invite_when_user_registers_in_another_browser(
def test_register_from_email_auth_invite(
client,
sample_invite,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_register_user,
mock_get_user,
mock_send_verify_email,
@@ -285,7 +285,7 @@ def test_register_from_email_auth_invite(
def test_can_register_email_auth_without_phone_number(
client,
sample_invite,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
mock_register_user,
mock_get_user,
mock_send_verify_email,
diff --git a/tests/app/main/views/test_user_profile.py b/tests/app/main/views/test_user_profile.py
index 30b1e07c4..6af454bdf 100644
--- a/tests/app/main/views/test_user_profile.py
+++ b/tests/app/main/views/test_user_profile.py
@@ -30,6 +30,7 @@ def test_should_redirect_after_name_change(
logged_in_client,
api_user_active,
mock_update_user_attribute,
+ mock_email_is_not_already_in_use
):
new_name = 'New Name'
data = {'new_name': new_name}
@@ -54,7 +55,7 @@ def test_should_show_email_page(
def test_should_redirect_after_email_change(
logged_in_client,
mock_login,
- mock_is_email_unique,
+ mock_email_is_not_already_in_use,
):
data = {'email_address': 'new_notify@notify.gov.uk'}
response = logged_in_client.post(
diff --git a/tests/conftest.py b/tests/conftest.py
index 819df1fa3..4e300e69e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,11 +11,15 @@ from bs4 import BeautifulSoup
from app import create_app
from app.notify_client.models import (
User,
- InvitedUser
+ InvitedUser,
+ InvitedOrgUser
)
from . import (
service_json,
+ organisation_json,
+ user_json,
+ invited_user,
TestClient,
template_json,
template_version_json,
@@ -25,7 +29,8 @@ from . import (
invite_json,
sample_uuid,
generate_uuid,
- single_notification_json
+ single_notification_json,
+ org_invite_json
)
from notifications_utils.url_safe_token import generate_token
@@ -1050,7 +1055,8 @@ def api_user_pending(fake_uuid):
'mobile_number': '07700 900762',
'state': 'pending',
'failed_login_count': 0,
- 'permissions': {}
+ 'permissions': {},
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1075,7 +1081,8 @@ def platform_admin_user(fake_uuid):
'manage_api_keys',
'view_activity']},
'platform_admin': True,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1094,7 +1101,8 @@ def api_user_active(fake_uuid, email_address='test@user.gov.uk'):
'permissions': {},
'platform_admin': False,
'auth_type': 'sms_auth',
- 'password_changed_at': str(datetime.utcnow())
+ 'password_changed_at': str(datetime.utcnow()),
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1113,7 +1121,8 @@ def api_user_active_email_auth(fake_uuid, email_address='test@user.gov.uk'):
'permissions': {},
'platform_admin': False,
'auth_type': 'email_auth',
- 'password_changed_at': str(datetime.utcnow())
+ 'password_changed_at': str(datetime.utcnow()),
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1132,7 +1141,8 @@ def api_nongov_user_active(fake_uuid):
'permissions': {},
'platform_admin': False,
'auth_type': 'sms_auth',
- 'password_changed_at': str(datetime.utcnow())
+ 'password_changed_at': str(datetime.utcnow()),
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1159,7 +1169,8 @@ def active_user_with_permissions(fake_uuid):
'manage_api_keys',
'view_activity']},
'platform_admin': False,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1186,7 +1197,8 @@ def active_user_no_mobile(fake_uuid):
'manage_api_keys',
'view_activity']},
'platform_admin': False,
- 'auth_type': 'email_auth'
+ 'auth_type': 'email_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1206,7 +1218,8 @@ def active_user_view_permissions(fake_uuid):
'failed_login_count': 0,
'permissions': {SERVICE_ONE_ID: ['view_activity']},
'platform_admin': False,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1230,7 +1243,8 @@ def active_user_manage_template_permission(fake_uuid):
'view_activity',
]},
'platform_admin': False,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1255,7 +1269,8 @@ def active_user_no_api_key_permission(fake_uuid):
'view_activity',
]},
'platform_admin': False,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1272,7 +1287,8 @@ def api_user_locked(fake_uuid):
'state': 'active',
'failed_login_count': 5,
'permissions': {},
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1290,7 +1306,8 @@ def api_user_request_password_reset(fake_uuid):
'failed_login_count': 5,
'permissions': {},
'password_changed_at': None,
- 'auth_type': 'sms_auth'
+ 'auth_type': 'sms_auth',
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1308,7 +1325,8 @@ def api_user_changed_password(fake_uuid):
'failed_login_count': 5,
'permissions': {},
'auth_type': 'sms_auth',
- 'password_changed_at': str(datetime.utcnow() + timedelta(minutes=1))
+ 'password_changed_at': str(datetime.utcnow() + timedelta(minutes=1)),
+ 'organisations': []
}
user = User(user_data)
return user
@@ -1495,13 +1513,13 @@ def mock_activate_user(mocker):
@pytest.fixture(scope='function')
-def mock_is_email_unique(mocker):
- return mocker.patch('app.user_api_client.is_email_unique', return_value=True)
+def mock_email_is_already_in_use(mocker):
+ return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=True)
@pytest.fixture(scope='function')
-def mock_is_email_not_unique(mocker):
- return mocker.patch('app.user_api_client.is_email_unique', return_value=False)
+def mock_email_is_not_already_in_use(mocker):
+ return mocker.patch('app.user_api_client.is_email_already_in_use', return_value=False)
@pytest.fixture(scope='function')
@@ -1900,14 +1918,14 @@ def mock_get_users_by_service(mocker):
'manage_users',
'manage_templates',
'manage_settings',
- 'manage_api_keys',
- 'access_developer_docs']},
+ 'manage_api_keys']},
'state': 'active',
'password_changed_at': None,
'name': 'Test User',
'email_address': 'notify@digital.cabinet-office.gov.uk',
'auth_type': 'sms_auth',
- 'failed_login_count': 0}]
+ 'failed_login_count': 0,
+ 'organisations': []}]
return [User(data[0])]
return mocker.patch('app.user_api_client.get_users_for_service', side_effect=_get_users_for_service, autospec=True)
@@ -2633,6 +2651,11 @@ def mock_update_service_callback_api(mocker):
return mocker.patch('app.service_api_client.update_service_callback_api', side_effect=_update_service_callback_api)
+@pytest.fixture(scope='function')
+def organisation_one(api_user_active):
+ return organisation_json('596364a0-858e-42c8-9062-a8fe822260af', 'organisation one', [api_user_active.id])
+
+
@pytest.fixture(scope='function')
def mock_get_organisations(mocker):
def _get_organisations():
@@ -2659,10 +2682,10 @@ def mock_get_organisations(mocker):
@pytest.fixture(scope='function')
def mock_get_organisation(mocker):
- def _get_organisation(organisation_id):
+ def _get_organisation(org_id):
return {
'name': 'Org 1',
- 'id': organisation_id,
+ 'id': org_id,
'active': True
}
@@ -2705,3 +2728,85 @@ def mock_get_organisation_services(mocker):
'app.organisations_client.get_organisation_services',
side_effect=_get_organisation_services
)
+
+
+@pytest.fixture(scope='function')
+def mock_get_users_for_organisation(mocker):
+ def _get_users_for_organisation(org_id):
+ return [
+ User(user_json(id_='1234', name='Test User 1')),
+ User(user_json(id_='5678', name='Test User 2', email_address='testt@gov.uk'))
+ ]
+
+ return mocker.patch(
+ 'app.user_api_client.get_users_for_organisation',
+ side_effect=_get_users_for_organisation
+ )
+
+
+@pytest.fixture(scope='function')
+def mock_get_invited_users_for_organisation(mocker):
+ def _get_invited_invited_users_for_organisation(org_id):
+ return [
+ invited_user(organisation='1234')
+ ]
+
+ return mocker.patch(
+ 'app.org_invite_api_client.get_invites_for_organisation',
+ side_effect=_get_invited_invited_users_for_organisation
+ )
+
+
+@pytest.fixture(scope='function')
+def sample_org_invite(mocker, organisation_one, status='pending'):
+ id_ = str(generate_uuid())
+ invited_by = organisation_one['users'][0]
+ email_address = 'invited_user@test.gov.uk'
+ organisation = organisation_one['id']
+ created_at = str(datetime.utcnow())
+
+ return org_invite_json(id_, invited_by, organisation, email_address, created_at, status)
+
+
+@pytest.fixture(scope='function')
+def mock_check_org_invite_token(mocker, sample_org_invite):
+ def _check_org_token(token):
+ return InvitedOrgUser(**sample_org_invite)
+
+ return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token)
+
+
+@pytest.fixture(scope='function')
+def mock_check_org_cancelled_invite_token(mocker, sample_org_invite):
+ sample_org_invite['status'] = 'cancelled'
+
+ def _check_org_token(token):
+ return InvitedOrgUser(**sample_org_invite)
+
+ return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token)
+
+
+@pytest.fixture(scope='function')
+def mock_check_org_accepted_invite_token(mocker, sample_org_invite):
+ sample_org_invite['status'] = 'accepted'
+
+ def _check_org_token(token):
+ return InvitedOrgUser(**sample_org_invite)
+
+ return mocker.patch('app.org_invite_api_client.check_token', side_effect=_check_org_token)
+
+
+@pytest.fixture(scope='function')
+def mock_accept_org_invite(mocker, sample_org_invite):
+ def _accept(organisation_id, invite_id):
+ return InvitedOrgUser(**sample_org_invite)
+
+ return mocker.patch('app.org_invite_api_client.accept_invite', side_effect=_accept)
+
+
+@pytest.fixture(scope='function')
+def mock_add_user_to_organisation(mocker, organisation_one, api_user_active):
+ def _add_user(organisation_id, user_id):
+ return api_user_active
+
+ return mocker.patch('app.user_api_client.add_user_to_organisation', side_effect=_add_user)