Merge pull request #19426 from edx/saleem-latif/ENT-1117

ENT-1117: Request password reset with recovery email address
This commit is contained in:
Saleem Latif
2018-12-18 15:07:46 +05:00
committed by GitHub
15 changed files with 537 additions and 32 deletions

View File

@@ -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 '
'<a href={support_url}">contact support</a> 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.

View File

@@ -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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/$',

View File

@@ -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):

View File

@@ -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('<div id="password-reset-form" class="form-wrapper hidden"></div>');
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);

View File

@@ -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);

View File

@@ -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'),

View File

@@ -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);

View File

@@ -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,

View File

@@ -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();
},

View File

@@ -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',

View File

@@ -0,0 +1,13 @@
<div class="js-form-feedback" aria-live="assertive" tabindex="-1">
</div>
<h2><%- gettext("Account Recovery") %></h2>
<form id="account-recovery" class="account-recovery-form password-reset-form" tabindex="-1" method="POST">
<p class="action-label"><%- gettext("Please enter your secondary email address below and we will send you instructions for recovering your account and setting a new password.") %></p>
<%= HtmlUtils.HTML(fields) %>
<button type="submit" class="action action-primary action-update js-reset"><%- gettext("Recover my account") %></button>
</form>

View File

@@ -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"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" />
</script>

View File

@@ -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).

View File

@@ -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.

View File

@@ -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()
}