From 55c8233b4f46b8fed568f88ab29f6d9d73765518 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Mon, 12 Aug 2024 08:01:38 +0530 Subject: [PATCH 01/10] Invite mobile workers using ConnectID phone number --- corehq/apps/users/forms.py | 28 +++++++- .../users/static/users/js/mobile_workers.js | 38 ++++++++++- .../users/templates/users/mobile_workers.html | 56 ++++++++++++---- corehq/apps/users/urls.py | 12 ++++ corehq/apps/users/views/mobile/users.py | 64 +++++++++++++++++-- 5 files changed, 175 insertions(+), 23 deletions(-) diff --git a/corehq/apps/users/forms.py b/corehq/apps/users/forms.py index 01e2d1d7abdf..33a1f686ddd5 100644 --- a/corehq/apps/users/forms.py +++ b/corehq/apps/users/forms.py @@ -58,6 +58,7 @@ from corehq.const import LOADTEST_HARD_LIMIT, USER_CHANGE_VIA_WEB from corehq.pillows.utils import MOBILE_USER_TYPE, WEB_USER_TYPE from corehq.toggles import ( + COMMCARE_CONNECT, LOCATION_HAS_USERS, TWO_STAGE_USER_PROVISIONING, TWO_STAGE_USER_PROVISIONING_BY_SMS, @@ -734,6 +735,13 @@ class NewMobileWorkerForm(forms.Form): ), required=False, ) + account_invite_by_cid = forms.BooleanField( + label=gettext_noop("Invite using ConnectID phone number?"), + help_text=gettext_noop( + "If checked, the user will be sent an SMS to join the project using their ConnectID app." + ), + required=False, + ) phone_number = forms.CharField( required=False, label=gettext_noop("Phone Number"), @@ -832,10 +840,15 @@ def __init__(self, project, request_user, *args, **kwargs): data_bind='value: send_account_confirmation_email', ) - if TWO_STAGE_USER_PROVISIONING_BY_SMS.enabled(self.domain): + # cid => connect-id + provision_by_cid = COMMCARE_CONNECT.enabled(self.domain) + + provision_by_sms = TWO_STAGE_USER_PROVISIONING_BY_SMS.enabled(self.domain) + + if provision_by_sms or provision_by_cid: confirm_account_by_sms_field = crispy.Field( - 'force_account_confirmation_by_sms', - data_bind='checked: force_account_confirmation_by_sms', + 'force_account_confirmation_by_sms' if provision_by_sms else 'account_invite_by_cid', + data_bind='checked: force_account_confirmation_by_sms || account_invite_by_cid', ) phone_number_field = crispy.Div( crispy.Field( @@ -934,6 +947,10 @@ def __init__(self, project, request_user, *args, **kwargs): && $root.stagedUser().force_account_confirmation_by_sms() --> {disabled_phone} + + {disabled_cid} +

