.+)/$',
diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py
index 01fe28741e..9ffeae26ea 100644
--- a/common/djangoapps/student/views/management.py
+++ b/common/djangoapps/student/views/management.py
@@ -57,6 +57,7 @@ from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
from openedx.core.djangoapps.theming.helpers import get_current_site
+from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.config.waffle import PREVENT_AUTH_USER_WRITES, SYSTEM_MAINTENANCE_MSG, waffle
from openedx.core.djangoapps.user_api.errors import UserNotFound, UserAPIInternalError
from openedx.core.djangoapps.user_api.models import UserRetirementRequest
@@ -76,6 +77,7 @@ from student.helpers import (
)
from student.message_types import EmailChange, PasswordReset
from student.models import (
+ AccountRecovery,
CourseEnrollment,
PasswordHistory,
PendingEmailChange,
@@ -723,6 +725,59 @@ def password_change_request_handler(request):
return HttpResponseBadRequest(_("No email address provided."))
+@require_http_methods(['POST'])
+def account_recovery_request_handler(request):
+ """
+ Handle account recovery requests.
+
+ Arguments:
+ request (HttpRequest)
+
+ Returns:
+ HttpResponse: 200 if the email was sent successfully
+ HttpResponse: 400 if there is no 'email' POST parameter
+ HttpResponse: 403 if the client has been rate limited
+ HttpResponse: 405 if using an unsupported HTTP method
+ HttpResponse: 404 if account recovery feature is not enabled
+
+ Example:
+
+ POST /account/account_recovery
+
+ """
+ if not is_secondary_email_feature_enabled():
+ raise Http404
+
+ limiter = BadRequestRateLimiter()
+ if limiter.is_rate_limit_exceeded(request):
+ AUDIT_LOG.warning("Account recovery rate limit exceeded")
+ return HttpResponseForbidden()
+
+ user = request.user
+ # Prefer logged-in user's email
+ email = request.POST.get('email')
+
+ if email:
+ try:
+ # Send an email with a link to direct user towards account recovery.
+ from openedx.core.djangoapps.user_api.accounts.api import request_account_recovery
+ request_account_recovery(email, request.is_secure())
+
+ # Check if a user exists with the given secondary email, if so then invalidate the existing oauth tokens.
+ user = user if user.is_authenticated else User.objects.get(
+ id=AccountRecovery.objects.get(secondary_email__iexact=email).user.id
+ )
+ destroy_oauth_tokens(user)
+ except UserNotFound:
+ AUDIT_LOG.warning(
+ "Account recovery attempt via invalid secondary email '{email}'.".format(email=email)
+ )
+
+ return HttpResponse(status=200)
+ else:
+ return HttpResponseBadRequest(_("No email address provided."))
+
+
@csrf_exempt
@require_POST
def password_reset(request):
diff --git a/lms/static/js/spec/student_account/account_recovery_spec.js b/lms/static/js/spec/student_account/account_recovery_spec.js
new file mode 100644
index 0000000000..32c82a9cfa
--- /dev/null
+++ b/lms/static/js/spec/student_account/account_recovery_spec.js
@@ -0,0 +1,152 @@
+(function(define) {
+ 'use strict';
+ define([
+ 'jquery',
+ 'underscore',
+ 'common/js/spec_helpers/template_helpers',
+ 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
+ 'js/student_account/models/AccountRecoveryModel',
+ 'js/student_account/views/AccountRecoveryView'
+ ],
+ function($, _, TemplateHelpers, AjaxHelpers, AccountRecoveryModel, AccountRecoveryView) {
+ describe('edx.student.account.AccountRecoveryView', function() {
+ var model = null,
+ view = null,
+ requests = null,
+ EMAIL = 'xsy@edx.org',
+ FORM_DESCRIPTION = {
+ method: 'post',
+ submit_url: '/account/password',
+ fields: [{
+ name: 'email',
+ label: 'Secondary email',
+ defaultValue: '',
+ type: 'text',
+ required: true,
+ placeholder: 'place@holder.org',
+ instructions: 'Enter your secondary email.',
+ restrictions: {}
+ }]
+ };
+
+ var createAccountRecoveryView = function(that) {
+ // Initialize the account recovery model
+ model = new AccountRecoveryModel({}, {
+ url: FORM_DESCRIPTION.submit_url,
+ method: FORM_DESCRIPTION.method
+ });
+
+ // Initialize the account recovery view
+ view = new AccountRecoveryView({
+ fields: FORM_DESCRIPTION.fields,
+ model: model
+ });
+
+ // Spy on AJAX requests
+ requests = AjaxHelpers.requests(that);
+ };
+
+ var submitEmail = function(validationSuccess) {
+ // Create a fake click event
+ var clickEvent = $.Event('click');
+
+ // Simulate manual entry of an email address
+ $('#password-reset-email').val(EMAIL);
+
+ // If validationSuccess isn't passed, we avoid
+ // spying on `view.validate` twice
+ if (!_.isUndefined(validationSuccess)) {
+ // Force validation to return as expected
+ spyOn(view, 'validate').and.returnValue({
+ isValid: validationSuccess,
+ message: 'Submission was validated.'
+ });
+ }
+
+ // Submit the email address
+ view.submitForm(clickEvent);
+ };
+
+ beforeEach(function() {
+ setFixtures('');
+ TemplateHelpers.installTemplate('templates/student_account/account_recovery');
+ TemplateHelpers.installTemplate('templates/student_account/form_field');
+ });
+
+ it('allows the user to request account recovery', function() {
+ var syncSpy, passwordEmailSentSpy;
+
+ createAccountRecoveryView(this);
+
+ // We expect these events to be triggered upon a successful account recovery
+ syncSpy = jasmine.createSpy('syncEvent');
+ passwordEmailSentSpy = jasmine.createSpy('passwordEmailSentEvent');
+ view.listenTo(view.model, 'sync', syncSpy);
+ view.listenTo(view, 'account-recovery-email-sent', passwordEmailSentSpy);
+
+ // Submit the form, with successful validation
+ submitEmail(true);
+
+ // Verify that the client contacts the server with the expected data
+ AjaxHelpers.expectRequest(
+ requests, 'POST',
+ FORM_DESCRIPTION.submit_url,
+ $.param({email: EMAIL})
+ );
+
+ // Respond with status code 200
+ AjaxHelpers.respondWithJson(requests, {});
+
+ // Verify that the events were triggered
+ expect(syncSpy).toHaveBeenCalled();
+ expect(passwordEmailSentSpy).toHaveBeenCalled();
+
+ // Verify that account recovery view has been removed
+ expect($(view.el).html().length).toEqual(0);
+ });
+
+ it('validates the email field', function() {
+ createAccountRecoveryView(this);
+
+ // Submit the form, with successful validation
+ submitEmail(true);
+
+ // Verify that validation of the email field occurred
+ expect(view.validate).toHaveBeenCalledWith($('#password-reset-email')[0]);
+
+ // Verify that no submission errors are visible
+ expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0);
+ });
+
+ it('displays account recovery validation errors', function() {
+ createAccountRecoveryView(this);
+
+ // Submit the form, with failed validation
+ submitEmail(false);
+
+ // Verify that submission errors are visible
+ expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(1);
+ });
+
+ it('displays error if the server returns an error while sending account recovery email', function() {
+ createAccountRecoveryView(this);
+ submitEmail(true);
+
+ // Simulate an error from the LMS servers
+ AjaxHelpers.respondWithError(requests);
+
+ // Expect that an error is displayed
+ expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(1);
+
+ // If we try again and succeed, the error should go away
+ submitEmail();
+
+ // This time, respond with status code 200
+ AjaxHelpers.respondWithJson(requests, {});
+
+ // Expect that the error is hidden
+ expect(view.$formFeedback.find('.' + view.formErrorsJsHook).length).toEqual(0);
+ });
+ });
+ });
+}).call(this, define || RequireJS.define);
diff --git a/lms/static/js/student_account/models/AccountRecoveryModel.js b/lms/static/js/student_account/models/AccountRecoveryModel.js
new file mode 100644
index 0000000000..2e127be7d9
--- /dev/null
+++ b/lms/static/js/student_account/models/AccountRecoveryModel.js
@@ -0,0 +1,38 @@
+(function(define) {
+ 'use strict';
+ define(['jquery', 'backbone'],
+ function($, Backbone) {
+ return Backbone.Model.extend({
+ defaults: {
+ email: ''
+ },
+ ajaxType: '',
+ urlRoot: '',
+
+ initialize: function(attributes, options) {
+ this.ajaxType = options.method;
+ this.urlRoot = options.url;
+ },
+
+ sync: function(method, model) {
+ var headers = {
+ 'X-CSRFToken': $.cookie('csrftoken')
+ };
+
+ // Only expects an email address.
+ $.ajax({
+ url: model.urlRoot,
+ type: model.ajaxType,
+ data: model.attributes,
+ headers: headers,
+ success: function() {
+ model.trigger('sync');
+ },
+ error: function(error) {
+ model.trigger('error', error);
+ }
+ });
+ }
+ });
+ });
+}).call(this, define || RequireJS.define);
diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js
index db3ec5c9b3..0be722953a 100644
--- a/lms/static/js/student_account/views/AccessView.js
+++ b/lms/static/js/student_account/views/AccessView.js
@@ -9,15 +9,19 @@
'js/student_account/models/LoginModel',
'js/student_account/models/PasswordResetModel',
'js/student_account/models/RegisterModel',
+ 'js/student_account/models/AccountRecoveryModel',
'js/student_account/views/LoginView',
'js/student_account/views/PasswordResetView',
'js/student_account/views/RegisterView',
'js/student_account/views/InstitutionLoginView',
'js/student_account/views/HintedLoginView',
+ 'js/student_account/views/AccountRecoveryView',
+ 'edx-ui-toolkit/js/utils/html-utils',
'js/vendor/history'
],
- function($, utility, _, _s, Backbone, LoginModel, PasswordResetModel, RegisterModel, LoginView,
- PasswordResetView, RegisterView, InstitutionLoginView, HintedLoginView) {
+ function($, utility, _, _s, Backbone, LoginModel, PasswordResetModel, RegisterModel, AccountRecoveryModel,
+ LoginView, PasswordResetView, RegisterView, InstitutionLoginView, HintedLoginView, AccountRecoveryView,
+ HtmlUtils) {
return Backbone.View.extend({
tpl: '#access-tpl',
events: {
@@ -27,6 +31,7 @@
login: {},
register: {},
passwordHelp: {},
+ accountRecoveryHelp: {},
institutionLogin: {},
hintedLogin: {}
},
@@ -63,6 +68,7 @@
login: options.login_form_desc,
register: options.registration_form_desc,
reset: options.password_reset_form_desc,
+ account_recovery: options.account_recovery_form_desc,
institution_login: null,
hinted_login: null
};
@@ -74,6 +80,7 @@
this.hideAuthWarnings = options.hide_auth_warnings || false;
this.pipelineUserDetails = options.third_party_auth.pipeline_user_details;
this.enterpriseName = options.enterprise_name || '';
+ this.isAccountRecoveryFeatureEnabled = options.is_account_recovery_feature_enabled || false;
// The login view listens for 'sync' events from the reset model
this.resetModel = new PasswordResetModel({}, {
@@ -81,6 +88,11 @@
url: '#'
});
+ this.accountRecoveryModel = new AccountRecoveryModel({}, {
+ method: 'GET',
+ url: '#'
+ });
+
this.render();
// Once the third party error message has been shown once,
@@ -93,10 +105,14 @@
},
render: function() {
- $(this.el).html(_.template(this.tpl)({
- mode: this.activeForm
- }));
-
+ HtmlUtils.setHtml(
+ $(this.el),
+ HtmlUtils.HTML(
+ _.template(this.tpl)({
+ mode: this.activeForm
+ })
+ )
+ )
this.postRender();
return this;
@@ -107,6 +123,9 @@
if (Backbone.history.getHash() === 'forgot-password-modal') {
this.resetPassword();
}
+ else if (Backbone.history.getHash() === 'account-recovery-modal') {
+ this.accountRecovery();
+ }
this.loadForm(this.activeForm);
},
@@ -126,6 +145,7 @@
fields: data.fields,
model: model,
resetModel: this.resetModel,
+ accountRecoveryModel: this.accountRecoveryModel,
thirdPartyAuth: this.thirdPartyAuth,
accountActivationMessages: this.accountActivationMessages,
platformName: this.platformName,
@@ -140,6 +160,9 @@
// Listen for 'password-help' event to toggle sub-views
this.listenTo(this.subview.login, 'password-help', this.resetPassword);
+ // Listen for 'account-recovery-help' event to toggle sub-views
+ this.listenTo(this.subview.login, 'account-recovery-help', this.accountRecovery);
+
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
this.listenTo(this.subview.login, 'auth-complete', this.authComplete);
},
@@ -160,6 +183,24 @@
$('.password-reset-form').focus();
},
+ account_recovery: function(data) {
+ this.accountRecoveryModel.ajaxType = data.method;
+ this.accountRecoveryModel.urlRoot = data.submit_url;
+
+ this.subview.accountRecoveryHelp = new AccountRecoveryView({
+ fields: data.fields,
+ model: this.accountRecoveryModel
+ });
+
+ // Listen for 'account-recovery-email-sent' event to toggle sub-views
+ this.listenTo(
+ this.subview.accountRecoveryHelp, 'account-recovery-email-sent', this.passwordEmailSent
+ );
+
+ // Focus on the form
+ $('.password-reset-form').focus();
+ },
+
register: function(data) {
var model = new RegisterModel({}, {
method: data.method,
@@ -216,6 +257,19 @@
this.element.scrollTop($('#password-reset-anchor'));
},
+ accountRecovery: function() {
+ if (this.isAccountRecoveryFeatureEnabled) {
+ window.analytics.track('edx.bi.account_recovery.viewed', {
+ category: 'user-engagement'
+ });
+
+ this.element.hide($(this.el).find('#login-anchor'));
+ this.loadForm('account_recovery');
+ this.element.scrollTop($('#password-reset-anchor'));
+ }
+
+ },
+
toggleForm: function(e) {
var type = $(e.currentTarget).data('type'),
$form = $('#' + type + '-form'),
diff --git a/lms/static/js/student_account/views/AccountRecoveryView.js b/lms/static/js/student_account/views/AccountRecoveryView.js
new file mode 100644
index 0000000000..5fa974dbad
--- /dev/null
+++ b/lms/static/js/student_account/views/AccountRecoveryView.js
@@ -0,0 +1,39 @@
+(function(define) {
+ 'use strict';
+ define([
+ 'jquery',
+ 'js/student_account/views/FormView'
+ ],
+ function($, FormView) {
+ return FormView.extend({
+ el: '#password-reset-form',
+
+ tpl: '#account_recovery-tpl',
+
+ events: {
+ 'click .js-reset': 'submitForm'
+ },
+
+ formType: 'password-reset',
+
+ requiredStr: '',
+ optionalStr: '',
+
+ submitButton: '.js-reset',
+
+ preRender: function() {
+ this.element.show($(this.el));
+ this.element.show($(this.el).parent());
+ this.listenTo(this.model, 'sync', this.saveSuccess);
+ },
+
+ saveSuccess: function() {
+ this.trigger('account-recovery-email-sent');
+
+ // Destroy the view (but not el) and unbind events
+ this.$el.empty().off();
+ this.stopListening();
+ }
+ });
+ });
+}).call(this, define || RequireJS.define);
diff --git a/lms/static/js/student_account/views/FormView.js b/lms/static/js/student_account/views/FormView.js
index 53d8d5e33b..e82e8d4c2a 100644
--- a/lms/static/js/student_account/views/FormView.js
+++ b/lms/static/js/student_account/views/FormView.js
@@ -55,10 +55,15 @@
render: function(html) {
var fields = html || '';
- $(this.el).html(_.template(this.tpl)({
- fields: fields
- }));
-
+ HtmlUtils.setHtml(
+ $(this.el),
+ HtmlUtils.HTML(
+ _.template(this.tpl)({
+ fields: fields,
+ HtmlUtils: HtmlUtils
+ })
+ )
+ )
this.postRender();
return this;
@@ -134,6 +139,12 @@
this.trigger('password-help');
},
+ accountRecovery: function(event) {
+ event.preventDefault();
+
+ this.trigger('account-recovery-help');
+ },
+
getFormData: function() {
var obj = {},
$form = this.$form,
diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js
index 264e00258c..509dcf50cd 100644
--- a/lms/static/js/student_account/views/LoginView.js
+++ b/lms/static/js/student_account/views/LoginView.js
@@ -23,6 +23,7 @@
events: {
'click .js-login': 'submitForm',
'click .forgot-password': 'forgotPassword',
+ 'click .account-recovery': 'accountRecovery',
'click .login-provider': 'thirdPartyAuth'
},
formType: 'login',
@@ -45,6 +46,7 @@
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName;
this.resetModel = data.resetModel;
+ this.accountRecoveryModel = data.accountRecoveryModel;
this.supportURL = data.supportURL;
this.passwordResetSupportUrl = data.passwordResetSupportUrl;
this.createAccountOption = data.createAccountOption;
@@ -55,27 +57,32 @@
this.listenTo(this.model, 'sync', this.saveSuccess);
this.listenTo(this.resetModel, 'sync', this.resetEmail);
+ this.listenTo(this.accountRecoveryModel, 'sync', this.resetEmail);
},
render: function(html) {
var fields = html || '';
- $(this.el).html(_.template(this.tpl)({
- // We pass the context object to the template so that
- // we can perform variable interpolation using sprintf
- context: {
- fields: fields,
- currentProvider: this.currentProvider,
- syncLearnerProfileData: this.syncLearnerProfileData,
- providers: this.providers,
- hasSecondaryProviders: this.hasSecondaryProviders,
- platformName: this.platformName,
- createAccountOption: this.createAccountOption,
- pipelineUserDetails: this.pipelineUserDetails,
- enterpriseName: this.enterpriseName
- }
- }));
-
+ HtmlUtils.setHtml(
+ $(this.el),
+ HtmlUtils.HTML(
+ _.template(this.tpl)({
+ // We pass the context object to the template so that
+ // we can perform variable interpolation using sprintf
+ context: {
+ fields: fields,
+ currentProvider: this.currentProvider,
+ syncLearnerProfileData: this.syncLearnerProfileData,
+ providers: this.providers,
+ hasSecondaryProviders: this.hasSecondaryProviders,
+ platformName: this.platformName,
+ createAccountOption: this.createAccountOption,
+ pipelineUserDetails: this.pipelineUserDetails,
+ enterpriseName: this.enterpriseName
+ }
+ })
+ )
+ )
this.postRender();
return this;
@@ -124,6 +131,13 @@
this.clearPasswordResetSuccess();
},
+ accountRecovery: function(event) {
+ event.preventDefault();
+
+ this.trigger('account-recovery-help');
+ this.clearPasswordResetSuccess();
+ },
+
postFormSubmission: function() {
this.clearPasswordResetSuccess();
},
diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js
index dd63fbdc47..a845786974 100644
--- a/lms/static/lms/js/spec/main.js
+++ b/lms/static/lms/js/spec/main.js
@@ -787,6 +787,7 @@
'js/spec/student_account/login_spec.js',
'js/spec/student_account/logistration_factory_spec.js',
'js/spec/student_account/password_reset_spec.js',
+ 'js/spec/student_account/account_recovery_spec.js',
'js/spec/student_account/register_spec.js',
'js/spec/student_account/shoppingcart_spec.js',
'js/spec/verify_student/image_input_spec.js',
diff --git a/lms/templates/student_account/account_recovery.underscore b/lms/templates/student_account/account_recovery.underscore
new file mode 100644
index 0000000000..d4ad0739af
--- /dev/null
+++ b/lms/templates/student_account/account_recovery.underscore
@@ -0,0 +1,13 @@
+
+
+
+<%- gettext("Account Recovery") %>
+
+
diff --git a/lms/templates/student_account/login_and_register.html b/lms/templates/student_account/login_and_register.html
index 41a3ab6e12..25a6353b5c 100644
--- a/lms/templates/student_account/login_and_register.html
+++ b/lms/templates/student_account/login_and_register.html
@@ -30,7 +30,7 @@
%block>
<%block name="header_extras">
- % for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset", "hinted_login"]:
+ % for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset", "account_recovery", "hinted_login"]:
diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py
index 1394f77c9f..3bc667eaaf 100644
--- a/openedx/core/djangoapps/user_api/accounts/api.py
+++ b/openedx/core/djangoapps/user_api/accounts/api.py
@@ -474,6 +474,36 @@ def request_password_change(email, is_secure):
raise errors.UserNotFound
+@helpers.intercept_errors(errors.UserAPIInternalError, ignore_errors=[errors.UserAPIRequestError])
+def request_account_recovery(email, is_secure):
+ """
+ Email a single-use link for performing a password reset so users can login with new email and password.
+
+ Arguments:
+ email (str): An email address
+ is_secure (bool): Whether the request was made with HTTPS
+
+ Raises:
+ errors.UserNotFound: Raised if secondary email address does not exist.
+ """
+ # Binding data to a form requires that the data be passed as a dictionary
+ # to the Form class constructor.
+ form = student_forms.AccountRecoveryForm({'email': email})
+
+ # Validate that a user exists with the given email address.
+ if form.is_valid():
+ # Generate a single-use link for performing a password reset
+ # and email it to the user.
+ form.save(
+ from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL),
+ use_https=is_secure,
+ request=get_current_request(),
+ )
+ else:
+ # No user with the provided email address exists.
+ raise errors.UserNotFound
+
+
def get_name_validation_error(name):
"""Get the built-in validation error message for when
the user's real name is invalid in some way (we wonder how).
diff --git a/openedx/core/djangoapps/user_api/api.py b/openedx/core/djangoapps/user_api/api.py
index eb3be3cf50..541826aac9 100644
--- a/openedx/core/djangoapps/user_api/api.py
+++ b/openedx/core/djangoapps/user_api/api.py
@@ -66,6 +66,54 @@ def get_password_reset_form():
return form_desc
+def get_account_recovery_form():
+ """
+ Return a description of the password reset, using secondary email, form.
+
+ This decouples clients from the API definition:
+ if the API decides to modify the form, clients won't need
+ to be updated.
+
+ See `user_api.helpers.FormDescription` for examples
+ of the JSON-encoded form description.
+
+ Returns:
+ HttpResponse
+
+ """
+ form_desc = FormDescription("post", reverse("account_recovery"))
+
+ # Translators: This label appears above a field on the password reset
+ # form meant to hold the user's email address.
+ email_label = _(u"Secondary email")
+
+ # Translators: This example email address is used as a placeholder in
+ # a field on the password reset form meant to hold the user's email address.
+ email_placeholder = _(u"username@domain.com")
+
+ # Translators: These instructions appear on the password reset form,
+ # immediately below a field meant to hold the user's email address.
+ email_instructions = _(
+ u"Secondary email address you registered with {platform_name} using account settings page"
+ ).format(
+ platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
+ )
+
+ form_desc.add_field(
+ "email",
+ field_type="email",
+ label=email_label,
+ placeholder=email_placeholder,
+ instructions=email_instructions,
+ restrictions={
+ "min_length": accounts.EMAIL_MIN_LENGTH,
+ "max_length": accounts.EMAIL_MAX_LENGTH,
+ }
+ )
+
+ return form_desc
+
+
def get_login_session_form(request):
"""Return a description of the login form.
diff --git a/openedx/core/djangoapps/user_authn/views/login_form.py b/openedx/core/djangoapps/user_authn/views/login_form.py
index 076240a759..05ff9b48a2 100644
--- a/openedx/core/djangoapps/user_authn/views/login_form.py
+++ b/openedx/core/djangoapps/user_authn/views/login_form.py
@@ -19,10 +19,12 @@ from openedx.core.djangoapps.external_auth.login_and_register import login as ex
from openedx.core.djangoapps.external_auth.login_and_register import register as external_auth_register
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.helpers import is_request_in_themed_site
+from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
from openedx.core.djangoapps.user_api.api import (
RegistrationFormFactory,
+ get_account_recovery_form,
get_login_session_form,
- get_password_reset_form
+ get_password_reset_form,
)
from openedx.core.djangoapps.user_authn.cookies import are_logged_in_cookies_set
from openedx.features.enterprise_support.api import enterprise_customer_for_request
@@ -129,8 +131,10 @@ def login_and_registration_form(request, initial_mode="login"):
'login_form_desc': json.loads(form_descriptions['login']),
'registration_form_desc': json.loads(form_descriptions['registration']),
'password_reset_form_desc': json.loads(form_descriptions['password_reset']),
+ 'account_recovery_form_desc': json.loads(form_descriptions['account_recovery']),
'account_creation_allowed': configuration_helpers.get_value(
- 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True))
+ 'ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True)),
+ 'is_account_recovery_feature_enabled': is_secondary_email_feature_enabled()
},
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in header
'responsive': True,
@@ -166,6 +170,7 @@ def _get_form_descriptions(request):
return {
'password_reset': get_password_reset_form().to_json(),
+ 'account_recovery': get_account_recovery_form().to_json(),
'login': get_login_session_form(request).to_json(),
'registration': RegistrationFormFactory().get_registration_form(request).to_json()
}