diff --git a/common/djangoapps/student/forms.py b/common/djangoapps/student/forms.py index d3b3cdb5a8..2881525413 100644 --- a/common/djangoapps/student/forms.py +++ b/common/djangoapps/student/forms.py @@ -21,18 +21,24 @@ from edx_ace import ace from edx_ace.recipient import Recipient from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import get_current_site from openedx.core.djangoapps.user_api import accounts as accounts_settings from openedx.core.djangoapps.user_api.preferences.api import get_user_preference from student.message_types import PasswordReset -from student.models import CourseEnrollmentAllowed, email_exists_or_retired +from student.models import AccountRecovery, CourseEnrollmentAllowed, email_exists_or_retired from util.password_policy_validators import validate_password -def send_password_reset_email_for_user(user, request): +def send_password_reset_email_for_user(user, request, preferred_email=None): """ Send out a password reset email for the given user. + + Arguments: + user (User): Django User object + request (HttpRequest): Django request object + preferred_email (str): Send email to this address if present, otherwise fallback to user's email address. """ site = get_current_site() message_context = get_base_template_context(site) @@ -51,7 +57,7 @@ def send_password_reset_email_for_user(user, request): }) msg = PasswordReset().personalize( - recipient=Recipient(user.username, user.email), + recipient=Recipient(user.username, preferred_email or user.email), language=get_user_preference(user, LANGUAGE_KEY), user_context=message_context, ) @@ -95,6 +101,44 @@ class PasswordResetFormNoActive(PasswordResetForm): send_password_reset_email_for_user(user, request) +class AccountRecoveryForm(PasswordResetFormNoActive): + error_messages = { + 'unknown': _( + HTML( + 'That secondary e-mail address doesn\'t have an associated user account. Are you sure you had added ' + 'a verified secondary email address for account recovery in your account settings? Please ' + 'contact support for further assistance.' + ) + ).format( + support_url=configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK), + ), + 'unusable': _( + Text( + 'The user account associated with this secondary e-mail address cannot reset the password.' + ) + ), + } + + def clean_email(self): + """ + This is a literal copy from Django's django.contrib.auth.forms.PasswordResetForm + Except removing the requirement of active users + Validates that a user exists with the given secondary email. + """ + email = self.cleaned_data["email"] + # The line below contains the only change, getting users via AccountRecovery + self.users_cache = User.objects.filter( + id__in=AccountRecovery.objects.filter(secondary_email__iexact=email).values_list('user') + ) + + if not len(self.users_cache): + raise forms.ValidationError(self.error_messages['unknown']) + if any((user.password.startswith(UNUSABLE_PASSWORD_PREFIX)) + for user in self.users_cache): + raise forms.ValidationError(self.error_messages['unusable']) + return email + + class TrueCheckbox(widgets.CheckboxInput): """ A checkbox widget that only accepts "true" (case-insensitive) as true. diff --git a/common/djangoapps/student/urls.py b/common/djangoapps/student/urls.py index 360d6a23c7..55b3911b11 100644 --- a/common/djangoapps/student/urls.py +++ b/common/djangoapps/student/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ # password reset in views (see below for password reset django views) url(r'^account/password$', views.password_change_request_handler, name='password_change_request'), + url(r'^account/account_recovery', views.account_recovery_request_handler, name='account_recovery'), url(r'^password_reset/$', views.password_reset, name='password_reset'), url( r'^password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/$', 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 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() }