'''.format( @@ -956,6 +973,11 @@ def __init__(self, project, request_user, *args, **kwargs): "will set their own password on confirming " "their account phone number." ), + disabled_cid = _( + "Setting a password is disabled. The user " + "will be to access by logging into their " + "ConnectID app." + ), short=_("Password must have at least {password_length} characters." ).format(password_length=settings.MINIMUM_PASSWORD_LENGTH) )), diff --git a/corehq/apps/users/static/users/js/mobile_workers.js b/corehq/apps/users/static/users/js/mobile_workers.js index 19243a020cf6..e1bd38b05e12 100644 --- a/corehq/apps/users/static/users/js/mobile_workers.js +++ b/corehq/apps/users/static/users/js/mobile_workers.js @@ -70,6 +70,7 @@ hqDefine("users/js/mobile_workers",[ email: '', send_account_confirmation_email: false, force_account_confirmation_by_sms: false, + account_invite_by_cid: false, phone_number: '', is_active: true, is_account_confirmed: true, @@ -93,7 +94,11 @@ hqDefine("users/js/mobile_workers",[ self.sendConfirmationEmailEnabled = ko.observable(self.force_account_confirmation()); // used by two-stage sms provisioning - self.phoneRequired = ko.observable(self.force_account_confirmation_by_sms()); + self.phoneRequired = ko.observable(self.force_account_confirmation_by_sms() || self.account_invite_by_cid()); + + self.passwordEnabled = ko.observable(!( + self.force_account_confirmation_by_sms() || self.force_account_confirmation() || self.account_invite_by_cid()) + ); self.passwordEnabled = ko.observable(!(self.force_account_confirmation_by_sms() || self.force_account_confirmation())); @@ -176,6 +181,31 @@ hqDefine("users/js/mobile_workers",[ }); }; + self.sendConnectIDInvite = function () { + var urlName = 'send_connectid_invite'; + var $modal = $('#confirm_' + self.user_id()); + + $modal.find(".btn").addSpinnerToButton(); + $.ajax({ + method: 'POST', + url: initialPageData.reverse(urlName, self.user_id()), + success: function (data) { + $modal.modal('hide'); + if (data.success) { + self.action_error(''); + } else { + self.action_error(data.error); + } + + }, + error: function () { + $modal.modal('hide'); + $modal.find(".btn").removeSpinnerFromButton(); + self.action_error(gettext("Issue communicating with server. Try again.")); + }, + }); + }; + return self; }; @@ -507,7 +537,7 @@ hqDefine("users/js/mobile_workers",[ user.send_account_confirmation_email(false); } }); - user.force_account_confirmation_by_sms.subscribe(function (enabled) { + var handlePhoneRequired = function (enabled) { if (enabled) { // make phone number required user.phoneRequired(true); @@ -521,7 +551,9 @@ hqDefine("users/js/mobile_workers",[ // enable password input user.passwordEnabled(true); } - }); + }; + user.force_account_confirmation_by_sms.subscribe(handlePhoneRequired); + user.account_invite_by_cid.subscribe(handlePhoneRequired); }); self.initializeUser = function () { diff --git a/corehq/apps/users/templates/users/mobile_workers.html b/corehq/apps/users/templates/users/mobile_workers.html index 4508713f6627..6b15e75cf149 100644 --- a/corehq/apps/users/templates/users/mobile_workers.html +++ b/corehq/apps/users/templates/users/mobile_workers.html @@ -25,6 +25,7 @@ {% registerurl 'deactivate_commcare_user' domain '---' %} {% registerurl 'send_confirmation_email' domain '---' %} {% registerurl 'send_confirmation_sms' domain '---' %} + {% registerurl 'send_connectid_invite' domain '---' %}
@@ -319,17 +320,21 @@

{% trans 'Mobile Workers' %}

- {% if request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING" %} - {% else %} - - {% endif %} + {% if request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING" %} + {% blocktrans %} + Send Confirmation Email + {% endblocktrans %} + {% elif request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING_BY_SMS" %} + {% blocktrans %} + Send Confirmation SMS + {% endblocktrans %} + {% elif request|toggle_enabled:"COMMCARE_CONNECT" %} + {% blocktrans %} + Send ConnectID Invite + {% endblocktrans %} + {% endif %}

@@ -450,7 +455,18 @@

{% trans "Close" %} - + +
@@ -493,11 +518,16 @@ data-bind="click: sendConfirmationEmail"> {% trans 'Send Account Confirmaton Email' %} - {% else %} + {% elif request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING_BY_SMS" %} + {% elif request|toggle_enabled:"COMMCARE_CONNECT" %} + {% endif %}
diff --git a/corehq/apps/users/urls.py b/corehq/apps/users/urls.py index 895b4e23b7f5..c8335edfe2c1 100644 --- a/corehq/apps/users/urls.py +++ b/corehq/apps/users/urls.py @@ -76,6 +76,8 @@ CommCareUserConfirmAccountView, send_confirmation_email, send_confirmation_sms, + send_connectid_invite, + confirm_connectid_user, CommcareUserUploadJobPollView, ClearCommCareUsers, link_connectid_user, @@ -253,6 +255,16 @@ CommCareUserConfirmAccountBySMSView.as_view(), name=CommCareUserConfirmAccountBySMSView.urlname ), + url( + r'^commcare/send_connectid_invite/(?P[ \w-]+)/$', + send_connectid_invite, + name='send_connectid_invite' + ), + url( + r'^commcare/confirm_connectid_user/$', + confirm_connectid_user, + name="confirm_connectid_user" + ), url( r'^commcare/link_connectid_user/$', link_connectid_user, diff --git a/corehq/apps/users/views/mobile/users.py b/corehq/apps/users/views/mobile/users.py index 7209d0f3f19e..6d9b3f3c014e 100644 --- a/corehq/apps/users/views/mobile/users.py +++ b/corehq/apps/users/views/mobile/users.py @@ -62,7 +62,7 @@ ) from corehq.apps.domain.extension_points import has_custom_clean_password from corehq.apps.domain.models import SMSAccountConfirmationSettings -from corehq.apps.domain.utils import guess_domain_language_for_sms +from corehq.apps.domain.utils import guess_domain_language_for_sms, encrypt_account_confirmation_info from corehq.apps.domain.views.base import DomainViewMixin from corehq.apps.es import FormES from corehq.apps.events.models import ( @@ -160,6 +160,7 @@ from corehq.util.dates import iso_string_to_datetime from corehq.util.jqueryrmi import JSONResponseMixin, allow_remote_invocation from corehq.util.metrics import metrics_counter +from corehq.util.view_utils import absolute_reverse from corehq.util.workbook_json.excel import ( WorkbookJSONError, WorksheetNotFound, @@ -721,9 +722,9 @@ def custom_data(self): @property def two_stage_user_confirmation(self): - return toggles.TWO_STAGE_USER_PROVISIONING.enabled( - self.domain - ) or toggles.TWO_STAGE_USER_PROVISIONING_BY_SMS.enabled(self.domain) + return (toggles.TWO_STAGE_USER_PROVISIONING.enabled(self.domain) + or toggles.TWO_STAGE_USER_PROVISIONING_BY_SMS.enabled(self.domain) + or toggles.COMMCARE_CONNECT.enabled(self.domain)) @property def page_context(self): @@ -1685,6 +1686,61 @@ def link_connectid_user(request, domain): return HttpResponse() +@require_can_edit_commcare_users +@location_safe +def send_connectid_invite(request, domain, user_id): + # Currently same as what send_confirmation_sms does + user = CommCareUser.get_by_user_id(user_id, domain) + if user.is_account_confirmed or not user.is_commcare_user() or user.domain != domain: + messages.error(request, "The user is already confirmed or is not a mobile user") + return HttpResponse(status=400) + + invite_code = encrypt_account_confirmation_info(user) + url = absolute_reverse("confirm_connectid_user", args=[user.domain]) + text_content = f""" + Link your ConnectID account to CommCare + username: {user.username} + domain: {domain} + invite_code: {invite_code} + url: {url} + Make a POST call to above url with invite_code and your ConnectID token + """ + print(text_content) + return send_sms( + domain=domain, + contact=None, + phone_number=user.default_phone_number, + text=text_content) + + +@csrf_exempt +@require_POST +def confirm_connectid_user(request, domain): + try: + token = request.POST["token"] + invite_code = request.POST["invite_code"] + invite = json.loads(b64_aes_decrypt(invite_code)) + user_id = invite['user_id'] + expiry_time = invite['time'] + except ValueError: + return HttpResponseBadRequest("Invalid Request") + + # Expiry limit is 3 days + if float(int(time.time()) - expiry_time) > 3 * 24 * 60 * 60: + return JsonResponse(data={"error": "Invite has expired"}) + + user = CommCareUser.get(user_id) + if user.domain != domain: + return HttpResponseBadRequest("Invalid Request") + + connectid_username = get_connectid_userinfo(token) + ConnectIDUserLink.objects.get_or_create( + connectid_username=connectid_username, commcare_user=user.get_django_user(), domain=domain + ) + user.confirm_account(password=None) + return json_response({'success': True}) + + @waf_allow('XSS_BODY') @csrf_exempt @require_POST From 6c180df0043b2acf1a306ca98faa4c5f08351534 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Mon, 12 Aug 2024 17:01:47 +0530 Subject: [PATCH 02/10] Add Delink/relink --- corehq/apps/domain/auth.py | 2 + corehq/apps/users/models.py | 1 + .../users/static/users/js/mobile_workers.js | 18 +++- .../users/templates/users/mobile_workers.html | 102 ++++++++++++++++++ corehq/apps/users/urls.py | 6 ++ corehq/apps/users/views/mobile/users.py | 46 +++++++- migrations.lock | 1 + 7 files changed, 171 insertions(+), 5 deletions(-) diff --git a/corehq/apps/domain/auth.py b/corehq/apps/domain/auth.py index f8eb19dd66a5..c639ae0c4ba1 100644 --- a/corehq/apps/domain/auth.py +++ b/corehq/apps/domain/auth.py @@ -375,6 +375,8 @@ def authenticate(self, request, username, password): domain=couch_user.domain, commcare_user__username=couch_user.username ) + if not link.is_active: + return None return link.commcare_user diff --git a/corehq/apps/users/models.py b/corehq/apps/users/models.py index 7fd197156b49..6fcb8ff24f0f 100644 --- a/corehq/apps/users/models.py +++ b/corehq/apps/users/models.py @@ -3282,6 +3282,7 @@ class ConnectIDUserLink(models.Model): connectid_username = models.TextField() commcare_user = models.ForeignKey(User, related_name='connectid_user', on_delete=models.CASCADE) domain = models.TextField() + is_active = models.BooleanField(default=True) class Meta: unique_together = ('domain', 'commcare_user') diff --git a/corehq/apps/users/static/users/js/mobile_workers.js b/corehq/apps/users/static/users/js/mobile_workers.js index e1bd38b05e12..82eb1d1be8b4 100644 --- a/corehq/apps/users/static/users/js/mobile_workers.js +++ b/corehq/apps/users/static/users/js/mobile_workers.js @@ -74,6 +74,7 @@ hqDefine("users/js/mobile_workers",[ phone_number: '', is_active: true, is_account_confirmed: true, + is_connect_link_active: null, deactivate_after_date: '', }); @@ -108,10 +109,7 @@ hqDefine("users/js/mobile_workers",[ return initialPageData.reverse('edit_commcare_user', self.user_id()); }); - self.is_active.subscribe(function (newValue) { - var urlName = newValue ? 'activate_commcare_user' : 'deactivate_commcare_user', - $modal = $('#' + (newValue ? 'activate_' : 'deactivate_') + self.user_id()); - + var toggle_active = function($modal, urlName) { $modal.find(".btn").addSpinnerToButton(); $.ajax({ method: 'POST', @@ -129,6 +127,18 @@ hqDefine("users/js/mobile_workers",[ self.action_error(gettext("Issue communicating with server. Try again.")); }, }); + }; + + self.is_active.subscribe(function (newValue) { + var urlName = newValue ? 'activate_commcare_user' : 'deactivate_commcare_user', + $modal = $('#' + (newValue ? 'activate_' : 'deactivate_') + self.user_id()); + toggle_active($modal, urlName); + }); + + self.is_connect_link_active.subscribe(function (newValue) { + var urlName = newValue ? 'activate_connectid_link' : 'deactivate_connectid_link', + $modal = $('#' + (newValue ? 'activate_connect_link_' : 'deactivate_connect_link_') + self.user_id()); + toggle_active($modal, urlName); }); self.sendConfirmationEmail = function () { diff --git a/corehq/apps/users/templates/users/mobile_workers.html b/corehq/apps/users/templates/users/mobile_workers.html index 6b15e75cf149..72cd3e8f5e2d 100644 --- a/corehq/apps/users/templates/users/mobile_workers.html +++ b/corehq/apps/users/templates/users/mobile_workers.html @@ -23,6 +23,8 @@ {% registerurl 'paginate_mobile_workers' domain %} {% registerurl 'activate_commcare_user' domain '---' %} {% registerurl 'deactivate_commcare_user' domain '---' %} + {% registerurl 'activate_connectid_link' domain '---' %} + {% registerurl 'deactivate_connectid_link' domain '---' %} {% registerurl 'send_confirmation_email' domain '---' %} {% registerurl 'send_confirmation_sms' domain '---' %} {% registerurl 'send_connectid_invite' domain '---' %} @@ -198,6 +200,7 @@

+ ConnectID @@ -295,6 +298,7 @@

{% trans 'Mobile Workers' %}

+ ConnectID @@ -446,6 +450,104 @@ + {% if request|toggle_enabled:"COMMCARE_CONNECT" %} +
+ +

+ + +

+
+ +
+ +

+ + +

+
+ + {% endif %} {% if two_stage_user_confirmation %}