From 7437bcfe12e1d5ebff627909b41b88bc377aa1b8 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sun, 21 Jun 2015 12:59:12 -0700 Subject: [PATCH] New provider config options, New Institution Login Menu - PR 8603 --- common/djangoapps/student/views.py | 23 ++- .../migrations/0003_add_config_options.py | 161 ++++++++++++++++++ common/djangoapps/third_party_auth/models.py | 30 +++- .../djangoapps/third_party_auth/pipeline.py | 7 + .../tests/specs/test_testshib.py | 10 +- .../student_account/test/test_views.py | 1 + lms/djangoapps/student_account/views.py | 12 +- lms/envs/common.py | 1 + lms/static/js/spec/main.js | 11 ++ .../js/spec/student_account/access_spec.js | 27 +++ .../student_account/institution_login_spec.js | 80 +++++++++ .../js/student_account/views/AccessView.js | 26 ++- .../js/student_account/views/FormView.js | 4 +- .../views/InstitutionLoginView.js | 30 ++++ .../js/student_account/views/LoginView.js | 4 + .../js/student_account/views/RegisterView.js | 21 ++- lms/static/sass/views/_login-register.scss | 80 ++++++--- .../student_account/access.underscore | 4 + .../institution_login.underscore | 31 ++++ .../institution_register.underscore | 31 ++++ .../student_account/login.underscore | 8 +- .../student_account/login_and_register.html | 2 +- .../student_account/register.underscore | 8 +- 23 files changed, 558 insertions(+), 54 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0003_add_config_options.py create mode 100644 lms/static/js/spec/student_account/institution_login_spec.js create mode 100644 lms/static/js/student_account/views/InstitutionLoginView.js create mode 100644 lms/templates/student_account/institution_login.underscore create mode 100644 lms/templates/student_account/institution_register.underscore diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index bb872c1a55..1420aa5432 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1498,6 +1498,13 @@ def create_account_with_params(request, params): dog_stats_api.increment("common.student.account_created") + # If the user is registering via 3rd party auth, track which provider they use + third_party_provider = None + running_pipeline = None + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + # Track the user's registration if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): tracking_context = tracker.get_tracker().resolve_context() @@ -1506,20 +1513,13 @@ def create_account_with_params(request, params): 'username': user.username, }) - # If the user is registering via 3rd party auth, track which provider they use - provider_name = None - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_from_pipeline(running_pipeline) - provider_name = current_provider.name - analytics.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', 'label': params.get('course_id'), - 'provider': provider_name + 'provider': third_party_provider.name if third_party_provider else None }, context={ 'Google Analytics': { @@ -1536,6 +1536,7 @@ def create_account_with_params(request, params): # 2. Random user generation for other forms of testing. # 3. External auth bypassing activation. # 4. Have the platform configured to not require e-mail activation. + # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) # # Note that this feature is only tested as a flag set one way or # the other for *new* systems. we need to be careful about @@ -1544,7 +1545,11 @@ def create_account_with_params(request, params): send_email = ( not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and - not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) + not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and + not ( + third_party_provider and third_party_provider.skip_email_verification and + user.email == running_pipeline['kwargs'].get('details', {}).get('email') + ) ) if send_email: context = { diff --git a/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py new file mode 100644 index 0000000000..6ff8a3d3a5 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0003_add_config_options.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'SAMLProviderConfig.secondary' + db.add_column('third_party_auth_samlproviderconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_registration_form' + db.add_column('third_party_auth_samlproviderconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'SAMLProviderConfig.skip_email_verification' + db.add_column('third_party_auth_samlproviderconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.secondary' + db.add_column('third_party_auth_oauth2providerconfig', 'secondary', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_registration_form' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_registration_form', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'OAuth2ProviderConfig.skip_email_verification' + db.add_column('third_party_auth_oauth2providerconfig', 'skip_email_verification', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'SAMLProviderConfig.secondary' + db.delete_column('third_party_auth_samlproviderconfig', 'secondary') + + # Deleting field 'SAMLProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_registration_form') + + # Deleting field 'SAMLProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_samlproviderconfig', 'skip_email_verification') + + # Deleting field 'OAuth2ProviderConfig.secondary' + db.delete_column('third_party_auth_oauth2providerconfig', 'secondary') + + # Deleting field 'OAuth2ProviderConfig.skip_registration_form' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_registration_form') + + # Deleting field 'OAuth2ProviderConfig.skip_email_verification' + db.delete_column('third_party_auth_oauth2providerconfig', 'skip_email_verification') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'third_party_auth.oauth2providerconfig': { + 'Meta': {'object_name': 'OAuth2ProviderConfig'}, + 'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlconfiguration': { + 'Meta': {'object_name': 'SAMLConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}), + 'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}), + 'private_key': ('django.db.models.fields.TextField', [], {}), + 'public_key': ('django.db.models.fields.TextField', [], {}) + }, + 'third_party_auth.samlproviderconfig': { + 'Meta': {'object_name': 'SAMLProviderConfig'}, + 'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}), + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}), + 'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}), + 'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'third_party_auth.samlproviderdata': { + 'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'}, + 'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'public_key': ('django.db.models.fields.TextField', [], {}), + 'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'}) + } + } + + complete_apps = ['third_party_auth'] \ No newline at end of file diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index b159c7d902..1550c54eba 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -8,7 +8,7 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone -from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_lazy as _ import json import logging from social.backends.base import BaseAuth @@ -54,7 +54,7 @@ class AuthNotConfigured(SocialAuthBaseException): self.provider_name = provider_name def __str__(self): - return _('Authentication with {} is currently unavailable.').format( + return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member self.provider_name ) @@ -68,10 +68,34 @@ class ProviderConfig(ConfigurationModel): help_text=( 'The Font Awesome (or custom) icon class to use on the login button for this provider. ' 'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university' - )) + ), + ) name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)") + secondary = models.BooleanField( + default=False, + help_text=_( + 'Secondary providers are displayed less prominently, ' + 'in a separate list of "Institution" login providers.' + ), + ) + skip_registration_form = models.BooleanField( + default=False, + help_text=_( + "If this option is enabled, users will not be asked to confirm their details " + "(name, email, etc.) during the registration process. Only select this option " + "for trusted providers that are known to provide accurate user information." + ), + ) + skip_email_verification = models.BooleanField( + default=False, + help_text=_( + "If this option is selected, users will not be required to confirm their " + "email, and their account will be activated immediately upon registration." + ), + ) prefix = None # used for provider_id. Set to a string value in subclass backend_name = None # Set to a field or fixed value in subclass + # "enabled" field is inherited from ConfigurationModel class Meta(object): # pylint: disable=missing-docstring diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index c3d18b7b45..5bc4f069dd 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -503,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia """Redirects to the registration page.""" return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER]) + def should_force_account_creation(): + """ For some third party providers, we auto-create user accounts """ + current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs}) + return current_provider and current_provider.skip_email_verification + if not user: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: return HttpResponseBadRequest() elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: # User has authenticated with the third party provider but we don't know which edX # account corresponds to them yet, if any. + if should_force_account_creation(): + return dispatch_to_register() return dispatch_to_login() elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]: # User has authenticated with the third party provider and now wants to finish diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index be17cf74a8..aacb945aa6 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -1,6 +1,7 @@ """ Third_party_auth integration tests using a mock version of the TestShib provider """ +from django.contrib.auth.models import User from django.core.urlresolvers import reverse import httpretty from mock import patch @@ -62,8 +63,6 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): self.assertEqual(response.status_code, 200) self.assertIn('Authentication with TestShib is currently unavailable.', response.content) - # Note: the following patch is only needed until https://github.com/edx/edx-platform/pull/8262 is merged - @patch.dict("django.conf.settings.FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True}) def test_register(self): self._configure_testshib_provider() self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. @@ -107,6 +106,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): # Now check that we can login again: self.client.logout() + self._verify_user_email('myself@testshib.org') self._test_return_login() def test_login(self): @@ -222,3 +222,9 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): content_type='application/x-www-form-urlencoded', data=self._read_data_file('testshib_response.txt'), ) + + def _verify_user_email(self, email): + """ Mark the user with the given email as verified """ + user = User.objects.get(email=email) + user.is_active = True + user.save() diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 508cd9b19b..c11c858c7d 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -359,6 +359,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi json.dumps({ "currentProvider": current_provider, "providers": providers, + "secondaryProviders": [], "finishAuthUrl": finish_auth_url, "errorMessage": None, }) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 1983dd99b7..ffc10c04b4 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -164,13 +164,14 @@ def _third_party_auth_context(request, redirect_to): context = { "currentProvider": None, "providers": [], + "secondaryProviders": [], "finishAuthUrl": None, "errorMessage": None, } if third_party_auth.is_enabled(): - context["providers"] = [ - { + for enabled in third_party_auth.provider.Registry.enabled(): + info = { "id": enabled.provider_id, "name": enabled.name, "iconClass": enabled.icon_class, @@ -185,8 +186,7 @@ def _third_party_auth_context(request, redirect_to): redirect_url=redirect_to, ), } - for enabled in third_party_auth.provider.Registry.enabled() - ] + context["providers" if not enabled.secondary else "secondaryProviders"].append(info) running_pipeline = pipeline.get(request) if running_pipeline is not None: @@ -194,6 +194,10 @@ def _third_party_auth_context(request, redirect_to): context["currentProvider"] = current_provider.name context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name) + if current_provider.skip_registration_form: + # As a reliable way of "skipping" the registration form, we just submit it automatically + context["autoSubmitRegForm"] = True + # Check for any error messages we may want to display: for msg in messages.get_messages(request): if msg.extra_tags.split()[0] == "social-auth": diff --git a/lms/envs/common.py b/lms/envs/common.py index 49b8d54a85..da207b500f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1274,6 +1274,7 @@ student_account_js = [ 'js/student_account/views/RegisterView.js', 'js/student_account/views/PasswordResetView.js', 'js/student_account/views/AccessView.js', + 'js/student_account/views/InstitutionLoginView.js', 'js/student_account/accessApp.js', ] diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 169377d7b7..6420609847 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -84,6 +84,7 @@ 'js/student_account/views/FormView': 'js/student_account/views/FormView', 'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel', 'js/student_account/views/LoginView': 'js/student_account/views/LoginView', + 'js/student_account/views/InstitutionLoginView': 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel', 'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView', 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', @@ -410,6 +411,14 @@ 'js/student_account/views/FormView' ] }, + 'js/student_account/views/InstitutionLoginView': { + exports: 'edx.student.account.InstitutionLoginView', + deps: [ + 'jquery', + 'underscore', + 'backbone' + ] + }, 'js/student_account/models/PasswordResetModel': { exports: 'edx.student.account.PasswordResetModel', deps: ['jquery', 'jquery.cookie', 'backbone'] @@ -450,6 +459,7 @@ 'js/student_account/views/LoginView', 'js/student_account/views/PasswordResetView', 'js/student_account/views/RegisterView', + 'js/student_account/views/InstitutionLoginView', 'js/student_account/models/LoginModel', 'js/student_account/models/PasswordResetModel', 'js/student_account/models/RegisterModel', @@ -613,6 +623,7 @@ 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/finish_auth_spec.js', 'lms/include/js/spec/student_account/login_spec.js', + 'lms/include/js/spec/student_account/institution_login_spec.js', 'lms/include/js/spec/student_account/register_spec.js', 'lms/include/js/spec/student_account/password_reset_spec.js', 'lms/include/js/spec/student_account/enrollment_spec.js', diff --git a/lms/static/js/spec/student_account/access_spec.js b/lms/static/js/spec/student_account/access_spec.js index a82da514e2..590c9df58f 100644 --- a/lms/static/js/spec/student_account/access_spec.js +++ b/lms/static/js/spec/student_account/access_spec.js @@ -58,6 +58,7 @@ define([ thirdPartyAuth: { currentProvider: null, providers: [], + secondaryProviders: [{name: "provider"}], finishAuthUrl: finishAuthUrl }, nextUrl: nextUrl, // undefined for default @@ -97,6 +98,8 @@ define([ TemplateHelpers.installTemplate('templates/student_account/register'); TemplateHelpers.installTemplate('templates/student_account/password_reset'); TemplateHelpers.installTemplate('templates/student_account/form_field'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); // Stub analytics tracking window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']); @@ -135,6 +138,30 @@ define([ assertForms('#login-form', '#register-form'); }); + it('toggles between the login and institution login view', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate clicking on institution login button + $('#login-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#login-form'); + + // Simulate selection of the login form + selectForm('login'); + assertForms('#login-form', '#institution_login-form'); + }); + + it('toggles between the register and institution register view', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate clicking on institution login button + $('#register-form .button-secondary-login[data-type="institution_login"]').click(); + assertForms('#institution_login-form', '#register-form'); + + // Simulate selection of the login form + selectForm('register'); + assertForms('#register-form', '#institution_login-form'); + }); + it('displays the reset password form', function() { ajaxSpyAndInitialize(this, 'login'); diff --git a/lms/static/js/spec/student_account/institution_login_spec.js b/lms/static/js/spec/student_account/institution_login_spec.js new file mode 100644 index 0000000000..208c975550 --- /dev/null +++ b/lms/static/js/spec/student_account/institution_login_spec.js @@ -0,0 +1,80 @@ +define([ + 'jquery', + 'underscore', + 'common/js/spec_helpers/template_helpers', + 'js/student_account/views/InstitutionLoginView', +], function($, _, TemplateHelpers, InstitutionLoginView) { + 'use strict'; + describe('edx.student.account.InstitutionLoginView', function() { + + var view = null, + PLATFORM_NAME = 'edX', + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [], + secondaryProviders: [ + { + id: 'oa2-google-oauth2', + name: 'Google', + iconClass: 'fa-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + id: 'oa2-facebook', + name: 'Facebook', + iconClass: 'fa-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }; + + var createInstLoginView = function(mode) { + // Initialize the login view + view = new InstitutionLoginView({ + mode: mode, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME + }); + view.render(); + }; + + beforeEach(function() { + setFixtures('
'); + TemplateHelpers.installTemplate('templates/student_account/institution_login'); + TemplateHelpers.installTemplate('templates/student_account/institution_register'); + }); + + it('displays a list of providers', function() { + createInstLoginView('login'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_login' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_login' + ); + }); + + it('displays a list of providers', function() { + createInstLoginView('register'); + expect($('#institution_login-form').html()).not.toBe(""); + var $google = $('li a:contains("Google")'); + expect($google).toBeVisible(); + expect($google).toHaveAttr( + 'href', '/auth/login/google-oauth2/?auth_entry=account_register' + ); + var $facebook = $('li a:contains("Facebook")'); + expect($facebook).toBeVisible(); + expect($facebook).toHaveAttr( + 'href', '/auth/login/facebook/?auth_entry=account_register' + ); + }); + + }); +}); diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js index 9cf1dcae15..4374eed3d2 100644 --- a/lms/static/js/student_account/views/AccessView.js +++ b/lms/static/js/student_account/views/AccessView.js @@ -18,7 +18,8 @@ var edx = edx || {}; subview: { login: {}, register: {}, - passwordHelp: {} + passwordHelp: {}, + institutionLogin: {} }, nextUrl: '/dashboard', @@ -52,7 +53,8 @@ var edx = edx || {}; this.formDescriptions = { login: obj.loginFormDesc, register: obj.registrationFormDesc, - reset: obj.passwordResetFormDesc + reset: obj.passwordResetFormDesc, + institution_login: null }; this.platformName = obj.platformName; @@ -148,6 +150,16 @@ var edx = edx || {}; // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. this.listenTo( this.subview.register, 'auth-complete', this.authComplete ); + }, + + institution_login: function ( unused ) { + this.subview.institutionLogin = new edx.student.account.InstitutionLoginView({ + thirdPartyAuth: this.thirdPartyAuth, + platformName: this.platformName, + mode: this.activeForm + }); + + this.subview.institutionLogin.render(); } }, @@ -180,9 +192,11 @@ var edx = edx || {}; category: 'user-engagement' }); - if ( !this.form.isLoaded( $form ) ) { + // Load the form. Institution login is always refreshed since it changes based on the previous form. + if ( !this.form.isLoaded( $form ) || type == "institution_login") { this.loadForm( type ); } + this.activeForm = type; this.element.hide( $(this.el).find('.submission-success') ); this.element.hide( $(this.el).find('.form-wrapper') ); @@ -190,11 +204,13 @@ var edx = edx || {}; this.element.scrollTop( $anchor ); // Update url without reloading page - History.pushState( null, document.title, '/' + type + queryStr ); + if (type != "institution_login") { + History.pushState( null, document.title, '/' + type + queryStr ); + } analytics.page( 'login_and_registration', type ); // Focus on the form - document.getElementById(type).focus(); + $("#" + type).focus(); }, /** diff --git a/lms/static/js/student_account/views/FormView.js b/lms/static/js/student_account/views/FormView.js index 12f0d51100..989be0bc86 100644 --- a/lms/static/js/student_account/views/FormView.js +++ b/lms/static/js/student_account/views/FormView.js @@ -215,7 +215,9 @@ var edx = edx || {}; submitForm: function( event ) { var data = this.getFormData(); - event.preventDefault(); + if (!_.isUndefined(event)) { + event.preventDefault(); + } this.toggleDisableButton(true); diff --git a/lms/static/js/student_account/views/InstitutionLoginView.js b/lms/static/js/student_account/views/InstitutionLoginView.js new file mode 100644 index 0000000000..524e3a63b3 --- /dev/null +++ b/lms/static/js/student_account/views/InstitutionLoginView.js @@ -0,0 +1,30 @@ +var edx = edx || {}; + +(function($, _, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.InstitutionLoginView = Backbone.View.extend({ + el: '#institution_login-form', + + initialize: function( data ) { + var tpl = data.mode == "register" ? '#institution_register-tpl' : '#institution_login-tpl'; + this.tpl = $(tpl).html(); + this.providers = data.thirdPartyAuth.secondaryProviders || []; + this.platformName = data.platformName; + }, + + render: function() { + $(this.el).html( _.template( this.tpl, { + // We pass the context object to the template so that + // we can perform variable interpolation using sprintf + providers: this.providers, + platformName: this.platformName + })); + + return this; + } + }); +})(jQuery, _, Backbone); diff --git a/lms/static/js/student_account/views/LoginView.js b/lms/static/js/student_account/views/LoginView.js index 79eb44d1ce..d54c65fddb 100644 --- a/lms/static/js/student_account/views/LoginView.js +++ b/lms/static/js/student_account/views/LoginView.js @@ -25,6 +25,9 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; @@ -45,6 +48,7 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js index 177bfe51da..294704521b 100644 --- a/lms/static/js/student_account/views/RegisterView.js +++ b/lms/static/js/student_account/views/RegisterView.js @@ -22,9 +22,13 @@ var edx = edx || {}; preRender: function( data ) { this.providers = data.thirdPartyAuth.providers || []; + this.hasSecondaryProviders = ( + data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length + ); this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.platformName = data.platformName; + this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm; this.listenTo( this.model, 'sync', this.saveSuccess ); }, @@ -41,12 +45,19 @@ var edx = edx || {}; currentProvider: this.currentProvider, errorMessage: this.errorMessage, providers: this.providers, + hasSecondaryProviders: this.hasSecondaryProviders, platformName: this.platformName } })); this.postRender(); + if (this.autoSubmit) { + $(this.el).hide(); + $('#register-honor_code').prop('checked', true); + this.submitForm(); + } + return this; }, @@ -63,6 +74,7 @@ var edx = edx || {}; }, saveError: function( error ) { + $(this.el).show(); // Show in case the form was hidden for auto-submission this.errors = _.flatten( _.map( JSON.parse(error.responseText), @@ -76,6 +88,13 @@ var edx = edx || {}; ); this.setErrors(); this.toggleDisableButton(false); - } + }, + + postFormSubmission: function() { + if (_.compact(this.errors).length) { + // The form did not get submitted due to validation errors. + $(this.el).show(); // Show in case the form was hidden for auto-submission + } + }, }); })(jQuery, _, gettext); diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss index 435ed65edf..aad4cab6f3 100644 --- a/lms/static/sass/views/_login-register.scss +++ b/lms/static/sass/views/_login-register.scss @@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5; background: $white; min-height: 100%; width: 100%; + $third-party-button-height: ($baseline*1.75); h2 { @extend %t-title5; @@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5; font-family: $sans-serif; } + .instructions { + @extend %t-copy-base; + } + /* Temp. fix until applied globally */ > { @include box-sizing(border-box); @@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5; } } - form { + form, + .wrapper-other-login { border: 1px solid $gray-l4; - border-radius: 5px; - padding: 0px 25px 20px 25px; + border-radius: ($baseline/4); + padding: 0 ($baseline*1.25) $baseline ($baseline*1.25); } .section-title { @@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5; } } - .nav-btn { + %nav-btn-base { @extend %btn-secondary-blue-outline; width: 100%; height: ($baseline*2); text-transform: none; text-shadow: none; - font-weight: 600; letter-spacing: normal; } + .nav-btn { + @extend %nav-btn-base; + @extend %t-strong; + } + .form-type, .toggle-form { @include box-sizing(border-box); @@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5; .login-provider { @extend %btn-secondary-grey-outline; - width: 130px; - padding: 0 0 0 ($baseline*2); - height: 34px; - text-align: left; + @extend %t-action4; + + @include padding(0, 0, 0, $baseline*2); + @include text-align(left); + + position: relative; + margin-right: ($baseline/4); + margin-bottom: $baseline; + border-color: $lightGrey1; + width: $baseline*6.5; + height: $third-party-button-height; text-shadow: none; text-transform: none; - position: relative; - font-size: 0.8em; - border-color: $lightGrey1; - - &:nth-of-type(odd) { - margin-right: 13px; - } .icon { - color: white; + @include left(0); + position: absolute; top: -1px; - left: 0; width: 30px; - height: 34px; - line-height: 34px; + bottom: -1px; + background: $m-blue-d3; + line-height: $third-party-button-height; text-align: center; + color: $white; } &:hover, @@ -378,16 +390,12 @@ $sm-btn-linkedin: #0077b5; background-image: none; .icon { - height: 32px; - line-height: 32px; top: 0; + bottom: 0; + line-height: ($third-party-button-height - 2px); } } - &:last-child { - margin-bottom: $baseline; - } - &.button-oa2-google-oauth2 { color: $sm-btn-google; @@ -447,6 +455,19 @@ $sm-btn-linkedin: #0077b5; } + .button-secondary-login { + @extend %nav-btn-base; + @extend %t-action4; + @extend %t-regular; + border-color: $lightGrey1; + padding: 0; + height: $third-party-button-height; + + &:hover { + border-color: $m-blue-d3; + } + } + /** Error Container - from _account.scss **/ .status { @include box-sizing(border-box); @@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5; } } + .institution-list { + + .institution { + @extend %t-copy-base; + } + } + @include media( max-width 330px) { .form-type { width: 98%; diff --git a/lms/templates/student_account/access.underscore b/lms/templates/student_account/access.underscore index 2eee3a2a3d..fff58d5cbf 100644 --- a/lms/templates/student_account/access.underscore +++ b/lms/templates/student_account/access.underscore @@ -9,3 +9,7 @@
+ +
+ +
diff --git a/lms/templates/student_account/institution_login.underscore b/lms/templates/student_account/institution_login.underscore new file mode 100644 index 0000000000..88861616e2 --- /dev/null +++ b/lms/templates/student_account/institution_login.underscore @@ -0,0 +1,31 @@ +
+
+

