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.
This commit is contained in:
Avital Oliver
2012-09-18 12:21:40 -07:00
committed by Nick Martin
parent c50cd1defa
commit b01cb66029
34 changed files with 504 additions and 126 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
<template name="configureLoginServicesDialogForFacebook">
<p>
First, you'll need to register your app on Facebook. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://developers.facebook.com/apps" target="_blank">https://developers.facebook.com/apps</a>
</li>
<li>
Create New App (Only a name is required.)
</li>
<li>
Under "Select how your app integrates with Facebook", expand "Website with Facebook Login".
</li>
<li>
Set Site URL to: <span class="url">{{siteUrl}}</span>
</li>
</ol>
</template>

View File

@@ -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'}
];
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
<template name="configureLoginServicesDialogForGoogle">
<p>
First, you'll need to get a Google Client ID. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://code.google.com/apis/console/" target="blank">https://code.google.com/apis/console/</a>
</li>
<li>
Open the "API Access" tab
</li>
<li>
Create another Client ID
</li>
<li>
Expand (more options)
</li>
<li>
Set Authorized Redirect URIs to: <span class="url">{{siteUrl}}_oauth/google?close</span>
</li>
<li>
Set Authorizes Javascript Origins to: <span class="url">{{siteUrl}}</span>
</li>
<li>
Create client ID
</li>
</ol>
</template>

View File

@@ -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'}
];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
<template name="configureLoginServicesDialogForTwitter">
<p>
First, you'll need to register your app on Twitter. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://dev.twitter.com/apps/new" target="_blank">https://dev.twitter.com/apps/new</a>
</li>
<li>
Set Callback URL to: <span class="url">{{siteUrl}}_oauth/twitter?close</span>
</li>
</ol>
</template>

View File

@@ -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'}
];
};

View File

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

View File

@@ -11,10 +11,12 @@
<div class="login-button" id="login-buttons-logout">Logout</div>
{{else}}
{{#if services}}
{{#if dropdown}}
{{> loginButtonsServicesDropdown}}
{{else}}
{{> loginButtonsServicesRow}}
{{#if configurationLoaded}}
{{#if dropdown}}
{{> loginButtonsServicesDropdown}}
{{else}}
{{> loginButtonsServicesRow}}
{{/if}}
{{/if}}
{{else}}
<div class="no-services">No login services configured.</div>
@@ -65,10 +67,17 @@
</div>
{{/if}}
{{else}}
<div class="login-button" id="login-buttons-{{name}}">
<div class="login-image" id="login-buttons-image-{{name}}"></div>
Sign in with {{name}}
</div>
{{#if configured}}
<div class="login-button" id="login-buttons-{{name}}">
<div class="login-image" id="login-buttons-image-{{name}}"></div>
Sign in with {{name}}
</div>
{{else}}
<div class="login-button configure-button" id="login-buttons-{{name}}">
<div class="login-image" id="login-buttons-image-{{name}}"></div>
Configure {{name}} Login
</div>
{{/if}}
{{/if}}
{{/each}}
</template>
@@ -176,8 +185,48 @@
{{/if}}
</template>
<template name="configureLoginServicesDialog">
{{#if visible}}
<div id="configure-login-services-dialog" class="accounts-dialog">
{{{configurationSteps}}}
<p>
Now, copy over some details.
</p>
<p>
<table>
<colgroup>
<col span="1" class="configuration_labels">
<col span="1" class="configuration_inputs">
</colgroup>
{{#each configurationFields}}
<tr>
<td>
<label for="configure-login-services-dialog-{{property}}">{{label}}</label>
</td>
<td>
<input id="configure-login-services-dialog-{{property}}" />
</td>
</tr>
{{/each}}
</table>
</p>
<div class="new-section">
<div class="login-button" id="configure-login-services-dismiss-button">I'll do this later</div>
{{#isolate}}
<div class="login-button login-button-configure {{#if saveDisabled}}login-button-disabled{{/if}}"
id="configure-login-services-dialog-save-configuration">
Save Configuration
</div>
{{/isolate}}
</div>
</div>
{{/if}}
</template>
<body>
{{> resetPasswordForm}}
{{> enrollAccountForm}}
{{> justValidatedUserForm}}
{{> configureLoginServicesDialog}}
</body>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
<template name="configureLoginServicesDialogForWeibo">
<p>
First, you'll need to register your app on Weibo. Follow these steps:
</p>
<ol>
<li>
Visit <a href="http://open.weibo.com/development" target="_blank">http://open.weibo.com/development</a>
</li>
<li>
Click the "创建应用" button
</li>
<li>
Select 网页应用在第三方网页内访问使用 (Web Applications)
</li>
<li>
Complete the registration process
</li>
<li>
Open 应用信息 (Application) -> 高级信息 (Senior Information)
</li>
<li>
Set OAuth2.0 授权回调页 (authorized callback page) to: <span class="url">{{siteUrl}}_oauth/weibo?close</span>
</li>
</ol>
</template>

View File

@@ -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'}
];
};

View File

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