Commit 2667f705 by Usman Khalid Committed by Andy Armstrong

Enable third party auth from account settings page.

TNL-1535
parent a378995f
...@@ -117,6 +117,7 @@ AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in' ...@@ -117,6 +117,7 @@ AUTH_EMAIL_OPT_IN_KEY = 'email_opt_in'
AUTH_ENTRY_DASHBOARD = 'dashboard' AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login' AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_REGISTER = 'register' AUTH_ENTRY_REGISTER = 'register'
AUTH_ENTRY_ACCOUNT_SETTINGS = 'account_settings'
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
...@@ -145,6 +146,7 @@ AUTH_DISPATCH_URLS = { ...@@ -145,6 +146,7 @@ AUTH_DISPATCH_URLS = {
AUTH_ENTRY_DASHBOARD: '/dashboard', AUTH_ENTRY_DASHBOARD: '/dashboard',
AUTH_ENTRY_LOGIN: '/login', AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register', AUTH_ENTRY_REGISTER: '/register',
AUTH_ENTRY_ACCOUNT_SETTINGS: '/account/settings',
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
...@@ -159,6 +161,7 @@ _AUTH_ENTRY_CHOICES = frozenset([ ...@@ -159,6 +161,7 @@ _AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_DASHBOARD, AUTH_ENTRY_DASHBOARD,
AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN,
AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER,
AUTH_ENTRY_ACCOUNT_SETTINGS,
# This is left-over from an A/B test # This is left-over from an A/B test
# of the new combined login/registration page (ECOM-369) # of the new combined login/registration page (ECOM-369)
......
...@@ -255,3 +255,17 @@ class AccountSettingsPageTest(WebAppTest): ...@@ -255,3 +255,17 @@ class AccountSettingsPageTest(WebAppTest):
u'', u'',
[u'Pushto', u''], [u'Pushto', u''],
) )
def test_connected_accounts(self):
"""
Test that fields for third party auth providers exist.
Currently there is no way to test the whole authentication process
because that would require accounts with the providers.
"""
for field_id, title, link_title in [
['auth-facebook', 'Facebook', 'Link'],
['auth-google', 'Google', 'Link'],
]:
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
...@@ -9,11 +9,14 @@ import json ...@@ -9,11 +9,14 @@ import json
import mock import mock
import ddt import ddt
import markupsafe import markupsafe
from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core import mail from django.core import mail
from django.contrib import messages
from django.contrib.messages.middleware import MessageMiddleware
from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory
from embargo.test_utils import restrict_course from embargo.test_utils import restrict_course
from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account from openedx.core.djangoapps.user_api.accounts.api import activate_account, create_account
...@@ -517,14 +520,24 @@ class AccountSettingsViewTest(TestCase): ...@@ -517,14 +520,24 @@ class AccountSettingsViewTest(TestCase):
'preferred_language', 'preferred_language',
] ]
@mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage')
def setUp(self): def setUp(self):
super(AccountSettingsViewTest, self).setUp() super(AccountSettingsViewTest, self).setUp()
self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD) self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.request = RequestFactory()
self.request.user = self.user
# Python-social saves auth failure notifcations in Django messages.
# See pipeline.get_duplicate_provider() for details.
self.request.COOKIES = {}
MessageMiddleware().process_request(self.request)
messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook')
def test_context(self): def test_context(self):
context = account_settings_context(self.user) context = account_settings_context(self.request)
user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username})
self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url) self.assertEqual(context['user_accounts_api_url'], user_accounts_api_url)
...@@ -535,6 +548,17 @@ class AccountSettingsViewTest(TestCase): ...@@ -535,6 +548,17 @@ class AccountSettingsViewTest(TestCase):
for attribute in self.FIELDS: for attribute in self.FIELDS:
self.assertIn(attribute, context['fields']) self.assertIn(attribute, context['fields'])
self.assertEqual(
context['user_accounts_api_url'], reverse("accounts_api", kwargs={'username': self.user.username})
)
self.assertEqual(
context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username})
)
self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook')
self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
def test_view(self): def test_view(self):
view_path = reverse('account_settings') view_path = reverse('account_settings')
......
...@@ -5,6 +5,7 @@ import json ...@@ -5,6 +5,7 @@ import json
from ipware.ip import get_ip from ipware.ip import get_ip
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import ( from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponseForbidden HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
...@@ -35,6 +36,7 @@ from student.views import ( ...@@ -35,6 +36,7 @@ from student.views import (
) )
from student_account.helpers import auth_pipeline_urls from student_account.helpers import auth_pipeline_urls
import third_party_auth import third_party_auth
from third_party_auth import pipeline
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from openedx.core.djangoapps.user_api.accounts.api import request_password_change from openedx.core.djangoapps.user_api.accounts.api import request_password_change
...@@ -321,19 +323,21 @@ def account_settings(request): ...@@ -321,19 +323,21 @@ def account_settings(request):
GET /account/settings GET /account/settings
""" """
return render_to_response('student_account/account_settings.html', account_settings_context(request.user)) return render_to_response('student_account/account_settings.html', account_settings_context(request))
def account_settings_context(user): def account_settings_context(request):
""" Context for the account settings page. """ Context for the account settings page.
Args: Args:
user (User): The user for whom the context is required. request: The request object.
Returns: Returns:
dict dict
""" """
user = request.user
country_options = [ country_options = [
(country_code, unicode(country_name)) (country_code, unicode(country_name))
for country_code, country_name in sorted( for country_code, country_name in sorted(
...@@ -344,8 +348,8 @@ def account_settings_context(user): ...@@ -344,8 +348,8 @@ def account_settings_context(user):
year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS] year_of_birth_options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]
context = { context = {
'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), 'auth': {},
'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), 'duplicate_provider': None,
'fields': { 'fields': {
'country': { 'country': {
'options': country_options, 'options': country_options,
...@@ -362,7 +366,33 @@ def account_settings_context(user): ...@@ -362,7 +366,33 @@ def account_settings_context(user):
}, 'preferred_language': { }, 'preferred_language': {
'options': settings.ALL_LANGUAGES, 'options': settings.ALL_LANGUAGES,
} }
},
'platform_name': settings.PLATFORM_NAME,
'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}),
'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}),
} }
}
if third_party_auth.is_enabled():
# If the account on the third party provider is already connected with another edX account,
# we display a message to the user.
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
auth_states = pipeline.get_provider_user_states(user)
context['auth']['providers'] = [{
'name': state.provider.NAME, # The name of the provider e.g. Facebook
'connected': state.has_account, # Whether the user's edX account is connected with the provider.
# If the user is not connected, they should be directed to this page to authenticate
# with the particular provider.
'connect_url': pipeline.get_login_url(
state.provider.NAME,
pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
# The url the user should be directed to after the auth process has completed.
redirect_url=reverse('account_settings'),
),
# If the user is connected, sending a POST request to this url removes the connection
# information for this provider from their edX account.
'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME),
} for state in auth_states]
return context return context
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/views/fields_helpers', 'js/spec/views/fields_helpers',
'js/spec/student_account/helpers', 'js/spec/student_account/helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_factory', 'js/student_account/views/account_settings_factory',
'js/student_account/views/account_settings_view' 'js/student_account/views/account_settings_view'
], ],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers, AccountSettingsPage, AccountSettingsView) { function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers,
AccountSettingsFieldViewSpecHelpers, AccountSettingsPage, AccountSettingsView) {
'use strict'; 'use strict';
describe("edx.user.AccountSettingsFactory", function () { describe("edx.user.AccountSettingsFactory", function () {
...@@ -27,6 +29,23 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -27,6 +29,23 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
} }
}; };
var AUTH_DATA = {
'providers': [
{
'name': "Network 1",
'connected': true,
'connect_url': 'yetanother1.com/auth/connect',
'disconnect_url': 'yetanother1.com/auth/disconnect'
},
{
'name': "Network 2",
'connected': true,
'connect_url': 'yetanother2.com/auth/connect',
'disconnect_url': 'yetanother2.com/auth/disconnect'
}
]
}
var requests; var requests;
beforeEach(function () { beforeEach(function () {
...@@ -43,7 +62,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -43,7 +62,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
requests = AjaxHelpers.requests(this); requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage( var context = AccountSettingsPage(
FIELDS_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
); );
var accountSettingsView = context.accountSettingsView; var accountSettingsView = context.accountSettingsView;
...@@ -67,7 +86,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -67,7 +86,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
requests = AjaxHelpers.requests(this); requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage( var context = AccountSettingsPage(
FIELDS_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
); );
var accountSettingsView = context.accountSettingsView; var accountSettingsView = context.accountSettingsView;
...@@ -99,7 +118,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -99,7 +118,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
requests = AjaxHelpers.requests(this); requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage( var context = AccountSettingsPage(
FIELDS_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
); );
var accountSettingsView = context.accountSettingsView; var accountSettingsView = context.accountSettingsView;
...@@ -120,7 +139,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -120,7 +139,7 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
requests = AjaxHelpers.requests(this); requests = AjaxHelpers.requests(this);
var context = AccountSettingsPage( var context = AccountSettingsPage(
FIELDS_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL FIELDS_DATA, AUTH_DATA, Helpers.USER_ACCOUNTS_API_URL, Helpers.USER_PREFERENCES_API_URL
); );
var accountSettingsView = context.accountSettingsView; var accountSettingsView = context.accountSettingsView;
...@@ -161,6 +180,13 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -161,6 +180,13 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
}, requests); }, requests);
} }
var section2Fields = sectionsData[2].fields;
expect(section2Fields.length).toBe(2);
for (var i = 0; i < section2Fields.length; i++) {
var view = section2Fields[i].view;
AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, view.options, requests);
}
}); });
}); });
}); });
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/spec/views/fields_helpers',
'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) {
'use strict';
var verifyAuthField = function (view, data, requests) {
var selector = '.u-field-value > a';
spyOn(view, 'redirect_to');
FieldViewsSpecHelpers.expectTitleAndMessageToBe(view, data.title, data.helpMessage);
expect(view.$(selector).text().trim()).toBe('Unlink');
view.$(selector).click();
FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking');
AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl);
AjaxHelpers.respondWithNoContent(requests);
expect(view.$(selector).text().trim()).toBe('Link');
FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.');
view.$(selector).click();
FieldViewsSpecHelpers.expectMessageContains(view, 'Linking');
expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl);
}
return {
verifyAuthField: verifyAuthField
};
});
define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers', define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
'js/views/fields', 'js/views/fields',
'js/spec/views/fields_helpers', 'js/spec/views/fields_helpers',
'js/spec/student_account/account_settings_fields_helpers',
'js/student_account/views/account_settings_fields', 'js/student_account/views/account_settings_fields',
'js/student_account/models/user_account_model', 'js/student_account/models/user_account_model',
'string_utils'], 'string_utils'],
function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers, AccountSettingsFieldViews, UserAccountModel) { function (Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViews, FieldViewsSpecHelpers,
AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews, UserAccountModel) {
'use strict'; 'use strict';
describe("edx.AccountSettingsFieldViews", function () { describe("edx.AccountSettingsFieldViews", function () {
...@@ -92,5 +94,21 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j ...@@ -92,5 +94,21 @@ define(['backbone', 'jquery', 'underscore', 'js/common_helpers/ajax_helpers', 'j
FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data);
AjaxHelpers.respondWithNoContent(requests); AjaxHelpers.respondWithNoContent(requests);
}); });
it("correctly links and unlinks from AuthFieldView", function() {
requests = AjaxHelpers.requests(this);
var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, {
title: 'Yet another social network',
helpMessage: '',
valueAttribute: 'auth-yet-another',
connected: true,
connectUrl: 'yetanother.com/auth/connect',
disconnectUrl: 'yetanother.com/auth/disconnect'
});
var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render();
AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests);
});
}); });
}); });
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
], function (gettext, $, _, Backbone, FieldViews, UserAccountModel, UserPreferencesModel, ], function (gettext, $, _, Backbone, FieldViews, UserAccountModel, UserPreferencesModel,
AccountSettingsFieldViews, AccountSettingsView) { AccountSettingsFieldViews, AccountSettingsView) {
return function (fieldsData, userAccountsApiUrl, userPreferencesApiUrl) { return function (fieldsData, authData, userAccountsApiUrl, userPreferencesApiUrl) {
var accountSettingsElement = $('.wrapper-account-settings'); var accountSettingsElement = $('.wrapper-account-settings');
...@@ -117,30 +117,26 @@ ...@@ -117,30 +117,26 @@
} }
] ]
}, },
{ ];
if (_.isArray(authData.providers)) {
var accountsSectionData = {
title: gettext('Connected Accounts'), title: gettext('Connected Accounts'),
fields: [ fields: _.map(authData.providers, function(provider) {
{ return {
view: new FieldViews.LinkFieldView({ 'view': new AccountSettingsFieldViews.AuthFieldView({
model: userAccountModel, title: provider['name'],
title: gettext('Facebook'), valueAttribute: 'auth-' + provider['name'].toLowerCase(),
valueAttribute: 'auth-facebook', helpMessage: '',
linkTitle: gettext('Link'), connected: provider['connected'],
helpMessage: gettext('Coming soon') connectUrl: provider['connect_url'],
}) disconnectUrl: provider['disconnect_url']
},
{
view: new FieldViews.LinkFieldView({
model: userAccountModel,
title: gettext('Google'),
valueAttribute: 'auth-google',
linkTitle: gettext('Link'),
helpMessage: gettext('Coming soon')
}) })
} }
] })
};
sectionsData.push(accountsSectionData);
} }
];
var accountSettingsView = new AccountSettingsView({ var accountSettingsView = new AccountSettingsView({
el: accountSettingsElement, el: accountSettingsElement,
......
...@@ -99,6 +99,74 @@ ...@@ -99,6 +99,74 @@
attributes[this.options.valueAttribute] = value; attributes[this.options.valueAttribute] = value;
this.saveAttributes(attributes); this.saveAttributes(attributes);
} }
});
AccountSettingsFieldViews.AuthFieldView = FieldViews.LinkFieldView.extend({
initialize: function (options) {
this._super(options);
_.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage');
},
render: function () {
this.$el.html(this.template({
id: this.options.valueAttribute,
title: this.options.title,
linkTitle: this.options.connected ? gettext('Unlink') : gettext('Link'),
linkHref: '',
message: this.helpMessage
}));
return this;
},
linkClicked: function (event) {
event.preventDefault();
this.showInProgressMessage();
if (this.options.connected) {
this.disconnect();
} else {
// Direct the user to the providers site to start the authentication process.
// See python-social-auth docs for more information.
this.redirect_to(this.options.connectUrl);
}
},
redirect_to: function (url) {
window.location.href = url;
},
disconnect: function () {
var data = {};
// Disconnects the provider from the user's edX account.
// See python-social-auth docs for more information.
var view = this;
$.ajax({
type: 'POST',
url: this.options.disconnectUrl,
data: data,
dataType: 'html',
success: function (data, status, xhr) {
view.options.connected = false;
view.render();
view.showSuccessMessage();
},
error: function (xhr, status, error) {
view.showErrorMessage(xhr);
}
});
},
inProgressMessage: function() {
return this.indicators['inProgress'] + (this.options.connected ? gettext('Unlinking') : gettext('Linking'));
},
successMessage: function() {
return this.indicators['success'] + gettext('Successfully unlinked.');
},
}); });
return AccountSettingsFieldViews; return AccountSettingsFieldViews;
......
...@@ -22,6 +22,12 @@ ...@@ -22,6 +22,12 @@
% endfor % endfor
</%block> </%block>
% if duplicate_provider:
<section>
<%include file='/dashboard/_dashboard_third_party_error.html' />
</section>
% endif
<div class="wrapper-account-settings"></div> <div class="wrapper-account-settings"></div>
<%block name="headextra"> <%block name="headextra">
...@@ -30,8 +36,13 @@ ...@@ -30,8 +36,13 @@
<script> <script>
(function (require) { (function (require) {
require(['js/student_account/views/account_settings_factory'], function(setupAccountSettingsSection) { require(['js/student_account/views/account_settings_factory'], function(setupAccountSettingsSection) {
var fieldsData = ${ json.dumps(fields) }; var fieldsData = ${ json.dumps(fields) };
setupAccountSettingsSection(fieldsData, '${user_accounts_api_url}', '${user_preferences_api_url}'); var authData = ${ json.dumps(auth) };
setupAccountSettingsSection(
fieldsData, authData, '${user_accounts_api_url}', '${user_preferences_api_url}'
);
}); });
}).call(this, require || RequireJS.require); }).call(this, require || RequireJS.require);
</script> </script>
......
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