+ + <%- gettext("Sign in with Institution/Campus Credentials") %> + +

+
+ +

<%- gettext("Choose your institution from the list below:") %>

+ + + +
+

+ <%- gettext("or") %> +

+
+ +
+ +
+
diff --git a/lms/templates/student_account/institution_register.underscore b/lms/templates/student_account/institution_register.underscore new file mode 100644 index 0000000000..ba97dd6e7e --- /dev/null +++ b/lms/templates/student_account/institution_register.underscore @@ -0,0 +1,31 @@ +
+
+

+ + <%- gettext("Register with Institution/Campus Credentials") %> + +

+
+ +

<%- gettext("Choose your institution from the list below:") %>

+ + + +
+

+ <%- gettext("or") %> +

+
+ +
+ +
+
diff --git a/lms/templates/student_account/login.underscore b/lms/templates/student_account/login.underscore index 8b7ad6e6df..58447bdba8 100644 --- a/lms/templates/student_account/login.underscore +++ b/lms/templates/student_account/login.underscore @@ -39,7 +39,7 @@ - <% if ( context.providers.length > 0 && !context.currentProvider ) { %> + <% if ( context.providers.length > 0 && !context.currentProvider || context.hasSecondaryProviders ) { %>

@@ -55,6 +55,12 @@ <% } }); %> + + <% if ( context.hasSecondaryProviders ) { %> + + <% } %>

<% } %> diff --git a/lms/templates/student_account/login_and_register.html b/lms/templates/student_account/login_and_register.html index 5bddd0b0c7..e218468d1e 100644 --- a/lms/templates/student_account/login_and_register.html +++ b/lms/templates/student_account/login_and_register.html @@ -15,7 +15,7 @@ <%block name="header_extras"> - % for template_name in ["account", "access", "form_field", "login", "register", "password_reset"]: + % for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset"]: diff --git a/lms/templates/student_account/register.underscore b/lms/templates/student_account/register.underscore index 1eb0b6feb7..285bd90a2e 100644 --- a/lms/templates/student_account/register.underscore +++ b/lms/templates/student_account/register.underscore @@ -19,7 +19,7 @@ <%- _.sprintf( gettext("We just need a little more information before you start learning with %(platformName)s."), context ) %>

- <% } else if ( context.providers.length > 0 ) { %> + <% } else if ( context.providers.length > 0 || context.hasSecondaryProviders ) { %>

@@ -35,6 +35,12 @@ <% } }); %> + + <% if ( context.hasSecondaryProviders ) { %> + + <% } %>