Commit 7437bcfe by Braden MacDonald

New provider config options, New Institution Login Menu - PR 8603

parent 5bf0b179
...@@ -1498,6 +1498,13 @@ def create_account_with_params(request, params): ...@@ -1498,6 +1498,13 @@ def create_account_with_params(request, params):
dog_stats_api.increment("common.student.account_created") 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 # Track the user's registration
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'): if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context() tracking_context = tracker.get_tracker().resolve_context()
...@@ -1506,20 +1513,13 @@ def create_account_with_params(request, params): ...@@ -1506,20 +1513,13 @@ def create_account_with_params(request, params):
'username': user.username, '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( analytics.track(
user.id, user.id,
"edx.bi.user.account.registered", "edx.bi.user.account.registered",
{ {
'category': 'conversion', 'category': 'conversion',
'label': params.get('course_id'), 'label': params.get('course_id'),
'provider': provider_name 'provider': third_party_provider.name if third_party_provider else None
}, },
context={ context={
'Google Analytics': { 'Google Analytics': {
...@@ -1536,6 +1536,7 @@ def create_account_with_params(request, params): ...@@ -1536,6 +1536,7 @@ def create_account_with_params(request, params):
# 2. Random user generation for other forms of testing. # 2. Random user generation for other forms of testing.
# 3. External auth bypassing activation. # 3. External auth bypassing activation.
# 4. Have the platform configured to not require e-mail 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 # 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 # the other for *new* systems. we need to be careful about
...@@ -1544,7 +1545,11 @@ def create_account_with_params(request, params): ...@@ -1544,7 +1545,11 @@ def create_account_with_params(request, params):
send_email = ( send_email = (
not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') 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: if send_email:
context = { context = {
......
# -*- 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
...@@ -8,7 +8,7 @@ from django.conf import settings ...@@ -8,7 +8,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
import json import json
import logging import logging
from social.backends.base import BaseAuth from social.backends.base import BaseAuth
...@@ -54,7 +54,7 @@ class AuthNotConfigured(SocialAuthBaseException): ...@@ -54,7 +54,7 @@ class AuthNotConfigured(SocialAuthBaseException):
self.provider_name = provider_name self.provider_name = provider_name
def __str__(self): def __str__(self):
return _('Authentication with {} is currently unavailable.').format( return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member
self.provider_name self.provider_name
) )
...@@ -68,10 +68,34 @@ class ProviderConfig(ConfigurationModel): ...@@ -68,10 +68,34 @@ class ProviderConfig(ConfigurationModel):
help_text=( help_text=(
'The Font Awesome (or custom) icon class to use on the login button for this provider. ' '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' '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)") 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 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 backend_name = None # Set to a field or fixed value in subclass
# "enabled" field is inherited from ConfigurationModel # "enabled" field is inherited from ConfigurationModel
class Meta(object): # pylint: disable=missing-docstring class Meta(object): # pylint: disable=missing-docstring
......
...@@ -503,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia ...@@ -503,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
"""Redirects to the registration page.""" """Redirects to the registration page."""
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER]) 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 not user:
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]: if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest() return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]: 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 # User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any. # account corresponds to them yet, if any.
if should_force_account_creation():
return dispatch_to_register()
return dispatch_to_login() return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]: elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
# User has authenticated with the third party provider and now wants to finish # User has authenticated with the third party provider and now wants to finish
......
""" """
Third_party_auth integration tests using a mock version of the TestShib provider 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 from django.core.urlresolvers import reverse
import httpretty import httpretty
from mock import patch from mock import patch
...@@ -62,8 +63,6 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -62,8 +63,6 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn('Authentication with TestShib is currently unavailable.', response.content) 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): def test_register(self):
self._configure_testshib_provider() self._configure_testshib_provider()
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
...@@ -107,6 +106,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -107,6 +106,7 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
# Now check that we can login again: # Now check that we can login again:
self.client.logout() self.client.logout()
self._verify_user_email('myself@testshib.org')
self._test_return_login() self._test_return_login()
def test_login(self): def test_login(self):
...@@ -222,3 +222,9 @@ class TestShibIntegrationTest(testutil.SAMLTestCase): ...@@ -222,3 +222,9 @@ class TestShibIntegrationTest(testutil.SAMLTestCase):
content_type='application/x-www-form-urlencoded', content_type='application/x-www-form-urlencoded',
data=self._read_data_file('testshib_response.txt'), 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()
...@@ -359,6 +359,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi ...@@ -359,6 +359,7 @@ class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMi
json.dumps({ json.dumps({
"currentProvider": current_provider, "currentProvider": current_provider,
"providers": providers, "providers": providers,
"secondaryProviders": [],
"finishAuthUrl": finish_auth_url, "finishAuthUrl": finish_auth_url,
"errorMessage": None, "errorMessage": None,
}) })
......
...@@ -164,13 +164,14 @@ def _third_party_auth_context(request, redirect_to): ...@@ -164,13 +164,14 @@ def _third_party_auth_context(request, redirect_to):
context = { context = {
"currentProvider": None, "currentProvider": None,
"providers": [], "providers": [],
"secondaryProviders": [],
"finishAuthUrl": None, "finishAuthUrl": None,
"errorMessage": None, "errorMessage": None,
} }
if third_party_auth.is_enabled(): if third_party_auth.is_enabled():
context["providers"] = [ for enabled in third_party_auth.provider.Registry.enabled():
{ info = {
"id": enabled.provider_id, "id": enabled.provider_id,
"name": enabled.name, "name": enabled.name,
"iconClass": enabled.icon_class, "iconClass": enabled.icon_class,
...@@ -185,8 +186,7 @@ def _third_party_auth_context(request, redirect_to): ...@@ -185,8 +186,7 @@ def _third_party_auth_context(request, redirect_to):
redirect_url=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) running_pipeline = pipeline.get(request)
if running_pipeline is not None: if running_pipeline is not None:
...@@ -194,6 +194,10 @@ def _third_party_auth_context(request, redirect_to): ...@@ -194,6 +194,10 @@ def _third_party_auth_context(request, redirect_to):
context["currentProvider"] = current_provider.name context["currentProvider"] = current_provider.name
context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_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: # Check for any error messages we may want to display:
for msg in messages.get_messages(request): for msg in messages.get_messages(request):
if msg.extra_tags.split()[0] == "social-auth": if msg.extra_tags.split()[0] == "social-auth":
......
...@@ -1274,6 +1274,7 @@ student_account_js = [ ...@@ -1274,6 +1274,7 @@ student_account_js = [
'js/student_account/views/RegisterView.js', 'js/student_account/views/RegisterView.js',
'js/student_account/views/PasswordResetView.js', 'js/student_account/views/PasswordResetView.js',
'js/student_account/views/AccessView.js', 'js/student_account/views/AccessView.js',
'js/student_account/views/InstitutionLoginView.js',
'js/student_account/accessApp.js', 'js/student_account/accessApp.js',
] ]
......
...@@ -84,6 +84,7 @@ ...@@ -84,6 +84,7 @@
'js/student_account/views/FormView': 'js/student_account/views/FormView', 'js/student_account/views/FormView': 'js/student_account/views/FormView',
'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel', 'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel',
'js/student_account/views/LoginView': 'js/student_account/views/LoginView', '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/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel',
'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView', 'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView',
'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel',
...@@ -410,6 +411,14 @@ ...@@ -410,6 +411,14 @@
'js/student_account/views/FormView' 'js/student_account/views/FormView'
] ]
}, },
'js/student_account/views/InstitutionLoginView': {
exports: 'edx.student.account.InstitutionLoginView',
deps: [
'jquery',
'underscore',
'backbone'
]
},
'js/student_account/models/PasswordResetModel': { 'js/student_account/models/PasswordResetModel': {
exports: 'edx.student.account.PasswordResetModel', exports: 'edx.student.account.PasswordResetModel',
deps: ['jquery', 'jquery.cookie', 'backbone'] deps: ['jquery', 'jquery.cookie', 'backbone']
...@@ -450,6 +459,7 @@ ...@@ -450,6 +459,7 @@
'js/student_account/views/LoginView', 'js/student_account/views/LoginView',
'js/student_account/views/PasswordResetView', 'js/student_account/views/PasswordResetView',
'js/student_account/views/RegisterView', 'js/student_account/views/RegisterView',
'js/student_account/views/InstitutionLoginView',
'js/student_account/models/LoginModel', 'js/student_account/models/LoginModel',
'js/student_account/models/PasswordResetModel', 'js/student_account/models/PasswordResetModel',
'js/student_account/models/RegisterModel', 'js/student_account/models/RegisterModel',
...@@ -613,6 +623,7 @@ ...@@ -613,6 +623,7 @@
'lms/include/js/spec/student_account/access_spec.js', '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/finish_auth_spec.js',
'lms/include/js/spec/student_account/login_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/register_spec.js',
'lms/include/js/spec/student_account/password_reset_spec.js', 'lms/include/js/spec/student_account/password_reset_spec.js',
'lms/include/js/spec/student_account/enrollment_spec.js', 'lms/include/js/spec/student_account/enrollment_spec.js',
......
...@@ -58,6 +58,7 @@ define([ ...@@ -58,6 +58,7 @@ define([
thirdPartyAuth: { thirdPartyAuth: {
currentProvider: null, currentProvider: null,
providers: [], providers: [],
secondaryProviders: [{name: "provider"}],
finishAuthUrl: finishAuthUrl finishAuthUrl: finishAuthUrl
}, },
nextUrl: nextUrl, // undefined for default nextUrl: nextUrl, // undefined for default
...@@ -97,6 +98,8 @@ define([ ...@@ -97,6 +98,8 @@ define([
TemplateHelpers.installTemplate('templates/student_account/register'); TemplateHelpers.installTemplate('templates/student_account/register');
TemplateHelpers.installTemplate('templates/student_account/password_reset'); TemplateHelpers.installTemplate('templates/student_account/password_reset');
TemplateHelpers.installTemplate('templates/student_account/form_field'); TemplateHelpers.installTemplate('templates/student_account/form_field');
TemplateHelpers.installTemplate('templates/student_account/institution_login');
TemplateHelpers.installTemplate('templates/student_account/institution_register');
// Stub analytics tracking // Stub analytics tracking
window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']); window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']);
...@@ -135,6 +138,30 @@ define([ ...@@ -135,6 +138,30 @@ define([
assertForms('#login-form', '#register-form'); 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() { it('displays the reset password form', function() {
ajaxSpyAndInitialize(this, 'login'); ajaxSpyAndInitialize(this, 'login');
......
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('<div id="institution_login-form"></div>');
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'
);
});
});
});
...@@ -18,7 +18,8 @@ var edx = edx || {}; ...@@ -18,7 +18,8 @@ var edx = edx || {};
subview: { subview: {
login: {}, login: {},
register: {}, register: {},
passwordHelp: {} passwordHelp: {},
institutionLogin: {}
}, },
nextUrl: '/dashboard', nextUrl: '/dashboard',
...@@ -52,7 +53,8 @@ var edx = edx || {}; ...@@ -52,7 +53,8 @@ var edx = edx || {};
this.formDescriptions = { this.formDescriptions = {
login: obj.loginFormDesc, login: obj.loginFormDesc,
register: obj.registrationFormDesc, register: obj.registrationFormDesc,
reset: obj.passwordResetFormDesc reset: obj.passwordResetFormDesc,
institution_login: null
}; };
this.platformName = obj.platformName; this.platformName = obj.platformName;
...@@ -148,6 +150,16 @@ var edx = edx || {}; ...@@ -148,6 +150,16 @@ var edx = edx || {};
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
this.listenTo( this.subview.register, 'auth-complete', this.authComplete ); 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 || {}; ...@@ -180,9 +192,11 @@ var edx = edx || {};
category: 'user-engagement' 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.loadForm( type );
} }
this.activeForm = type;
this.element.hide( $(this.el).find('.submission-success') ); this.element.hide( $(this.el).find('.submission-success') );
this.element.hide( $(this.el).find('.form-wrapper') ); this.element.hide( $(this.el).find('.form-wrapper') );
...@@ -190,11 +204,13 @@ var edx = edx || {}; ...@@ -190,11 +204,13 @@ var edx = edx || {};
this.element.scrollTop( $anchor ); this.element.scrollTop( $anchor );
// Update url without reloading page // 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 ); analytics.page( 'login_and_registration', type );
// Focus on the form // Focus on the form
document.getElementById(type).focus(); $("#" + type).focus();
}, },
/** /**
......
...@@ -215,7 +215,9 @@ var edx = edx || {}; ...@@ -215,7 +215,9 @@ var edx = edx || {};
submitForm: function( event ) { submitForm: function( event ) {
var data = this.getFormData(); var data = this.getFormData();
event.preventDefault(); if (!_.isUndefined(event)) {
event.preventDefault();
}
this.toggleDisableButton(true); this.toggleDisableButton(true);
......
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);
...@@ -25,6 +25,9 @@ var edx = edx || {}; ...@@ -25,6 +25,9 @@ var edx = edx || {};
preRender: function( data ) { preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || []; this.providers = data.thirdPartyAuth.providers || [];
this.hasSecondaryProviders = (
data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length
);
this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName; this.platformName = data.platformName;
...@@ -45,6 +48,7 @@ var edx = edx || {}; ...@@ -45,6 +48,7 @@ var edx = edx || {};
currentProvider: this.currentProvider, currentProvider: this.currentProvider,
errorMessage: this.errorMessage, errorMessage: this.errorMessage,
providers: this.providers, providers: this.providers,
hasSecondaryProviders: this.hasSecondaryProviders,
platformName: this.platformName platformName: this.platformName
} }
})); }));
......
...@@ -22,9 +22,13 @@ var edx = edx || {}; ...@@ -22,9 +22,13 @@ var edx = edx || {};
preRender: function( data ) { preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || []; this.providers = data.thirdPartyAuth.providers || [];
this.hasSecondaryProviders = (
data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length
);
this.currentProvider = data.thirdPartyAuth.currentProvider || ''; this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || ''; this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName; this.platformName = data.platformName;
this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm;
this.listenTo( this.model, 'sync', this.saveSuccess ); this.listenTo( this.model, 'sync', this.saveSuccess );
}, },
...@@ -41,12 +45,19 @@ var edx = edx || {}; ...@@ -41,12 +45,19 @@ var edx = edx || {};
currentProvider: this.currentProvider, currentProvider: this.currentProvider,
errorMessage: this.errorMessage, errorMessage: this.errorMessage,
providers: this.providers, providers: this.providers,
hasSecondaryProviders: this.hasSecondaryProviders,
platformName: this.platformName platformName: this.platformName
} }
})); }));
this.postRender(); this.postRender();
if (this.autoSubmit) {
$(this.el).hide();
$('#register-honor_code').prop('checked', true);
this.submitForm();
}
return this; return this;
}, },
...@@ -63,6 +74,7 @@ var edx = edx || {}; ...@@ -63,6 +74,7 @@ var edx = edx || {};
}, },
saveError: function( error ) { saveError: function( error ) {
$(this.el).show(); // Show in case the form was hidden for auto-submission
this.errors = _.flatten( this.errors = _.flatten(
_.map( _.map(
JSON.parse(error.responseText), JSON.parse(error.responseText),
...@@ -76,6 +88,13 @@ var edx = edx || {}; ...@@ -76,6 +88,13 @@ var edx = edx || {};
); );
this.setErrors(); this.setErrors();
this.toggleDisableButton(false); 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); })(jQuery, _, gettext);
...@@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5; ...@@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5;
background: $white; background: $white;
min-height: 100%; min-height: 100%;
width: 100%; width: 100%;
$third-party-button-height: ($baseline*1.75);
h2 { h2 {
@extend %t-title5; @extend %t-title5;
...@@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5; ...@@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5;
font-family: $sans-serif; font-family: $sans-serif;
} }
.instructions {
@extend %t-copy-base;
}
/* Temp. fix until applied globally */ /* Temp. fix until applied globally */
> { > {
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5; ...@@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5;
} }
} }
form { form,
.wrapper-other-login {
border: 1px solid $gray-l4; border: 1px solid $gray-l4;
border-radius: 5px; border-radius: ($baseline/4);
padding: 0px 25px 20px 25px; padding: 0 ($baseline*1.25) $baseline ($baseline*1.25);
} }
.section-title { .section-title {
...@@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5; ...@@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5;
} }
} }
.nav-btn { %nav-btn-base {
@extend %btn-secondary-blue-outline; @extend %btn-secondary-blue-outline;
width: 100%; width: 100%;
height: ($baseline*2); height: ($baseline*2);
text-transform: none; text-transform: none;
text-shadow: none; text-shadow: none;
font-weight: 600;
letter-spacing: normal; letter-spacing: normal;
} }
.nav-btn {
@extend %nav-btn-base;
@extend %t-strong;
}
.form-type, .form-type,
.toggle-form { .toggle-form {
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5; ...@@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5;
.login-provider { .login-provider {
@extend %btn-secondary-grey-outline; @extend %btn-secondary-grey-outline;
width: 130px; @extend %t-action4;
padding: 0 0 0 ($baseline*2);
height: 34px; @include padding(0, 0, 0, $baseline*2);
text-align: left; @include text-align(left);
text-shadow: none;
text-transform: none;
position: relative; position: relative;
font-size: 0.8em; margin-right: ($baseline/4);
margin-bottom: $baseline;
border-color: $lightGrey1; border-color: $lightGrey1;
width: $baseline*6.5;
&:nth-of-type(odd) { height: $third-party-button-height;
margin-right: 13px; text-shadow: none;
} text-transform: none;
.icon { .icon {
color: white; @include left(0);
position: absolute; position: absolute;
top: -1px; top: -1px;
left: 0;
width: 30px; width: 30px;
height: 34px; bottom: -1px;
line-height: 34px; background: $m-blue-d3;
line-height: $third-party-button-height;
text-align: center; text-align: center;
color: $white;
} }
&:hover, &:hover,
...@@ -378,16 +390,12 @@ $sm-btn-linkedin: #0077b5; ...@@ -378,16 +390,12 @@ $sm-btn-linkedin: #0077b5;
background-image: none; background-image: none;
.icon { .icon {
height: 32px;
line-height: 32px;
top: 0; top: 0;
bottom: 0;
line-height: ($third-party-button-height - 2px);
} }
} }
&:last-child {
margin-bottom: $baseline;
}
&.button-oa2-google-oauth2 { &.button-oa2-google-oauth2 {
color: $sm-btn-google; color: $sm-btn-google;
...@@ -447,6 +455,19 @@ $sm-btn-linkedin: #0077b5; ...@@ -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 **/ /** Error Container - from _account.scss **/
.status { .status {
@include box-sizing(border-box); @include box-sizing(border-box);
...@@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5; ...@@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5;
} }
} }
.institution-list {
.institution {
@extend %t-copy-base;
}
}
@include media( max-width 330px) { @include media( max-width 330px) {
.form-type { .form-type {
width: 98%; width: 98%;
......
...@@ -9,3 +9,7 @@ ...@@ -9,3 +9,7 @@
<section id="password-reset-anchor" class="form-type"> <section id="password-reset-anchor" class="form-type">
<div id="password-reset-form" class="form-wrapper hidden" aria-hidden="true"></div> <div id="password-reset-form" class="form-wrapper hidden" aria-hidden="true"></div>
</section> </section>
<section id="institution_login-anchor" class="form-type">
<div id="institution_login-form" class="form-wrapper hidden" aria-hidden="true"></div>
</section>
<div class="wrapper-other-login">
<div class="section-title lines">
<h2>
<span class="text">
<%- gettext("Sign in with Institution/Campus Credentials") %>
</span>
</h2>
</div>
<p class="instructions"><%- gettext("Choose your institution from the list below:") %></p>
<ul class="institution-list">
<% _.each( _.sortBy(providers, "name"), function( provider ) {
if ( provider.loginUrl ) { %>
<li class="institution">
<a class="institution-login-link" href="<%- provider.loginUrl %>"><%- provider.name %></a>
</li>
<% }
}); %>
</ul>
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("or") %></span>
</h2>
</div>
<div class="toggle-form">
<button class="nav-btn form-toggle" data-type="login"><%- gettext("Back to sign in") %></button>
</div>
</div>
<div class="wrapper-other-login">
<div class="section-title lines">
<h2>
<span class="text">
<%- gettext("Register with Institution/Campus Credentials") %>
</span>
</h2>
</div>
<p class="instructions"><%- gettext("Choose your institution from the list below:") %></p>
<ul class="institution-list">
<% _.each( _.sortBy(providers, "name"), function( provider ) {
if ( provider.registerUrl ) { %>
<li class="institution">
<a class="institution-login-link" href="<%- provider.registerUrl %>"><%- provider.name %></a>
</li>
<% }
}); %>
</ul>
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("or") %></span>
</h2>
</div>
<div class="toggle-form">
<button class="nav-btn form-toggle" data-type="register"><%- gettext("Register through edX") %></button>
</div>
</div>
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
<button type="submit" class="action action-primary action-update js-login login-button"><%- gettext("Sign in") %></button> <button type="submit" class="action action-primary action-update js-login login-button"><%- gettext("Sign in") %></button>
<% if ( context.providers.length > 0 && !context.currentProvider ) { %> <% if ( context.providers.length > 0 && !context.currentProvider || context.hasSecondaryProviders ) { %>
<div class="login-providers"> <div class="login-providers">
<div class="section-title lines"> <div class="section-title lines">
<h2> <h2>
...@@ -55,6 +55,12 @@ ...@@ -55,6 +55,12 @@
</button> </button>
<% } <% }
}); %> }); %>
<% if ( context.hasSecondaryProviders ) { %>
<button type="button" class="button-secondary-login form-toggle" data-type="institution_login">
<%- gettext("Use my institution/campus credentials") %>
</button>
<% } %>
</div> </div>
<% } %> <% } %>
</form> </form>
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
</%block> </%block>
<%block name="header_extras"> <%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"]:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" /> <%static:include path="student_account/${template_name}.underscore" />
</script> </script>
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
<%- _.sprintf( gettext("We just need a little more information before you start learning with %(platformName)s."), context ) %> <%- _.sprintf( gettext("We just need a little more information before you start learning with %(platformName)s."), context ) %>
</p> </p>
</div> </div>
<% } else if ( context.providers.length > 0 ) { %> <% } else if ( context.providers.length > 0 || context.hasSecondaryProviders ) { %>
<div class="login-providers"> <div class="login-providers">
<div class="section-title lines"> <div class="section-title lines">
<h2> <h2>
...@@ -35,6 +35,12 @@ ...@@ -35,6 +35,12 @@
</button> </button>
<% } <% }
}); %> }); %>
<% if ( context.hasSecondaryProviders ) { %>
<button type="button" class="button-secondary-login form-toggle" data-type="institution_login">
<%- gettext("Use my institution/campus credentials") %>
</button>
<% } %>
</div> </div>
<div class="section-title lines"> <div class="section-title lines">
<h2> <h2>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment