diff --git a/corehq/apps/domain/auth.py b/corehq/apps/domain/auth.py
index 0c2e4f734d62..6d472b2d995d 100644
--- a/corehq/apps/domain/auth.py
+++ b/corehq/apps/domain/auth.py
@@ -376,6 +376,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/user_importer/importer.py b/corehq/apps/user_importer/importer.py
index 4974c5d96c64..c8c3974dcd10 100644
--- a/corehq/apps/user_importer/importer.py
+++ b/corehq/apps/user_importer/importer.py
@@ -51,7 +51,11 @@
InvitationStatus
)
from corehq.const import USER_CHANGE_VIA_BULK_IMPORTER
-from corehq.toggles import DOMAIN_PERMISSIONS_MIRROR, TABLEAU_USER_SYNCING
+from corehq.toggles import (
+ DOMAIN_PERMISSIONS_MIRROR,
+ TABLEAU_USER_SYNCING,
+ COMMCARE_CONNECT,
+)
from corehq.apps.sms.util import validate_phone_number
from dimagi.utils.logging import notify_error
@@ -103,8 +107,12 @@ def check_headers(user_specs, domain, upload_couch_user, is_web_upload=False):
"User Syncing is enabled can upload files with 'Tableau Role' and/or 'Tableau Groups' fields."
))
+ if COMMCARE_CONNECT.enabled(domain):
+ allowed_headers.add('send_connectid_invite')
+
illegal_headers = headers - allowed_headers - conditionally_allowed_headers
+
if is_web_upload:
missing_headers = web_required_headers - headers
else:
@@ -504,6 +512,9 @@ def _parse_username(self):
def _parse_password(self):
from corehq.apps.user_importer.validation import is_password
+ if self.column_values["send_connectid_invite"]:
+ # password login is disabled for connectid
+ password = ''
if self.row.get('password'):
password = str(self.row.get('password'))
elif self.column_values["send_confirmation_sms"]:
@@ -537,10 +548,10 @@ def _process_column_values(self):
}
for v in ['is_active', 'is_account_confirmed', 'send_confirmation_email',
- 'remove_web_user', 'send_confirmation_sms']:
+ 'remove_web_user', 'send_confirmation_sms', 'send_connectid_invite']:
values[v] = spec_value_to_boolean_or_none(self.row, v)
- if values["send_confirmation_sms"] and not values["user_id"]:
+ if (values["send_confirmation_sms"] or values["send_connectid_invite"]) and not values["user_id"]:
values["is_account_confirmed"] = False
else:
values["is_account_confirmed"] = values["is_account_confirmed"]
@@ -698,6 +709,9 @@ def _process_web_user(self):
send_account_confirmation_if_necessary(self.user)
if cv["send_confirmation_sms"]:
send_account_confirmation_sms_if_necessary(self.user)
+ elif cv["send_connectid_invite"]:
+ from corehq.apps.users.views.mobile.users import deliver_connectid_invite
+ deliver_connectid_invite(self.user)
class WebUserRow(BaseUserRow):
diff --git a/corehq/apps/user_importer/validation.py b/corehq/apps/user_importer/validation.py
index 0cb2c2bc1ac2..f53380932c5e 100644
--- a/corehq/apps/user_importer/validation.py
+++ b/corehq/apps/user_importer/validation.py
@@ -67,7 +67,8 @@ def get_user_import_validators(domain_obj, all_specs, is_web_user_import, all_us
NewUserPasswordValidator(domain),
PasswordValidator(domain) if validate_passwords else noop,
GroupValidator(domain, allowed_groups),
- ConfirmationSmsValidator(domain)
+ ConfirmationSmsValidator(domain),
+ ConnectIdInviteValidator(domain)
]
@@ -501,6 +502,10 @@ def validate_spec(self, spec):
return self.error_existing_user.format(self.confirmation_sms_header, errors_formatted)
+class ConnectIdInviteValidator(ConfirmationSmsValidator):
+ confirmation_sms_header = "send_connectid_invite"
+
+
class LocationValidator(ImportValidator):
error_message_user_access = _("Based on your locations you do not have permission to edit this user or user "
"invitation")
diff --git a/corehq/apps/users/forms.py b/corehq/apps/users/forms.py
index dad316a36dd2..f7865a2fa350 100644
--- a/corehq/apps/users/forms.py
+++ b/corehq/apps/users/forms.py
@@ -57,6 +57,7 @@
from corehq.pillows.utils import MOBILE_USER_TYPE, WEB_USER_TYPE
from corehq.feature_previews import USE_LOCATION_DISPLAY_NAME
from corehq.toggles import (
+ COMMCARE_CONNECT,
TWO_STAGE_USER_PROVISIONING,
TWO_STAGE_USER_PROVISIONING_BY_SMS,
)
@@ -670,6 +671,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"),
@@ -768,10 +776,16 @@ 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:
+ varname = 'account_invite_by_cid' if provision_by_cid else 'force_account_confirmation_by_sms'
confirm_account_by_sms_field = crispy.Field(
- 'force_account_confirmation_by_sms',
- data_bind='checked: force_account_confirmation_by_sms',
+ varname,
+ data_bind=f'checked: {varname}',
)
phone_number_field = crispy.Div(
crispy.Field(
@@ -867,9 +881,14 @@ def __init__(self, project, request_user, *args, **kwargs):
{disabled_email}
+ && ($root.stagedUser().force_account_confirmation_by_sms()
+ || $root.stagedUser().account_invite_by_cid) -->
{disabled_phone}
+
+ {disabled_cid}
+
'''.format(
@@ -892,6 +911,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/migrations/0076_connectiduserlink_is_active.py b/corehq/apps/users/migrations/0076_connectiduserlink_is_active.py
new file mode 100644
index 000000000000..afcf7a44e74e
--- /dev/null
+++ b/corehq/apps/users/migrations/0076_connectiduserlink_is_active.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.14 on 2024-08-12 09:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0075_hqapikey_encrypted_key'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='connectiduserlink',
+ name='is_active',
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/corehq/apps/users/models.py b/corehq/apps/users/models.py
index 49c0bb034b4e..ab6afe194d9b 100644
--- a/corehq/apps/users/models.py
+++ b/corehq/apps/users/models.py
@@ -3315,6 +3315,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 1e130439e1d2..6814e73f5d18 100644
--- a/corehq/apps/users/static/users/js/mobile_workers.js
+++ b/corehq/apps/users/static/users/js/mobile_workers.js
@@ -72,9 +72,11 @@ 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,
+ is_connect_link_active: null,
deactivate_after_date: '',
});
@@ -95,9 +97,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.passwordEnabled = ko.observable(!(
+ self.force_account_confirmation_by_sms() || self.force_account_confirmation() || self.account_invite_by_cid())
+ );
self.action_error = ko.observable(''); // error when activating/deactivating a user
@@ -105,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',
@@ -126,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 () {
@@ -178,6 +191,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;
};
@@ -294,7 +332,7 @@ hqDefine("users/js/mobile_workers",[
return self.STATUS.DISABLED;
}
- if (self.stagedUser().force_account_confirmation_by_sms()) {
+ if (self.stagedUser().force_account_confirmation_by_sms() || self.stagedUser().account_invite_by_cid()) {
return self.STATUS.DISABLED;
}
@@ -511,7 +549,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);
@@ -525,7 +563,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 5ccdb6a03c8b..3ada14853ec1 100644
--- a/corehq/apps/users/templates/users/mobile_workers.html
+++ b/corehq/apps/users/templates/users/mobile_workers.html
@@ -24,8 +24,11 @@
{% 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 +201,7 @@
+ ConnectID
@@ -295,6 +299,7 @@ {% trans 'Mobile Workers' %}
+ ConnectID
@@ -320,17 +325,21 @@ {% trans 'Mobile Workers' %}
- {% if request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING" %}
- {% trans "Send Confirmation Email" %}
-
- {% else %}
-
- {% trans "Send Confirmation SMS" %}
-
- {% 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 %}
@@ -442,6 +451,104 @@
{% trans "Deactivate Mobile Worker" %}
+ {% if request|toggle_enabled:"COMMCARE_CONNECT" %}
+
+
+ {% trans "Reactivate Connect Lik" %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% blocktrans %}
+ Are you sure you want to reactivate Connect Link for this mobile worker?
+ {% endblocktrans %}
+
+
+
+
+
+
+ {% blocktrans %}
+ Deactivating disables users from logging in using their ConnectID account
+ {% endblocktrans %}
+
+
+
+
+
+
+
+
+ {% trans "Deactivate Connect Link" %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% blocktrans %}
+ Are you sure you want to deactivate ConnectID link for this mobile worker?
+ {% endblocktrans %}
+
+
+
+
+
+
+ {% blocktrans %}
+ De-linked ConnectID users can no longer access this project
+ {% endblocktrans %}
+
+
+
+
+
+
+ {% endif %}
{% if two_stage_user_confirmation %}
@@ -451,7 +558,18 @@
{% trans "Deactivate Mobile Worker" %}
×
{% trans "Close" %}
- {% trans "Confirm Account" %}
+
+
+ {% if request|toggle_enabled:"COMMCARE_CONNECT" %}
+ {% blocktrans %}
+ Re-Invite
+ {% endblocktrans %}
+ {% else %}
+ {% blocktrans %}
+ Confirm Account
+ {% endblocktrans %}
+ {% endif %}
+
@@ -460,11 +578,15 @@
{% trans "Confirm Account" %}
Would you like to send the account confirmation
email to this account?
{% endblocktrans %}
- {% else %}
+ {% elif request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING_BY_SMS" %}
{% blocktrans %}
Would you like to send the account confirmation
SMS to this account?
{% endblocktrans %}
+ {% elif request|toggle_enabled:"COMMCARE_CONNECT" %}
+ {% blocktrans %}
+ Would you like to reinvite the ConnectID User?
+ {% endblocktrans %}
{% endif %}
@@ -477,11 +599,16 @@
{% trans "Confirm Account" %}
The user will be sent an email with instructions to
set a password and be able to login to this project.
{% endblocktrans %}
- {% else %}
+ {% elif request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING_BY_SMS" %}
{% blocktrans %}
The user will be sent a SMS with instructions to
set a password and be able to login to this project.
{% endblocktrans %}
+ {% elif request|toggle_enabled:"COMMCARE_CONNECT" %}
+ {% blocktrans %}
+ The user will be sent a ConnectID SMS with instructions to
+ login to this project using their ConnectID account.
+ {% endblocktrans %}
{% endif %}
@@ -494,11 +621,16 @@
{% trans "Confirm Account" %}
data-bind="click: sendConfirmationEmail">
{% trans 'Send Account Confirmaton Email' %}
- {% else %}
+ {% elif request|toggle_enabled:"TWO_STAGE_USER_PROVISIONING_BY_SMS" %}
{% trans 'Send Account Confirmaton SMS' %}
+ {% elif request|toggle_enabled:"COMMCARE_CONNECT" %}
+
+ {% trans 'Send ConnectID Invite' %}
+
{% endif %}
diff --git a/corehq/apps/users/urls.py b/corehq/apps/users/urls.py
index 389acecbee41..90340729e644 100644
--- a/corehq/apps/users/urls.py
+++ b/corehq/apps/users/urls.py
@@ -59,9 +59,11 @@
UploadCommCareUsers,
UserUploadStatusView,
activate_commcare_user,
+ activate_connectid_link,
count_commcare_users,
count_web_users,
deactivate_commcare_user,
+ deactivate_connectid_link,
delete_commcare_user,
demo_restore_job_poll,
download_commcare_users,
@@ -75,6 +77,8 @@
CommCareUserConfirmAccountView,
send_confirmation_email,
send_confirmation_sms,
+ send_connectid_invite,
+ confirm_connectid_user,
CommcareUserUploadJobPollView,
ClearCommCareUsers,
link_connectid_user,
@@ -158,6 +162,10 @@
url(r'^commcare/account/(?P[ \w-]+)/groups/$', update_user_groups, name='update_user_groups'),
url(r'^commcare/activate/(?P[ \w-]+)/$', activate_commcare_user, name='activate_commcare_user'),
url(r'^commcare/deactivate/(?P[ \w-]+)/$', deactivate_commcare_user, name='deactivate_commcare_user'),
+ url(r'^commcare/activate_connectid_link/(?P[ \w-]+)/$',
+ activate_connectid_link, name='activate_connectid_link'),
+ url(r'^commcare/deactivate_connectid_link/(?P[ \w-]+)/$',
+ deactivate_connectid_link, name='deactivate_connectid_link'),
url(
r'^commcare/send_confirmation_email/(?P[ \w-]+)/$',
send_confirmation_email,
@@ -247,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 3b3f8b5ed87b..2c716f79af06 100644
--- a/corehq/apps/users/views/mobile/users.py
+++ b/corehq/apps/users/views/mobile/users.py
@@ -1,11 +1,14 @@
import io
import json
import re
+import requests
import time
+from django.conf import settings
from django.contrib import messages
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core.exceptions import ValidationError
+from django.db.models import F
from django.http import (
Http404,
HttpResponse,
@@ -31,7 +34,7 @@
from casexml.apps.phone.models import SyncLogSQL
from couchexport.models import Format
from couchexport.writers import Excel2007ExportWriter
-from dimagi.utils.web import json_response
+from dimagi.utils.web import json_response, get_site_domain
from dimagi.utils.logging import notify_exception
from soil import DownloadBase
from soil.exceptions import TaskFailedError
@@ -61,7 +64,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 (
@@ -86,6 +89,7 @@
MobileWorkerAccountConfirmationForm,
)
from corehq.apps.sms.api import send_sms
+from corehq.apps.sms.util import clean_phone_number
from corehq.apps.user_importer.exceptions import UserUploadError
from corehq.apps.users.account_confirmation import (
send_account_confirmation_if_necessary,
@@ -157,6 +161,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 +726,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):
@@ -799,10 +804,15 @@ def create_mobile_worker(self, in_data):
if self.new_mobile_worker_form.cleaned_data['send_account_confirmation_email']:
send_account_confirmation_if_necessary(couch_user)
+
if self.new_mobile_worker_form.cleaned_data['force_account_confirmation_by_sms']:
phone_number = self.new_mobile_worker_form.cleaned_data['phone_number']
couch_user.set_default_phone_number(phone_number)
send_account_confirmation_sms_if_necessary(couch_user)
+ if self.new_mobile_worker_form.cleaned_data['account_invite_by_cid']:
+ phone_number = self.new_mobile_worker_form.cleaned_data['phone_number']
+ couch_user.set_default_phone_number(phone_number)
+ deliver_connectid_invite(couch_user, self.request)
plan_limit, user_count = Subscription.get_plan_and_user_count_by_domain(self.domain)
check_and_send_limit_email(self.domain, plan_limit, user_count, user_count - 1)
@@ -824,14 +834,19 @@ def create_attendee_for_user(self, commcare_user):
def _build_commcare_user(self):
username = self.new_mobile_worker_form.cleaned_data['username']
- password = self.new_mobile_worker_form.cleaned_data['new_password']
+ if self.new_mobile_worker_form.cleaned_data['account_invite_by_cid']:
+ # Passwordless login using ConnectID
+ password = ''
+ else:
+ password = self.new_mobile_worker_form.cleaned_data['new_password']
first_name = self.new_mobile_worker_form.cleaned_data['first_name']
email = self.new_mobile_worker_form.cleaned_data['email']
last_name = self.new_mobile_worker_form.cleaned_data['last_name']
location_id = self.new_mobile_worker_form.cleaned_data['location_id']
is_account_confirmed = not (
self.new_mobile_worker_form.cleaned_data['force_account_confirmation']
- or self.new_mobile_worker_form.cleaned_data['force_account_confirmation_by_sms'])
+ or self.new_mobile_worker_form.cleaned_data['force_account_confirmation_by_sms']
+ or self.new_mobile_worker_form.cleaned_data['account_invite_by_cid'])
role_id = UserRole.commcare_user_default(self.domain).get_id
commcare_user = CommCareUser.create(
@@ -881,6 +896,7 @@ def _construct_form_data(self, in_data):
'force_account_confirmation': user_data.get('force_account_confirmation'),
'send_account_confirmation_email': user_data.get('send_account_confirmation_email'),
'force_account_confirmation_by_sms': user_data.get('force_account_confirmation_by_sms'),
+ 'account_invite_by_cid': user_data.get('account_invite_by_cid'),
'phone_number': user_data.get('phone_number'),
'deactivate_after_date': user_data.get('deactivate_after_date'),
'domain': self.domain,
@@ -924,6 +940,32 @@ def _modify_user_status(request, domain, user_id, is_active):
})
+@require_can_edit_commcare_users
+@require_POST
+@location_safe
+def activate_connectid_link(request, domain, user_id):
+ return _toggle_connectid_link(request, domain, user_id, True)
+
+
+@require_can_edit_commcare_users
+@require_POST
+@location_safe
+def deactivate_connectid_link(request, domain, user_id):
+ return _toggle_connectid_link(request, domain, user_id, False)
+
+
+def _toggle_connectid_link(request, domain, user_id, is_active):
+ if not toggles.COMMCARE_CONNECT.enabled(domain):
+ return HttpResponseBadRequest()
+ user = CommCareUser.get_by_user_id(user_id, domain)
+ connect_link = ConnectIDUserLink.objects.get(commcare_user=user.get_django_user())
+ connect_link.is_active = is_active
+ connect_link.save()
+ return JsonResponse({
+ 'success': True,
+ })
+
+
@require_can_edit_commcare_users
@require_POST
@location_safe
@@ -981,6 +1023,22 @@ def _status_string(user_data):
else:
return _('Pending Confirmation')
+ def get_connect_links_by_username(users):
+ if not toggles.COMMCARE_CONNECT.enabled(domain):
+ return {}
+ usernames = [
+ f"{u['base_username']}@{domain}.commcarehq.org"
+ for u in users
+ ]
+ links = ConnectIDUserLink.objects.filter(
+ commcare_user__username__in=usernames
+ ).annotate(commcare_username=F("commcare_user__username")).all()
+ return {
+ raw_username(link.commcare_username): link
+ for link in links
+ }
+
+ connect_links = get_connect_links_by_username(users)
for user in users:
date_registered = user.pop('created_on', '')
if date_registered:
@@ -988,6 +1046,8 @@ def _status_string(user_data):
# make sure these are always set and default to true
user['is_active'] = user.get('is_active', True)
user['is_account_confirmed'] = user.get('is_account_confirmed', True)
+ connect_link = connect_links.get(user.get('base_username'))
+ user['is_connect_link_active'] = connect_link.is_active if connect_link else None
user.update({
'username': user.pop('base_username', ''),
'user_id': user.pop('_id'),
@@ -1600,6 +1660,69 @@ def link_connectid_user(request, domain):
return HttpResponse()
+@require_can_edit_commcare_users
+@location_safe
+def send_connectid_invite(request, domain, user_id):
+ user = CommCareUser.get_by_user_id(user_id, domain)
+ if user.domain != domain:
+ return HttpResponse(status=400)
+ is_sent = deliver_connectid_invite(user)
+ if not is_sent:
+ return HttpResponse(status=400)
+ return HttpResponse(status=200)
+
+
+def deliver_connectid_invite(user, request=None):
+ # ConnectID server delivers the invite to user
+ if user.is_account_confirmed or not user.is_commcare_user():
+ if request:
+ messages.error(request, "The user is already confirmed or is not a mobile user")
+ return False
+
+ invite_code = encrypt_account_confirmation_info(user)
+ response = requests.post(
+ settings.CONNECTID_USERINVITE_URL,
+ data={
+ "phone_number": clean_phone_number(user.default_phone_number),
+ "callback_url": absolute_reverse("confirm_connectid_user", args=[user.domain]),
+ "user_domain": user.domain,
+ "username": user.raw_username,
+ "invite_code": invite_code,
+ },
+ auth=(settings.CONNECTID_CLIENT_ID, settings.CONNECTID_SECRET_KEY)
+ )
+ response.raise_for_status()
+ return True
+
+
+@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
diff --git a/migrations.lock b/migrations.lock
index a79a90b63a89..b32d15efe43b 100644
--- a/migrations.lock
+++ b/migrations.lock
@@ -1267,6 +1267,7 @@ users
0073_rm_location_from_user_data
0074_alter_sqluserdata_profile
0075_hqapikey_encrypted_key
+ 0076_connectiduserlink_is_active
util
0001_initial
0002_complaintbouncemeta_permanentbouncemeta_transientbounceemail
diff --git a/settings.py b/settings.py
index 4babe68eba4d..3f01e6ca8141 100755
--- a/settings.py
+++ b/settings.py
@@ -1145,6 +1145,7 @@ def _pkce_required(client_id):
FCM_CREDS = None
CONNECTID_USERINFO_URL = 'http://localhost:8080/o/userinfo'
+CONNECTID_USERINVITE_URL = 'http://localhost:8080/users/forward_hq_invite'
MAX_MOBILE_UCR_LIMIT = 300 # used in corehq.apps.cloudcare.util.should_restrict_web_apps_usage
MAX_MOBILE_UCR_SIZE = 100000 # max number of rows allowed when syncing a mobile UCR