From b01cb66029bc5f83bb14421e1da3d20ee3fbb0db Mon Sep 17 00:00:00 2001 From: Avital Oliver Date: Tue, 18 Sep 2012 12:21:40 -0700 Subject: [PATCH] New login service configuration system + UI. Instead of configuring client ids and app secret from code, we store those in Mongo documents. This means it's easier to work with multiple deployments of the same app. Also, this allows for a wizard-style UI to configure your login services. --- packages/accounts-base/accounts_client.js | 26 +++ packages/accounts-base/accounts_common.js | 10 ++ packages/accounts-base/accounts_server.js | 19 ++- packages/accounts-facebook/facebook_client.js | 9 +- packages/accounts-facebook/facebook_common.js | 7 +- .../accounts-facebook/facebook_configure.html | 19 +++ .../accounts-facebook/facebook_configure.js | 10 ++ packages/accounts-facebook/facebook_server.js | 14 +- packages/accounts-facebook/package.js | 5 + packages/accounts-google/google_client.js | 9 +- packages/accounts-google/google_common.js | 5 +- .../accounts-google/google_configure.html | 28 +++ packages/accounts-google/google_configure.js | 10 ++ packages/accounts-google/google_server.js | 10 +- packages/accounts-google/package.js | 5 + .../accounts-oauth-helper/oauth_server.js | 14 +- .../accounts-oauth1-helper/oauth1_binding.js | 2 +- .../accounts-oauth1-helper/oauth1_server.js | 12 +- .../accounts-oauth1-helper/oauth1_tests.js | 8 +- .../accounts-oauth2-helper/oauth2_tests.js | 8 +- packages/accounts-twitter/package.js | 5 + packages/accounts-twitter/twitter_client.js | 7 +- packages/accounts-twitter/twitter_common.js | 6 - .../accounts-twitter/twitter_configure.html | 13 ++ .../accounts-twitter/twitter_configure.js | 11 ++ packages/accounts-twitter/twitter_server.js | 4 - packages/accounts-ui/login_buttons.html | 65 ++++++- packages/accounts-ui/login_buttons.js | 159 ++++++++++++++---- packages/accounts-ui/login_buttons.less | 66 +++++++- packages/accounts-weibo/package.js | 5 + packages/accounts-weibo/weibo_client.js | 9 +- packages/accounts-weibo/weibo_configure.html | 25 +++ packages/accounts-weibo/weibo_configure.js | 11 ++ packages/accounts-weibo/weibo_server.js | 14 +- 34 files changed, 504 insertions(+), 126 deletions(-) create mode 100644 packages/accounts-facebook/facebook_configure.html create mode 100644 packages/accounts-facebook/facebook_configure.js create mode 100644 packages/accounts-google/google_configure.html create mode 100644 packages/accounts-google/google_configure.js create mode 100644 packages/accounts-twitter/twitter_configure.html create mode 100644 packages/accounts-twitter/twitter_configure.js create mode 100644 packages/accounts-weibo/weibo_configure.html create mode 100644 packages/accounts-weibo/weibo_configure.js diff --git a/packages/accounts-base/accounts_client.js b/packages/accounts-base/accounts_client.js index ffbecd6938..fa7854b9e2 100644 --- a/packages/accounts-base/accounts_client.js +++ b/packages/accounts-base/accounts_client.js @@ -35,4 +35,30 @@ return Meteor.user(); }); } + + // XXX this can be simplified if we merge in + // https://github.com/meteor/meteor/pull/273 + var loginServicesConfigured = false; + var loginServicesConfiguredListeners = {}; // context.id -> context + Meteor.subscribe("loginServiceConfiguration", function () { + loginServicesConfigured = true; + _.each(loginServicesConfiguredListeners, function(context) { + context.invalidate(); + }); + }); + + // A reactive function returning whether the + // loginServiceConfiguration subscription is ready. Used by + // accounts-ui to hide the login button until we have all the + // configuration loaded + Meteor.accounts.loginServicesConfigured = function () { + if (loginServicesConfigured) + return true; + + // not yet complete, save the context for invalidation once we are. + var context = Meteor.deps.Context.current; + if (context) + loginServicesConfiguredListeners[context.id] = context; + return false; + }; })(); diff --git a/packages/accounts-base/accounts_common.js b/packages/accounts-base/accounts_common.js index 2ce6c2fb96..3aaf9a7062 100644 --- a/packages/accounts-base/accounts_common.js +++ b/packages/accounts-base/accounts_common.js @@ -31,7 +31,17 @@ Meteor.users = new Meteor.Collection( null /*driver*/, true /*preventAutopublish*/); +// Table containing documents with configuration options for each +// login service +Meteor.accounts.configuration = new Meteor.Collection( + "accounts._loginServiceConfiguration", + null /*manager*/, + null /*driver*/, + true /*preventAutopublish*/); + // Thrown when trying to use a login service which is not configured Meteor.accounts.ConfigError = function(description) { this.message = description; }; +Meteor.accounts.ConfigError.prototype = new Error(); +Meteor.accounts.ConfigError.prototype.name = 'Meteor.accounts.ConfigError'; \ No newline at end of file diff --git a/packages/accounts-base/accounts_server.js b/packages/accounts-base/accounts_server.js index 50b8bd0d81..f6519fefd3 100644 --- a/packages/accounts-base/accounts_server.js +++ b/packages/accounts-base/accounts_server.js @@ -193,7 +193,7 @@ /// - /// PUBLISHING USER OBJECTS + /// PUBLISHING DATA /// // Always publish the current user's record to the client. @@ -214,6 +214,23 @@ Meteor.default_server.publish(null, handler, {is_auto: true}); }); + // Publish all login service configuration fields other than secret. + Meteor.publish("loginServiceConfiguration", function () { + return Meteor.accounts.configuration.find({}, {fields: {secret: 0}}); + }); + + // Allow a one-time configuration for a login service. + Meteor.accounts.configuration.allow({}); // disallow mutators + Meteor.methods({ + "configureLoginService": function(options) { + if (!Meteor.accounts.configuration.findOne({service: options.service})) + Meteor.accounts.configuration.insert(options); + else + throw new Meteor.Error(403, "Service " + options.service + " already configured"); + } + }); + + /// /// RESTRICTING WRITES TO USER OBJECTS /// diff --git a/packages/accounts-facebook/facebook_client.js b/packages/accounts-facebook/facebook_client.js index 3d34aac30e..d3901c310a 100644 --- a/packages/accounts-facebook/facebook_client.js +++ b/packages/accounts-facebook/facebook_client.js @@ -1,7 +1,8 @@ (function () { Meteor.loginWithFacebook = function () { - if (!Meteor.accounts.facebook._appId || !Meteor.accounts.facebook._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.facebook.config first"); + var config = Meteor.accounts.configuration.findOne({service: 'facebook'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); var state = Meteor.uuid(); var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); @@ -13,8 +14,8 @@ scope = Meteor.accounts.facebook._options.scope.join(','); var loginUrl = - 'https://www.facebook.com/dialog/oauth?client_id=' + Meteor.accounts.facebook._appId + - '&redirect_uri=' + Meteor.accounts.facebook._appUrl + '/_oauth/facebook?close' + + 'https://www.facebook.com/dialog/oauth?client_id=' + config.appId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') + '&display=' + display + '&scope=' + scope + '&state=' + state; Meteor.accounts.oauth.initiateLogin(state, loginUrl); diff --git a/packages/accounts-facebook/facebook_common.js b/packages/accounts-facebook/facebook_common.js index d3ba6e0771..2c1fca99f9 100644 --- a/packages/accounts-facebook/facebook_common.js +++ b/packages/accounts-facebook/facebook_common.js @@ -1,12 +1,7 @@ if (!Meteor.accounts.facebook) { Meteor.accounts.facebook = {}; - Meteor.accounts.facebook._requireConfigs = ['_appId', '_appUrl']; } -Meteor.accounts.facebook.config = function(appId, appUrl, options) { - Meteor.accounts.facebook._appId = appId; - Meteor.accounts.facebook._appUrl = appUrl; +Meteor.accounts.facebook.config = function(options) { Meteor.accounts.facebook._options = options; }; - - diff --git a/packages/accounts-facebook/facebook_configure.html b/packages/accounts-facebook/facebook_configure.html new file mode 100644 index 0000000000..c24b24b823 --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.html @@ -0,0 +1,19 @@ + diff --git a/packages/accounts-facebook/facebook_configure.js b/packages/accounts-facebook/facebook_configure.js new file mode 100644 index 0000000000..26eee7761f --- /dev/null +++ b/packages/accounts-facebook/facebook_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServicesDialogForFacebook.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServicesDialogForFacebook.fields = function () { + return [ + {property: 'appId', label: 'App ID'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-facebook/facebook_server.js b/packages/accounts-facebook/facebook_server.js index b24d745e51..0ce7db31c4 100644 --- a/packages/accounts-facebook/facebook_server.js +++ b/packages/accounts-facebook/facebook_server.js @@ -1,9 +1,5 @@ (function () { - Meteor.accounts.facebook.setSecret = function (secret) { - Meteor.accounts.facebook._secret = secret; - }; - Meteor.accounts.oauth.registerService('facebook', 2, function(query) { var accessToken = getAccessToken(query); @@ -22,13 +18,17 @@ }); var getAccessToken = function (query) { + var config = Meteor.accounts.configuration.findOne({service: 'facebook'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); + // Request an access token var result = Meteor.http.get( "https://graph.facebook.com/oauth/access_token", { params: { - client_id: Meteor.accounts.facebook._appId, - redirect_uri: Meteor.accounts.facebook._appUrl + "/_oauth/facebook?close", - client_secret: Meteor.accounts.facebook._secret, + client_id: config.appId, + redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"), + client_secret: config.secret, code: query.code } }); diff --git a/packages/accounts-facebook/package.js b/packages/accounts-facebook/package.js index c8ab9ecc19..9b63589120 100644 --- a/packages/accounts-facebook/package.js +++ b/packages/accounts-facebook/package.js @@ -6,6 +6,11 @@ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); api.use('accounts-oauth2-helper', ['client', 'server']); api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['facebook_configure.html', 'facebook_configure.js'], + 'client'); api.add_files('facebook_common.js', ['client', 'server']); api.add_files('facebook_server.js', 'server'); diff --git a/packages/accounts-google/google_client.js b/packages/accounts-google/google_client.js index 431b853fe9..97bd9cb7bb 100644 --- a/packages/accounts-google/google_client.js +++ b/packages/accounts-google/google_client.js @@ -1,7 +1,8 @@ (function () { Meteor.loginWithGoogle = function () { - if (!Meteor.accounts.google._clientId || !Meteor.accounts.google._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.google.config first"); + var config = Meteor.accounts.configuration.findOne({service: 'google'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); var state = Meteor.uuid(); @@ -20,9 +21,9 @@ var loginUrl = 'https://accounts.google.com/o/oauth2/auth' + '?response_type=code' + - '&client_id=' + Meteor.accounts.google._clientId + + '&client_id=' + config.clientId + '&scope=' + flat_scope + - '&redirect_uri=' + Meteor.accounts.google._appUrl + '/_oauth/google?close' + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') + '&state=' + state; Meteor.accounts.oauth.initiateLogin(state, loginUrl); diff --git a/packages/accounts-google/google_common.js b/packages/accounts-google/google_common.js index e5071871bc..b68429d14b 100644 --- a/packages/accounts-google/google_common.js +++ b/packages/accounts-google/google_common.js @@ -1,10 +1,7 @@ if (!Meteor.accounts.google) { Meteor.accounts.google = {}; - Meteor.accounts.google._requireConfigs = ['_clientId', '_appUrl']; } -Meteor.accounts.google.config = function(clientId, appUrl, options) { - Meteor.accounts.google._clientId = clientId; - Meteor.accounts.google._appUrl = appUrl; +Meteor.accounts.google.config = function(options) { Meteor.accounts.google._options = options; }; diff --git a/packages/accounts-google/google_configure.html b/packages/accounts-google/google_configure.html new file mode 100644 index 0000000000..c2d97de2c1 --- /dev/null +++ b/packages/accounts-google/google_configure.html @@ -0,0 +1,28 @@ + diff --git a/packages/accounts-google/google_configure.js b/packages/accounts-google/google_configure.js new file mode 100644 index 0000000000..3ab531e1b6 --- /dev/null +++ b/packages/accounts-google/google_configure.js @@ -0,0 +1,10 @@ +Template.configureLoginServicesDialogForGoogle.siteUrl = function () { + return Meteor.absoluteUrl(); +}; + +Template.configureLoginServicesDialogForGoogle.fields = function () { + return [ + {property: 'clientId', label: 'Client ID'}, + {property: 'secret', label: 'Client secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-google/google_server.js b/packages/accounts-google/google_server.js index 295010ab38..85903a10be 100644 --- a/packages/accounts-google/google_server.js +++ b/packages/accounts-google/google_server.js @@ -22,12 +22,16 @@ }); var getAccessToken = function (query) { + var config = Meteor.accounts.configuration.findOne({service: 'google'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); + var result = Meteor.http.post( "https://accounts.google.com/o/oauth2/token", {params: { code: query.code, - client_id: Meteor.accounts.google._clientId, - client_secret: Meteor.accounts.google._secret, - redirect_uri: Meteor.accounts.google._appUrl + "/_oauth/google?close", + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/google?close"), grant_type: 'authorization_code' }}); diff --git a/packages/accounts-google/package.js b/packages/accounts-google/package.js index 4db58deaaf..e6484baadb 100644 --- a/packages/accounts-google/package.js +++ b/packages/accounts-google/package.js @@ -6,6 +6,11 @@ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); api.use('accounts-oauth2-helper', ['client', 'server']); api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['google_configure.html', 'google_configure.js'], + 'client'); api.add_files('google_common.js', ['client', 'server']); api.add_files('google_server.js', 'server'); diff --git a/packages/accounts-oauth-helper/oauth_server.js b/packages/accounts-oauth-helper/oauth_server.js index c6f221afe5..15f8e2e6a5 100644 --- a/packages/accounts-oauth-helper/oauth_server.js +++ b/packages/accounts-oauth-helper/oauth_server.js @@ -142,17 +142,9 @@ // Make sure we're configured var ensureConfigured = function(serviceName) { - var service = Meteor.accounts[serviceName]; - - _.each(service._requireConfigs, function(key) { - if (!service[key]) - throw new Meteor.accounts.ConfigError( - "Need to call Meteor.accounts." + serviceName + ".config first"); - }); - - if (Meteor.isServer && !service._secret) - throw new Meteor.accounts.ConfigError( - "Need to call Meteor.accounts." + serviceName + ".setSecret first"); + if (!Meteor.accounts.configuration.findOne({service: serviceName})) { + throw new Meteor.accounts.ConfigError("Service not configured"); + }; }; Meteor.accounts.oauth._renderOauthResults = function(res, query) { diff --git a/packages/accounts-oauth1-helper/oauth1_binding.js b/packages/accounts-oauth1-helper/oauth1_binding.js index 8f03ef8451..835ca74cc1 100644 --- a/packages/accounts-oauth1-helper/oauth1_binding.js +++ b/packages/accounts-oauth1-helper/oauth1_binding.js @@ -116,7 +116,7 @@ OAuth1Binding.prototype._call = function(method, url, headers, params) { }); if (response.error) { - Meteor._debug('Error sending OAuth1 HTTP call', method, url, params, authString); + Meteor._debug('Error sending OAuth1 HTTP call', response.content, method, url, params, authString); throw response.error; } diff --git a/packages/accounts-oauth1-helper/oauth1_server.js b/packages/accounts-oauth1-helper/oauth1_server.js index 3203f08955..4025862d4d 100644 --- a/packages/accounts-oauth1-helper/oauth1_server.js +++ b/packages/accounts-oauth1-helper/oauth1_server.js @@ -7,8 +7,14 @@ // connect middleware Meteor.accounts.oauth1._handleRequest = function (service, query, res) { - var config = Meteor.accounts[service.serviceName]; - var oauthBinding = new OAuth1Binding(config._consumerKey, config._secret, config._urls); + var config = Meteor.accounts.configuration.findOne({service: service.serviceName}); + if (!config) { + throw new Meteor.accounts.ConfigError("Service " + service.serviceName + " not configured"); + } + + var urls = Meteor.accounts[service.serviceName]._urls; + var oauthBinding = new OAuth1Binding( + config.consumerKey, config.secret, urls); if (query.requestTokenAndRedirect) { // step 1 - get and store a request token @@ -20,7 +26,7 @@ Meteor.accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken; // redirect to provider login, which will redirect back to "step 2" below - var redirectUrl = config._urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; + var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken; res.writeHead(302, {'Location': redirectUrl}); res.end(); diff --git a/packages/accounts-oauth1-helper/oauth1_tests.js b/packages/accounts-oauth1-helper/oauth1_tests.js index e7a004cc7a..e081ed7c1c 100644 --- a/packages/accounts-oauth1-helper/oauth1_tests.js +++ b/packages/accounts-oauth1-helper/oauth1_tests.js @@ -18,9 +18,9 @@ Tinytest.add("oauth1 - loginResultForState is stored", function (test) { Meteor.accounts.oauth._loginResultForState = {}; Meteor.accounts.oauth._services = {}; + if (!Meteor.accounts.configuration.findOne({service: 'twitterfoo'})) + Meteor.accounts.configuration.insert({service: 'twitterfoo'}); Meteor.accounts.twitterfoo = {}; - Meteor.accounts.twitterfoo._requireConfigs = []; - Meteor.accounts.twitterfoo._secret = 'XXX'; // register a fake login service - twitterfoo Meteor.accounts.oauth.registerService("twitterfoo", 1, function (query) { @@ -78,9 +78,9 @@ Tinytest.add("oauth1 - error in user creation", function (test) { var twitterfailAccessToken = Meteor.uuid(); var twitterfailAccessTokenSecret = Meteor.uuid(); + if (!Meteor.accounts.configuration.findOne({service: 'twitterfail'})) + Meteor.accounts.configuration.insert({service: 'twitterfail'}); Meteor.accounts.twitterfail = {}; - Meteor.accounts.twitterfail._requireConfigs = []; - Meteor.accounts.twitterfail._secret = 'XXX'; // Wire up access token so that verification passes Meteor.accounts.oauth1._requestTokens[state] = twitterfailAccessToken; diff --git a/packages/accounts-oauth2-helper/oauth2_tests.js b/packages/accounts-oauth2-helper/oauth2_tests.js index 8584926d69..56171fec8b 100644 --- a/packages/accounts-oauth2-helper/oauth2_tests.js +++ b/packages/accounts-oauth2-helper/oauth2_tests.js @@ -8,9 +8,9 @@ Tinytest.add("oauth2 - loginResultForState is stored", function (test) { Meteor.accounts.oauth._loginResultForState = {}; Meteor.accounts.oauth._services = {}; + if (!Meteor.accounts.configuration.findOne({service: 'foobook'})) + Meteor.accounts.configuration.insert({service: 'foobook'}); Meteor.accounts.foobook = {}; - Meteor.accounts.foobook._requireConfigs = []; - Meteor.accounts.foobook._secret = 'XXX'; // register a fake login service - foobook Meteor.accounts.oauth.registerService("foobook", 2, function (query) { @@ -49,9 +49,9 @@ Tinytest.add("oauth2 - error in user creation", function (test) { var state = Meteor.uuid(); var failbookId = Meteor.uuid(); + if (!Meteor.accounts.configuration.findOne({service: 'failbook'})) + Meteor.accounts.configuration.insert({service: 'failbook'}); Meteor.accounts.failbook = {}; - Meteor.accounts.failbook._requireConfigs = []; - Meteor.accounts.failbook._secret = 'XXX'; // register a failing login service Meteor.accounts.oauth.registerService("failbook", 2, function (query) { diff --git a/packages/accounts-twitter/package.js b/packages/accounts-twitter/package.js index a144429e23..bae64bbd53 100644 --- a/packages/accounts-twitter/package.js +++ b/packages/accounts-twitter/package.js @@ -6,6 +6,11 @@ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); api.use('accounts-oauth1-helper', ['client', 'server']); api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['twitter_configure.html', 'twitter_configure.js'], + 'client'); api.add_files('twitter_common.js', ['client', 'server']); api.add_files('twitter_server.js', 'server'); diff --git a/packages/accounts-twitter/twitter_client.js b/packages/accounts-twitter/twitter_client.js index b75dcf378e..4e64ed5050 100644 --- a/packages/accounts-twitter/twitter_client.js +++ b/packages/accounts-twitter/twitter_client.js @@ -1,7 +1,8 @@ (function () { Meteor.loginWithTwitter = function () { - if (!Meteor.accounts.twitter._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.twitter.config first"); + var config = Meteor.accounts.configuration.findOne({service: 'twitter'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); var state = Meteor.uuid(); // We need to keep state across the next two 'steps' so we're adding @@ -10,7 +11,7 @@ // url back to app, enters "step 2" as described in // packages/accounts-oauth1-helper/oauth1_server.js - var callbackUrl = Meteor.accounts.twitter._appUrl + '/_oauth/twitter?close&state=' + state; + var callbackUrl = Meteor.absoluteUrl('_oauth/twitter?close&state=' + state); // url to app, enters "step 1" as described in // packages/accounts-oauth1-helper/oauth1_server.js diff --git a/packages/accounts-twitter/twitter_common.js b/packages/accounts-twitter/twitter_common.js index 19fea5f6cb..72d57689fa 100644 --- a/packages/accounts-twitter/twitter_common.js +++ b/packages/accounts-twitter/twitter_common.js @@ -1,13 +1,7 @@ if (!Meteor.accounts.twitter) { Meteor.accounts.twitter = {}; - Meteor.accounts.twitter._requireConfigs = ['_consumerKey', '_appUrl']; } -Meteor.accounts.twitter.config = function(consumerKey, appUrl) { - Meteor.accounts.twitter._consumerKey = consumerKey; - Meteor.accounts.twitter._appUrl = appUrl; -}; - Meteor.accounts.twitter._urls = { requestToken: "https://api.twitter.com/oauth/request_token", authorize: "https://api.twitter.com/oauth/authorize", diff --git a/packages/accounts-twitter/twitter_configure.html b/packages/accounts-twitter/twitter_configure.html new file mode 100644 index 0000000000..a8720a8fc2 --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.html @@ -0,0 +1,13 @@ + diff --git a/packages/accounts-twitter/twitter_configure.js b/packages/accounts-twitter/twitter_configure.js new file mode 100644 index 0000000000..e5e7f05e72 --- /dev/null +++ b/packages/accounts-twitter/twitter_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServicesDialogForTwitter.siteUrl = function () { + // Twitter doesn't recognize localhost as a domain name + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServicesDialogForTwitter.fields = function () { + return [ + {property: 'consumerKey', label: 'Consumer key'}, + {property: 'secret', label: 'Consumer secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-twitter/twitter_server.js b/packages/accounts-twitter/twitter_server.js index ea3d557982..900331d31b 100644 --- a/packages/accounts-twitter/twitter_server.js +++ b/packages/accounts-twitter/twitter_server.js @@ -1,9 +1,5 @@ (function () { - Meteor.accounts.twitter.setSecret = function (consumerSecret) { - Meteor.accounts.twitter._secret = consumerSecret; - }; - Meteor.accounts.oauth.registerService('twitter', 1, function(oauthBinding) { var identity = oauthBinding.get('https://api.twitter.com/1/account/verify_credentials.json'); diff --git a/packages/accounts-ui/login_buttons.html b/packages/accounts-ui/login_buttons.html index 5dc344b2ae..5538f45028 100644 --- a/packages/accounts-ui/login_buttons.html +++ b/packages/accounts-ui/login_buttons.html @@ -11,10 +11,12 @@
Logout
{{else}} {{#if services}} - {{#if dropdown}} - {{> loginButtonsServicesDropdown}} - {{else}} - {{> loginButtonsServicesRow}} + {{#if configurationLoaded}} + {{#if dropdown}} + {{> loginButtonsServicesDropdown}} + {{else}} + {{> loginButtonsServicesRow}} + {{/if}} {{/if}} {{else}}
No login services configured.
@@ -65,10 +67,17 @@ {{/if}} {{else}} -
- - Sign in with {{name}} -
+ {{#if configured}} +
+ + Sign in with {{name}} +
+ {{else}} +
+ + Configure {{name}} Login +
+ {{/if}} {{/if}} {{/each}} @@ -176,8 +185,48 @@ {{/if}} + + {{> resetPasswordForm}} {{> enrollAccountForm}} {{> justValidatedUserForm}} + {{> configureLoginServicesDialog}} diff --git a/packages/accounts-ui/login_buttons.js b/packages/accounts-ui/login_buttons.js index 64be104617..da00f2068c 100644 --- a/packages/accounts-ui/login_buttons.js +++ b/packages/accounts-ui/login_buttons.js @@ -11,6 +11,10 @@ var RESET_PASSWORD_TOKEN_KEY = 'Meteor.loginButtons.resetPasswordToken'; var ENROLL_ACCOUNT_TOKEN_KEY = 'Meteor.loginButtons.enrollAccountToken'; var JUST_VALIDATED_USER_KEY = 'Meteor.loginButtons.justValidatedUser'; + var CONFIGURE_LOGIN_SERVICES_DIALOG_VISIBLE = 'Meteor.loginButtons.configureLoginServicesDialogVisible'; + var CONFIGURE_LOGIN_SERVICES_DIALOG_SERVICE_NAME = "Meteor.loginButtons.configureLoginServicesDialogServiceName"; + var CONFIGURE_LOGIN_SERVICES_DIALOG_SAVE_ENABLED = "Meteor.accounts.facebook.saveEnabled"; + var resetSession = function () { Session.set(IN_SIGNUP_FLOW_KEY, false); @@ -29,57 +33,43 @@ // loginButtons template // + configureService = function(name) { + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_VISIBLE, true); + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_SERVICE_NAME, name); + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_SAVE_ENABLED, false); + }; + Template.loginButtons.events = { 'click #login-buttons-Facebook': function () { - try { + if (Meteor.accounts.configuration.findOne({service: 'facebook'})) { Meteor.loginWithFacebook(); - } catch (e) { - if (e instanceof Meteor.accounts.ConfigError) - alert("Facebook API key not set. Configure app details with " - + "Meteor.accounts.facebook.config() " - + "and Meteor.accounts.facebook.setSecret()"); - else - throw e; + } else { + configureService("Facebook"); // XXX refactor "Facebook" -> "facebook" } }, 'click #login-buttons-Google': function () { - try { + if (Meteor.accounts.configuration.findOne({service: 'google'})) { Meteor.loginWithGoogle(); - } catch (e) { - if (e instanceof Meteor.accounts.ConfigError) - alert("Google API key not set. Configure app details with " - + "Meteor.accounts.google.config() and " - + "Meteor.accounts.google.setSecret()"); - else - throw e; - }; + } else { + configureService("Google"); + } }, 'click #login-buttons-Weibo': function () { - try { + if (Meteor.accounts.configuration.findOne({service: 'weibo'})) { Meteor.loginWithWeibo(); - } catch (e) { - if (e instanceof Meteor.accounts.ConfigError) - alert("Weibo API key not set. Configure app details with " - + "Meteor.accounts.weibo.config() and " - + "Meteor.accounts.weibo.setSecret()"); - else - throw e; - }; + } else { + configureService("Weibo"); + } }, 'click #login-buttons-Twitter': function () { - try { + if (Meteor.accounts.configuration.findOne({service: 'twitter'})) { Meteor.loginWithTwitter(); - } catch (e) { - if (e instanceof Meteor.accounts.ConfigError) - alert("Twitter API key not set. Configure app details with " - + "Meteor.accounts.twitter.config() and " - + "Meteor.accounts.twitter.setSecret()"); - else - throw e; - }; + } else { + configureService("Twitter"); + } }, 'click #login-buttons-logout': function() { @@ -97,13 +87,17 @@ return service.name === 'Password'; }); - return hasPasswordService || services.length > 2; + return hasPasswordService || services.length > 1; }; Template.loginButtons.services = function () { return getLoginServices(); }; + Template.loginButtons.configurationLoaded = function () { + return Meteor.accounts.loginServicesConfigured(); + }; + Template.loginButtons.displayName = function () { var user = Meteor.user(); if (!user) @@ -260,6 +254,10 @@ || !Meteor.accounts._options.requireUsername; }; + Template.loginButtonsServicesRow.configured = function () { + return !!Meteor.accounts.configuration.findOne({service: this.name.toLowerCase()}); + }; + // // loginButtonsMessage template @@ -329,7 +327,7 @@ }, 'click .login-close-text': function () { resetSession(); - } + } }; Template.loginButtonsServicesDropdown.dropdownVisible = function () { @@ -456,6 +454,93 @@ } }); + // + // configureLoginServicesDialog template + // + + Template.configureLoginServicesDialog.events({ + 'click #configure-login-services-dismiss-button': function () { + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_VISIBLE, false); + }, + 'click #configure-login-services-dialog-save-configuration': function () { + if (Session.get(CONFIGURE_LOGIN_SERVICES_DIALOG_SAVE_ENABLED)) { + // Prepare the configuration document for this login service + var serviceName = Session.get(CONFIGURE_LOGIN_SERVICES_DIALOG_SERVICE_NAME).toLowerCase(); + var configuration = { + service: serviceName + }; + _.each(configurationFields(), function(field) { + configuration[field.property] = document.getElementById( + 'configure-login-services-dialog-' + field.property).value; + }); + + // Configure this login service + Meteor.call("configureLoginService", configuration, function (error, result) { + if (error) + Meteor._debug("Error configurating login service " + serviceName, error); + else + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_VISIBLE, false); + }); + } + } + }); + + Template.configureLoginServicesDialog.events({ + 'input': function (event) { + // if the event fired on one of the configuration input fields, + // check whether we should enable the 'save configuration' button + if (event.target.id.indexOf('configure-login-services-dialog') === 0) + updateSaveDisabled(); + } + }); + + // check whether the 'save configuration' button should be enabled. + // this is a really strange way to implement this and a Forms + // Abstraction would make all of this reactive, and simpler. + var updateSaveDisabled = function () { + var saveEnabled = true; + _.any(configurationFields(), function(field) { + if (document.getElementById( + 'configure-login-services-dialog-' + field.property).value === '') { + saveEnabled = false; + return true; + } else { + return false; + } + }); + + Session.set(CONFIGURE_LOGIN_SERVICES_DIALOG_SAVE_ENABLED, saveEnabled); + }; + + // Returns the appropriate template for this login service. This + // template should be defined in the service's package + var configureLoginServicesDialogTemplateForService = function () { + var serviceName = Session.get(CONFIGURE_LOGIN_SERVICES_DIALOG_SERVICE_NAME); + return Template['configureLoginServicesDialogFor' + serviceName]; + }; + + var configurationFields = function () { + var template = configureLoginServicesDialogTemplateForService(); + return template.fields(); + }; + + Template.configureLoginServicesDialog.configurationFields = function () { + return configurationFields(); + }; + + Template.configureLoginServicesDialog.visible = function () { + return Session.get(CONFIGURE_LOGIN_SERVICES_DIALOG_VISIBLE); + }; + + Template.configureLoginServicesDialog.configurationSteps = function () { + // renders the appropriate template + return configureLoginServicesDialogTemplateForService()(); + }; + + Template.configureLoginServicesDialog.saveDisabled = function () { + return !Session.get(CONFIGURE_LOGIN_SERVICES_DIALOG_SAVE_ENABLED); + }; + // // helpers // diff --git a/packages/accounts-ui/login_buttons.less b/packages/accounts-ui/login_buttons.less index e16371f657..9403ca3cb6 100644 --- a/packages/accounts-ui/login_buttons.less +++ b/packages/accounts-ui/login_buttons.less @@ -15,7 +15,7 @@ margin-left: 4px; } -@login-buttons-accounts-dialog-width: 158px; +@login-buttons-accounts-dialog-width: 178px; #login-buttons .login-button, .accounts-dialog .login-button { float: left; @@ -41,12 +41,20 @@ -o-border-radius: 3px; } +#login-buttons .login-button-disabled, .accounts-dialog .login-button-disabled { + color: #ccc; +} + +#login-buttons .configure-button { + background: red; +} + #login-buttons .login-link-text { margin-left: 5px; /* so that other elements aren't too close */ } .accounts-dialog .login-button { - width: 158px; + width: @login-buttons-accounts-dialog-width; margin-bottom: 4px; } @@ -106,7 +114,7 @@ padding-left: @login-buttons-accounts-dialog-padding-left; padding-bottom: 8px; - width: 167px; + width: @login-buttons-accounts-dialog-width + 9; /* not sure what this 9 is */ } #login-dropdown-list { @@ -137,7 +145,7 @@ } .accounts-dialog input { - width: 162px; + width: @login-buttons-accounts-dialog-width + 4; } .accounts-dialog .login-button-form-submit { @@ -177,7 +185,7 @@ float: left; } -#enroll-account-form, #reset-password-form, #just-validated-user-form { +#enroll-account-form, #reset-password-form, #just-validated-user-form, #configure-login-services-dialog { z-index: 1000; position: fixed; @@ -189,6 +197,18 @@ margin-top: -40px; /* = approximately -height/2, though height can change */ } +@configure-login-services-dialog-width: 530px; +#configure-login-services-dialog { + width: @configure-login-services-dialog-width; + margin-left: -(@configure-login-services-dialog-width + + @login-buttons-accounts-dialog-padding-left) / 2; + margin-top: -180px; /* = approximately -height/2, though height can change */ +} + +#configure-login-services-dialog .login-button-configure { + float: right; +} + #just-validated-dismiss-button { margin-top: 4px; } @@ -206,3 +226,39 @@ background-color: rgba(0, 0, 0, 0.7); } + +#configure-login-services-dialog table { + width: 100%; +} + +#configure-login-services-dialog .configuration_labels { + width: 30%; +} + +#configure-login-services-dialog .configuration_inputs { + width: 70%; +} + +#configure-login-services-dialog input { + width: 100%; + font-family: "Courier New", Courier, monospace; +} + +#configure-login-services-dialog ol { + margin-top: 10px; + margin-bottom: 10px; +} + +#configure-login-services-dialog .new-section { + margin-top: 10px; +} + +#configure-login-services-dialog ol li { + margin-left: 30px; +} + +#configure-login-services-dialog .url { + font-family: "Courier New", Courier, monospace; +} + + diff --git a/packages/accounts-weibo/package.js b/packages/accounts-weibo/package.js index d1cad4e758..c178954131 100644 --- a/packages/accounts-weibo/package.js +++ b/packages/accounts-weibo/package.js @@ -6,6 +6,11 @@ Package.on_use(function(api) { api.use('accounts-base', ['client', 'server']); api.use('accounts-oauth2-helper', ['client', 'server']); api.use('http', ['client', 'server']); + api.use('templating', 'client'); + + api.add_files( + ['weibo_configure.html', 'weibo_configure.js'], + 'client'); api.add_files('weibo_common.js', ['client', 'server']); api.add_files('weibo_server.js', 'server'); diff --git a/packages/accounts-weibo/weibo_client.js b/packages/accounts-weibo/weibo_client.js index 9371dc08cd..c9dcf36b0e 100644 --- a/packages/accounts-weibo/weibo_client.js +++ b/packages/accounts-weibo/weibo_client.js @@ -1,15 +1,16 @@ (function () { Meteor.loginWithWeibo = function () { - if (!Meteor.accounts.weibo._clientId || !Meteor.accounts.weibo._appUrl) - throw new Meteor.accounts.ConfigError("Need to call Meteor.accounts.weibo.config first"); + var config = Meteor.accounts.configuration.findOne({service: 'weibo'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); var state = Meteor.uuid(); // XXX need to support configuring access_type and scope var loginUrl = 'https://api.weibo.com/oauth2/authorize' + '?response_type=code' + - '&client_id=' + Meteor.accounts.weibo._clientId + - '&redirect_uri=' + Meteor.accounts.weibo._appUrl + '/_oauth/weibo?close' + + '&client_id=' + config.clientId + + '&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) + '&state=' + state; Meteor.accounts.oauth.initiateLogin(state, loginUrl); diff --git a/packages/accounts-weibo/weibo_configure.html b/packages/accounts-weibo/weibo_configure.html new file mode 100644 index 0000000000..a509880207 --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.html @@ -0,0 +1,25 @@ + diff --git a/packages/accounts-weibo/weibo_configure.js b/packages/accounts-weibo/weibo_configure.js new file mode 100644 index 0000000000..1f13efdce7 --- /dev/null +++ b/packages/accounts-weibo/weibo_configure.js @@ -0,0 +1,11 @@ +Template.configureLoginServicesDialogForWeibo.siteUrl = function () { + // Weibo doesn't recognize localhost as a domain + return Meteor.absoluteUrl({replaceLocalhost: true}); +}; + +Template.configureLoginServicesDialogForWeibo.fields = function () { + return [ + {property: 'clientId', label: 'App Key'}, + {property: 'secret', label: 'App Secret'} + ]; +}; \ No newline at end of file diff --git a/packages/accounts-weibo/weibo_server.js b/packages/accounts-weibo/weibo_server.js index 822ea48c44..1c95214001 100644 --- a/packages/accounts-weibo/weibo_server.js +++ b/packages/accounts-weibo/weibo_server.js @@ -1,9 +1,5 @@ (function () { - Meteor.accounts.weibo.setSecret = function (secret) { - Meteor.accounts.weibo._secret = secret; - }; - Meteor.accounts.oauth.registerService('weibo', 2, function(query) { var accessToken = getAccessToken(query); @@ -24,12 +20,16 @@ }); var getAccessToken = function (query) { + var config = Meteor.accounts.configuration.findOne({service: 'weibo'}); + if (!config) + throw new Meteor.accounts.ConfigError("Service not configured"); + var result = Meteor.http.post( "https://api.weibo.com/oauth2/access_token", {params: { code: query.code, - client_id: Meteor.accounts.weibo._clientId, - client_secret: Meteor.accounts.weibo._secret, - redirect_uri: Meteor.accounts.weibo._appUrl + "/_oauth/weibo?close", + client_id: config.clientId, + client_secret: config.secret, + redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}), grant_type: 'authorization_code' }});