Merge branch 'auth' into devel

This commit is contained in:
Nick Martin
2012-10-10 00:02:54 -07:00
118 changed files with 9462 additions and 287 deletions

3
.gitignore vendored
View File

@@ -3,5 +3,6 @@
/dev_bundle
/dev_bundle*.tar.gz
/dist
\#*#
\#*\#
.\#*
.idea

View File

@@ -4,4 +4,5 @@
# but you can also edit it by hand.
autopublish
insecure
preserve-inputs

View File

@@ -337,13 +337,28 @@ To call methods on another Meteor application or subscribe to its data
sets, call `Meteor.connect` with the URL of the application.
`Meteor.connect` returns an object which provides:
* `subscribe`
* `methods` (to define stubs)
* `call`
* `apply`
* `status`
* `reconnect`
* `subscribe` -
Subscribe to a record set. See
<a href="#meteor_subscribe">Meteor.subscribe</a>.
* `call` -
Invoke a method. See <a href="#meteor_call">Meteor.call</a>.
* `apply` -
Invoke a method with an argument array. See
<a href="#meteor_apply">Meteor.apply</a>.
* `methods` -
Define client-only stubs for methods defined on the remote server. See
<a href="#meteor_methods">Meteor.methods</a>.
* `status` -
Get the current connection status. See
<a href="#meteor_status">Meteor.status</a>.
* `reconnect` -
See <a href="meteor_reconnect">Meteor.reconnect</a>.
* `onReconnect` - Set this to a function to be called as the first step of
reconnecting. This function can call methods which will be executed before
any other outstanding methods. For example, this can be used to re-establish
the appropriate authentication context on the new connection.
By default, clients open a connection to the server from which they're loaded.
When you call `Meteor.subscribe`, `Meteor.status`, `Meteor.call`, and
`Meteor.apply`, you are using a connection back to that default
server.
@@ -1237,7 +1252,7 @@ Matches a particular type of event, such as 'click'.
{{#dtdd "<em>eventtype selector</em>"}}
Matches a particular type of event, but only when it appears on
an element that matches a certain CSS selector.
an element that matches a certain CSS selector.
{{/dtdd}}
{{#dtdd "<em>event1, event2</em>"}}

View File

@@ -239,7 +239,7 @@ Template.api.meteor_call = {
Template.api.meteor_apply = {
id: "meteor_apply",
name: "Meteor.apply(name, params [, asyncCallback])",
name: "Meteor.apply(name, params [, options] [, asyncCallback])",
locus: "Anywhere",
descr: ["Invoke a method passing an array of arguments."],
args: [
@@ -252,6 +252,12 @@ Template.api.meteor_apply = {
{name: "asyncCallback",
type: "Function",
descr: "Optional callback. If passed, the method runs asynchronously, instead of synchronously, and calls asyncCallback passing either the error or the result."}
],
options: [
{name: "wait",
type: "Boolean",
descr: "(Client only) If true, don't send any subsequent method calls until this one is completed. "
+ "Only run the callback for this method once all previous method calls have completed."}
]
};
@@ -288,18 +294,19 @@ Template.api.connect = {
Template.api.meteor_collection = {
id: "meteor_collection",
name: "new Meteor.Collection(name, manager)", // driver undocumented
name: "new Meteor.Collection(name, [options])",
locus: "Anywhere",
descr: ["Constructor for a Collection"],
args: [
{name: "name",
type: "String",
descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."},
descr: "The name of the collection. If null, creates an unmanaged (unsynchronized) local collection."}
],
options: [
{name: "manager",
type: "Object",
descr: "The Meteor connection that will manage this collection, defaults to `Meteor` if null. Unmanaged (`name` is null) collections cannot specify a manager."
}
// driver
]
};
@@ -760,7 +767,7 @@ Template.api.equals = {
Template.api.httpcall = {
id: "meteor_http_call",
name: "Meteor.http.call(method, url, [options], [asyncCallback])",
name: "Meteor.http.call(method, url [, options] [, asyncCallback])",
locus: "Anywhere",
descr: ["Perform an outbound HTTP request."],
args: [

View File

@@ -4,4 +4,5 @@
# but you can also edit it by hand.
autopublish
insecure
preserve-inputs

View File

@@ -6,5 +6,12 @@
underscore
backbone
spiderable
accounts-ui
accounts-weibo
accounts-google
accounts-facebook
accounts-password
accounts-twitter
jquery
preserve-inputs
accounts-github

View File

@@ -44,6 +44,9 @@ h3 {
right: 0;
top: 0;
bottom: 0;
}
#tag-filter, #main-pane, #side-pane, #bottom-pane {
overflow: hidden;
}
@@ -55,6 +58,10 @@ h3 {
border-bottom: 1px solid #999;
}
#tag-filter {
height: 44px; /* same as in #top-tag-filter */
}
#help {
padding: 8px;
}
@@ -261,3 +268,19 @@ h3 {
width: 80px;
}
.toggle-privacy-wrapper {
float: right;
width: 110px;
}
.toggle-privacy {
margin-top: 15px;
float: right;
cursor: pointer;
}
.login-bar {
float: right;
/* center login buttons vertically in our top bar */
padding: 10px 10px 0px 0px;
}

View File

@@ -4,6 +4,9 @@
<body>
<div id="top-tag-filter">
<div class="login-bar login-buttons-dropdown-hangs-left">
{{> loginButtons}}
</div>
{{> tag_filter}}
</div>
@@ -68,6 +71,21 @@
<div class="todo-text">{{text}}</div>
</div>
{{/if}}
{{#if currentUser}}
<div class="toggle-privacy-wrapper">
<div class="toggle-privacy tag">
{{#if privateTo}}
<span class="make-public">
make public
</span>
{{else}}
<span class="make-private">
make private
</span>
{{/if}}
</div>
</div>
{{/if}}
<div class="item-tags">
{{#each tag_objs}}
<div class="tag removable_tag">
@@ -98,5 +116,3 @@
{{/each}}
</div>
</template>

View File

@@ -19,7 +19,6 @@ Session.set('editing_listname', null);
// When editing todo text, ID of the todo
Session.set('editing_itemname', null);
// Subscribe to 'lists' collection on startup.
// Select a list once data has arrived.
Meteor.subscribe('lists', function () {
@@ -217,11 +216,22 @@ Template.todo_item.events({
evt.target.parentNode.style.opacity = 0;
// wait for CSS animation to finish
Meteor.setTimeout(function () {
Todos.update({_id: id}, {$pull: {tags: tag}});
Todos.update(id, {$pull: {tags: tag}});
}, 300);
},
'click .make-public': function () {
Todos.update(this._id, {$set: {privateTo: null}});
},
'click .make-private': function () {
Todos.update(this._id, {$set: {
privateTo: Meteor.user()._id
}});
}
});
Template.todo_item.events(okCancelEvents(
'#todo-input',
{

View File

@@ -0,0 +1,19 @@
Meteor.startup(function() {
var canModify = function(userId, tasks) {
return _.all(tasks, function(task) {
return !task.privateTo || task.privateTo === userId;
});
};
Todos.allow({
insert: function () { return true; },
update: canModify,
remove: canModify,
fetch: ['privateTo']
});
Lists.allow({
insert: function () { return true; }
// can't update or remove
});
});

View File

@@ -14,8 +14,13 @@ Meteor.publish('lists', function () {
// timestamp: Number}
Todos = new Meteor.Collection("todos");
// Publish all items for requested list_id.
// Publish visible items for requested list_id.
Meteor.publish('todos', function (list_id) {
return Todos.find({list_id: list_id});
return Todos.find({
list_id: list_id,
privateTo: {
$in: [null, this.userId]
}
});
});

View File

@@ -1,7 +1,7 @@
Leaderboard = Meteor.connect("http://leader2.meteor.com/sockjs");
// XXX I'd rather this be Leaderboard.Players.. can this API be easier?
Players = new Meteor.Collection("players", Leaderboard);
Players = new Meteor.Collection("players", {manager: Leaderboard});
Template.main.events = {
'keydown': function () {

View File

@@ -1,15 +1,15 @@
sub
sub xcxc
sub xcxc yzyz
sub xcxc {}
sub undefinedSub
sub undefinedSub someArg
sub undefinedSub {}
sub allApps
sub myApp "foo.bar"
sub myApp ["foo.meteor.com"]
call
call xcxc
call xcxc yzyz
call xcxc {}
call undefinedMethod
call undefinedMethod yzyz
call undefinedMethod {}
call vote
call vote []
call vote ["foo.meteor.com"]

View File

@@ -3,5 +3,6 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
insecure
jquery
preserve-inputs

View File

@@ -0,0 +1,109 @@
(function () {
Meteor.userId = function () {
return Meteor.default_connection.userId();
};
var userLoadedListeners = new Meteor.deps._ContextSet;
var currentUserSubscriptionData;
Meteor.userLoaded = function () {
userLoadedListeners.addCurrentContext();
return currentUserSubscriptionData && currentUserSubscriptionData.loaded;
};
// This calls userId and userLoaded, both of which are reactive.
Meteor.user = function () {
var userId = Meteor.userId();
if (!userId)
return null;
if (Meteor.userLoaded())
return Meteor.users.findOne(userId);
// Not yet loaded: return a minimal object.
return {_id: userId};
};
Accounts._makeClientLoggedOut = function() {
Accounts._unstoreLoginToken();
Meteor.default_connection.setUserId(null);
Meteor.default_connection.onReconnect = null;
userLoadedListeners.invalidateAll();
if (currentUserSubscriptionData) {
currentUserSubscriptionData.handle.stop();
currentUserSubscriptionData = null;
}
};
Accounts._makeClientLoggedIn = function(userId, token) {
Accounts._storeLoginToken(userId, token);
Meteor.default_connection.setUserId(userId);
Meteor.default_connection.onReconnect = function() {
Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
if (error) {
Accounts._makeClientLoggedOut();
throw error;
} else {
// nothing to do
}
});
};
userLoadedListeners.invalidateAll();
if (currentUserSubscriptionData) {
currentUserSubscriptionData.handle.stop();
}
var data = currentUserSubscriptionData = {loaded: false};
data.handle = Meteor.subscribe(
"meteor.currentUser", function () {
// Important! We use "data" here, not "currentUserSubscriptionData", so
// that if we log out and in again before this subscription is ready, we
// don't make currentUserSubscriptionData look ready just because this
// older iteration of subscribing is ready.
data.loaded = true;
userLoadedListeners.invalidateAll();
});
};
Meteor.logout = function (callback) {
Meteor.apply('logout', [], {wait: true}, function(error, result) {
if (error) {
callback && callback(error);
} else {
Accounts._makeClientLoggedOut();
callback && callback();
}
});
};
// If we're using Handlebars, register the {{currentUser}} and
// {{currentUserLoaded}} global helpers.
if (typeof Handlebars !== 'undefined') {
Handlebars.registerHelper('currentUser', function () {
return Meteor.user();
});
Handlebars.registerHelper('currentUserLoaded', function () {
return Meteor.userLoaded();
});
}
// XXX this can be simplified if we merge in
// https://github.com/meteor/meteor/pull/273
var loginServicesConfigured = false;
var loginServicesConfiguredListeners = new Meteor.deps._ContextSet;
Meteor.subscribe("meteor.loginServiceConfiguration", function () {
loginServicesConfigured = true;
loginServicesConfiguredListeners.invalidateAll();
});
// 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
Accounts.loginServicesConfigured = function () {
if (loginServicesConfigured)
return true;
// not yet complete, save the context for invalidation once we are.
loginServicesConfiguredListeners.addCurrentContext();
return false;
};
})();

View File

@@ -0,0 +1,59 @@
if (typeof Accounts === 'undefined')
Accounts = {};
if (!Accounts._options) {
Accounts._options = {};
}
// @param options {Object} an object with fields:
// - sendVerificationEmail {Boolean}
// Send email address verification emails to new users created from
// client signups.
// - forbidClientAccountCreation {Boolean}
// Do not allow clients to create accounts directly.
Accounts.config = function(options) {
_.each(["sendVerificationEmail", "forbidClientAccountCreation"], function(key) {
if (key in options) {
if (key in Accounts._options)
throw new Error("Can't set `" + key + "` more than once");
else
Accounts._options[key] = options[key];
}
});
};
// Users table. Don't use the normal autopublish, since we want to hide
// some fields. Code to autopublish this is in accounts_server.js.
// XXX Allow users to configure this collection name.
Meteor.users = new Meteor.Collection("users", {_preventAutopublish: true});
// There is an allow call in accounts_server that restricts this
// collection.
// Table containing documents with configuration options for each
// login service
Accounts.loginServiceConfiguration = new Meteor.Collection(
"meteor_accounts_loginServiceConfiguration", {_preventAutopublish: true});
// Leave this collection open in insecure mode. In theory, someone could
// hijack your oauth connect requests to a different endpoint or appId,
// but you did ask for 'insecure'. The advantage is that it is much
// easier to write a configuration wizard that works only in insecure
// mode.
// Thrown when trying to use a login service which is not configured
Accounts.ConfigError = function(description) {
this.message = description;
};
Accounts.ConfigError.prototype = new Error();
Accounts.ConfigError.prototype.name = 'Accounts.ConfigError';
// Thrown when the user cancels the login process (eg, closes an oauth
// popup, declines retina scan, etc)
Accounts.LoginCancelledError = function(description) {
this.message = description;
this.cancelled = true;
};
Accounts.LoginCancelledError.prototype = new Error();
Accounts.LoginCancelledError.prototype.name = 'Accounts.LoginCancelledError';

View File

@@ -0,0 +1,338 @@
(function () {
///
/// LOGIN HANDLERS
///
Meteor.methods({
// @returns {Object|null}
// If successful, returns {token: reconnectToken, id: userId}
// If unsuccessful (for example, if the user closed the oauth login popup),
// returns null
login: function(options) {
var result = tryAllLoginHandlers(options);
if (result !== null)
this.setUserId(result.id);
return result;
},
logout: function() {
this.setUserId(null);
}
});
Accounts._loginHandlers = [];
// Try all of the registered login handlers until one of them
// doesn't return `undefined` (NOT null), meaning it handled this
// call to `login`. Return that return value.
var tryAllLoginHandlers = function (options) {
var result = undefined;
_.find(Accounts._loginHandlers, function(handler) {
var maybeResult = handler(options);
if (maybeResult !== undefined) {
result = maybeResult;
return true;
} else {
return false;
}
});
if (result === undefined) {
throw new Meteor.Error(400, "Unrecognized options for login request");
} else {
return result;
}
};
// @param handler {Function} A function that receives an options object
// (as passed as an argument to the `login` method) and returns one of:
// - `undefined`, meaning don't handle;
// - `null`, meaning the user didn't actually log in;
// - {id: userId, accessToken: *}, if the user logged in successfully.
Accounts.registerLoginHandler = function(handler) {
Accounts._loginHandlers.push(handler);
};
// support reconnecting using a meteor login token
Accounts._generateStampedLoginToken = function () {
return {token: Meteor.uuid(), when: +(new Date)};
};
Accounts.registerLoginHandler(function(options) {
if (options.resume) {
var user = Meteor.users.findOne(
{"services.resume.loginTokens.token": options.resume});
if (!user)
throw new Meteor.Error(403, "Couldn't find login token");
return {
token: options.resume,
id: user._id
};
} else {
return undefined;
}
});
///
/// CURRENT USER
///
Meteor.userId = function () {
// This function only works if called inside a method. In theory, it
// could also be called from publish statements, since they also
// have a userId associated with them. However, given that publish
// functions aren't reactive, using any of the infomation from
// Meteor.user() in a publish function will always use the value
// from when the function first runs. This is likely not what the
// user expects. The way to make this work in a publish is to do
// Meteor.find(this.userId()).observe and recompute when the user
// record changes.
var currentInvocation = Meteor._CurrentInvocation.get();
if (!currentInvocation)
throw new Error("Meteor.userId can only be invoked in method calls. Use this.userId in publish functions.");
return currentInvocation.userId;
};
Meteor.user = function () {
var userId = Meteor.userId();
if (!userId)
return null;
return Meteor.users.findOne(userId);
};
///
/// CREATE USER HOOKS
///
var onCreateUserHook = null;
Accounts.onCreateUser = function (func) {
if (onCreateUserHook)
throw new Error("Can only call onCreateUser once");
else
onCreateUserHook = func;
};
// XXX see comment on Accounts.createUser in passwords_server about adding a
// third "server options" argument.
var defaultCreateUserHook = function (options, extra, user) {
// This hook gets 'extra' directly from the createUser method, so make sure
// we don't allow users to set any fields at creation time that they won't
// later be able to set according to the default Meteor.users.allow. Set
// your own onCreateUser if you want users to be able to specify other
// fields at creation time.
if (_.any(extra, function(value, key) {return key != 'profile';})) {
console.log(JSON.stringify(extra));
throw new Meteor.Error(400, "Disallowed fields in extra");
}
return _.extend(user, extra);
};
Accounts.insertUserDoc = function (options, extra, user) {
// add created at timestamp (and protect passed in user object from
// modification)
user = _.extend({createdAt: +(new Date)}, user);
var fullUser;
if (onCreateUserHook) {
fullUser = onCreateUserHook(options, extra, user);
// This is *not* part of the API. We need this because we can't isolate
// the global server environment between tests, meaning we can't test
// both having a create user hook set and not having one set.
if (fullUser === 'TEST DEFAULT HOOK')
fullUser = defaultCreateUserHook(options, extra, user);
} else {
fullUser = defaultCreateUserHook(options, extra, user);
}
_.each(validateNewUserHooks, function (hook) {
if (!hook(fullUser))
throw new Meteor.Error(403, "User validation failed");
});
var result = {};
if (options.generateLoginToken) {
var stampedToken = Accounts._generateStampedLoginToken();
result.token = stampedToken.token;
Meteor._ensure(fullUser, 'services', 'resume');
if (_.has(fullUser.services.resume, 'loginTokens'))
fullUser.services.resume.loginTokens.push(stampedToken);
else
fullUser.services.resume.loginTokens = [stampedToken];
}
try {
result.id = Meteor.users.insert(fullUser);
} catch (e) {
// XXX string parsing sucks, maybe
// https://jira.mongodb.org/browse/SERVER-3069 will get fixed one day
if (e.name !== 'MongoError') throw e;
var match = e.err.match(/^E11000 duplicate key error index: ([^ ]+)/);
if (!match) throw e;
if (match[1].indexOf('$emails.address') !== -1)
throw new Meteor.Error(403, "Email already exists.");
if (match[1].indexOf('username') !== -1)
throw new Meteor.Error(403, "Username already exists.");
// XXX better error reporting for services.facebook.id duplicate, etc
throw e;
}
return result;
};
var validateNewUserHooks = [];
Accounts.validateNewUser = function (func) {
validateNewUserHooks.push(func);
};
///
/// MANAGING USER OBJECTS
///
// Updates or creates a user after we authenticate with a 3rd party.
//
// @param serviceName {String} Service name (eg, twitter).
// @param serviceData {Object} Data to store in the user's record
// under services[serviceName]. Must include an "id" field
// which is a unique identifier for the user in the service.
// @param extra {Object, optional} Any additional fields to place on the user
// object
// @returns {Object} Object with token and id keys, like the result
// of the "login" method.
Accounts.updateOrCreateUserFromExternalService = function(
serviceName, serviceData, extra) {
extra = extra || {};
if (serviceName === "password" || serviceName === "resume")
throw new Error(
"Can't use updateOrCreateUserFromExternalService with internal service "
+ serviceName);
if (!_.has(serviceData, 'id'))
throw new Error(
"Service data for service " + serviceName + " must include id");
// Look for a user with the appropriate service user id.
var selector = {};
selector["services." + serviceName + ".id"] = serviceData.id;
var user = Meteor.users.findOne(selector);
if (user) {
// don't overwrite existing fields
// XXX subobjects (aka 'profile', 'services')?
var newKeys = _.difference(_.keys(extra), _.keys(user));
var newAttrs = _.pick(extra, newKeys);
var stampedToken = Accounts._generateStampedLoginToken();
var result = {token: stampedToken.token};
Meteor.users.update(
user._id,
{$set: newAttrs, $push: {'services.resume.loginTokens': stampedToken}});
result.id = user._id;
return result;
} else {
// Create a new user.
var servicesClause = {};
servicesClause[serviceName] = serviceData;
var insertOptions = {services: servicesClause, generateLoginToken: true};
// Build a user doc; clone to make sure sure mutating
// insertOptions.services doesn't affect user.services or vice versa.
user = {services: JSON.parse(JSON.stringify(servicesClause))};
return Accounts.insertUserDoc(insertOptions, extra, user);
}
};
///
/// PUBLISHING DATA
///
// Publish the current user's record to the client.
// XXX This should just be a universal subscription, but we want to know when
// we've gotten the data after a 'login' method, which currently requires
// us to unsub, sub, and wait for onComplete. This is wasteful because
// we're actually guaranteed to have the data by the time that 'login'
// returns. But we don't expose a callback to Meteor.apply which lets us
// know when the data has been processed (ie, quiescence, or at least
// partial quiescence).
Meteor.publish("meteor.currentUser", function() {
if (this.userId)
return Meteor.users.find(
{_id: this.userId},
{fields: {profile: 1, username: 1,
// We do let the UI know if emails are verified but we don't
// want to publish the verificationTokens field!
'emails.address': 1, 'emails.verified': 1}});
else {
this.complete();
return null;
}
}, {is_auto: true});
// If autopublish is on, also publish everyone else's user record.
Meteor.default_server.onAutopublish(function () {
var handler = function () {
return Meteor.users.find(
{}, {fields: {profile: 1, username: 1}});
};
Meteor.default_server.publish(null, handler, {is_auto: true});
});
// Publish all login service configuration fields other than secret.
Meteor.publish("meteor.loginServiceConfiguration", function () {
return Accounts.loginServiceConfiguration.find({}, {fields: {secret: 0}});
}, {is_auto: true}); // not techincally autopublish, but stops the warning.
// Allow a one-time configuration for a login service. Modifications
// to this collection are also allowed in insecure mode.
Meteor.methods({
"configureLoginService": function(options) {
// Don't let random users configure a service we haven't added yet (so
// that when we do later add it, it's set up with their configuration
// instead of ours).
if (!Accounts[options.service])
throw new Meteor.Error(403, "Service unknown");
if (Accounts.loginServiceConfiguration.findOne({service: options.service}))
throw new Meteor.Error(403, "Service " + options.service + " already configured");
Accounts.loginServiceConfiguration.insert(options);
}
});
///
/// RESTRICTING WRITES TO USER OBJECTS
///
Meteor.users.allow({
// clients can modify the profile field of their own document, and
// nothing else.
update: function (userId, docs, fields, modifier) {
// if there is more than one doc, at least one of them isn't our
// user record.
if (docs.length !== 1)
return false;
// make sure it is our record
var user = docs[0];
if (user._id !== userId)
return false;
// user can only modify the 'profile' field. sets to multiple
// sub-keys (eg profile.foo and profile.bar) are merged into entry
// in the fields list.
if (fields.length !== 1 || fields[0] !== 'profile')
return false;
return true;
},
fields: ['_id'] // we only look at _id.
});
/// DEFAULT INDEXES ON USERS
Meteor.users._ensureIndex('username', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('emails.address', {unique: 1, sparse: 1});
Meteor.users._ensureIndex('services.resume.loginTokens.token',
{unique: 1, sparse: 1});
}) ();

View File

@@ -0,0 +1,130 @@
Tinytest.add('accounts - updateOrCreateUserFromExternalService', function (test) {
var facebookId = Meteor.uuid();
var weiboId1 = Meteor.uuid();
var weiboId2 = Meteor.uuid();
// create an account with facebook
var uid1 = Accounts.updateOrCreateUserFromExternalService(
'facebook', {id: facebookId}, {profile: {foo: 1}}).id;
test.equal(Meteor.users.find({"services.facebook.id": facebookId}).count(), 1);
test.equal(Meteor.users.findOne({"services.facebook.id": facebookId}).profile.foo, 1);
// create again with the same id, see that we get the same user. profile
// doesn't get overwritten in this implementation (though we should do
// something better with merging later).
var uid2 = Accounts.updateOrCreateUserFromExternalService(
'facebook', {id: facebookId}, {profile: {foo: 1000, bar: 2}}).id;
test.equal(uid1, uid2);
test.equal(Meteor.users.find({"services.facebook.id": facebookId}).count(), 1);
test.equal(Meteor.users.findOne(uid1).profile.foo, 1);
test.equal(Meteor.users.findOne(uid1).profile.bar, undefined);
// cleanup
Meteor.users.remove(uid1);
// users that have different service ids get different users
uid1 = Accounts.updateOrCreateUserFromExternalService(
'weibo', {id: weiboId1}, {profile: {foo: 1}}).id;
uid2 = Accounts.updateOrCreateUserFromExternalService(
'weibo', {id: weiboId2}, {profile: {bar: 2}}).id;
test.equal(Meteor.users.find({"services.weibo.id": {$in: [weiboId1, weiboId2]}}).count(), 2);
test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).profile.foo, 1);
test.equal(Meteor.users.findOne({"services.weibo.id": weiboId1}).emails, undefined);
test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).profile.bar, 2);
test.equal(Meteor.users.findOne({"services.weibo.id": weiboId2}).emails, undefined);
// cleanup
Meteor.users.remove(uid1);
Meteor.users.remove(uid2);
});
Tinytest.add('accounts - insertUserDoc username', function (test) {
var userIn = {
username: Meteor.uuid()
};
// user does not already exist. create a user object with fields set.
var result = Accounts.insertUserDoc(
userIn,
{profile: {name: 'Foo Bar'}},
userIn
);
var userOut = Meteor.users.findOne(result.id);
test.equal(typeof userOut.createdAt, 'number');
test.equal(userOut.profile.name, 'Foo Bar');
test.equal(userOut.username, userIn.username);
// run the hook again. now the user exists, so it throws an error.
test.throws(function () {
Accounts.insertUserDoc(
userIn,
{profile: {name: 'Foo Bar'}},
userIn
);
});
// cleanup
Meteor.users.remove(result.id);
});
Tinytest.add('accounts - insertUserDoc email', function (test) {
var email1 = Meteor.uuid();
var email2 = Meteor.uuid();
var email3 = Meteor.uuid();
var userIn = {
emails: [{address: email1, verified: false},
{address: email2, verified: true}]
};
// user does not already exist. create a user object with fields set.
var result = Accounts.insertUserDoc(
userIn,
{profile: {name: 'Foo Bar'}},
userIn
);
var userOut = Meteor.users.findOne(result.id);
test.equal(typeof userOut.createdAt, 'number');
test.equal(userOut.profile.name, 'Foo Bar');
test.equal(userOut.emails, userIn.emails);
// run the hook again with the exact same emails.
// run the hook again. now the user exists, so it throws an error.
test.throws(function () {
Accounts.insertUserDoc(
userIn,
{profile: {name: 'Foo Bar'}},
userIn
);
});
// now with only one of them.
test.throws(function () {
Accounts.insertUserDoc(
{}, {}, {emails: [{address: email1}]}
);
});
test.throws(function () {
Accounts.insertUserDoc(
{}, {}, {emails: [{address: email2}]}
);
});
// a third email works.
var result3 = Accounts.insertUserDoc(
{}, {}, {emails: [{address: email3}]}
);
var user3 = Meteor.users.findOne(result3.id);
test.equal(typeof user3.createdAt, 'number');
// cleanup
Meteor.users.remove(result.id);
Meteor.users.remove(result3.id);
});

View File

@@ -0,0 +1,97 @@
(function() {
// To be used as the local storage key
var loginTokenKey = "Meteor.loginToken";
var userIdKey = "Meteor.userId";
// Call this from the top level of the test file for any test that does
// logging in and out, to protect multiple tabs running the same tests
// simultaneously from interfering with each others' localStorage.
Accounts._isolateLoginTokenForTest = function () {
loginTokenKey = loginTokenKey + Meteor.uuid();
userIdKey = userIdKey + Meteor.uuid();
};
Accounts._storeLoginToken = function(userId, token) {
localStorage.setItem(userIdKey, userId);
localStorage.setItem(loginTokenKey, token);
// to ensure that the localstorage poller doesn't end up trying to
// connect a second time
Accounts._lastLoginTokenWhenPolled = token;
};
Accounts._unstoreLoginToken = function() {
localStorage.removeItem(userIdKey);
localStorage.removeItem(loginTokenKey);
// to ensure that the localstorage poller doesn't end up trying to
// connect a second time
Accounts._lastLoginTokenWhenPolled = null;
};
Accounts._storedLoginToken = function() {
return localStorage.getItem(loginTokenKey);
};
Accounts._storedUserId = function() {
return localStorage.getItem(userIdKey);
};
})();
// Login with a Meteor access token
//
// XXX having errorCallback only here is weird since other login
// methods will have different callbacks. Standardize this.
Meteor.loginWithToken = function (token, errorCallback) {
Meteor.apply('login', [{resume: token}], {wait: true}, function(error, result) {
if (error) {
errorCallback();
throw error;
}
Accounts._makeClientLoggedIn(result.id, result.token);
});
};
if (!Accounts._preventAutoLogin) {
// Immediately try to log in via local storage, so that any DDP
// messages are sent after we have established our user account
var token = Accounts._storedLoginToken();
if (token) {
// On startup, optimistically present us as logged in while the
// request is in flight. This reduces page flicker on startup.
var userId = Accounts._storedUserId();
userId && Meteor.default_connection.setUserId(userId);
Meteor.loginWithToken(token, function () {
Accounts._makeClientLoggedOut();
});
}
}
// Poll local storage every 3 seconds to login if someone logged in in
// another tab
Accounts._lastLoginTokenWhenPolled = token;
Accounts._pollStoredLoginToken = function() {
if (Accounts._preventAutoLogin)
return;
var currentLoginToken = Accounts._storedLoginToken();
// != instead of !== just to make sure undefined and null are treated the same
if (Accounts._lastLoginTokenWhenPolled != currentLoginToken) {
if (currentLoginToken)
Meteor.loginWithToken(currentLoginToken); // XXX should we pass a callback here?
else
Meteor.logout();
}
Accounts._lastLoginTokenWhenPolled = currentLoginToken;
};
// Semi-internal API. Call this function to re-enable auto login after
// if it was disabled at startup.
Accounts._enableAutoLogin = function () {
Accounts._preventAutoLogin = false;
Accounts._pollStoredLoginToken();
};
setInterval(Accounts._pollStoredLoginToken, 3000);

View File

@@ -0,0 +1,25 @@
Package.describe({
summary: "A user account system"
});
Package.on_use(function (api) {
api.use('underscore', 'server');
api.use('localstorage-polyfill', 'client');
api.use('accounts-urls', 'client');
// need this because of the Meteor.users collection but in the future
// we'd probably want to abstract this away
api.use('mongo-livedata', ['client', 'server']);
api.add_files('accounts_common.js', ['client', 'server']);
api.add_files('accounts_server.js', 'server');
api.add_files('localstorage_token.js', 'client');
api.add_files('accounts_client.js', 'client');
});
Package.on_test(function (api) {
api.use('accounts-base');
api.use('tinytest');
api.add_files('accounts_tests.js', 'server');
});

View File

@@ -0,0 +1,35 @@
(function () {
Meteor.loginWithFacebook = function (options, callback) {
// support both (options, callback) and (callback).
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'});
if (!config) {
callback && callback(new Accounts.ConfigError("Service not configured"));
return;
}
var state = Meteor.uuid();
var mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent);
var display = mobile ? 'touch' : 'popup';
var scope = "email";
if (options && options.requestPermissions)
scope = options.requestPermissions.join(',');
var loginUrl =
'https://www.facebook.com/dialog/oauth?client_id=' + config.appId +
'&redirect_uri=' + Meteor.absoluteUrl('_oauth/facebook?close') +
'&display=' + display + '&scope=' + scope + '&state=' + state;
Accounts.oauth.initiateLogin(state, loginUrl, callback);
};
})();

View File

@@ -0,0 +1,3 @@
if (!Accounts.facebook) {
Accounts.facebook = {};
}

View File

@@ -0,0 +1,19 @@
<template name="configureLoginServiceDialogForFacebook">
<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.configureLoginServiceDialogForFacebook.siteUrl = function () {
return Meteor.absoluteUrl();
};
Template.configureLoginServiceDialogForFacebook.fields = function () {
return [
{property: 'appId', label: 'App ID'},
{property: 'secret', label: 'App Secret'}
];
};

View File

@@ -0,0 +1,76 @@
(function () {
Accounts.oauth.registerService('facebook', 2, function(query) {
var accessToken = getAccessToken(query);
var identity = getIdentity(accessToken);
return {
serviceData: {
id: identity.id,
accessToken: accessToken,
email: identity.email
},
extra: {profile: {name: identity.name}}
};
});
var getAccessToken = function (query) {
var config = Accounts.loginServiceConfiguration.findOne({service: 'facebook'});
if (!config)
throw new Accounts.ConfigError("Service not configured");
// Request an access token
var result = Meteor.http.get(
"https://graph.facebook.com/oauth/access_token", {
params: {
client_id: config.appId,
redirect_uri: Meteor.absoluteUrl("_oauth/facebook?close"),
client_secret: config.secret,
code: query.code
}
});
if (result.error)
throw result.error;
var response = result.content;
// Errors come back as JSON but success looks like a query encoded
// in a url
var error_response;
try {
// Just try to parse so that we know if we failed or not,
// while storing the parsed results
error_response = JSON.parse(response);
} catch (e) {
error_response = null;
}
if (error_response) {
throw new Meteor.Error(500, "Error trying to get access token from Facebook", error_response);
} else {
// Success! Extract the facebook access token from the
// response
var fbAccessToken;
_.each(response.split('&'), function(kvString) {
var kvArray = kvString.split('=');
if (kvArray[0] === 'access_token')
fbAccessToken = kvArray[1];
// XXX also parse the "expires" argument?
});
if (!fbAccessToken)
throw new Meteor.Error(500, "Couldn't find access token in HTTP response.");
return fbAccessToken;
}
};
var getIdentity = function (accessToken) {
var result = Meteor.http.get("https://graph.facebook.com/me", {
params: {access_token: accessToken}});
if (result.error)
throw result.error;
return result.data;
};
}) ();

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Login service for Facebook accounts"
});
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');
api.add_files('facebook_client.js', 'client');
});

View File

@@ -0,0 +1,29 @@
(function () {
Meteor.loginWithGithub = function (options, callback) {
// support both (options, callback) and (callback).
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
var config = Accounts.loginServiceConfiguration.findOne({service: 'github'});
if (!config) {
callback && callback(new Accounts.ConfigError("Service not configured"));
return;
}
var state = Meteor.uuid();
var required_scope = ['user'];
var scope = _.union((options && options.requestPermissions) || [], required_scope);
var flat_scope = _.map(scope, encodeURIComponent).join('+');
var loginUrl =
'https://github.com/login/oauth/authorize' +
'?client_id=' + config.clientId +
'&scope=' + flat_scope +
'&redirect_uri=' + Meteor.absoluteUrl('_oauth/github?close') +
'&state=' + state;
Accounts.oauth.initiateLogin(state, loginUrl, callback, {width: 900, height: 450});
};
}) ();

View File

@@ -0,0 +1,3 @@
if (!Accounts.github) {
Accounts.github = {};
}

View File

@@ -0,0 +1,16 @@
<template name="configureLoginServiceDialogForGithub">
<p>
First, you'll need to get a Github Client ID. Follow these steps:
</p>
<ol>
<li>
Visit <a href="https://github.com/settings/applications/new" target="blank">https://github.com/settings/applications/new</a>
</li>
<li>
Set Main URL to to: <span class="url">{{siteUrl}}</span>
</li>
<li>
Set Callback URL to: <span class="url">{{siteUrl}}_oauth/github?close</span>
</li>
</ol>
</template>

View File

@@ -0,0 +1,10 @@
Template.configureLoginServiceDialogForGithub.siteUrl = function () {
return Meteor.absoluteUrl();
};
Template.configureLoginServiceDialogForGithub.fields = function () {
return [
{property: 'clientId', label: 'Client ID'},
{property: 'secret', label: 'Client Secret'}
];
};

View File

@@ -0,0 +1,46 @@
(function () {
Accounts.oauth.registerService('github', 2, function(query) {
var accessToken = getAccessToken(query);
var identity = getIdentity(accessToken);
return {
serviceData: {
id: identity.id,
accessToken: accessToken,
email: identity.email,
username: identity.login
},
extra: {profile: {name: identity.name}}
};
});
var getAccessToken = function (query) {
var config = Accounts.loginServiceConfiguration.findOne({service: 'github'});
if (!config)
throw new Accounts.ConfigError("Service not configured");
var result = Meteor.http.post(
"https://github.com/login/oauth/access_token", {headers: {Accept: 'application/json'}, params: {
code: query.code,
client_id: config.clientId,
client_secret: config.secret,
redirect_uri: Meteor.absoluteUrl("_oauth/github?close"),
state: query.state
}});
if (result.error) // if the http response was an error
throw result.error;
if (result.data.error) // if the http response was a json object with an error attribute
throw result.data;
return result.data.access_token;
};
var getIdentity = function (accessToken) {
var result = Meteor.http.get(
"https://api.github.com/user",
{params: {access_token: accessToken}});
if (result.error)
throw result.error;
return result.data;
};
}) ();

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Login service for Github accounts"
});
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(
['github_configure.html', 'github_configure.js'],
'client');
api.add_files('github_common.js', ['client', 'server']);
api.add_files('github_server.js', 'server');
api.add_files('github_client.js', 'client');
});

View File

@@ -0,0 +1,39 @@
(function () {
Meteor.loginWithGoogle = function (options, callback) {
// support both (options, callback) and (callback).
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
var config = Accounts.loginServiceConfiguration.findOne({service: 'google'});
if (!config) {
callback && callback(new Accounts.ConfigError("Service not configured"));
return;
}
var state = Meteor.uuid();
// always need this to get user id from google.
var required_scope = ['https://www.googleapis.com/auth/userinfo.profile'];
var scope = ['https://www.googleapis.com/auth/userinfo.email'];
if (options && options.requestPermissions)
scope = options.requestPermissions;
scope = _.union(scope, required_scope);
var flat_scope = _.map(scope, encodeURIComponent).join('+');
// Might be good to have a way to set access_type=offline. Need to
// both set it here and store the refresh token on the server.
var loginUrl =
'https://accounts.google.com/o/oauth2/auth' +
'?response_type=code' +
'&client_id=' + config.clientId +
'&scope=' + flat_scope +
'&redirect_uri=' + Meteor.absoluteUrl('_oauth/google?close') +
'&state=' + state;
Accounts.oauth.initiateLogin(state, loginUrl, callback);
};
}) ();

View File

@@ -0,0 +1,3 @@
if (!Accounts.google) {
Accounts.google = {};
}

View File

@@ -0,0 +1,28 @@
<template name="configureLoginServiceDialogForGoogle">
<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.configureLoginServiceDialogForGoogle.siteUrl = function () {
return Meteor.absoluteUrl();
};
Template.configureLoginServiceDialogForGoogle.fields = function () {
return [
{property: 'clientId', label: 'Client ID'},
{property: 'secret', label: 'Client secret'}
];
};

View File

@@ -0,0 +1,48 @@
(function () {
Accounts.oauth.registerService('google', 2, function(query) {
var accessToken = getAccessToken(query);
var identity = getIdentity(accessToken);
return {
serviceData: {
id: identity.id,
accessToken: accessToken,
email: identity.email
},
extra: {profile: {name: identity.name}}
};
});
var getAccessToken = function (query) {
var config = Accounts.loginServiceConfiguration.findOne({service: 'google'});
if (!config)
throw new Accounts.ConfigError("Service not configured");
var result = Meteor.http.post(
"https://accounts.google.com/o/oauth2/token", {params: {
code: query.code,
client_id: config.clientId,
client_secret: config.secret,
redirect_uri: Meteor.absoluteUrl("_oauth/google?close"),
grant_type: 'authorization_code'
}});
if (result.error) // if the http response was an error
throw result.error;
if (result.data.error) // if the http response was a json object with an error attribute
throw result.data;
return result.data.access_token;
};
var getIdentity = function (accessToken) {
var result = Meteor.http.get(
"https://www.googleapis.com/oauth2/v1/userinfo",
{params: {access_token: accessToken}});
if (result.error)
throw result.error;
return result.data;
};
})();

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Login service for Google accounts"
});
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');
api.add_files('google_client.js', 'client');
});

View File

@@ -0,0 +1,83 @@
(function () {
// Open a popup window pointing to a OAuth handshake page
//
// @param state {String} The OAuth state generated by the client
// @param url {String} url to page
// @param callback {Function} Callback function to call on
// completion. Takes one argument, null on success, or Error on
// error.
// @param dimensions {optional Object(width, height)} The dimensions of
// the popup. If not passed defaults to something sane
Accounts.oauth.initiateLogin = function(state, url, callback, dimensions) {
// XXX these dimensions worked well for facebook and google, but
// it's sort of weird to have these here. Maybe an optional
// argument instead?
var popup = openCenteredPopup(
url,
(dimensions && dimensions.width) || 650,
(dimensions && dimensions.height) || 331);
var checkPopupOpen = setInterval(function() {
// Fix for #328 - added a second test criteria (popup.closed === undefined)
// to humour this Android quirk:
// http://code.google.com/p/android/issues/detail?id=21061
if (popup.closed || popup.closed === undefined) {
clearInterval(checkPopupOpen);
tryLoginAfterPopupClosed(state, callback);
}
}, 100);
};
// Send an OAuth login method to the server. If the user authorized
// access in the popup this should log the user in, otherwise
// nothing should happen.
var tryLoginAfterPopupClosed = function(state, callback) {
Meteor.apply('login', [
{oauth: {state: state}}
], {wait: true}, function(error, result) {
if (error) {
// got an error from the server. report it back.
callback && callback(error);
} else if (!result) {
// got an empty response from the server. This means our oauth
// state wasn't recognized, which could be either because the
// popup was closed by the user before completion, or some sort
// of error where the oauth provider didn't talk to our server
// correctly and closed the popup somehow.
//
// we assume it was user canceled, and report it as such. this
// will mask failures where things are misconfigured such that
// the server doesn't see the request but does close the
// window. This seems unlikely.
callback &&
callback(new Accounts.LoginCancelledError("Popup closed"));
} else {
Accounts._makeClientLoggedIn(result.id, result.token);
callback && callback();
}
});
};
var openCenteredPopup = function(url, width, height) {
var screenX = typeof window.screenX !== 'undefined'
? window.screenX : window.screenLeft;
var screenY = typeof window.screenY !== 'undefined'
? window.screenY : window.screenTop;
var outerWidth = typeof window.outerWidth !== 'undefined'
? window.outerWidth : document.body.clientWidth;
var outerHeight = typeof window.outerHeight !== 'undefined'
? window.outerHeight : (document.body.clientHeight - 22);
// Use `outerWidth - width` and `outerHeight - height` for help in
// positioning the popup centered relative to the current window
var left = screenX + (outerWidth - width) / 2;
var top = screenY + (outerHeight - height) / 2;
var features = ('width=' + width + ',height=' + height +
',left=' + left + ',top=' + top);
var newwindow = window.open(url, 'Login', features);
if (newwindow.focus)
newwindow.focus();
return newwindow;
};
})();

View File

@@ -0,0 +1 @@
Accounts.oauth = {};

View File

@@ -0,0 +1,180 @@
(function () {
var connect = __meteor_bootstrap__.require("connect");
Accounts.oauth._services = {};
// Register a handler for an OAuth service. The handler will be called
// when we get an incoming http request on /_oauth/{serviceName}. This
// handler should use that information to fetch data about the user
// logging in.
//
// @param name {String} e.g. "google", "facebook"
// @param version {Number} OAuth version (1 or 2)
// @param handleOauthRequest {Function(oauthBinding|query)}
// - (For OAuth1 only) oauthBinding {OAuth1Binding} bound to the appropriate provider
// - (For OAuth2 only) query {Object} parameters passed in query string
// - return value is:
// - {serviceData, (optional extra)} where serviceData should end
// up in the user's services[name] field
// - `null` if the user declined to give permissions
Accounts.oauth.registerService = function (name, version, handleOauthRequest) {
if (Accounts.oauth._services[name])
throw new Error("Already registered the " + name + " OAuth service");
// Accounts.updateOrCreateUserFromExternalService does a lookup by this id,
// so this should be a unique index. You might want to add indexes for other
// fields returned by your service (eg services.github.login) but you can do
// that in your app.
Meteor.users._ensureIndex('services.' + name + '.id',
{unique: 1, sparse: 1});
Accounts.oauth._services[name] = {
serviceName: name,
version: version,
handleOauthRequest: handleOauthRequest
};
};
// When we get an incoming OAuth http request we complete the oauth
// handshake, account and token setup before responding. The
// results are stored in this map which is then read when the login
// method is called. Maps state --> return value of `login`
//
// XXX we should periodically clear old entries
Accounts.oauth._loginResultForState = {};
// Listen to calls to `login` with an oauth option set
Accounts.registerLoginHandler(function (options) {
if (!options.oauth)
return undefined; // don't handle
var result = Accounts.oauth._loginResultForState[options.oauth.state];
if (result === undefined) // not using `!result` since can be null
// We weren't notified of the user authorizing the login.
return null;
else if (result instanceof Error)
// We tried to login, but there was a fatal error. Report it back
// to the user.
throw result;
else
return result;
});
// Listen to incoming OAuth http requests
__meteor_bootstrap__.app
.use(connect.query())
.use(function(req, res, next) {
// Need to create a Fiber since we're using synchronous http
// calls and nothing else is wrapping this in a fiber
// automatically
Fiber(function () {
Accounts.oauth._middleware(req, res, next);
}).run();
});
Accounts.oauth._middleware = function (req, res, next) {
// Make sure to catch any exceptions because otherwise we'd crash
// the runner
try {
var serviceName = oauthServiceName(req);
if (!serviceName) {
// not an oauth request. pass to next middleware.
next();
return;
}
var service = Accounts.oauth._services[serviceName];
// Skip everything if there's no service set by the oauth middleware
if (!service)
throw new Error("Unexpected OAuth service " + serviceName);
// Make sure we're configured
ensureConfigured(serviceName);
if (service.version === 1)
Accounts.oauth1._handleRequest(service, req.query, res);
else if (service.version === 2)
Accounts.oauth2._handleRequest(service, req.query, res);
else
throw new Error("Unexpected OAuth version " + service.version);
} catch (err) {
// if we got thrown an error, save it off, it will get passed to
// the approporiate login call (if any) and reported there.
//
// The other option would be to display it in the popup tab that
// is still open at this point, ignoring the 'close' or 'redirect'
// we were passed. But then the developer wouldn't be able to
// style the error or react to it in any way.
if (req.query.state && err instanceof Error)
Accounts.oauth._loginResultForState[req.query.state] = err;
// also log to the server console, so the developer sees it.
Meteor._debug("Exception in oauth request handler", err);
// XXX the following is actually wrong. if someone wants to
// redirect rather than close once we are done with the OAuth
// flow, as supported by
// Accounts.oauth_renderOauthResults, this will still
// close the popup instead. Once we fully support the redirect
// flow (by supporting that in places such as
// packages/facebook/facebook_client.js) we should revisit this.
//
// close the popup. because nobody likes them just hanging
// there. when someone sees this multiple times they might
// think to check server logs (we hope?)
closePopup(res);
}
};
// Handle /_oauth/* paths and extract the service name
//
// @returns {String|null} e.g. "facebook", or null if this isn't an
// oauth request
var oauthServiceName = function (req) {
// req.url will be "/_oauth/<service name>?<action>"
var barePath = req.url.substring(0, req.url.indexOf('?'));
var splitPath = barePath.split('/');
// Any non-oauth request will continue down the default
// middlewares.
if (splitPath[1] !== '_oauth')
return null;
// Find service based on url
var serviceName = splitPath[2];
return serviceName;
};
// Make sure we're configured
var ensureConfigured = function(serviceName) {
if (!Accounts.loginServiceConfiguration.findOne({service: serviceName})) {
throw new Accounts.ConfigError("Service not configured");
};
};
Accounts.oauth._renderOauthResults = function(res, query) {
// We support ?close and ?redirect=URL. Any other query should
// just serve a blank page
if ('close' in query) { // check with 'in' because we don't set a value
closePopup(res);
} else if (query.redirect) {
res.writeHead(302, {'Location': query.redirect});
res.end();
} else {
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('', 'utf-8');
}
};
var closePopup = function(res) {
res.writeHead(200, {'Content-Type': 'text/html'});
var content =
'<html><head><script>window.close()</script></head></html>';
res.end(content, 'utf-8');
};
})();

View File

@@ -0,0 +1,12 @@
Package.describe({
summary: "Common code for OAuth-based login services",
internal: true
});
Package.on_use(function (api) {
api.use('accounts-base', ['client', 'server']);
api.add_files('oauth_common.js', ['client', 'server']);
api.add_files('oauth_client.js', 'client');
api.add_files('oauth_server.js', 'server');
});

View File

@@ -0,0 +1,137 @@
var crypto = __meteor_bootstrap__.require("crypto");
var querystring = __meteor_bootstrap__.require("querystring");
// An OAuth1 wrapper around http calls which helps get tokens and
// takes care of HTTP headers
//
// @param consumerKey {String} As supplied by the OAuth1 provider
// @param consumerSecret {String} As supplied by the OAuth1 provider
// @param urls {Object}
// - requestToken (String): url
// - authorize (String): url
// - accessToken (String): url
// - authenticate (String): url
OAuth1Binding = function(consumerKey, consumerSecret, urls) {
this._consumerKey = consumerKey;
this._secret = consumerSecret;
this._urls = urls;
};
OAuth1Binding.prototype.prepareRequestToken = function(callbackUrl) {
var self = this;
var headers = self._buildHeader({
oauth_callback: callbackUrl
});
var response = self._call('POST', self._urls.requestToken, headers);
var tokens = querystring.parse(response.content);
// XXX should we also store oauth_token_secret here?
if (!tokens.oauth_callback_confirmed)
throw new Error("oauth_callback_confirmed false when requesting oauth1 token", tokens);
self.requestToken = tokens.oauth_token;
};
OAuth1Binding.prototype.prepareAccessToken = function(query) {
var self = this;
var headers = self._buildHeader({
oauth_token: query.oauth_token
});
var params = {
oauth_verifier: query.oauth_verifier
};
var response = self._call('POST', self._urls.accessToken, headers, params);
var tokens = querystring.parse(response.content);
self.accessToken = tokens.oauth_token;
self.accessTokenSecret = tokens.oauth_token_secret;
};
OAuth1Binding.prototype.call = function(method, url) {
var self = this;
var headers = self._buildHeader({
oauth_token: self.accessToken
});
var response = self._call(method, url, headers);
return response.data;
};
OAuth1Binding.prototype.get = function(url) {
return this.call('GET', url);
};
OAuth1Binding.prototype._buildHeader = function(headers) {
var self = this;
return _.extend({
oauth_consumer_key: self._consumerKey,
oauth_nonce: Meteor.uuid().replace(/\W/g, ''),
oauth_signature_method: 'HMAC-SHA1',
oauth_timestamp: (new Date().valueOf()/1000).toFixed().toString(),
oauth_version: '1.0'
}, headers);
};
OAuth1Binding.prototype._getSignature = function(method, url, rawHeaders, accessTokenSecret) {
var self = this;
var headers = self._encodeHeader(rawHeaders);
var parameters = _.map(headers, function(val, key) {
return key + '=' + val;
}).sort().join('&');
var signatureBase = [
method,
encodeURIComponent(url),
encodeURIComponent(parameters)
].join('&');
var signingKey = encodeURIComponent(self._secret) + '&';
if (accessTokenSecret)
signingKey += encodeURIComponent(accessTokenSecret);
return crypto.createHmac('SHA1', signingKey).update(signatureBase).digest('base64');
};
OAuth1Binding.prototype._call = function(method, url, headers, params) {
var self = this;
// Get the signature
headers.oauth_signature = self._getSignature(method, url, headers, self.accessTokenSecret);
// Make a authorization string according to oauth1 spec
var authString = self._getAuthHeaderString(headers);
// Make signed request
var response = Meteor.http.call(method, url, {
params: params,
headers: {
Authorization: authString
}
});
if (response.error) {
Meteor._debug('Error sending OAuth1 HTTP call', response.content, method, url, params, authString);
throw response.error;
}
return response;
};
OAuth1Binding.prototype._encodeHeader = function(header) {
return _.reduce(header, function(memo, val, key) {
memo[encodeURIComponent(key)] = encodeURIComponent(val);
return memo;
}, {});
};
OAuth1Binding.prototype._getAuthHeaderString = function(headers) {
return 'OAuth ' + _.map(headers, function(val, key) {
return encodeURIComponent(key) + '="' + encodeURIComponent(val) + '"';
}).sort().join(', ');
};

View File

@@ -0,0 +1 @@
Accounts.oauth1 = {};

View File

@@ -0,0 +1,67 @@
(function () {
var connect = __meteor_bootstrap__.require("connect");
// A place to store request tokens pending verification
Accounts.oauth1._requestTokens = {};
// connect middleware
Accounts.oauth1._handleRequest = function (service, query, res) {
var config = Accounts.loginServiceConfiguration.findOne({service: service.serviceName});
if (!config) {
throw new Accounts.ConfigError("Service " + service.serviceName + " not configured");
}
var urls = Accounts[service.serviceName]._urls;
var oauthBinding = new OAuth1Binding(
config.consumerKey, config.secret, urls);
if (query.requestTokenAndRedirect) {
// step 1 - get and store a request token
// Get a request token to start auth process
oauthBinding.prepareRequestToken(query.requestTokenAndRedirect);
// Keep track of request token so we can verify it on the next step
Accounts.oauth1._requestTokens[query.state] = oauthBinding.requestToken;
// redirect to provider login, which will redirect back to "step 2" below
var redirectUrl = urls.authenticate + '?oauth_token=' + oauthBinding.requestToken;
res.writeHead(302, {'Location': redirectUrl});
res.end();
} else {
// step 2, redirected from provider login - complete the login
// process: if the user authorized permissions, get an access
// token and access token secret and log in as user
// Get the user's request token so we can verify it and clear it
var requestToken = Accounts.oauth1._requestTokens[query.state];
delete Accounts.oauth1._requestTokens[query.state];
// Verify user authorized access and the oauth_token matches
// the requestToken from previous step
if (query.oauth_token && query.oauth_token === requestToken) {
// Prepare the login results before returning. This way the
// subsequent call to the `login` method will be immediate.
// Get the access token for signing requests
oauthBinding.prepareAccessToken(query);
// Run service-specific handler.
var oauthResult = service.handleOauthRequest(oauthBinding);
// Get or create user doc and login token for reconnect.
Accounts.oauth._loginResultForState[query.state] =
Accounts.updateOrCreateUserFromExternalService(
service.serviceName, oauthResult.serviceData, oauthResult.extra);
}
}
// Either close the window, redirect, or render nothing
// if all else fails
Accounts.oauth._renderOauthResults(res, query);
};
})();

View File

@@ -0,0 +1,134 @@
Tinytest.add("oauth1 - loginResultForState is stored", function (test) {
var http = __meteor_bootstrap__.require('http');
var twitterfooId = Meteor.uuid();
var twitterfooName = 'nickname' + Meteor.uuid();
var twitterfooAccessToken = Meteor.uuid();
var twitterfooAccessTokenSecret = Meteor.uuid();
OAuth1Binding.prototype.prepareRequestToken = function() {};
OAuth1Binding.prototype.prepareAccessToken = function() {
this.accessToken = twitterfooAccessToken;
this.accessTokenSecret = twitterfooAccessTokenSecret;
};
// XXX XXX test isolation fail! Avital: but actually -- why would
// we run server tests more than once? or even more so in parallel?
Accounts.oauth._loginResultForState = {};
Accounts.oauth._services = {};
if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfoo'}))
Accounts.loginServiceConfiguration.insert({service: 'twitterfoo'});
Accounts.twitterfoo = {};
// register a fake login service - twitterfoo
Accounts.oauth.registerService("twitterfoo", 1, function (query) {
return {
serviceData: {
id: twitterfooId,
screenName: twitterfooName,
accessToken: twitterfooAccessToken,
accessTokenSecret: twitterfooAccessTokenSecret
}
};
});
// simulate logging in using twitterfoo
Accounts.oauth1._requestTokens['STATE'] = twitterfooAccessToken;
var req = {
method: "POST",
url: "/_oauth/twitterfoo?close",
query: {
state: "STATE",
oauth_token: twitterfooAccessToken
}
};
Accounts.oauth._middleware(req, new http.ServerResponse(req));
// verify that a user is created
var user = Meteor.users.findOne(
{"services.twitterfoo.screenName": twitterfooName});
test.notEqual(user, undefined);
test.equal(user.services.twitterfoo.accessToken,
twitterfooAccessToken);
test.equal(user.services.twitterfoo.accessTokenSecret,
twitterfooAccessTokenSecret);
// and that that user has a login token
test.equal(user.services.resume.loginTokens.length, 1);
var token = user.services.resume.loginTokens[0].token;
test.notEqual(token, undefined);
// and that the login result for that user is prepared
test.equal(
Accounts.oauth._loginResultForState['STATE'].id, user._id);
test.equal(
Accounts.oauth._loginResultForState['STATE'].token, token);
});
Tinytest.add("oauth1 - error in user creation", function (test) {
var http = __meteor_bootstrap__.require('http');
var state = Meteor.uuid();
var twitterfailId = Meteor.uuid();
var twitterfailName = 'nickname' + Meteor.uuid();
var twitterfailAccessToken = Meteor.uuid();
var twitterfailAccessTokenSecret = Meteor.uuid();
if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfail'}))
Accounts.loginServiceConfiguration.insert({service: 'twitterfail'});
Accounts.twitterfail = {};
// Wire up access token so that verification passes
Accounts.oauth1._requestTokens[state] = twitterfailAccessToken;
// register a failing login service
Accounts.oauth.registerService("twitterfail", 1, function (query) {
return {
serviceData: {
id: twitterfailId,
screenName: twitterfailName,
accessToken: twitterfailAccessToken,
accessTokenSecret: twitterfailAccessTokenSecret
},
extra: {
profile: {invalid: true}
}
};
});
// a way to fail new users. duplicated from passwords_tests, but
// shouldn't hurt.
Accounts.validateNewUser(function (user) {
return !(user.profile && user.profile.invalid);
});
// simulate logging in with failure
Meteor._suppress_log(1);
var req = {
method: "POST",
url: "/_oauth/twitterfail?close",
query: {
state: state,
oauth_token: twitterfailAccessToken
}
};
Accounts.oauth._middleware(req, new http.ServerResponse(req));
// verify that a user is not created
var user = Meteor.users.findOne({"services.twitter.screenName": twitterfailName});
test.equal(user, undefined);
// verify an error is stored in login state
test.equal(Accounts.oauth._loginResultForState[state].error, 403);
// verify error is handed back to login method.
test.throws(function () {
Meteor.apply('login', [{oauth: {version: 1, state: state}}]);
});
});

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Common code for OAuth1-based login services",
internal: true
});
Package.on_use(function (api) {
api.use('accounts-oauth-helper', 'client');
api.use('accounts-base', ['client', 'server']);
api.add_files('oauth1_binding.js', 'server');
api.add_files('oauth1_common.js', ['client', 'server']);
api.add_files('oauth1_server.js', 'server');
});
Package.on_test(function (api) {
api.use('accounts-oauth1-helper', 'server');
api.add_files("oauth1_tests.js", 'server');
});

View File

@@ -0,0 +1 @@
Accounts.oauth2 = {};

View File

@@ -0,0 +1,25 @@
(function () {
var connect = __meteor_bootstrap__.require("connect");
// connect middleware
Accounts.oauth2._handleRequest = function (service, query, res) {
// check if user authorized access
if (!query.error) {
// Prepare the login results before returning. This way the
// subsequent call to the `login` method will be immediate.
// Run service-specific handler.
var oauthResult = service.handleOauthRequest(query);
// Get or create user doc and login token for reconnect.
Accounts.oauth._loginResultForState[query.state] =
Accounts.updateOrCreateUserFromExternalService(
service.serviceName, oauthResult.serviceData, oauthResult.extra);
}
// Either close the window, redirect, or render nothing
// if all else fails
Accounts.oauth._renderOauthResults(res, query);
};
})();

View File

@@ -0,0 +1,91 @@
Tinytest.add("oauth2 - loginResultForState is stored", function (test) {
var http = __meteor_bootstrap__.require('http');
var foobookId = Meteor.uuid();
// XXX XXX test isolation fail! Avital: but actually -- why would
// we run server tests more than once? or even more so in parallel?
Accounts.oauth._loginResultForState = {};
Accounts.oauth._services = {};
if (!Accounts.loginServiceConfiguration.findOne({service: 'foobook'}))
Accounts.loginServiceConfiguration.insert({service: 'foobook'});
Accounts.foobook = {};
// register a fake login service - foobook
Accounts.oauth.registerService("foobook", 2, function (query) {
return {serviceData: {id: foobookId}};
});
// simulate logging in using foobook
var req = {method: "POST",
url: "/_oauth/foobook?close",
query: {state: "STATE"}};
Accounts.oauth._middleware(req, new http.ServerResponse(req));
// verify that a user is created
var user = Meteor.users.findOne({"services.foobook.id": foobookId});
test.notEqual(user, undefined);
test.equal(user.services.foobook.id, foobookId);
// and that that user has a login token
test.equal(user.services.resume.loginTokens.length, 1);
var token = user.services.resume.loginTokens[0].token;
test.notEqual(token, undefined);
// and that the login result for that user is prepared
test.equal(
Accounts.oauth._loginResultForState['STATE'].id, user._id);
test.equal(
Accounts.oauth._loginResultForState['STATE'].token, token);
});
Tinytest.add("oauth2 - error in user creation", function (test) {
var http = __meteor_bootstrap__.require('http');
var state = Meteor.uuid();
var failbookId = Meteor.uuid();
if (!Accounts.loginServiceConfiguration.findOne({service: 'failbook'}))
Accounts.loginServiceConfiguration.insert({service: 'failbook'});
Accounts.failbook = {};
// register a failing login service
Accounts.oauth.registerService("failbook", 2, function (query) {
return {
serviceData: {
id: failbookId
},
extra: {
profile: {invalid: true}
}
};
});
// a way to fail new users. duplicated from passwords_tests, but
// shouldn't hurt.
Accounts.validateNewUser(function (user) {
return !(user.profile && user.profile.invalid);
});
// simulate logging in with failure
Meteor._suppress_log(1);
var req = {method: "POST",
url: "/_oauth/failbook?close",
query: {state: state}};
Accounts.oauth._middleware(req, new http.ServerResponse(req));
// verify that a user is not created
var user = Meteor.users.findOne({"services.failbook.id": failbookId});
test.equal(user, undefined);
// verify an error is stored in login state
test.equal(Accounts.oauth._loginResultForState[state].error, 403);
// verify error is handed back to login method.
test.throws(function () {
Meteor.apply('login', [{oauth: {version: 2, state: state}}]);
});
});

View File

@@ -0,0 +1,17 @@
Package.describe({
summary: "Common code for OAuth2-based login services",
internal: true
});
Package.on_use(function (api) {
api.use('accounts-oauth-helper', 'client');
api.use('accounts-base', ['client', 'server']);
api.add_files('oauth2_common.js', ['client', 'server']);
api.add_files('oauth2_server.js', 'server');
});
Package.on_test(function (api) {
api.use('accounts-oauth2-helper', 'server');
api.add_files("oauth2_tests.js", 'server');
});

View File

@@ -0,0 +1,53 @@
Accounts.emailTemplates = {
from: "Meteor Accounts <no-reply@meteor.com>",
siteName: Meteor.absoluteUrl().replace(/^https?:\/\//, '').replace(/\/$/, ''),
resetPassword: {
subject: function(user) {
return "How to reset your password on " + Accounts.emailTemplates.siteName;
},
text: function(user, url) {
var greeting = (user.profile && user.profile.name) ?
("Hello " + user.profile.name + ",") : "Hello,";
return greeting + "\n"
+ "\n"
+ "To reset your password, simply click the link below.\n"
+ "\n"
+ url + "\n"
+ "\n"
+ "Thanks.\n";
}
},
verifyEmail: {
subject: function(user) {
return "How to verify email address on " + Accounts.emailTemplates.siteName;
},
text: function(user, url) {
var greeting = (user.profile && user.profile.name) ?
("Hello " + user.profile.name + ",") : "Hello,";
return greeting + "\n"
+ "\n"
+ "To verify your account email, simply click the link below.\n"
+ "\n"
+ url + "\n"
+ "\n"
+ "Thanks.\n";
}
},
enrollAccount: {
subject: function(user) {
return "An account has been created for you on " + Accounts.emailTemplates.siteName;
},
text: function(user, url) {
var greeting = (user.profile && user.profile.name) ?
("Hello " + user.profile.name + ",") : "Hello,";
return greeting + "\n"
+ "\n"
+ "To start using the service, simply click the link below.\n"
+ "\n"
+ url + "\n"
+ "\n"
+ "Thanks.\n";
}
}
};

View File

@@ -0,0 +1,234 @@
(function () {
// intentionally initialize later so that we can debug tests after
// they fail without trying to recreate a user with the same email
// address
var email1;
var email2;
var email3;
var email4;
var resetPasswordToken;
var verifyEmailToken;
var enrollAccountToken;
Accounts._isolateLoginTokenForTest();
testAsyncMulti("accounts emails - reset password flow", [
function (test, expect) {
email1 = Meteor.uuid() + "-intercept@example.com";
Accounts.createUser({email: email1, password: 'foobar'},
expect(function (error) {
test.equal(error, undefined);
}));
},
function (test, expect) {
Accounts.forgotPassword({email: email1}, expect(function (error) {
test.equal(error, undefined);
}));
},
function (test, expect) {
Meteor.call("getInterceptedEmails", email1, expect(function (error, result) {
test.notEqual(result, undefined);
test.equal(result.length, 2); // the first is the email verification
var content = result[1];
var match = content.match(
new RegExp(window.location.protocol + "//" +
window.location.host + "/#\\/reset-password/(\\S*)"));
test.isTrue(match);
resetPasswordToken = match[1];
}));
},
function (test, expect) {
Accounts.resetPassword(resetPasswordToken, "newPassword", expect(function(error) {
test.isFalse(error);
}));
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
function (test, expect) {
Meteor.loginWithPassword(
{email: email1}, "newPassword",
expect(function (error) {
test.isFalse(error);
}));
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
}
]);
var getVerifyEmailToken = function (email, test, expect) {
Meteor.call("getInterceptedEmails", email, expect(function (error, result) {
test.isFalse(error);
test.notEqual(result, undefined);
test.equal(result.length, 1);
var content = result[0];
var match = content.match(
new RegExp(window.location.protocol + "//" +
window.location.host + "/#\\/verify-email/(\\S*)"));
test.isTrue(match);
verifyEmailToken = match[1];
}));
};
var waitUntilLoggedIn = function (test, expect) {
var unblockNextFunction = expect();
var quiesceCallback = function () {
Meteor._autorun(function (handle) {
if (!Meteor.userLoaded()) return;
handle.stop();
unblockNextFunction();
});
};
return expect(function (error) {
test.equal(error, undefined);
Meteor.default_connection.onQuiesce(quiesceCallback);
});
};
testAsyncMulti("accounts emails - verify email flow", [
function (test, expect) {
email2 = Meteor.uuid() + "-intercept@example.com";
email3 = Meteor.uuid() + "-intercept@example.com";
Accounts.createUser(
{email: email2, password: 'foobar'},
waitUntilLoggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
test.equal(Meteor.user().emails[0].address, email2);
test.isFalse(Meteor.user().emails[0].verified);
// We should NOT be publishing verification tokens!
test.isFalse(_.has(Meteor.user().emails[0], 'verificationTokens'));
},
function (test, expect) {
getVerifyEmailToken(email2, test, expect);
},
function (test, expect) {
// Log out, to test that verifyEmail logs us back in. (And if we don't
// do that, waitUntilLoggedIn won't be able to prevent race conditions.)
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
function (test, expect) {
Accounts.verifyEmail(verifyEmailToken,
waitUntilLoggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
test.equal(Meteor.user().emails[0].address, email2);
test.isTrue(Meteor.user().emails[0].verified);
},
function (test, expect) {
Meteor.call(
"addEmailForTestAndVerify", email3,
expect(function (error, result) {
test.isFalse(error);
}));
},
function (test, expect) {
Meteor.default_connection.onQuiesce(expect(function () {
test.equal(Meteor.user().emails.length, 2);
test.equal(Meteor.user().emails[1].address, email3);
test.isFalse(Meteor.user().emails[1].verified);
}));
},
function (test, expect) {
getVerifyEmailToken(email3, test, expect);
},
function (test, expect) {
// Log out, to test that verifyEmail logs us back in. (And if we don't
// do that, waitUntilLoggedIn won't be able to prevent race conditions.)
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
function (test, expect) {
Accounts.verifyEmail(verifyEmailToken,
waitUntilLoggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails[1].address, email3);
test.isTrue(Meteor.user().emails[1].verified);
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
}
]);
var getEnrollAccountToken = function (email, test, expect) {
Meteor.call("getInterceptedEmails", email, expect(function (error, result) {
test.notEqual(result, undefined);
test.equal(result.length, 1);
var content = result[0];
var match = content.match(
new RegExp(window.location.protocol + "//" +
window.location.host + "/#\\/enroll-account/(\\S*)"));
test.isTrue(match);
enrollAccountToken = match[1];
}));
};
testAsyncMulti("accounts emails - enroll account flow", [
function (test, expect) {
email4 = Meteor.uuid() + "-intercept@example.com";
Meteor.call("createUserOnServer", email4,
expect(function (error, result) {
test.isFalse(error);
var user = result;
test.equal(user.emails.length, 1);
test.equal(user.emails[0].address, email4);
test.isFalse(user.emails[0].verified);
}));
},
function (test, expect) {
getEnrollAccountToken(email4, test, expect);
},
function (test, expect) {
Accounts.resetPassword(enrollAccountToken, 'password',
waitUntilLoggedIn(test, expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
test.equal(Meteor.user().emails[0].address, email4);
test.isTrue(Meteor.user().emails[0].verified);
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
},
function (test, expect) {
Meteor.loginWithPassword({email: email4}, 'password',
waitUntilLoggedIn(test ,expect));
},
function (test, expect) {
test.equal(Meteor.user().emails.length, 1);
test.equal(Meteor.user().emails[0].address, email4);
test.isTrue(Meteor.user().emails[0].verified);
},
function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
}
]);
}) ();

View File

@@ -0,0 +1,40 @@
(function () {
//
// a mechanism to intercept emails sent to addressing including
// the string "intercept", storing them in an array that can then
// be retrieved using the getInterceptedEmails method
//
var oldEmailSend = Email.send;
var interceptedEmails = {}; // (email address) -> (array of contents)
Email.send = function (options) {
var to = options.to;
if (to.indexOf('intercept') === -1) {
oldEmailSend(options);
} else {
if (!interceptedEmails[to])
interceptedEmails[to] = [];
interceptedEmails[to].push(options.text);
}
};
Meteor.methods({
getInterceptedEmails: function (email) {
return interceptedEmails[email];
},
addEmailForTestAndVerify: function (email) {
Meteor.users.update(
{_id: this.userId},
{$push: {emails: {address: email, verified: false}}});
Accounts.sendVerificationEmail(this.userId, email);
},
createUserOnServer: function (email) {
var userId = Accounts.createUser({email: email});
Accounts.sendEnrollmentEmail(userId);
return Meteor.users.findOne(userId);
}
});
}) ();

View File

@@ -0,0 +1,22 @@
Package.describe({
summary: "Password support for accounts."
});
Package.on_use(function(api) {
api.use('accounts-base', ['client', 'server']);
api.use('srp', ['client', 'server']);
api.use('email', ['server']);
api.add_files('email_templates.js', 'server');
api.add_files('passwords_server.js', 'server');
api.add_files('passwords_client.js', 'client');
api.add_files('passwords_common.js', ['server', 'client']);
});
Package.on_test(function(api) {
api.use(['accounts-password', 'tinytest', 'test-helpers', 'deps']);
api.add_files('passwords_tests_setup.js', 'server');
api.add_files('passwords_tests.js', ['client', 'server']);
api.add_files('email_tests_setup.js', 'server');
api.add_files('email_tests.js', 'client');
});

View File

@@ -0,0 +1,190 @@
(function () {
Accounts.createUser = function (options, extra, callback) {
options = _.clone(options); // we'll be modifying options
if (typeof extra === "function") {
callback = extra;
extra = {};
}
if (!options.password)
throw new Error("Must set options.password");
var verifier = Meteor._srp.generateVerifier(options.password);
// strip old password, replacing with the verifier object
delete options.password;
options.srp = verifier;
Meteor.apply('createUser', [options, extra], {wait: true},
function (error, result) {
if (error || !result) {
error = error || new Error("No result");
callback && callback(error);
return;
}
Accounts._makeClientLoggedIn(result.id, result.token);
callback && callback(undefined, {message: 'Success'});
});
};
// @param selector {String|Object} One of the following:
// - {username: (username)}
// - {email: (email)}
// - a string which may be a username or email, depending on whether
// it contains "@".
// @param password {String}
// @param callback {Function(error|undefined)}
Meteor.loginWithPassword = function (selector, password, callback) {
var srp = new Meteor._srp.Client(password);
var request = srp.startExchange();
if (typeof selector === 'string')
if (selector.indexOf('@') === -1)
selector = {username: selector};
else
selector = {email: selector};
request.user = selector;
Meteor.apply('beginPasswordExchange', [request], function (error, result) {
if (error || !result) {
error = error || new Error("No result from call to beginPasswordExchange");
callback && callback(error);
return;
}
var response = srp.respondToChallenge(result);
Meteor.apply('login', [
{srp: response}
], {wait: true}, function (error, result) {
if (error || !result) {
error = error || new Error("No result from call to login");
callback && callback(error);
return;
}
if (!srp.verifyConfirmation({HAMK: result.HAMK})) {
callback && callback(new Error("Server is cheating!"));
return;
}
Accounts._makeClientLoggedIn(result.id, result.token);
callback && callback();
});
});
};
// @param oldPassword {String|null}
// @param newPassword {String}
// @param callback {Function(error|undefined)}
Accounts.changePassword = function (oldPassword, newPassword, callback) {
if (!Meteor.user()) {
callback && callback(new Error("Must be logged in to change password."));
return;
}
var verifier = Meteor._srp.generateVerifier(newPassword);
if (!oldPassword) {
Meteor.apply('changePassword', [{srp: verifier}], function (error, result) {
if (error || !result) {
callback && callback(
error || new Error("No result from changePassword."));
} else {
callback && callback();
}
});
} else { // oldPassword
var srp = new Meteor._srp.Client(oldPassword);
var request = srp.startExchange();
request.user = {id: Meteor.user()._id};
Meteor.apply('beginPasswordExchange', [request], function (error, result) {
if (error || !result) {
callback && callback(
error || new Error("No result from call to beginPasswordExchange"));
return;
}
var response = srp.respondToChallenge(result);
response.srp = verifier;
Meteor.apply('changePassword', [response], function (error, result) {
if (error || !result) {
callback && callback(
error || new Error("No result from changePassword."));
} else {
if (!srp.verifyConfirmation(result)) {
// Monkey business!
callback && callback(new Error("Old password verification failed."));
} else {
callback && callback();
}
}
});
});
}
};
// Sends an email to a user with a link that can be used to reset
// their password
//
// @param options {Object}
// - email: (email)
// @param callback (optional) {Function(error|undefined)}
Accounts.forgotPassword = function(options, callback) {
if (!options.email)
throw new Error("Must pass options.email");
Meteor.call("forgotPassword", options, callback);
};
// Resets a password based on a token originally created by
// Accounts.forgotPassword, and then logs in the matching user.
//
// @param token {String}
// @param newPassword {String}
// @param callback (optional) {Function(error|undefined)}
Accounts.resetPassword = function(token, newPassword, callback) {
if (!token)
throw new Error("Need to pass token");
if (!newPassword)
throw new Error("Need to pass newPassword");
var verifier = Meteor._srp.generateVerifier(newPassword);
Meteor.apply(
"resetPassword", [token, verifier], {wait: true},
function (error, result) {
if (error || !result) {
error = error || new Error("No result from call to resetPassword");
callback && callback(error);
return;
}
Accounts._makeClientLoggedIn(result.id, result.token);
callback && callback();
});
};
// Verifies a user's email address based on a token originally
// created by Accounts.sendVerificationEmail
//
// @param token {String}
// @param callback (optional) {Function(error|undefined)}
Accounts.verifyEmail = function(token, callback) {
if (!token)
throw new Error("Need to pass token");
Meteor.call(
"verifyEmail", token,
function (error, result) {
if (error || !result) {
error = error || new Error("No result from call to verifyEmail");
callback && callback(error);
return;
}
Accounts._makeClientLoggedIn(result.id, result.token);
callback && callback();
});
};
})();

View File

@@ -0,0 +1 @@
Accounts.password = {};

View File

@@ -0,0 +1,446 @@
(function () {
var selectorFromUserQuery = function (user) {
if (!user)
throw new Meteor.Error(400, "Must pass a user property in request");
if (_.keys(user).length !== 1)
throw new Meteor.Error(400, "User property must have exactly one field");
var selector;
if (user.id)
selector = {_id: user.id};
else if (user.username)
selector = {username: user.username};
else if (user.email)
selector = {"emails.address": user.email};
else
throw new Meteor.Error(400, "Must pass username, email, or id in request.user");
return selector;
};
Meteor.methods({
// @param request {Object} with fields:
// user: either {username: (username)}, {email: (email)}, or {id: (userId)}
// A: hex encoded int. the client's public key for this exchange
// @returns {Object} with fields:
// identiy: string uuid
// salt: string uuid
// B: hex encoded int. server's public key for this exchange
beginPasswordExchange: function (request) {
var selector = selectorFromUserQuery(request.user);
var user = Meteor.users.findOne(selector);
if (!user)
throw new Meteor.Error(403, "User not found");
if (!user.services || !user.services.password ||
!user.services.password.srp)
throw new Meteor.Error(403, "User has no password set");
var verifier = user.services.password.srp;
var srp = new Meteor._srp.Server(verifier);
var challenge = srp.issueChallenge({A: request.A});
// save off results in the current session so we can verify them
// later.
this._sessionData.srpChallenge =
{ userId: user._id, M: srp.M, HAMK: srp.HAMK };
return challenge;
},
changePassword: function (options) {
if (!this.userId)
throw new Meteor.Error(401, "Must be logged in");
// If options.M is set, it means we went through a challenge with
// the old password.
if (!options.M /* could allow unsafe password changes here */) {
throw new Meteor.Error(403, "Old password required.");
}
if (options.M) {
var serialized = this._sessionData.srpChallenge;
if (!serialized || serialized.M !== options.M)
throw new Meteor.Error(403, "Incorrect password");
if (serialized.userId !== this.userId)
// No monkey business!
throw new Meteor.Error(403, "Incorrect password");
// Only can use challenges once.
delete this._sessionData.srpChallenge;
}
var verifier = options.srp;
if (!verifier && options.password) {
verifier = Meteor._srp.generateVerifier(options.password);
}
if (!verifier || !verifier.identity || !verifier.salt ||
!verifier.verifier)
throw new Meteor.Error(400, "Invalid verifier");
// XXX this should invalidate all login tokens other than the current one
// (or it should assign a new login token, replacing existing ones)
Meteor.users.update({_id: this.userId},
{$set: {'services.password.srp': verifier}});
var ret = {passwordChanged: true};
if (serialized)
ret.HAMK = serialized.HAMK;
return ret;
},
forgotPassword: function (options) {
var email = options.email;
if (!email)
throw new Meteor.Error(400, "Need to set options.email");
var user = Meteor.users.findOne({"emails.address": email});
if (!user)
throw new Meteor.Error(403, "User not found");
Accounts.sendResetPasswordEmail(user._id, email);
},
resetPassword: function (token, newVerifier) {
if (!token)
throw new Meteor.Error(400, "Need to pass token");
if (!newVerifier)
throw new Meteor.Error(400, "Need to pass newVerifier");
var user = Meteor.users.findOne({"services.password.reset.token": token});
if (!user)
throw new Meteor.Error(403, "Token expired");
var email = user.services.password.reset.email;
if (!_.include(_.pluck(user.emails || [], 'address'), email))
throw new Meteor.Error(403, "Token has invalid email address");
var stampedLoginToken = Accounts._generateStampedLoginToken();
// Update the user record by:
// - Changing the password verifier to the new one
// - Replacing all valid login tokens with new ones (changing
// password should invalidate existing sessions).
// - Forgetting about the reset token that was just used
// - Verifying their email, since they got the password reset via email.
Meteor.users.update({_id: user._id, 'emails.address': email}, {
$set: {'services.password.srp': newVerifier,
'services.resume.loginTokens': [stampedLoginToken],
'emails.$.verified': true},
$unset: {'services.password.reset': 1}
});
this.setUserId(user._id);
return {token: stampedLoginToken.token, id: user._id};
},
verifyEmail: function (token) {
if (!token)
throw new Meteor.Error(400, "Need to pass token");
var user = Meteor.users.findOne({'emails.verificationTokens.token': token});
if (!user)
throw new Meteor.Error(403, "Verify email link expired");
// Log the user in with a new login token.
var stampedLoginToken = Accounts._generateStampedLoginToken();
// By including the token again in the query, we can use 'emails.$' in the
// modifier to get a reference to the specific object in the emails
// array. See
// http://www.mongodb.org/display/DOCS/Updating/#Updating-The%24positionaloperator)
// http://www.mongodb.org/display/DOCS/Updating#Updating-%24pull
Meteor.users.update(
{_id: user._id, 'emails.verificationTokens.token': token}, {
$set: {'emails.$.verified': true},
$pull: {'emails.$.verificationTokens': {token: token}},
$push: {'services.resume.loginTokens': stampedLoginToken}});
this.setUserId(user._id);
return {token: stampedLoginToken.token, id: user._id};
}
});
// send the user an email with a link that when opened allows the user
// to set a new password, without the old password.
Accounts.sendResetPasswordEmail = function (userId, email) {
// Make sure the user exists, and email is one of their addresses.
var user = Meteor.users.findOne(userId);
if (!user)
throw new Error("Can't find user");
// pick the first email if we weren't passed an email.
if (!email && user.emails && user.emails[0])
email = user.emails[0].address;
// make sure we have a valid email
if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email))
throw new Error("No such email for user.");
var token = Meteor.uuid();
var when = +(new Date);
Meteor.users.update(userId, {$set: {
"services.password.reset": {
token: token,
email: email,
when: when
}
}});
var resetPasswordUrl = Accounts.urls.resetPassword(token);
Email.send({
to: email,
from: Accounts.emailTemplates.from,
subject: Accounts.emailTemplates.resetPassword.subject(user),
text: Accounts.emailTemplates.resetPassword.text(user, resetPasswordUrl)});
};
// send the user an email with a link that when opened marks that
// address as verified
Accounts.sendVerificationEmail = function (userId, email) {
// XXX Also generate a link using which someone can delete this
// account if they own said address but weren't those who created
// this account.
// Make sure the user exists, and email is one of their addresses.
var user = Meteor.users.findOne(userId);
if (!user)
throw new Error("Can't find user");
// pick the first unverified email if we weren't passed an email.
if (!email) {
email = _.find(user.emails || [], function (e) { return !e.verified; });
email = (email || {}).address;
}
// make sure we have a valid email
if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email))
throw new Error("No such email for user.");
var stampedToken = {token: Meteor.uuid(), when: +(new Date)};
Meteor.users.update({_id: userId, 'emails.address': email},
{$push: {'emails.$.verificationTokens': stampedToken}});
var verifyEmailUrl = Accounts.urls.verifyEmail(stampedToken.token);
Email.send({
to: email,
from: Accounts.emailTemplates.from,
subject: Accounts.emailTemplates.verifyEmail.subject(user),
text: Accounts.emailTemplates.verifyEmail.text(user, verifyEmailUrl)
});
};
// send the user an email informing them that their account was created, with
// a link that when opened both marks their email as verified and forces them
// to choose their password. The email must be one of the addresses in the
// user's emails field, or undefined to pick the first email automatically.
Accounts.sendEnrollmentEmail = function (userId, email) {
// XXX refactor! This is basically identical to sendResetPasswordEmail.
// Make sure the user exists, and email is in their addresses.
var user = Meteor.users.findOne(userId);
if (!user)
throw new Error("Can't find user");
// pick the first email if we weren't passed an email.
if (!email && user.emails && user.emails[0])
email = user.emails[0].address;
// make sure we have a valid email
if (!email || !_.contains(_.pluck(user.emails || [], 'address'), email))
throw new Error("No such email for user.");
var token = Meteor.uuid();
var when = +(new Date);
Meteor.users.update(userId, {$set: {
"services.password.reset": {
token: token,
email: email,
when: when
}
}});
var enrollAccountUrl = Accounts.urls.enrollAccount(token);
Email.send({
to: email,
from: Accounts.emailTemplates.from,
subject: Accounts.emailTemplates.enrollAccount.subject(user),
text: Accounts.emailTemplates.enrollAccount.text(user, enrollAccountUrl)
});
};
// handler to login with password
Accounts.registerLoginHandler(function (options) {
if (!options.srp)
return undefined; // don't handle
if (!options.srp.M)
throw new Meteor.Error(400, "Must pass M in options.srp");
// we're always called from within a 'login' method, so this should
// be safe.
var currentInvocation = Meteor._CurrentInvocation.get();
var serialized = currentInvocation._sessionData.srpChallenge;
if (!serialized || serialized.M !== options.srp.M)
throw new Meteor.Error(403, "Incorrect password");
// Only can use challenges once.
delete currentInvocation._sessionData.srpChallenge;
var userId = serialized.userId;
var user = Meteor.users.findOne(userId);
// Was the user deleted since the start of this challenge?
if (!user)
throw new Meteor.Error(403, "User not found");
var stampedLoginToken = Accounts._generateStampedLoginToken();
Meteor.users.update(
userId, {$push: {'services.resume.loginTokens': stampedLoginToken}});
return {token: stampedLoginToken.token, id: userId, HAMK: serialized.HAMK};
});
// handler to login with plaintext password.
//
// The meteor client doesn't use this, it is for other DDP clients who
// haven't implemented SRP. Since it sends the password in plaintext
// over the wire, it should only be run over SSL!
//
// Also, it might be nice if servers could turn this off. Or maybe it
// should be opt-in, not opt-out? Accounts.config option?
Accounts.registerLoginHandler(function (options) {
if (!options.password || !options.user)
return undefined; // don't handle
var selector = selectorFromUserQuery(options.user);
var user = Meteor.users.findOne(selector);
if (!user)
throw new Meteor.Error(403, "User not found");
if (!user.services || !user.services.password ||
!user.services.password.srp)
throw new Meteor.Error(403, "User has no password set");
// Just check the verifier output when the same identity and salt
// are passed. Don't bother with a full exchange.
var verifier = user.services.password.srp;
var newVerifier = Meteor._srp.generateVerifier(options.password, {
identity: verifier.identity, salt: verifier.salt});
if (verifier.verifier !== newVerifier.verifier)
throw new Meteor.Error(403, "Incorrect password");
var stampedLoginToken = Accounts._generateStampedLoginToken();
Meteor.users.update(
user._id, {$push: {'services.resume.loginTokens': stampedLoginToken}});
return {token: stampedLoginToken.token, id: user._id};
});
Meteor.setPassword = function (userId, newPassword) {
var user = Meteor.users.findOne(userId);
if (!user)
throw new Meteor.Error(403, "User not found");
var newVerifier = Meteor._srp.generateVerifier(newPassword);
Meteor.users.update({_id: user._id}, {
$set: {'services.password.srp': newVerifier}});
};
////////////
// Creating users:
// Shared createUser function called from the createUser method, both
// if originates in client or server code. Calls user provided hooks,
// does the actual user insertion.
//
// returns an object with id: userId, and (if options.generateLoginToken is
// set) token: loginToken.
var createUser = function (options, extra) {
extra = extra || {};
var username = options.username;
var email = options.email;
if (!username && !email)
throw new Meteor.Error(400, "Need to set a username or email");
// Raw password. The meteor client doesn't send this, but a DDP
// client that didn't implement SRP could send this. This should
// only be done over SSL.
if (options.password) {
if (options.srp)
throw new Meteor.Error(400, "Don't pass both password and srp in options");
options.srp = Meteor._srp.generateVerifier(options.password);
}
var user = {services: {}};
if (options.srp)
user.services.password = {srp: options.srp}; // XXX validate verifier
if (username)
user.username = username;
if (email)
user.emails = [{address: email, verified: false}];
return Accounts.insertUserDoc(options, extra, user);
};
// method for create user. Requests come from the client.
Meteor.methods({
createUser: function (options, extra) {
options = _.clone(options);
options.generateLoginToken = true;
if (Accounts._options.forbidClientAccountCreation)
throw new Meteor.Error(403, "Signups forbidden");
// Create user. result contains id and token.
var result = createUser(options, extra);
// safety belt. createUser is supposed to throw on error. send 500 error
// instead of sending a verification email with empty userid.
if (!result.id)
throw new Error("createUser failed to insert new user");
// If `Accounts._options.sendVerificationEmail` is set, register
// a token to verify the user's primary email, and send it to
// that address.
if (options.email && Accounts._options.sendVerificationEmail)
Accounts.sendVerificationEmail(result.id, options.email);
// client gets logged in as the new user afterwards.
this.setUserId(result.id);
return result;
}
});
// Create user directly on the server.
//
// Unlike the client version, this does not log you in as this user
// after creation.
//
// returns userId or throws an error if it can't create
//
// XXX add another argument ("server options") that gets sent to onCreateUser,
// which is always empty when called from the createUser method? eg, "admin:
// true", which we want to prevent the client from setting, but which a custom
// method calling Accounts.createUser could set?
Accounts.createUser = function (options, extra, callback) {
options = _.clone(options);
options.generateLoginToken = false;
if (typeof extra === "function") {
callback = extra;
extra = {};
}
// XXX allow an optional callback?
if (callback) {
throw new Error("Meteor.createUser with callback not supported on the server yet.");
}
var userId = createUser(options, extra).id;
return userId;
};
// PASSWORD-SPECIFIC INDEXES ON USERS
Meteor.users._ensureIndex('emails.validationTokens.token',
{unique: 1, sparse: 1});
Meteor.users._ensureIndex('emails.password.reset.token',
{unique: 1, sparse: 1});
})();

View File

@@ -0,0 +1,334 @@
if (Meteor.isClient) (function () {
// XXX note, only one test can do login/logout things at once! for
// now, that is this test.
Accounts._isolateLoginTokenForTest();
var logoutStep = function (test, expect) {
Meteor.logout(expect(function (error) {
test.equal(error, undefined);
test.equal(Meteor.user(), null);
}));
};
var verifyUsername = function (someUsername, test, expect) {
var callWhenLoaded = expect(function() {
test.equal(Meteor.user().username, someUsername);
});
return function () {
Meteor._autorun(function(handle) {
if (!Meteor.userLoaded()) return;
handle.stop();
callWhenLoaded();
});
};
};
var loggedInAs = function (someUsername, test, expect) {
var quiesceCallback = verifyUsername(someUsername, test, expect);
return expect(function (error) {
test.equal(error, undefined);
Meteor.default_connection.onQuiesce(quiesceCallback);
});
};
// declare variable outside the testAsyncMulti, so we can refer to
// them from multiple tests, but initialize them to new values inside
// the test so when we use the 'debug' link in the tests, they get new
// values and the tests don't fail.
var username, username2, username3;
var email;
var password, password2, password3;
testAsyncMulti("passwords - long series", [
function (test, expect) {
username = Meteor.uuid();
username2 = Meteor.uuid();
username3 = Meteor.uuid();
// use -intercept so that we don't print to the console
email = Meteor.uuid() + '-intercept@example.com';
password = 'password';
password2 = 'password2';
password3 = 'password3';
},
function (test, expect) {
Accounts.createUser(
{username: username, email: email, password: password},
loggedInAs(username, test, expect));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword(username, password,
loggedInAs(username, test, expect));
},
logoutStep,
// This next step tests reactive contexts which are reactive on
// Meteor.user() without explicitly calling Meteor.userLoaded() --- we want
// to make sure that user loading finishing invalidates them too.
function (test, expect) {
// Set up a reactive context that only refreshes when Meteor.user() is
// invalidated.
var user;
var handle1 = Meteor._autorun(function () {
user = Meteor.user();
});
// At the beginning, we're not logged in.
test.equal(user, null);
// This will get called once a second context (which does explicitly call
// Meteor.userLoaded()) tells us we are ready.
var callWhenLoaded = expect(function () {
Meteor.flush();
// ... and this means that the first context did refresh and give us
// data.
test.isTrue(user.emails);
handle1.stop();
});
var waitForLoaded = expect(function () {
Meteor._autorun(function(handle2) {
if (!Meteor.userLoaded()) return;
handle2.stop();
callWhenLoaded();
});
});
Meteor.loginWithPassword(username, password, expect(function (error) {
test.equal(error, undefined);
test.notEqual(Meteor.userId(), null);
// Since userId has changed, the first autorun has been invalidated, so
// flush will re-run it and user will become not null. In the *CURRENT
// IMPLEMENTATION*, we will have just called _makeClientLoggedIn which
// just started a new meteor.currentUser subscription. There is no way
// that it is complete yet because we haven't gotten back to the event
// loop to actually get the data, so user.emails hasn't been populated
// yet. (That said, if we redo how userLoaded is implemented to not
// involve unsub/sub, it's possible that this test may become flaky by
// the test.isFalse failing.)
Meteor.flush();
test.notEqual(user, null);
test.isFalse(user.emails);
waitForLoaded();
}));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword({username: username}, password,
loggedInAs(username, test, expect));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword(email, password,
loggedInAs(username, test, expect));
},
logoutStep,
function (test, expect) {
Meteor.loginWithPassword({email: email}, password,
loggedInAs(username, test, expect));
},
logoutStep,
// plain text password. no API for this, have to send a raw message.
function (test, expect) {
Meteor.call(
// wrong password
'login', {user: {email: email}, password: password2},
expect(function (error, result) {
test.isTrue(error);
test.isFalse(result);
test.isFalse(Meteor.user());
}));
},
function (test, expect) {
var quiesceCallback = verifyUsername(username, test, expect);
Meteor.call(
// right password
'login', {user: {email: email}, password: password},
expect(function (error, result) {
test.equal(error, undefined);
test.isTrue(result.id);
test.isTrue(result.token);
// emulate the real login behavior, so as not to confuse test.
Accounts._makeClientLoggedIn(result.id, result.token);
Meteor.default_connection.onQuiesce(quiesceCallback);
}));
},
// change password with bad old password. we stay logged in.
function (test, expect) {
var quiesceCallback = verifyUsername(username, test, expect);
Accounts.changePassword(password2, password2, expect(function (error) {
test.isTrue(error);
Meteor.default_connection.onQuiesce(quiesceCallback);
}));
},
// change password with good old password.
function (test, expect) {
Accounts.changePassword(password, password2,
loggedInAs(username, test, expect));
},
logoutStep,
// old password, failed login
function (test, expect) {
Meteor.loginWithPassword(email, password, expect(function (error) {
test.isTrue(error);
test.isFalse(Meteor.user());
}));
},
// new password, success
function (test, expect) {
Meteor.loginWithPassword(email, password2,
loggedInAs(username, test, expect));
},
logoutStep,
// create user with raw password
function (test, expect) {
var quiesceCallback = verifyUsername(username2, test, expect);
Meteor.call('createUser', {username: username2, password: password2},
expect(function (error, result) {
test.equal(error, undefined);
test.isTrue(result.id);
test.isTrue(result.token);
// emulate the real login behavior, so as not to confuse test.
Accounts._makeClientLoggedIn(result.id, result.token);
Meteor.default_connection.onQuiesce(quiesceCallback);
}));
},
logoutStep,
function(test, expect) {
Meteor.loginWithPassword({username: username2}, password2,
loggedInAs(username2, test, expect));
},
logoutStep,
// test Accounts.validateNewUser
function(test, expect) {
Accounts.createUser({username: username3, password: password3},
// should fail the new user validators
{profile: {invalid: true}},
expect(function (error) {
test.equal(error.error, 403);
test.equal(
error.reason,
"User validation failed");
}));
},
logoutStep,
function(test, expect) {
Accounts.createUser({username: username3, password: password3},
// should fail the new user validator with a special
// exception
{profile: {invalidAndThrowException: true}},
expect(function (error) {
test.equal(
error.reason,
"An exception thrown within Accounts.validateNewUser");
}));
},
// test Accounts.onCreateUser
function(test, expect) {
Accounts.createUser(
{username: username3, password: password3},
{testOnCreateUserHook: true},
loggedInAs(username3, test, expect));
},
function(test, expect) {
test.equal(Meteor.user().profile.touchedByOnCreateUser, true);
},
// test Meteor.user(). This test properly belongs in
// accounts-base/accounts_tests.js, but this is where the tests that
// actually log in are.
function(test, expect) {
var clientUser = Meteor.user();
Meteor.call('testMeteorUser', expect(function (err, result) {
test.equal(result._id, clientUser._id);
test.equal(result.profile.touchedByOnCreateUser, true);
test.equal(err, undefined);
}));
},
logoutStep,
function(test, expect) {
var clientUser = Meteor.user();
test.equal(clientUser, null);
Meteor.call('testMeteorUser', expect(function (err, result) {
test.equal(err, undefined);
test.equal(result, null);
}));
}
]);
}) ();
if (Meteor.isServer) (function () {
Tinytest.add(
'passwords - setup more than one onCreateUserHook',
function (test) {
test.throws(function() {
Accounts.onCreateUser(function () {});
});
});
Tinytest.add(
'passwords - createUser hooks',
function (test) {
var email = Meteor.uuid() + '@example.com';
test.throws(function () {
// should fail the new user validators
Accounts.createUser({email: email}, {profile: {invalid: true}});
});
// disable sending emails
var oldEmailSend = Email.send;
Email.send = function() {};
var userId = Accounts.createUser({email: email},
{testOnCreateUserHook: true});
Email.send = oldEmailSend;
test.isTrue(userId);
var user = Meteor.users.findOne(userId);
test.equal(user.profile.touchedByOnCreateUser, true);
});
Tinytest.add(
'passwords - setPassword',
function (test) {
var username = Meteor.uuid();
var userId = Accounts.createUser({username: username}, {});
var user = Meteor.users.findOne(userId);
// no services yet.
test.equal(user.services.password, undefined);
// set a new password.
Meteor.setPassword(userId, 'new password');
user = Meteor.users.findOne(userId);
var oldVerifier = user.services.password.srp;
test.isTrue(user.services.password.srp);
// reset with the same password, see we get a different verifier
Meteor.setPassword(userId, 'new password');
user = Meteor.users.findOne(userId);
var newVerifier = user.services.password.srp;
test.notEqual(oldVerifier.salt, newVerifier.salt);
test.notEqual(oldVerifier.identity, newVerifier.identity);
test.notEqual(oldVerifier.verifier, newVerifier.verifier);
// cleanup
Meteor.users.remove(userId);
});
// This test properly belongs in accounts-base/accounts_tests.js, but
// this is where the tests that actually log in are.
Tinytest.add('accounts - user() out of context', function (test) {
// basic server context, no method.
test.throws(function () {
Meteor.user();
});
});
// XXX would be nice to test Accounts.config({forbidClientAccountCreation: true})
}) ();

View File

@@ -0,0 +1,39 @@
Accounts.validateNewUser(function (user) {
if (user.profile && user.profile.invalidAndThrowException)
throw new Meteor.Error(403, "An exception thrown within Accounts.validateNewUser");
return !(user.profile && user.profile.invalid);
});
Accounts.onCreateUser(function (options, extra, user) {
if (extra.testOnCreateUserHook) {
user.profile = (user.profile || {});
user.profile.touchedByOnCreateUser = true;
return user;
} else {
return 'TEST DEFAULT HOOK';
}
});
// Because this is global state that affects every client, we can't turn
// it on and off during the tests. Doing so would mean two simultaneous
// test runs could collide with each other.
//
// We should probably have some sort of server-isolation between
// multiple test runs. Perhaps a separate server instance per run. This
// problem isn't unique to this test, there are other places in the code
// where we do various hacky things to work around the lack of
// server-side isolation.
//
// For now, we just test the one configuration state. You can comment
// out each configuration option and see that the tests fail.
Accounts.config({
sendVerificationEmail: true
});
// This test properly belongs in accounts-base/accounts_tests.js, but
// this is where the tests that actually log in are.
Meteor.methods({
testMeteorUser: function () { return Meteor.user(); }
});

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Login service for Twitter accounts"
});
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');
api.add_files('twitter_client.js', 'client');
});

View File

@@ -0,0 +1,34 @@
(function () {
// XXX support options.requestPermissions as we do for Facebook, Google, Github
Meteor.loginWithTwitter = function (options, callback) {
// support both (options, callback) and (callback).
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
var config = Accounts.loginServiceConfiguration.findOne({service: 'twitter'});
if (!config) {
callback && callback(new Accounts.ConfigError("Service not configured"));
return;
}
var state = Meteor.uuid();
// We need to keep state across the next two 'steps' so we're adding
// a state parameter to the url and the callback url that we'll be returned
// to by oauth provider
// url back to app, enters "step 2" as described in
// packages/accounts-oauth1-helper/oauth1_server.js
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
var url = '/_oauth/twitter/?requestTokenAndRedirect='
+ encodeURIComponent(callbackUrl)
+ '&state=' + state;
Accounts.oauth.initiateLogin(state, url, callback);
};
})();

View File

@@ -0,0 +1,10 @@
if (!Accounts.twitter) {
Accounts.twitter = {};
}
Accounts.twitter._urls = {
requestToken: "https://api.twitter.com/oauth/request_token",
authorize: "https://api.twitter.com/oauth/authorize",
accessToken: "https://api.twitter.com/oauth/access_token",
authenticate: "https://api.twitter.com/oauth/authenticate"
};

View File

@@ -0,0 +1,13 @@
<template name="configureLoginServiceDialogForTwitter">
<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.configureLoginServiceDialogForTwitter.siteUrl = function () {
// Twitter doesn't recognize localhost as a domain name
return Meteor.absoluteUrl({replaceLocalhost: true});
};
Template.configureLoginServiceDialogForTwitter.fields = function () {
return [
{property: 'consumerKey', label: 'Consumer key'},
{property: 'secret', label: 'Consumer secret'}
];
};

View File

@@ -0,0 +1,20 @@
(function () {
Accounts.oauth.registerService('twitter', 1, function(oauthBinding) {
var identity = oauthBinding.get('https://api.twitter.com/1/account/verify_credentials.json');
return {
serviceData: {
id: identity.id,
screenName: identity.screen_name,
accessToken: oauthBinding.accessToken,
accessTokenSecret: oauthBinding.accessTokenSecret
},
extra: {
profile: {
name: identity.name
}
}
};
});
}) ();

View File

@@ -0,0 +1,41 @@
if (!Accounts.ui)
Accounts.ui = {};
if (!Accounts.ui._options) {
Accounts.ui._options = {
requestPermissions: {}
};
}
Accounts.ui.config = function(options) {
if (options.passwordSignupFields) {
if (_.contains([
"USERNAME_AND_EMAIL",
"USERNAME_AND_OPTIONAL_EMAIL",
"USERNAME_ONLY",
"EMAIL_ONLY"
], options.passwordSignupFields)) {
if (Accounts.ui._options.passwordSignupFields)
throw new Error("Can't set `passwordSignupFields` more than once");
else
Accounts.ui._options.passwordSignupFields = options.passwordSignupFields;
} else {
throw new Error("Invalid option for `passwordSignupFields`: " + options.passwordSignupFields);
}
}
if (options.requestPermissions) {
_.each(options.requestPermissions, function (scope, service) {
if (Accounts.ui._options.requestPermissions[service])
throw new Error("Can't set `requestPermissions` more than once for " + service);
else
Accounts.ui._options.requestPermissions[service] = scope;
});
}
};
Accounts.ui._passwordSignupFields = function () {
return Accounts.ui._options.passwordSignupFields || "EMAIL_ONLY";
};

View File

@@ -0,0 +1,50 @@
<template name="loginButtons">
<div id="login-buttons">
{{#if currentUser}}
{{> _loginButtonsLoggedIn}}
{{else}}
{{> _loginButtonsLoggedOut}}
{{/if}}
</div>
</template>
<template name="_loginButtonsLoggedIn">
{{#if dropdown}}
{{> _loginButtonsLoggedInDropdown}}
{{else}}
<div class="login-header">
{{#if currentUserLoaded}}
{{displayName}}
{{else}}
<div class="loading"></div>
{{/if}}
</div>
<div class="login-button" id="login-buttons-logout">Logout</div>
{{/if}}
</template>
<template name="_loginButtonsLoggedOut">
{{#if services}} {{! if at least one service is configured }}
{{#if configurationLoaded}}
{{#if dropdown}} {{! if more than one service configured, or password is configured}}
{{> _loginButtonsLoggedOutDropdown}}
{{else}}
{{#with singleService}} {{! at this point there must be only one configured services }}
{{> _loginButtonsLoggedOutSingleLoginButton}}
{{/with}}
{{/if}}
{{/if}}
{{else}}
<div class="no-services">No login services configured</div>
{{/if}}
</template>
<!-- used in various places to display messages to user -->
<template name="_loginButtonsMessages">
{{#if errorMessage}}
<div class="message error-message">{{errorMessage}}</div>
{{/if}}
{{#if infoMessage}}
<div class="message info-message">{{infoMessage}}</div>
{{/if}}
</template>

View File

@@ -0,0 +1,146 @@
(function () {
if (!Accounts._loginButtons)
Accounts._loginButtons = {};
// for convenience
var loginButtonsSession = Accounts._loginButtonsSession;
// shared between dropdown and single mode
Template.loginButtons.events({
'click #login-buttons-logout': function() {
Meteor.logout(function () {
loginButtonsSession.closeDropdown();
});
}
});
//
// loginButtonLoggedOut template
//
Template._loginButtonsLoggedOut.dropdown = function () {
return Accounts._loginButtons.dropdown();
};
Template._loginButtonsLoggedOut.services = function () {
return Accounts._loginButtons.getLoginServices();
};
Template._loginButtonsLoggedOut.singleService = function () {
var services = Accounts._loginButtons.getLoginServices();
if (services.length !== 1)
throw new Error(
"Shouldn't be rendering this template with more than one configured service");
return services[0];
};
Template._loginButtonsLoggedOut.configurationLoaded = function () {
return Accounts.loginServicesConfigured();
};
//
// loginButtonsLoggedIn template
//
// decide whether we should show a dropdown rather than a row of
// buttons
Template._loginButtonsLoggedIn.dropdown = function () {
return Accounts._loginButtons.dropdown();
};
Template._loginButtonsLoggedIn.displayName = function () {
return Accounts._loginButtons.displayName();
};
//
// loginButtonsMessage template
//
Template._loginButtonsMessages.errorMessage = function () {
return loginButtonsSession.get('errorMessage');
};
Template._loginButtonsMessages.infoMessage = function () {
return loginButtonsSession.get('infoMessage');
};
//
// helpers
//
Accounts._loginButtons.displayName = function () {
var user = Meteor.user();
if (!user)
return '';
if (user.profile && user.profile.name)
return user.profile.name;
if (user.username)
return user.username;
if (user.emails && user.emails[0] && user.emails[0].address)
return user.emails[0].address;
return '';
};
Accounts._loginButtons.getLoginServices = function () {
var ret = [];
// make sure to put password last, since this is how it is styled
// in the ui as well.
_.each(
['facebook', 'google', 'weibo', 'twitter', 'github', 'password'],
function (service) {
if (Accounts[service])
ret.push({name: service});
});
return ret;
};
Accounts._loginButtons.hasPasswordService = function () {
return Accounts.password;
};
Accounts._loginButtons.dropdown = function () {
return Accounts._loginButtons.hasPasswordService() || Accounts._loginButtons.getLoginServices().length > 1;
};
// XXX improve these. should this be in accounts-password instead?
//
// XXX these will become configurable, and will be validated on
// the server as well.
Accounts._loginButtons.validateUsername = function (username) {
if (username.length >= 3) {
return true;
} else {
loginButtonsSession.set('errorMessage', "Username must be at least 3 characters long");
return false;
}
};
Accounts._loginButtons.validateEmail = function (email) {
if (Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL" && email === '')
return true;
if (email.indexOf('@') !== -1) {
return true;
} else {
loginButtonsSession.set('errorMessage', "Invalid email");
return false;
}
};
Accounts._loginButtons.validatePassword = function (password) {
if (password.length >= 6) {
return true;
} else {
loginButtonsSession.set('errorMessage', "Password must be at least 6 characters long");
return false;
}
};
})();

View File

@@ -0,0 +1,122 @@
<body>
{{> _resetPasswordDialog}}
{{> _enrollAccountDialog}}
{{> _justVerifiedEmailDialog}}
{{> _configureLoginServiceDialog}}
<!-- if we're not showing a dropdown, we need some other place to show messages -->
{{> _loginButtonsMessagesDialog}}
</body>
<template name="_resetPasswordDialog">
{{#if inResetPasswordFlow}}
<div class="hide-background"></div>
<div class="accounts-dialog accounts-centered-dialog">
<label id="reset-password-new-password-label" for="reset-password-new-password">
New password
</label>
<div>
<input id="reset-password-new-password" type="password" />
</div>
{{> _loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-reset-password-button">
Set password
</div>
<a class="additional-link" id="login-buttons-cancel-reset-password">
Cancel
</a>
</div>
{{/if}}
</template>
<template name="_enrollAccountDialog">
{{#if inEnrollAccountFlow}}
<div class="hide-background"></div>
<div class="accounts-dialog accounts-centered-dialog">
<label id="enroll-account-password-label" for="enroll-account-password">
Choose a password
</label>
<div>
<input id="enroll-account-password" type="password" />
</div>
{{> _loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-enroll-account-button">
Create account
</div>
<a class="additional-link" id="login-buttons-cancel-enroll-account">
Cancel
</a>
</div>
{{/if}}
</template>
<template name="_justVerifiedEmailDialog">
{{#if visible}}
<div class="accounts-dialog accounts-centered-dialog">
Email verified
<div class="login-button" id="just-verified-dismiss-button">Dismiss</div>
</div>
{{/if}}
</template>
<template name="_configureLoginServiceDialog">
{{#if visible}}
<div id="configure-login-service-dialog" class="accounts-dialog accounts-centered-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-service-dialog-{{property}}">{{label}}</label>
</td>
<td>
<input id="configure-login-service-dialog-{{property}}" />
</td>
</tr>
{{/each}}
</table>
</p>
<div class="new-section">
<div class="login-button" id="configure-login-service-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-service-dialog-save-configuration">
Save Configuration
</div>
{{/isolate}}
</div>
</div>
{{/if}}
</template>
<template name="_loginButtonsMessagesDialog">
{{#if visible}}
<div class="accounts-dialog accounts-centered-dialog" id="login-buttons-message-dialog">
{{> _loginButtonsMessages}}
<div class="login-button" id="messages-dialog-dismiss-button">Dismiss</div>
</div>
{{/if}}
</template>

View File

@@ -0,0 +1,235 @@
(function () {
// for convenience
var loginButtonsSession = Accounts._loginButtonsSession;
//
// populate the session so that the appropriate dialogs are
// displayed by reading variables set by accounts-urls, which parses
// special URLs. since accounts-ui depends on accounts-urls, we are
// guaranteed to have these set at this point.
//
if (Accounts._resetPasswordToken) {
loginButtonsSession.set('resetPasswordToken', Accounts._resetPasswordToken);
}
if (Accounts._enrollAccountToken) {
loginButtonsSession.set('enrollAccountToken', Accounts._enrollAccountToken);
}
// Needs to be in Meteor.startup because of a package loading order
// issue. We can't be sure that accounts-password is loaded earlier
// than accounts-ui so Accounts.verifyEmail might not be defined.
Meteor.startup(function () {
if (Accounts._verifyEmailToken) {
Accounts.verifyEmail(Accounts._verifyEmailToken, function(error) {
Accounts._enableAutoLogin();
if (!error)
loginButtonsSession.set('justVerifiedEmail', true);
// XXX show something if there was an error.
});
}
});
//
// resetPasswordDialog template
//
Template._resetPasswordDialog.events({
'click #login-buttons-reset-password-button': function () {
resetPassword();
},
'keypress #reset-password-new-password': function (event) {
if (event.keyCode === 13)
resetPassword();
},
'click #login-buttons-cancel-reset-password': function () {
loginButtonsSession.set('resetPasswordToken', null);
Accounts._enableAutoLogin();
}
});
var resetPassword = function () {
loginButtonsSession.resetMessages();
var newPassword = document.getElementById('reset-password-new-password').value;
if (!Accounts._loginButtons.validatePassword(newPassword))
return;
Accounts.resetPassword(
loginButtonsSession.get('resetPasswordToken'), newPassword,
function (error) {
if (error) {
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
} else {
loginButtonsSession.set('resetPasswordToken', null);
Accounts._enableAutoLogin();
}
});
};
Template._resetPasswordDialog.inResetPasswordFlow = function () {
return loginButtonsSession.get('resetPasswordToken');
};
//
// enrollAccountDialog template
//
Template._enrollAccountDialog.events({
'click #login-buttons-enroll-account-button': function () {
enrollAccount();
},
'keypress #enroll-account-password': function (event) {
if (event.keyCode === 13)
enrollAccount();
},
'click #login-buttons-cancel-enroll-account': function () {
loginButtonsSession.set('enrollAccountToken', null);
Accounts._enableAutoLogin();
}
});
var enrollAccount = function () {
loginButtonsSession.resetMessages();
var password = document.getElementById('enroll-account-password').value;
if (!Accounts._loginButtons.validatePassword(password))
return;
Accounts.resetPassword(
loginButtonsSession.get('enrollAccountToken'), password,
function (error) {
if (error) {
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
} else {
loginButtonsSession.set('enrollAccountToken', null);
Accounts._enableAutoLogin();
}
});
};
Template._enrollAccountDialog.inEnrollAccountFlow = function () {
return loginButtonsSession.get('enrollAccountToken');
};
//
// justVerifiedEmailDialog template
//
Template._justVerifiedEmailDialog.events({
'click #just-verified-dismiss-button': function () {
loginButtonsSession.set('justVerifiedEmail', false);
}
});
Template._justVerifiedEmailDialog.visible = function () {
return loginButtonsSession.get('justVerifiedEmail');
};
//
// loginButtonsMessagesDialog template
//
Template._loginButtonsMessagesDialog.events({
'click #messages-dialog-dismiss-button': function () {
loginButtonsSession.resetMessages();
}
});
Template._loginButtonsMessagesDialog.visible = function () {
var hasMessage = loginButtonsSession.get('infoMessage') || loginButtonsSession.get('errorMessage');
return !Accounts._loginButtons.dropdown() && hasMessage;
};
//
// configureLoginServiceDialog template
//
Template._configureLoginServiceDialog.events({
'click #configure-login-service-dismiss-button': function () {
loginButtonsSession.set('configureLoginServiceDialogVisible', false);
},
'click #configure-login-service-dialog-save-configuration': function () {
if (loginButtonsSession.get('configureLoginServiceDialogVisible')) {
// Prepare the configuration document for this login service
var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName');
var configuration = {
service: serviceName
};
_.each(configurationFields(), function(field) {
configuration[field.property] = document.getElementById(
'configure-login-service-dialog-' + field.property).value
.replace(/^\s*|\s*$/g, ""); // trim;
});
// Configure this login service
Meteor.call("configureLoginService", configuration, function (error, result) {
if (error)
Meteor._debug("Error configuring login service " + serviceName, error);
else
loginButtonsSession.set('configureLoginServiceDialogVisible', false);
});
}
},
'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-service-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 anyFieldEmpty = _.any(configurationFields(), function(field) {
return document.getElementById(
'configure-login-service-dialog-' + field.property).value === '';
});
loginButtonsSession.set('configureLoginServiceDialogSaveDisabled', anyFieldEmpty);
};
// Returns the appropriate template for this login service. This
// template should be defined in the service's package
var configureLoginServiceDialogTemplateForService = function () {
var serviceName = loginButtonsSession.get('configureLoginServiceDialogServiceName');
return Template['configureLoginServiceDialogFor' + capitalize(serviceName)];
};
var configurationFields = function () {
var template = configureLoginServiceDialogTemplateForService();
return template.fields();
};
Template._configureLoginServiceDialog.configurationFields = function () {
return configurationFields();
};
Template._configureLoginServiceDialog.visible = function () {
return loginButtonsSession.get('configureLoginServiceDialogVisible');
};
Template._configureLoginServiceDialog.configurationSteps = function () {
// renders the appropriate template
return configureLoginServiceDialogTemplateForService()();
};
Template._configureLoginServiceDialog.saveDisabled = function () {
return loginButtonsSession.get('configureLoginServiceDialogSaveDisabled');
};
// XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js
var capitalize = function(str){
str = str == null ? '' : String(str);
return str.charAt(0).toUpperCase() + str.slice(1);
};
}) ();

View File

@@ -0,0 +1,168 @@
<!-- -->
<!-- LOGGED IN -->
<!-- -->
<template name="_loginButtonsLoggedInDropdown">
<div class="login-link-and-dropdown-list">
{{#if currentUserLoaded}}
<a class="login-link-text" id="login-name-link">
{{displayName}} ▾
</a>
{{#if dropdownVisible}}
<div id="login-dropdown-list" class="accounts-dialog">
<a class="login-close-text">Close</a>
<div class="login-close-text-clear"></div>
{{#if inMessageOnlyFlow}}
{{> _loginButtonsMessages}}
{{else}}
{{#if inChangePasswordFlow}}
{{> _loginButtonsChangePassword}}
{{else}}
{{> _loginButtonsLoggedInDropdownActions}}
{{/if}}
{{/if}}
</div>
{{/if}}
{{else}}
<div class="loading"></div>
{{/if}}
</div>
</template>
<template name="_loginButtonsLoggedInDropdownActions">
{{#if allowChangingPassword}}
<div class="login-button" id="login-buttons-open-change-password">
Change password
</div>
{{/if}}
<div class="login-button" id="login-buttons-logout">
Logout
</div>
</template>
<!-- -->
<!-- LOGGED OUT -->
<!-- -->
<template name="_loginButtonsLoggedOutDropdown">
<div class="login-link-and-dropdown-list {{additionalClasses}}">
<a class="login-link-text" id="login-sign-in-link">Sign in ▾</a>
{{#if dropdownVisible}}
<div id="login-dropdown-list" class="accounts-dialog">
<a class="login-close-text">Close</a>
<div class="login-close-text-clear"></div>
{{> _loginButtonsLoggedOutAllServices}}
</div>
{{/if}}
</div>
</template>
<template name="_loginButtonsLoggedOutAllServices">
{{#each services}}
{{#if isPasswordService}}
{{#if hasOtherServices}} {{! the password service will always come last }}
{{> _loginButtonsLoggedOutPasswordServiceSeparator}}
{{/if}}
{{> _loginButtonsLoggedOutPasswordService}}
{{else}}
{{> _loginButtonsLoggedOutSingleLoginButton}}
{{/if}}
{{/each}}
{{#unless hasPasswordService}}
{{> _loginButtonsMessages}}
{{/unless}}
</template>
<template name="_loginButtonsLoggedOutPasswordServiceSeparator">
<div class="or">
<span class="hline">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="or-text">or</span>
<span class="hline">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
</div>
</template>
<template name="_loginButtonsLoggedOutPasswordService">
{{#if inForgotPasswordFlow}}
{{> _forgotPasswordForm}}
{{else}}
<div class="login-form login-password-form">
{{#each fields}}
{{> _loginButtonsFormField}}
{{/each}}
{{> _loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-password">
{{#if inSignupFlow}}
Create account
{{else}}
Sign in
{{/if}}
</div>
{{#if inLoginFlow}}
<div class="additional-link-container">
<a id="signup-link" class="additional-link">Create account</a>
</div>
{{#if showForgotPasswordLink}}
<div class="additional-link-container">
<a id="forgot-password-link" class="additional-link">Forgot password</a>
</div>
{{/if}}
{{/if}}
{{#if inSignupFlow}}
{{> _loginButtonsBackToLoginLink}}
{{/if}}
</div>
{{/if}}
</template>
<template name="_forgotPasswordForm">
<div class="login-form">
<div id="forgot-password-email-label-and-input"> {{! XXX we should probably use loginButtonsFormField }}
<label id="forgot-password-email-label" for="forgot-password-email">Email</label>
<input id="forgot-password-email"/>
</div>
{{> _loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-forgot-password">
Reset password
</div>
{{> _loginButtonsBackToLoginLink}}
</div>
</template>
<template name="_loginButtonsBackToLoginLink">
<div class="additional-link-container">
<a id="back-to-login-link" class="additional-link">Sign in</a>
</div>
</template>
<template name="_loginButtonsFormField">
{{#if visible}}
<div id="login-{{fieldName}}-label-and-input">
<label id="login-{{fieldName}}-label" for="login-{{fieldName}}">
{{fieldLabel}}
</label>
<input id="login-{{fieldName}}" type="{{inputType}}" />
</div>
{{/if}}
</template>
<template name="_loginButtonsChangePassword">
{{#each fields}}
{{> _loginButtonsFormField}}
{{/each}}
{{> _loginButtonsMessages}}
<div class="login-button login-button-form-submit" id="login-buttons-do-change-password">
Change password
</div>
</template>

View File

@@ -0,0 +1,494 @@
(function () {
// for convenience
var loginButtonsSession = Accounts._loginButtonsSession;
// events shared between loginButtonsLoggedOutDropdown and
// loginButtonsLoggedInDropdown
Template.loginButtons.events({
'click #login-name-link, click #login-sign-in-link': function () {
loginButtonsSession.set('dropdownVisible', true);
Meteor.flush();
correctDropdownZIndexes();
},
'click .login-close-text': function () {
loginButtonsSession.closeDropdown();
}
});
//
// loginButtonsLoggedInDropdown template and related
//
Template._loginButtonsLoggedInDropdown.events({
'click #login-buttons-open-change-password': function() {
loginButtonsSession.resetMessages();
loginButtonsSession.set('inChangePasswordFlow', true);
}
});
Template._loginButtonsLoggedInDropdown.displayName = function () {
return Accounts._loginButtons.displayName();
};
Template._loginButtonsLoggedInDropdown.inChangePasswordFlow = function () {
return loginButtonsSession.get('inChangePasswordFlow');
};
Template._loginButtonsLoggedInDropdown.inMessageOnlyFlow = function () {
return loginButtonsSession.get('inMessageOnlyFlow');
};
Template._loginButtonsLoggedInDropdown.dropdownVisible = function () {
return loginButtonsSession.get('dropdownVisible');
};
Template._loginButtonsLoggedInDropdownActions.allowChangingPassword = function () {
// it would be more correct to check whether the user has a password set,
// but in order to do that we'd have to send more data down to the client,
// and it'd be preferable not to send down the entire service.password document.
//
// instead we use the heuristic: if the user has a username or email set.
var user = Meteor.user();
return user.username || (user.emails && user.emails[0] && user.emails[0].address);
};
//
// loginButtonsLoggedOutDropdown template and related
//
Template._loginButtonsLoggedOutDropdown.events({
'click #login-buttons-password': function () {
loginOrSignup();
},
'keypress #forgot-password-email': function (event) {
if (event.keyCode === 13)
forgotPassword();
},
'click #login-buttons-forgot-password': function () {
forgotPassword();
},
'click #signup-link': function () {
loginButtonsSession.resetMessages();
// store values of fields before swtiching to the signup form
var username = trimmedElementValueById('login-username');
var email = trimmedElementValueById('login-email');
var usernameOrEmail = trimmedElementValueById('login-username-or-email');
// notably not trimmed. a password could (?) start or end with a space
var password = elementValueById('login-password');
loginButtonsSession.set('inSignupFlow', true);
loginButtonsSession.set('inForgotPasswordFlow', false);
// force the ui to update so that we have the approprate fields to fill in
Meteor.flush();
// update new fields with appropriate defaults
if (username !== null)
document.getElementById('login-username').value = username;
else if (email !== null)
document.getElementById('login-email').value = email;
else if (usernameOrEmail !== null)
if (usernameOrEmail.indexOf('@') === -1)
document.getElementById('login-username').value = usernameOrEmail;
else
document.getElementById('login-email').value = usernameOrEmail;
// "login-password" is preserved thanks to the preserve-inputs package
// Force redrawing the `login-dropdown-list` element because of
// a bizarre Chrome bug in which part of the DIV is not redrawn
// in case you had tried to unsuccessfully log in before
// switching to the signup form.
//
// Found tip on how to force a redraw on
// http://stackoverflow.com/questions/3485365/how-can-i-force-webkit-to-redraw-repaint-to-propagate-style-changes/3485654#3485654
var redraw = document.getElementById('login-dropdown-list');
redraw.style.display = 'none';
redraw.offsetHeight; // it seems that this line does nothing but is necessary for the redraw to work
redraw.style.display = 'block';
},
'click #forgot-password-link': function () {
loginButtonsSession.resetMessages();
// store values of fields before swtiching to the signup form
var email = trimmedElementValueById('login-email');
var usernameOrEmail = trimmedElementValueById('login-username-or-email');
loginButtonsSession.set('inSignupFlow', false);
loginButtonsSession.set('inForgotPasswordFlow', true);
// force the ui to update so that we have the approprate fields to fill in
Meteor.flush();
// update new fields with appropriate defaults
if (email !== null)
document.getElementById('forgot-password-email').value = email;
else if (usernameOrEmail !== null)
if (usernameOrEmail.indexOf('@') !== -1)
document.getElementById('forgot-password-email').value = usernameOrEmail;
},
'click #back-to-login-link': function () {
loginButtonsSession.resetMessages();
var username = trimmedElementValueById('login-username');
var email = trimmedElementValueById('login-email')
|| trimmedElementValueById('forgot-password-email'); // Ughh. Standardize on names?
loginButtonsSession.set('inSignupFlow', false);
loginButtonsSession.set('inForgotPasswordFlow', false);
// force the ui to update so that we have the approprate fields to fill in
Meteor.flush();
if (document.getElementById('login-username'))
document.getElementById('login-username').value = username;
if (document.getElementById('login-email'))
document.getElementById('login-email').value = email;
// "login-password" is preserved thanks to the preserve-inputs package
if (document.getElementById('login-username-or-email'))
document.getElementById('login-username-or-email').value = email || username;
},
'keypress #login-username, keypress #login-email, keypress #login-username-or-email, keypress #login-password, keypress #login-password-again': function (event) {
if (event.keyCode === 13)
loginOrSignup();
}
});
// additional classes that can be helpful in styling the dropdown
Template._loginButtonsLoggedOutDropdown.additionalClasses = function () {
if (!Accounts.password) {
return false;
} else {
if (loginButtonsSession.get('inSignupFlow')) {
return 'login-form-create-account';
} else if (loginButtonsSession.get('inForgotPasswordFlow')) {
return 'login-form-forgot-password';
} else {
return 'login-form-sign-in';
}
}
};
Template._loginButtonsLoggedOutDropdown.dropdownVisible = function () {
return loginButtonsSession.get('dropdownVisible');
};
Template._loginButtonsLoggedOutDropdown.hasPasswordService = function () {
return Accounts._loginButtons.hasPasswordService();
};
Template._loginButtonsLoggedOutAllServices.services = function () {
return Accounts._loginButtons.getLoginServices();
};
Template._loginButtonsLoggedOutAllServices.isPasswordService = function () {
return this.name === 'password';
};
Template._loginButtonsLoggedOutAllServices.hasOtherServices = function () {
return Accounts._loginButtons.getLoginServices().length > 1;
};
Template._loginButtonsLoggedOutAllServices.hasPasswordService = function () {
return Accounts._loginButtons.hasPasswordService();
};
Template._loginButtonsLoggedOutPasswordService.fields = function () {
var loginFields = [
{fieldName: 'username-or-email', fieldLabel: 'Username or Email',
visible: function () {
return _.contains(
["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL"],
Accounts.ui._passwordSignupFields());
}},
{fieldName: 'username', fieldLabel: 'Username',
visible: function () {
return Accounts.ui._passwordSignupFields() === "USERNAME_ONLY";
}},
{fieldName: 'email', fieldLabel: 'Email',
visible: function () {
return Accounts.ui._passwordSignupFields() === "EMAIL_ONLY";
}},
{fieldName: 'password', fieldLabel: 'Password', inputType: 'password',
visible: function () {
return true;
}}
];
var signupFields = [
{fieldName: 'username', fieldLabel: 'Username',
visible: function () {
return _.contains(
["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"],
Accounts.ui._passwordSignupFields());
}},
{fieldName: 'email', fieldLabel: 'Email',
visible: function () {
return _.contains(
["USERNAME_AND_EMAIL", "EMAIL_ONLY"],
Accounts.ui._passwordSignupFields());
}},
{fieldName: 'email', fieldLabel: 'Email (optional)',
visible: function () {
return Accounts.ui._passwordSignupFields() === "USERNAME_AND_OPTIONAL_EMAIL";
}},
{fieldName: 'password', fieldLabel: 'Password', inputType: 'password',
visible: function () {
return true;
}},
{fieldName: 'password-again', fieldLabel: 'Password (again)',
inputType: 'password',
visible: function () {
// No need to make users double-enter their password if
// they'll necessarily have an email set, since they can use
// the "forgot password" flow.
return _.contains(
["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"],
Accounts.ui._passwordSignupFields());
}}
];
return loginButtonsSession.get('inSignupFlow') ? signupFields : loginFields;
};
Template._loginButtonsLoggedOutPasswordService.inForgotPasswordFlow = function () {
return loginButtonsSession.get('inForgotPasswordFlow');
};
Template._loginButtonsLoggedOutPasswordService.inLoginFlow = function () {
return !loginButtonsSession.get('inSignupFlow') && !loginButtonsSession.get('inForgotPasswordFlow');
};
Template._loginButtonsLoggedOutPasswordService.inSignupFlow = function () {
return loginButtonsSession.get('inSignupFlow');
};
Template._loginButtonsLoggedOutPasswordService.showForgotPasswordLink = function () {
return _.contains(
["USERNAME_AND_EMAIL", "USERNAME_AND_OPTIONAL_EMAIL", "EMAIL_ONLY"],
Accounts.ui._passwordSignupFields());
};
//
// loginButtonsChangePassword template
//
Template._loginButtonsChangePassword.events({
'keypress #login-old-password, keypress #login-password, keypress #login-password-again': function (event) {
if (event.keyCode === 13)
changePassword();
},
'click #login-buttons-do-change-password': function () {
changePassword();
}
});
Template._loginButtonsChangePassword.fields = function () {
return [
{fieldName: 'old-password', fieldLabel: 'Current Password', inputType: 'password',
visible: function () {
return true;
}},
{fieldName: 'password', fieldLabel: 'New Password', inputType: 'password',
visible: function () {
return true;
}},
{fieldName: 'password-again', fieldLabel: 'New Password (again)',
inputType: 'password',
visible: function () {
// No need to make users double-enter their password if
// they'll necessarily have an email set, since they can use
// the "forgot password" flow.
return _.contains(
["USERNAME_AND_OPTIONAL_EMAIL", "USERNAME_ONLY"],
Accounts.ui._passwordSignupFields());
}}
];
};
//
// helpers
//
var elementValueById = function(id) {
var element = document.getElementById(id);
if (!element)
return null;
else
return element.value;
};
var trimmedElementValueById = function(id) {
var element = document.getElementById(id);
if (!element)
return null;
else
return element.value.replace(/^\s*|\s*$/g, ""); // trim;
};
var loginOrSignup = function () {
if (loginButtonsSession.get('inSignupFlow'))
signup();
else
login();
};
var login = function () {
loginButtonsSession.resetMessages();
var username = trimmedElementValueById('login-username');
var email = trimmedElementValueById('login-email');
var usernameOrEmail = trimmedElementValueById('login-username-or-email');
// notably not trimmed. a password could (?) start or end with a space
var password = elementValueById('login-password');
var loginSelector;
if (username !== null) {
if (!Accounts._loginButtons.validateUsername(username))
return;
else
loginSelector = {username: username};
} else if (email !== null) {
if (!Accounts._loginButtons.validateEmail(email))
return;
else
loginSelector = {email: email};
} else if (usernameOrEmail !== null) {
// XXX not sure how we should validate this. but this seems good enough (for now),
// since an email must have at least 3 characters anyways
if (!Accounts._loginButtons.validateUsername(usernameOrEmail))
return;
else
loginSelector = usernameOrEmail;
} else {
throw new Error("Unexpected -- no element to use as a login user selector");
}
Meteor.loginWithPassword(loginSelector, password, function (error, result) {
if (error) {
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
} else {
loginButtonsSession.closeDropdown();
}
});
};
var signup = function () {
loginButtonsSession.resetMessages();
var options = {}; // to be passed to Meteor.createUser
var username = trimmedElementValueById('login-username');
if (username !== null) {
if (!Accounts._loginButtons.validateUsername(username))
return;
else
options.username = username;
}
var email = trimmedElementValueById('login-email');
if (email !== null) {
if (!Accounts._loginButtons.validateEmail(email))
return;
else
options.email = email;
}
// notably not trimmed. a password could (?) start or end with a space
var password = elementValueById('login-password');
if (!Accounts._loginButtons.validatePassword(password))
return;
else
options.password = password;
if (!matchPasswordAgainIfPresent())
return;
Accounts.createUser(options, function (error) {
if (error) {
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
} else {
loginButtonsSession.closeDropdown();
}
});
};
var forgotPassword = function () {
loginButtonsSession.resetMessages();
var email = trimmedElementValueById("forgot-password-email");
if (email.indexOf('@') !== -1) {
Accounts.forgotPassword({email: email}, function (error) {
if (error)
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
else
loginButtonsSession.set('infoMessage', "Email sent");
});
} else {
loginButtonsSession.set('errorMessage', "Invalid email");
}
};
var changePassword = function () {
loginButtonsSession.resetMessages();
// notably not trimmed. a password could (?) start or end with a space
var oldPassword = elementValueById('login-old-password');
// notably not trimmed. a password could (?) start or end with a space
var password = elementValueById('login-password');
if (!Accounts._loginButtons.validatePassword(password))
return;
if (!matchPasswordAgainIfPresent())
return;
Accounts.changePassword(oldPassword, password, function (error) {
if (error) {
loginButtonsSession.set('errorMessage', error.reason || "Unknown error");
} else {
loginButtonsSession.set('inChangePasswordFlow', false);
loginButtonsSession.set('inMessageOnlyFlow', true);
loginButtonsSession.set('infoMessage', "Password changed");
}
});
};
var matchPasswordAgainIfPresent = function () {
// notably not trimmed. a password could (?) start or end with a space
var passwordAgain = elementValueById('login-password-again');
if (passwordAgain !== null) {
// notably not trimmed. a password could (?) start or end with a space
var password = elementValueById('login-password');
if (password !== passwordAgain) {
loginButtonsSession.set('errorMessage', "Passwords don't match");
return false;
}
}
return true;
};
var correctDropdownZIndexes = function () {
// IE <= 7 has a z-index bug that means we can't just give the
// dropdown a z-index and expect it to stack above the rest of
// the page even if nothing else has a z-index. The nature of
// the bug is that all positioned elements are considered to
// have z-index:0 (not auto) and therefore start new stacking
// contexts, with ties broken by page order.
//
// The fix, then is to give z-index:1 to all ancestors
// of the dropdown having z-index:0.
for(var n = document.getElementById('login-dropdown-list').parentNode;
n.nodeName !== 'BODY';
n = n.parentNode)
if (n.style.zIndex === 0)
n.style.zIndex = 1;
};
}) ();

View File

@@ -0,0 +1,21 @@
/* These should be in their respective packages. https://app.asana.com/0/988582960612/1477837179813 */
#login-buttons-image-google {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAADCklEQVQ4jSXSy2ucVRjA4d97zvdNJpPJbTJJE9rYaCINShZtRCFIA1bbLryBUlyoLQjqVl12W7UbN4qb1gtuYhFRRBCDBITaesFbbI3RFBLSptEY05l0ZjLfnMvrov/Bs3gAcF71x6VVHTk+o8nDH+hrH89rUK9Z9Yaen57S3wVtGaMBNGC0IegWKIDxTtVaOHVugZVmH3HX3Zz+4l+W1xvkOjuZfPsspY4CNkZELEgEIJKwYlBjEwjec/mfCMVuorVs76R8+P0KYMmP30U2dT8eIZqAR2ipRcWjEYxGSCRhV08e04oYMoxYLi97EI9YCJ0FHBYbIVGDlUBLwRlLIuYW6chEmQt/rJO09RJjhjEJEYvJYGNhkbUhw43OXtIWDFRq9G87nAaSK6sVRm8r8fzRMWbOX2Xx7ypd7ZET03sQhDOz73DqSJOrd+7HSo4QIu0Nx/4rOzx+cRXZ9+z7+uqJ+3hiepxK3fHZT2tMjXYzOtzL6dmznPzhLexgN0QlxAAYxAlqUqRmkf5j59RlNQ6MFHhgcpCTTx8EUb5e+plD7x4jjg1ANCAgrRQAdR7xKXjBlGyLYi7PxaUmb8z8xcpGHVXLHaXdjI0egKyJiQYTEhSPREVIEUBNC+Mqm+xpz3j0njLPHB2nsh1QgeG+IS48dYbD5YNoo0ZUAbVEuTUoKuBSZOarX/WhyQn6eg2+usDWf0s0tq8zNPYk+WI/Lnge++hlvlyfQ3NdECzGRWKwEEA0qNY251n69kV6+Y0kbaCZoebG2X3oU7pKoyxuXOPe945zs9DCeosGIXoBDyaLdf6ce4Hbk+/Y299ksKtAuaeNsiyw8c1LKIZ95b0MdgxA5giixACpTxEPSau6QdFfI5/2cLPmEW+JAQrtJUJzDXF1dkwHzVodJMX4HFEcQQMaFdPeM0Jb/4PUtzzaLKAhRyJFwo6lbegRNFfk819muV5dR4JBQoQdQ2xFiDmSNDHiaptamR9Gq5cQ18AledrGDpOfeI5Lq8u88smbhMRisoSAgAYghdfn5H/JkHuRZ1owLAAAAABJRU5ErkJggg==);
}
#login-buttons-image-facebook {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAq0lEQVQ4jWP8//8/AyWAhYGBgcEmauYZBgYGYxL1nj2yLN2ECcohVTNcDwsxKlXlhRm6yzwZRAS5GRgYGBhsombC5ZhwaUIGyJrRAVEuwGYzSS7AB/C64MiydKx8ZJfgNeDN+68MDAwIL8D4RLsgIHsJis0wPjKgOAyoE4hcnGwMGkpiBBUbacvA2TfuvaKiC759/3X23NUnOPMDtgTEwMBwloGBgYGR0uwMAGOPLJS9mkQHAAAAAElFTkSuQmCC);
}
#login-buttons-image-weibo {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKySURBVDhPY2AgEpR5sjf/nS/6//UkoX+XJltuCvVxkcOp9cyZM1w/r13TuXvmDD9MkYIwg7qrNrubnzFb6J5intPHqrnvCnIwyKIYsmrVKuaFYWEFW2Sk79zX0f6/REHhKFABC0zRsky+rXMSeZdKCTLIHqgUvLAknW8L3IAQDw/RFlbWnQ801P+DNN8D4n0qyk94GRiEjTg5Lbz4+YOCdbhjVmTxbZwex7PUW58t8O1Ukf9gA2IDAoRPWFudfayt9f+mpsb/6yrK/28qKf4/ISf7YZu83K07QMNe6On9nyWusMtVm813azH/UWctZo/vc8TABjB3CApufAzSqKjw/7apyf+nMdH/XxUX/X+RnfX/qY/3/5tqqv/vq6v936KsfB2onltaiEHGx5AteFep4EmGUEHB1Adamv9v6er8fztp0v//79////nr1/+3X778B4N///5/O3jw/0N39//nlBQ/louLd4MMAWImcPhsU1G6DfLvt717wepnz537X0FB4T8fL+//AH///2/evgWL/7l///9dE+P/b4AWTZSWXg/UzAj2/w2gs59mZYEV7d+//z8rE9N/JUXF/w62tiD//a+urIS4BAgeA712Cxg2F40M36alpXGBDTgmI/3hdUU5WEFjff3/wvx8MNvcxARsQE1VFUQ30Et37Oz+P1RV+b/J0nIjUATigmgBvtzH5mb//9++/f/mkyf/A4KC/nv7+oI1W1hb/3/1+fP//9+//39ekP//CVDzTlnZxxtnz1ZBSUDeDAyZh7W13nybOeP/7W1b/09rbf2/FhgWHy9c+P912bL/D11d/l+WEP8/SUR4Ox8DA6pmmEkpHh4ya0JCim4lJGx7kZp8821CwrN7Hh4Pr7m6nDoSET61PjDQichsA3T7//+s/16/5gXSkIAa1AAAh8dhOVd5xHAAAAAASUVORK5CYII=);
}
#login-buttons-image-twitter {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAByklEQVQ4jaVTz0sbQRh92V10l006GaKJCtEtmqMYU0Qpwqb4B6zgXdT0WEr7B0ih4MGLP05CUWMvHkQwglhvGhsvKmJOBhTUQjWU2slilKarrAfdZROTQ8m7fPMx33szb75vXKZpohpwVbEBCNaCMUYopXppAWOMxDNsOPf3H1WIeDoSURYYYwQAKKW6y7KgLe2vam11KyMRZcEpEP6SOkwbUgc4ATAKUF8YW2fXhZejvaHPsc7gvH2DnCfQGEtdxrd/5NRJteUDpVTf+5kLp2WlA6JsCyZv9ChplPKdTfJZkYWhEF3bvnV3fb36NZSY3dP6Q/5V4hFvIAaKPckE8W5pLBIQdwHAthBdPtpJuhpeAwDu74DrP4/R1/Ts4cwBWg/gN+DowoSqTBPezAMAeAHw+suSw4Q7schFApF6af19a+2yLVIB7xR+0Zk75yCveu82FMnMViKHCXcSa3PPVBJAX5BszL2SP2kNwvdy5M1e+S2AogME4HFYPibPpxKZC03nRAp/M+Dx2UWDzTXfpttrx72ikCoVtrrAAwgdXBk9iazxxtpskfhs1O86aHXXpAEcA7ivJGDBDcDnyAsA2FMsi1KB/0bVv/EBBBSY9mZ7PAsAAAAASUVORK5CYII=);
}
#login-buttons-image-github {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wJGBYxHYxl31wAAAHpSURBVDjLpZI/aFNRFMZ/973bJqGRPopV4qNq/+SpTYnWRhCKilShg9BGcHOM+GfQoZuLk4iLgw4qZNBaHLuIdBNHl7Ta1qdNFI3SihnaNG1MpH3vuiQYQnwZvHCG893zffc751z4z6PX5T5gA1DAKnAaOAQEgAfAVeCpl+CeCrlRuEC6maO4h0A1wl4tPAHMqNUthvrDdHYY7A3t4rDVjeO6rBU2FaABM1WCrBNoi48Mi+nH9yj+KtPibAKwJXfQ5vcRG7soUnYmWEuQgAEIYBv4cGpoILI0Z4tyYYPegS6UguyijZQ6J45GSNmZHzUcJYD2ii2Ajv7efZ8WZ6ZwXFj79hXpayW4O0SL1Nl/8jzZlZ9dQLFS70pgvZKIyGD0yvu5eRmMnrk1PjI81ir1qBACTdPevXj95mVuNX8XKDQc/+T334bZZ104cvzYw2s3J3qAL5WXSsDbf61NNMBu+wOBs+VSyQ84Nfhg028ZGx3/qyy0lC7lgi7lghBitoon03lvB8l0/k7Wnk+8mny0cyXzEcfZxgwfZPTyRMHsOzAFXE9YhtNQIJnOx4FpJXT1eSkn2g0frqMoFrfoCXcqlCOAGwnLuO/l4JymcWl5uRxzXUKghBAiZ5r+WaV4lrCM555zqO+x2d0ftGmpiA/0k70AAAAASUVORK5CYII=);
}

View File

@@ -0,0 +1,62 @@
(function () {
var VALID_KEYS = [
'dropdownVisible',
// XXX consider replacing these with one key that has an enum for values.
'inSignupFlow',
'inForgotPasswordFlow',
'inChangePasswordFlow',
'inMessageOnlyFlow',
'errorMessage',
'infoMessage',
'resetPasswordToken',
'enrollAccountToken',
'justVerifiedEmail',
'configureLoginServiceDialogVisible',
'configureLoginServiceDialogServiceName',
'configureLoginServiceDialogSaveDisabled'
];
var validateKey = function (key) {
if (!_.contains(VALID_KEYS, key))
throw new Error("Invalid key in loginButtonsSession: " + key);
};
var KEY_PREFIX = "Meteor.loginButtons.";
// XXX we should have a better pattern for code private to a package like this one
Accounts._loginButtonsSession = {
set: function(key, value) {
validateKey(key);
Session.set(KEY_PREFIX + key, value);
},
get: function(key) {
validateKey(key);
return Session.get(KEY_PREFIX + key);
},
closeDropdown: function () {
this.set('inSignupFlow', false);
this.set('inForgotPasswordFlow', false);
this.set('inChangePasswordFlow', false);
this.set('inMessageOnlyFlow', false);
this.set('dropdownVisible', false);
this.resetMessages();
},
resetMessages: function () {
this.set("errorMessage", null);
this.set("infoMessage", null);
},
configureService: function (name) {
this.set('configureLoginServiceDialogVisible', true);
this.set('configureLoginServiceDialogServiceName', name);
this.set('configureLoginServiceDialogSaveDisabled', true);
}
};
}) ();

View File

@@ -0,0 +1,11 @@
<template name="_loginButtonsLoggedOutSingleLoginButton">
<div class="login-button {{#unless configured}}configure-button{{/unless}}"
id="login-buttons-{{name}}">
<div class="login-image" id="login-buttons-image-{{name}}"></div>
{{#if configured}}
<span class="sign-in-text-{{name}}">Sign in with {{capitalizedName}}</span>
{{else}}
<span class="configure-text-{{name}}">Configure {{capitalizedName}} Login</span>
{{/if}}
</div>
</template>

View File

@@ -0,0 +1,48 @@
(function () {
// for convenience
var loginButtonsSession = Accounts._loginButtonsSession;
Template._loginButtonsLoggedOutSingleLoginButton.events({
'click .login-button': function () {
var serviceName = this.name;
loginButtonsSession.resetMessages();
var callback = function (err) {
if (!err) {
loginButtonsSession.closeDropdown();
} else if (err instanceof Accounts.LoginCancelledError) {
// do nothing
} else if (err instanceof Accounts.ConfigError) {
loginButtonsSession.configureService(serviceName);
} else {
loginButtonsSession.set('errorMessage', err.reason || "Unknown error");
}
};
var loginWithService = Meteor["loginWith" + capitalize(serviceName)];
var options = {}; // use default scope unless specified
if (Accounts.ui._options.requestPermissions[serviceName])
options.requestPermissions = Accounts.ui._options.requestPermissions[serviceName];
loginWithService(options, callback);
}
});
Template._loginButtonsLoggedOutSingleLoginButton.configured = function () {
return !!Accounts.loginServiceConfiguration.findOne({service: this.name});
};
Template._loginButtonsLoggedOutSingleLoginButton.capitalizedName = function () {
if (this.name === 'github')
// XXX we should allow service packages to set their capitalized name
return 'GitHub';
else
return capitalize(this.name);
};
// XXX from http://epeli.github.com/underscore.string/lib/underscore.string.js
var capitalize = function(str){
str = str == null ? '' : String(str);
return str.charAt(0).toUpperCase() + str.slice(1);
};
}) ();

View File

@@ -0,0 +1,23 @@
Package.describe({
summary: "Unstyled version of login widgets"
});
Package.on_use(function (api) {
api.use(['accounts-urls', 'accounts-base', 'underscore', 'templating'], 'client');
api.add_files([
'accounts_ui.js',
'login_buttons_images.css',
'login_buttons.html',
'login_buttons_single.html',
'login_buttons_dropdown.html',
'login_buttons_dialogs.html',
'login_buttons_session.js',
'login_buttons.js',
'login_buttons_single.js',
'login_buttons_dropdown.js',
'login_buttons_dialogs.js'], 'client');
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
Package.describe({
summary: "Simple templates to add login widgets to an app."
});
Package.on_use(function (api) {
api.use('accounts-ui-unstyled', 'client');
api.use('less', 'server');
api.add_files(['login_buttons.less'], 'client');
});

View File

@@ -0,0 +1,9 @@
Package.describe({
summary: "Generate and consume reset password and verify account URLs",
internal: true
});
Package.on_use(function (api) {
api.add_files('url_client.js', 'client');
api.add_files('url_server.js', 'server');
});

View File

@@ -0,0 +1,47 @@
(function () {
if (typeof Accounts === 'undefined')
Accounts = {};
// reads a reset password token from the url's hash fragment, if it's
// there. if so prevent automatically logging in since it could be
// confusing to be logged in as user A while resetting password for
// user B
//
// reset password urls use hash fragments instead of url paths/query
// strings so that the reset password token is not sent over the wire
// on the http request
var match;
match = window.location.hash.match(/^\#\/reset-password\/(.*)$/);
if (match) {
Accounts._preventAutoLogin = true;
Accounts._resetPasswordToken = match[1];
window.location.hash = '';
}
// reads a verify email token from the url's hash fragment, if
// it's there. also don't automatically log the user is, as for
// reset password links.
//
// XXX we don't need to use hash fragments in this case, and having
// the token appear in the url's path would allow us to use a custom
// middleware instead of verifying the email on pageload, which
// would be faster but less DDP-ish (and more specifically, any
// non-web DDP app, such as an iOS client, would do something more
// in line with the hash fragment approach)
match = window.location.hash.match(/^\#\/verify-email\/(.*)$/);
if (match) {
Accounts._preventAutoLogin = true;
Accounts._verifyEmailToken = match[1];
window.location.hash = '';
}
// reads an account enrollment token from the url's hash fragment, if
// it's there. also don't automatically log the user is, as for
// reset password links.
match = window.location.hash.match(/^\#\/enroll-account\/(.*)$/);
if (match) {
Accounts._preventAutoLogin = true;
Accounts._enrollAccountToken = match[1];
window.location.hash = '';
}
})();

View File

@@ -0,0 +1,17 @@
if (typeof Accounts === 'undefined')
Accounts = {};
if (!Accounts.urls)
Accounts.urls = {};
Accounts.urls.resetPassword = function (token) {
return Meteor.absoluteUrl('#/reset-password/' + token);
};
Accounts.urls.verifyEmail = function (token) {
return Meteor.absoluteUrl('#/verify-email/' + token);
};
Accounts.urls.enrollAccount = function (token) {
return Meteor.absoluteUrl('#/enroll-account/' + token);
};

View File

@@ -0,0 +1,18 @@
Package.describe({
summary: "Login service for Sina Weibo accounts"
});
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');
api.add_files('weibo_client.js', 'client');
});

View File

@@ -0,0 +1,28 @@
(function () {
// XXX support options.requestPermissions as we do for Facebook, Google, Github
Meteor.loginWithWeibo = function (options, callback) {
// support both (options, callback) and (callback).
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'});
if (!config) {
callback && callback(new Accounts.ConfigError("Service not configured"));
return;
}
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=' + config.clientId +
'&redirect_uri=' + Meteor.absoluteUrl('_oauth/weibo?close', {replaceLocalhost: true}) +
'&state=' + state;
Accounts.oauth.initiateLogin(state, loginUrl, callback);
};
}) ();

View File

@@ -0,0 +1,3 @@
if (!Accounts.weibo) {
Accounts.weibo = {};
}

View File

@@ -0,0 +1,25 @@
<template name="configureLoginServiceDialogForWeibo">
<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 green "创建应用" button
</li>
<li>
Select 网页应用在第三方网页内访问使用 (Web Applications)
</li>
<li>
Complete the registration process (Google Translate works well here)
</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.configureLoginServiceDialogForWeibo.siteUrl = function () {
// Weibo doesn't recognize localhost as a domain
return Meteor.absoluteUrl({replaceLocalhost: true});
};
Template.configureLoginServiceDialogForWeibo.fields = function () {
return [
{property: 'clientId', label: 'App Key'},
{property: 'secret', label: 'App Secret'}
];
};

View File

@@ -0,0 +1,50 @@
(function () {
Accounts.oauth.registerService('weibo', 2, function(query) {
var accessToken = getAccessToken(query);
var identity = getIdentity(accessToken.access_token, parseInt(accessToken.uid, 10));
return {
serviceData: {
id: accessToken.uid,
accessToken: accessToken.access_token,
screenName: identity.screen_name
},
extra: {profile: {name: identity.screen_name}}
};
});
var getAccessToken = function (query) {
var config = Accounts.loginServiceConfiguration.findOne({service: 'weibo'});
if (!config)
throw new Accounts.ConfigError("Service not configured");
var result = Meteor.http.post(
"https://api.weibo.com/oauth2/access_token", {params: {
code: query.code,
client_id: config.clientId,
client_secret: config.secret,
redirect_uri: Meteor.absoluteUrl("_oauth/weibo?close", {replaceLocalhost: true}),
grant_type: 'authorization_code'
}});
if (result.error) // if the http response was an error
throw result.error;
if (typeof result.content === "string")
result.content = JSON.parse(result.content);
if (result.content.error) // if the http response was a json object with an error attribute
throw result.content;
return result.content;
};
var getIdentity = function (accessToken, userId) {
var result = Meteor.http.get(
"https://api.weibo.com/2/users/show.json",
{params: {access_token: accessToken, uid: userId}});
if (result.error)
throw result.error;
return result.data;
};
})();

View File

@@ -62,7 +62,7 @@
try {
f(ctx);
} catch (e) {
Meteor._debug("Exception from Meteor.flush:", e);
Meteor._debug("Exception from Meteor.flush:", e.stack);
}
});
delete ctx._callbacks; // maybe help the GC

View File

@@ -0,0 +1 @@
Meteor.Collection.insecure = true;

View File

@@ -0,0 +1,8 @@
Package.describe({
summary: "Allow all database writes by default"
});
Package.on_use(function (api) {
api.use(['mongo-livedata']);
api.add_files(['insecure.js'], 'server');
});

View File

@@ -1,25 +1,46 @@
// XXX namespacing
Meteor._MethodInvocation = function (isSimulation, unblock) {
Meteor._MethodInvocation = function (options) {
var self = this;
// true if we're running not the actual method, but a stub (that is,
// if we're on the client and presently running a simulation of a
// server-side method for latency compensation purposes). never true
// except in a client such as a browser, since there's no point in
// running stubs unless you have a zero-latency connection to the
// user.
this.isSimulation = isSimulation;
// if we're on a client (which may be a browser, or in the future a
// server connecting to another server) and presently running a
// simulation of a server-side method for latency compensation
// purposes). not currently true except in a client such as a browser,
// since there's usually no point in running stubs unless you have a
// zero-latency connection to the user.
this.isSimulation = options.isSimulation;
// XXX Backwards compatibility only. Remove this before 1.0.
this.is_simulation = isSimulation;
this.is_simulation = this.isSimulation;
// call this function to allow other method invocations (from the
// same client) to continue running without waiting for this one to
// complete.
this.unblock = unblock || function () {};
this.unblock = options.unblock || function () {};
// current user id
this.userId = options.userId;
// sets current user id in all appropriate server contexts and
// reruns subscriptions
this._setUserId = options.setUserId || function () {};
// Scratch data scoped to this connection (livedata_connection on the
// client, livedata_session on the server). This is only used
// internally, but we should have real and documented API for this
// sort of thing someday.
this._sessionData = options.sessionData;
};
_.extend(Meteor._MethodInvocation.prototype, {
setUserId: function(userId) {
this.userId = userId;
this._setUserId(userId);
}
});
Meteor._CurrentInvocation = new Meteor.EnvironmentVariable;
Meteor.Error = function (error, reason, details) {
@@ -43,4 +64,4 @@ Meteor.Error = function (error, reason, details) {
self.details = details;
};
Meteor.Error.prototype = new Error;
Meteor.Error.prototype = new Error;

View File

@@ -12,8 +12,16 @@ Meteor._capture_subs = null;
// @param url {String|Object} URL to Meteor app or sockjs endpoint (deprecated),
// or an object as a test hook (see code)
Meteor._LivedataConnection = function (url, restart_on_update) {
// Options:
// reloadOnUpdate: should we try to reload when the server says
// there's new code available?
// reloadWithOutstanding: is it OK to reload if there are outstanding methods?
Meteor._LivedataConnection = function (url, options) {
var self = this;
options = _.extend({
reloadOnUpdate: false,
reloadWithOutstanding: false
}, options);
// as a test hook, allow passing a stream instead of a url.
if (typeof url === "object") {
@@ -29,8 +37,27 @@ Meteor._LivedataConnection = function (url, restart_on_update) {
self.stores = {}; // name -> object with methods
self.method_handlers = {}; // name -> func
self.next_method_id = 1;
// waiting for results of method
// --- Three classes of outstanding methods ---
// 1. either already sent, or waiting to be sent with no special
// consideration once we reconnect
self.outstanding_methods = []; // each item has keys: msg, callback
// 2. the sole outstanding method that needs to be waited on, or null
// same keys as outstanding_methods (notably wait is implicitly true
// but not set)
self.outstanding_wait_method = null; // same keys as outstanding_methods
// stores response from `outstanding_wait_method` while we wait for
// previous method calls to complete, as received in _livedata_result
self.outstanding_wait_method_response = null;
// 3. methods blocked on outstanding_wait_method being completed.
self.blocked_methods = []; // each item has keys: msg, callback, wait
// if set, called when we reconnect, queuing method calls _before_
// the existing outstanding ones
self.onReconnect = null;
// waiting for data from method
self.unsatisfied_methods = {}; // map from method_id -> true
// sub was ready, is no longer (due to reconnect)
@@ -48,21 +75,26 @@ Meteor._LivedataConnection = function (url, restart_on_update) {
// yet ready.
self.sub_ready_callbacks = {};
// Per-connection scratch area. This is only used internally, but we
// should have real and documented API for this sort of thing someday.
self.sessionData = {};
// just for testing
self.quiesce_callbacks = [];
// Setup auto-reload persistence.
Meteor._reload.onMigrate(function (retry) {
if (!self._readyToMigrate()) {
if (self._retryMigrate)
throw new Error("Two migrations in progress?");
self._retryMigrate = retry;
return false;
}
return [true];
});
// Block auto-reload while we're waiting for method responses.
if (!options.reloadWithOutstanding) {
Meteor._reload.onMigrate(function (retry) {
if (!self._readyToMigrate()) {
if (self._retryMigrate)
throw new Error("Two migrations in progress?");
self._retryMigrate = retry;
return false;
} else {
return [true];
}
});
}
// Setup stream (if not overriden above)
self.stream = self.stream || new Meteor._Stream(self.url);
@@ -94,7 +126,6 @@ Meteor._LivedataConnection = function (url, restart_on_update) {
});
self.stream.on('reset', function () {
// Send a connect message at the beginning of the stream.
// NOTE: reset is called even on the first connection, so this is
// the only place we send this message.
@@ -115,10 +146,15 @@ Meteor._LivedataConnection = function (url, restart_on_update) {
// immediately before disconnection.. do we need to add app-level
// acking of data messages?
// Send pending methods.
_.each(self.outstanding_methods, function (m) {
self.stream.send(JSON.stringify(m.msg));
});
// If an `onReconnect` handler is set, call it first. Go through
// some hoops to ensure that methods that are called from within
// `onReconnect` get executed _before_ ones that were originally
// outstanding (since `onReconnect` is used to re-establish auth
// certificates)
if (self.onReconnect)
self._callOnReconnectAndSendAppropriateOutstandingMethods();
else
self._sendOutstandingMethods();
// add new subscriptions at the end. this way they take effect after
// the handlers and we don't see flicker.
@@ -128,13 +164,14 @@ Meteor._LivedataConnection = function (url, restart_on_update) {
});
});
if (restart_on_update)
if (options.reloadOnUpdate) {
self.stream.on('update_available', function () {
// Start trying to migrate to a new version. Until all packages
// signal that they're ready for a migration, the app will
// continue running normally.
Meteor._reload.reload();
});
}
// we never terminate the observe(), since there is no way to
// destroy a LivedataConnection.. but this shouldn't matter, since we're
@@ -186,7 +223,11 @@ _.extend(Meteor._LivedataConnection.prototype, {
if (args.length && typeof args[args.length - 1] === "function")
var callback = args.pop();
var existing = self.subs.find({name: name, args: args}, {reactive: false}).fetch();
// Look for existing subs (ignore those with count=0, since they're going to
// get removed on the next time through the event loop).
var existing = self.subs.find(
{name: name, args: args, count: {$gt: 0}},
{reactive: false}).fetch();
if (existing && existing[0]) {
// already subbed, inc count.
@@ -242,11 +283,23 @@ _.extend(Meteor._LivedataConnection.prototype, {
return this.apply(name, args, callback);
},
apply: function (name, args, callback) {
// @param options {Optional Object}
// wait: Boolean - Should we block subsequent method calls on this
// method's result having been received?
// (does not affect methods called from within this method)
// @param callback {Optional Function}
apply: function (name, args, options, callback) {
var self = this;
var enclosing = Meteor._CurrentInvocation.get();
if (callback)
// We were passed 3 arguments. They may be either (name, args, options)
// or (name, args, callback)
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (callback) {
// XXX would it be better form to do the binding in stream.on,
// or caller, instead of here?
callback = Meteor.bindEnvironment(callback, function (e) {
@@ -254,8 +307,8 @@ _.extend(Meteor._LivedataConnection.prototype, {
Meteor._debug("Exception while delivering result of invoking '" +
name + "'", e.stack);
});
}
var isSimulation = enclosing && enclosing.isSimulation;
if (Meteor.isClient) {
// If on a client, run the stub, if we have one. The stub is
// supposed to make some temporary writes to the database to
@@ -271,7 +324,14 @@ _.extend(Meteor._LivedataConnection.prototype, {
// of the stub as our return value.
var stub = self.method_handlers[name];
if (stub) {
var invocation = new Meteor._MethodInvocation(true /* isSimulation */);
var setUserId = function(userId) {
self.setUserId(userId);
};
var invocation = new Meteor._MethodInvocation({
isSimulation: true,
userId: self.userId(), setUserId: setUserId,
sessionData: self.sessionData
});
try {
var ret = Meteor._CurrentInvocation.withValue(invocation,function () {
return stub.apply(invocation, args);
@@ -283,10 +343,10 @@ _.extend(Meteor._LivedataConnection.prototype, {
}
// If we're in a simulation, stop and return the result we have,
// rather than going on to do an RPC. This can only happen on
// the client (since we only bother with stubs and simulations
// on the client.) If there was not stub, we'll end up returning
// undefined.
// rather than going on to do an RPC. If there was no stub,
// we'll end up returning undefined.
var enclosing = Meteor._CurrentInvocation.get();
var isSimulation = enclosing && enclosing.isSimulation;
if (isSimulation) {
if (callback) {
callback(exception, ret);
@@ -337,9 +397,31 @@ _.extend(Meteor._LivedataConnection.prototype, {
params: args,
id: '' + (self.next_method_id++)
};
self.outstanding_methods.push({msg: msg, callback: callback});
if (self.outstanding_wait_method) {
self.blocked_methods.push({
msg: msg,
callback: callback,
wait: options.wait
});
} else {
var method_object = {
msg: msg,
callback: callback
};
if (options.wait)
self.outstanding_wait_method = method_object;
else
self.outstanding_methods.push(method_object);
self.stream.send(JSON.stringify(msg));
}
// Even if we are waiting on other method calls mark this method
// as unsatisfied so that the user never ends up seeing
// intermediate versions of the server's datastream
self.unsatisfied_methods[msg.id] = true;
self.stream.send(JSON.stringify(msg));
// If we're using the default callback on the server,
// synchronously return the result from the remote host.
@@ -351,16 +433,37 @@ _.extend(Meteor._LivedataConnection.prototype, {
}
},
status: function () {
status: function (/*passthrough args*/) {
var self = this;
return self.stream.status();
return self.stream.status.apply(self.stream, arguments);
},
reconnect: function () {
reconnect: function (/*passthrough args*/) {
var self = this;
return self.stream.reconnect();
return self.stream.reconnect.apply(self.stream, arguments);
},
///
/// Reactive user system
/// XXX Can/should this be generalized pattern?
///
userId: function () {
var self = this;
if (self._userIdListeners)
self._userIdListeners.addCurrentContext();
return self._userId;
},
setUserId: function (userId) {
var self = this;
self._userId = userId;
if (self._userIdListeners)
self._userIdListeners.invalidateAll();
},
_userId: null,
_userIdListeners: Meteor.deps && new Meteor.deps._ContextSet,
// PRIVATE: called when we are up-to-date with the server. intended
// for use only in tests. currently, you are very limited in what
// you may do inside your callback -- in particular, don't do
@@ -507,36 +610,86 @@ _.extend(Meteor._LivedataConnection.prototype, {
},
_livedata_result: function (msg) {
var self = this;
// id, result or error. error has error (code), reason, details
var self = this;
// find the outstanding request
// should be O(1) in nearly all realistic use cases
for (var i = 0; i < self.outstanding_methods.length; i++) {
var m = self.outstanding_methods[i];
if (m.msg.id === msg.id)
break;
var m;
if (self.outstanding_wait_method &&
self.outstanding_wait_method.msg.id === msg.id) {
m = self.outstanding_wait_method;
self.outstanding_wait_method_response = msg;
} else {
for (var i = 0; i < self.outstanding_methods.length; i++) {
m = self.outstanding_methods[i];
if (m.msg.id === msg.id)
break;
}
// remove
self.outstanding_methods.splice(i, 1);
}
if (!m) {
// XXX write a better error
Meteor._debug("Can't interpret method response message");
Meteor._debug("Can't match method response to original method call", msg);
return;
}
// remove
self.outstanding_methods.splice(i, 1);
if (self.outstanding_wait_method) {
// Wait until we have completed all outstanding methods.
if (self.outstanding_methods.length === 0 &&
self.outstanding_wait_method_response) {
// deliver result
if (m.callback) {
// callback will have already been bindEnvironment'd by apply(),
// so no need to catch exceptions
if ('error' in msg)
m.callback(new Meteor.Error(msg.error.error, msg.error.reason,
msg.error.details));
else
// msg.result may be undefined if the method didn't return a
// value
m.callback(undefined, msg.result);
// Start by saving the outstanding wait method details, since
// we're going to reshift the blocked ones and try to send
// them *before* calling the method callback. It is necessary
// to call method callbacks last since they might themselves
// call other methods
var savedOutstandingWaitMethod = self.outstanding_wait_method;
var savedOutstandingWaitMethodResponse = self.outstanding_wait_method_response;
self.outstanding_wait_method_response = null;
self.outstanding_wait_method = null;
// Find first blocked method with wait: true
var i;
for (i = 0; i < self.blocked_methods.length; i++)
if (self.blocked_methods[i].wait)
break;
// Move as many blocked methods as we can into
// outstanding_methods and outstanding_wait_method if needed
self.outstanding_methods = _.first(self.blocked_methods, i);
if (i !== self.blocked_methods.length) {
self.outstanding_wait_method = self.blocked_methods[i];
self.blocked_methods = _.rest(self.blocked_methods, i+1);
} else {
self.blocked_methods = [];
}
// Send any new outstanding methods after we reshift the
// blocked methods. Intentionally do this before calling the
// method response because they might call additional methods
// that shouldn't be sent twice.
self._sendOutstandingMethods();
// Fire necessary outstanding method callbacks, making sure we
// only fire the outstanding wait method after all other outstanding
// methods' callbacks were fired
if (m === savedOutstandingWaitMethod) {
self._deliverMethodResponse(savedOutstandingWaitMethod,
savedOutstandingWaitMethodResponse /*(=== msg)*/);
} else {
self._deliverMethodResponse(m, msg);
self._deliverMethodResponse(savedOutstandingWaitMethod,
savedOutstandingWaitMethodResponse /*(!== msg)*/);
}
} else {
if (m !== self.outstanding_wait_method)
self._deliverMethodResponse(m, msg);
}
} else {
self._deliverMethodResponse(m, msg);
}
// if we were blocking a migration, see if it's now possible to
@@ -547,16 +700,84 @@ _.extend(Meteor._LivedataConnection.prototype, {
}
},
// @param method {Object} as in `outstanding_methods`
// @param response {Object{id, result | error}}
_deliverMethodResponse: function(method, response) {
// callback will have already been bindEnvironment'd by apply(),
// so no need to catch exceptions
if ('error' in response) {
method.callback(new Meteor.Error(
response.error.error, response.error.reason,
response.error.details));
} else {
// msg.result may be undefined if the method didn't return a
// value
method.callback(undefined, response.result);
}
},
_sendOutstandingMethods: function() {
var self = this;
_.each(self.outstanding_methods, function (m) {
self.stream.send(JSON.stringify(m.msg));
});
if (self.outstanding_wait_method)
self.stream.send(JSON.stringify(self.outstanding_wait_method.msg));
},
_livedata_error: function (msg) {
Meteor._debug("Received error from server: ", msg.reason);
if (msg.offending_message)
Meteor._debug("For: ", msg.offending_message);
},
// true if we're OK for a migration to happen
_readyToMigrate: function () {
_callOnReconnectAndSendAppropriateOutstandingMethods: function() {
var self = this;
return self.outstanding_methods.length === 0;
var old_outstanding_methods = self.outstanding_methods;
var old_outstanding_wait_method = self.outstanding_wait_method;
var old_blocked_methods = self.blocked_methods;
self.outstanding_methods = [];
self.outstanding_wait_method = null;
self.blocked_methods = [];
self.onReconnect();
if (self.outstanding_wait_method) {
// self.onReconnect() caused us to wait on a method. Add all old
// methods to blocked_methods, and we don't need to send any
// additional methods
self.blocked_methods = self.blocked_methods.concat(
old_outstanding_methods);
if (old_outstanding_wait_method) {
self.blocked_methods.push(_.extend(
old_outstanding_wait_method, {wait: true}));
}
self.blocked_methods = self.blocked_methods.concat(
old_blocked_methods);
} else {
// self.onReconnect() did not cause us to wait on a method. Add
// as many methods as we can to outstanding_methods and send
// them
_.each(old_outstanding_methods, function(method) {
self.outstanding_methods.push(method);
self.stream.send(JSON.stringify(method.msg));
});
self.outstanding_wait_method = old_outstanding_wait_method;
if (self.outstanding_wait_method)
self.stream.send(JSON.stringify(self.outstanding_wait_method.msg));
self.blocked_methods = old_blocked_methods;
}
},
_readyToMigrate: function() {
var self = this;
return self.outstanding_methods.length === 0 &&
!self.outstanding_wait_method &&
self.blocked_methods.length === 0;
}
});
@@ -568,8 +789,9 @@ _.extend(Meteor, {
// "/",
// "http://subdomain.meteor.com/sockjs" (deprecated),
// "/sockjs" (deprecated)
connect: function (url, _restartOnUpdate) {
var ret = new Meteor._LivedataConnection(url, _restartOnUpdate);
connect: function (url, _reloadOnUpdate) {
var ret = new Meteor._LivedataConnection(
url, {reloadOnUpdate: _reloadOnUpdate});
Meteor._LivedataConnection._allConnections.push(ret); // hack. see below.
return ret;
},

View File

@@ -1,3 +1,10 @@
var newConnection = function (stream) {
// Some of these tests leave outstanding methods with no result yet
// returned. This should not block us from re-running tests when sources
// change.
return new Meteor._LivedataConnection(stream, {reloadWithOutstanding: true});
};
var test_got_message = function (test, stream, expected) {
if (stream.sent.length === 0) {
test.fail({error: 'no message received', expected: expected});
@@ -12,12 +19,7 @@ var test_got_message = function (test, stream, expected) {
test.equal(got, expected);
};
var SESSION_ID = '17';
Tinytest.add("livedata stub - receive data", function (test) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var startAndConnect = function(test, stream) {
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
@@ -25,6 +27,15 @@ Tinytest.add("livedata stub - receive data", function (test) {
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
};
var SESSION_ID = '17';
Tinytest.add("livedata stub - receive data", function (test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
startAndConnect(test, stream);
// data comes in for unknown collection.
var coll_name = Meteor.uuid();
@@ -33,6 +44,8 @@ Tinytest.add("livedata stub - receive data", function (test) {
// break throught the black box and test internal state
test.length(conn.queued[coll_name], 1);
// XXX: Test that the old signature of passing manager directly instead of in
// options works.
var coll = new Meteor.Collection(coll_name, conn);
// queue has been emptied and doc is in db.
@@ -46,19 +59,11 @@ Tinytest.add("livedata stub - receive data", function (test) {
test.isUndefined(conn.queued[coll_name]);
});
Tinytest.add("livedata stub - subscribe", function (test) {
Tinytest.addAsync("livedata stub - subscribe", function (test, onComplete) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var conn = newConnection(stream);
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
test.length(stream.sent, 0);
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
startAndConnect(test, stream);
// subscribe
var callback_fired = false;
@@ -67,6 +72,7 @@ Tinytest.add("livedata stub - subscribe", function (test) {
});
test.isFalse(callback_fired);
test.length(stream.sent, 1);
var message = JSON.parse(stream.sent.shift());
var id = message.id;
delete message.id;
@@ -75,18 +81,42 @@ Tinytest.add("livedata stub - subscribe", function (test) {
// get the sub satisfied. callback fires.
stream.receive({msg: 'data', 'subs': [id]});
test.isTrue(callback_fired);
// This defers the actual unsub message, so we need to set a timeout
// to observe the message. We also test that we can resubscribe even
// before the unsub has been sent.
//
// Note: it would be perfectly fine for livedata_connection to send the unsub
// synchronously, so if this test fails just because we've made that change,
// that's OK! This is a regression test for a failure case where it *never*
// sent the unsub if there was a quick resub afterwards.
//
// XXX rewrite Meteor.defer to guarantee ordered execution so we don't have to
// use setTimeout
sub.stop();
conn.subscribe('my_data');
test.length(stream.sent, 1);
message = JSON.parse(stream.sent.shift());
var id2 = message.id;
test.notEqual(id, id2);
delete message.id;
test.equal(message, {msg: 'sub', name: 'my_data', params: []});
setTimeout(function() {
test.length(stream.sent, 1);
var message = JSON.parse(stream.sent.shift());
test.equal(message, {msg: 'unsub', id: id});
onComplete();
}, 10);
});
Tinytest.add("livedata stub - this", function (test) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var conn = newConnection(stream);
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
startAndConnect(test, stream);
conn.methods({test_this: function() {
test.isTrue(this.isSimulation);
@@ -112,18 +142,12 @@ Tinytest.add("livedata stub - this", function (test) {
Tinytest.add("livedata stub - methods", function (test) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var conn = newConnection(stream);
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
test.length(stream.sent, 0);
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
startAndConnect(test, stream);
var coll_name = Meteor.uuid();
var coll = new Meteor.Collection(coll_name, conn);
var coll = new Meteor.Collection(coll_name, {manager: conn});
// setup method
conn.methods({do_something: function (x) {
@@ -211,18 +235,12 @@ Tinytest.add("livedata stub - methods", function (test) {
// method calls another method in simulation. see not sent.
Tinytest.add("livedata stub - sub methods", function (test) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var conn = newConnection(stream);
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
test.length(stream.sent, 0);
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
startAndConnect(test, stream);
var coll_name = Meteor.uuid();
var coll = new Meteor.Collection(coll_name, conn);
var coll = new Meteor.Collection(coll_name, {manager: conn});
// setup methods
conn.methods({
@@ -287,18 +305,12 @@ Tinytest.add("livedata stub - sub methods", function (test) {
// data is shown
Tinytest.add("livedata stub - reconnect", function (test) {
var stream = new Meteor._StubStream();
var conn = new Meteor._LivedataConnection(stream);
var conn = newConnection(stream);
stream.reset(); // initial connection start.
test_got_message(test, stream, {msg: 'connect'});
test.length(stream.sent, 0);
stream.receive({msg: 'connected', session: SESSION_ID});
test.length(stream.sent, 0);
startAndConnect(test, stream);
var coll_name = Meteor.uuid();
var coll = new Meteor.Collection(coll_name, conn);
var coll = new Meteor.Collection(coll_name, {manager: conn});
// setup observers
var counts = {added: 0, removed: 0, changed: 0, moved: 0};
@@ -344,9 +356,12 @@ Tinytest.add("livedata stub - reconnect", function (test) {
conn.call('do_something', function () {
method_callback_fired = true;
});
conn.apply('do_something', [], {wait: true});
test.isFalse(method_callback_fired);
var method_message = JSON.parse(stream.sent.shift());
var wait_method_message = JSON.parse(stream.sent.shift());
test.equal(method_message, {msg: 'method', method: 'do_something',
params: [], id:method_message.id});
@@ -356,13 +371,13 @@ Tinytest.add("livedata stub - reconnect", function (test) {
test.equal(coll.find({c:3}).count(), 0);
test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0});
// stream reset. reconnect!
// we send a connect, our pending messages, and our subs.
stream.reset();
test_got_message(test, stream, {msg: 'connect', session: SESSION_ID});
test_got_message(test, stream, method_message);
test_got_message(test, stream, wait_method_message);
test_got_message(test, stream, sub_message);
// reconnect with different session id
@@ -378,10 +393,12 @@ Tinytest.add("livedata stub - reconnect", function (test) {
test.equal(counts, {added: 1, removed: 0, changed: 1, moved: 0});
// satisfy and return method callback
stream.receive({msg: 'data', methods: [method_message.id]});
stream.receive({msg: 'data',
methods: [method_message.id, wait_method_message.id]});
test.isFalse(method_callback_fired);
stream.receive({msg: 'result', id:method_message.id, result:"bupkis"});
stream.receive({msg: 'result', id:wait_method_message.id, result:"bupkis"});
test.isTrue(method_callback_fired);
// still no update.
@@ -399,7 +416,195 @@ Tinytest.add("livedata stub - reconnect", function (test) {
handle.stop();
});
Tinytest.add("livedata connection - reactive userId", function (test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
test.equal(conn.userId(), null);
conn.setUserId(1337);
test.equal(conn.userId(), 1337);
});
Tinytest.add("livedata connection - two wait methods with reponse in order", function (test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
startAndConnect(test, stream);
// setup method
conn.methods({do_something: function (x) {}});
var responses = [];
conn.apply('do_something', ['one!'], function() { responses.push('one'); });
var one_message = JSON.parse(stream.sent.shift());
test.equal(one_message.params, ['one!']);
conn.apply('do_something', ['two!'], {wait: true}, function() {
responses.push('two');
});
var two_message = JSON.parse(stream.sent.shift());
test.equal(two_message.params, ['two!']);
test.equal(responses, []);
conn.apply('do_something', ['three!'], function() {
responses.push('three');
});
conn.apply('do_something', ['four!'], {wait: true}, function() {
responses.push('four');
});
conn.apply('do_something', ['five!'], function() { responses.push('five'); });
// Verify that we did not send "three!" since we're waiting for
// "one!" and "two!" to send their response back
test.equal(stream.sent.length, 0);
stream.receive({msg: 'result', id: one_message.id});
test.equal(responses, ['one']);
test.equal(stream.sent.length, 0);
stream.receive({msg: 'result', id: two_message.id});
test.equal(responses, ['one', 'two']);
// Verify that we just sent "three!" and "four!" now that we got
// responses for "one!" and "two!"
test.equal(stream.sent.length, 2);
var three_message = JSON.parse(stream.sent.shift());
test.equal(three_message.params, ['three!']);
var four_message = JSON.parse(stream.sent.shift());
test.equal(four_message.params, ['four!']);
stream.receive({msg: 'result', id: three_message.id});
test.equal(responses, ['one', 'two', 'three']);
test.equal(stream.sent.length, 0);
stream.receive({msg: 'result', id: four_message.id});
test.equal(responses, ['one', 'two', 'three', 'four']);
// Verify that we just sent "five!"
test.equal(stream.sent.length, 1);
var five_message = JSON.parse(stream.sent.shift());
test.equal(five_message.params, ['five!']);
});
Tinytest.add("livedata connection - one wait method with response out of order", function (test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
startAndConnect(test, stream);
// setup method
conn.methods({do_something: function (x) {}});
var responses = [];
conn.apply('do_something', ['one!'], function() { responses.push('one'); });
var one_message = JSON.parse(stream.sent.shift());
test.equal(one_message.params, ['one!']);
conn.apply('do_something', ['two!'], {wait: true}, function() {
responses.push('two');
});
var two_message = JSON.parse(stream.sent.shift());
test.equal(two_message.params, ['two!']);
test.equal(responses, []);
conn.apply('do_something', ['three!']);
// Verify that we did not send "three!" since we're waiting for
// "one!" and "two!" to send their response back
test.equal(stream.sent.length, 0);
stream.receive({msg: 'result', id: two_message.id});
test.equal(responses, []);
test.equal(stream.sent.length, 0);
stream.receive({msg: 'result', id: one_message.id});
test.equal(responses, ['one', 'two']); // Namely not two, one
// Verify that we just sent "three!" now that we got responses for
// "one!" and "two!"
test.equal(stream.sent.length, 1);
var three_message = JSON.parse(stream.sent.shift());
test.equal(three_message.params, ['three!']);
// Since we sent it, it should no longer be in "blocked_methods".
test.equal(conn.blocked_methods, []);
});
Tinytest.add("livedata connection - onReconnect prepends messages correctly with a wait method", function(test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
startAndConnect(test, stream);
// setup method
conn.methods({do_something: function (x) {}});
conn.onReconnect = function() {
conn.apply('do_something', ['reconnect one']);
conn.apply('do_something', ['reconnect two'], {wait: true});
conn.apply('do_something', ['reconnect three']);
};
conn.apply('do_something', ['one']);
conn.apply('do_something', ['two'], {wait: true});
conn.apply('do_something', ['three']);
// reconnect
stream.sent = [];
stream.reset();
test_got_message(
test, stream, {msg: 'connect', session: conn.last_session_id});
// Test that we sent what we expect to send, and we're blocked on
// what we expect to be blocked. The subsequent logic to correctly
// read the wait flag is tested separately.
test.equal(_.map(stream.sent, function(msg) {
return JSON.parse(msg).params[0];
}), ['reconnect one', 'reconnect two']);
test.equal(_.map(conn.blocked_methods, function(method) {
return [method.msg.params[0], method.wait];
}), [
['reconnect three', undefined/*==false*/],
['one', undefined/*==false*/],
['two', true],
['three', undefined/*==false*/]
]);
});
Tinytest.add("livedata connection - onReconnect prepends messages correctly without a wait method", function(test) {
var stream = new Meteor._StubStream();
var conn = newConnection(stream);
startAndConnect(test, stream);
// setup method
conn.methods({do_something: function (x) {}});
conn.onReconnect = function() {
conn.apply('do_something', ['reconnect one']);
conn.apply('do_something', ['reconnect two']);
conn.apply('do_something', ['reconnect three']);
};
conn.apply('do_something', ['one']);
conn.apply('do_something', ['two'], {wait: true});
conn.apply('do_something', ['three']);
// reconnect
stream.sent = [];
stream.reset();
test_got_message(
test, stream, {msg: 'connect', session: conn.last_session_id});
// Test that we sent what we expect to send, and we're blocked on
// what we expect to be blocked. The subsequent logic to correctly
// read the wait flag is tested separately.
test.equal(_.map(stream.sent, function(msg) {
return JSON.parse(msg).params[0];
}), ['reconnect one', 'reconnect two', 'reconnect three', 'one', 'two']);
test.equal(_.map(conn.blocked_methods, function(method) {
return [method.msg.params[0], method.wait];
}), [
['three', undefined/*==false*/]
]);
});
// XXX also test:
// - reconnect, with session resume.
// - restart on update flag
// - on_update event
// - reloading when the app changes, including session migration

View File

@@ -30,6 +30,18 @@ Meteor._LivedataSession = function (server) {
// map from collection name -> id -> key -> subscription id -> true
self.provides_key = {};
// if set, ignore flush requests on any subsubcription on this
// session. when set this back to false, don't forget to call flush
// manually. this is sometimes needed because subscriptions
// frequently call flush
self.dontFlush = false;
self.userId = null;
// Per-connection scratch area. This is only used internally, but we
// should have real and documented API for this sort of thing someday.
self.sessionData = {};
};
_.extend(Meteor._LivedataSession.prototype, {
@@ -269,8 +281,16 @@ _.extend(Meteor._LivedataSession.prototype, {
return;
}
var invocation = new Meteor._MethodInvocation(false /* isSimulation */,
unblock);
var setUserId = function(userId) {
self._setUserId(userId);
};
var invocation = new Meteor._MethodInvocation({
isSimulation: false,
userId: self.userId, setUserId: setUserId,
unblock: unblock,
sessionData: self.sessionData
});
try {
var ret =
Meteor._CurrentWriteFence.withValue(fence, function () {
@@ -305,6 +325,20 @@ _.extend(Meteor._LivedataSession.prototype, {
}
},
// Sets the current user id in all appropriate contexts and reruns
// all subscriptions
_setUserId: function(userId) {
var self = this;
self.userId = userId;
this._rerunAllSubscriptions();
// XXX figure out the login token that was just used, and set up an observe
// on the user doc so that deleting the user or the login token disconnects
// the session. For now, if you want to make sure that your deleted users
// don't have any continuing sessions, you can restart the server, but we
// should make it automatic.
},
_startSubscription: function (handler, priority, sub_id, params) {
var self = this;
@@ -314,23 +348,29 @@ _.extend(Meteor._LivedataSession.prototype, {
else
self.universal_subs.push(sub);
try {
var res = handler.apply(sub, params || []);
} catch (e) {
Meteor._debug("Internal exception while starting subscription", sub_id,
e.stack);
return;
}
// Store a function to re-run the handler in case we want to rerun
// subscriptions, for example when the current user id changes
sub._runHandler = function() {
try {
var res = handler.apply(sub, params || []);
} catch (e) {
Meteor._debug("Internal exception while starting subscription", sub_id,
e.stack);
return;
}
// if Meteor._RemoteCollectionDriver is available (defined in
// mongo-livedata), automatically wire up handlers that return a
// Cursor. otherwise, the handler is completely responsible for
// delivering its own data messages and registering stop
// functions.
//
// XXX generalize
if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor))
sub._publishCursor(res);
// if Meteor._RemoteCollectionDriver is available (defined in
// mongo-livedata), automatically wire up handlers that return a
// Cursor. otherwise, the handler is completely responsible for
// delivering its own data messages and registering stop
// functions.
//
// XXX generalize
if (Meteor._RemoteCollectionDriver && (res instanceof Meteor._Mongo.Cursor))
sub._publishCursor(res);
};
sub._runHandler();
},
// tear down specified subscription
@@ -358,7 +398,30 @@ _.extend(Meteor._LivedataSession.prototype, {
self.universal_subs = [];
},
// return the current value for a particular key, as given by the
// Rerun all subscriptions without sending intermediate state down
// the wire
_rerunAllSubscriptions: function () {
var self = this;
var rerunSub = function(sub) {
sub._teardown();
sub.userId = self.userId;
sub._runHandler();
};
var flushSub = function(sub) {
sub.flush();
};
self.dontFlush = true;
_.each(self.named_subs, rerunSub);
_.each(self.universal_subs, rerunSub);
self.dontFlush = false;
_.each(self.named_subs, flushSub);
_.each(self.universal_subs, flushSub);
},
// RETURN the current value for a particular key, as given by the
// current contents of each subscription's snapshot.
_effectiveValueForKey: function (collection_name, id, key) {
var self = this;
@@ -388,6 +451,12 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) {
// LivedataSession
this.session = session;
// Give access to sessionData in subscriptions as well as
// methods. This is not currently used, but is included for
// consistency. We should have real and documented API for this sort
// of thing someday.
this._sessionData = session.sessionData;
// my subscription ID (generated by client, null for universal subs).
this.sub_id = sub_id;
@@ -413,6 +482,8 @@ Meteor._LivedataSubscription = function (session, sub_id, priority) {
// stop callbacks to g/c this sub. called w/ zero arguments.
this.stop_callbacks = [];
this.userId = session.userId;
};
_.extend(Meteor._LivedataSubscription.prototype, {
@@ -422,22 +493,7 @@ _.extend(Meteor._LivedataSubscription.prototype, {
if (self.stopped)
return;
// tell listeners, so they can clean up
for (var i = 0; i < this.stop_callbacks.length; i++)
(this.stop_callbacks[i])();
// remove our data from the client (possibly unshadowing data from
// lower priority subscriptions)
self.pending_data = {};
self.pending_complete = false;
for (var name in self.snapshot) {
self.pending_data[name] = {};
for (var id in self.snapshot[name]) {
self.pending_data[name][id] = {};
for (var key in self.snapshot[name][id])
self.pending_data[name][id][key] = undefined;
}
}
self._teardown();
self.flush();
self.stopped = true;
},
@@ -478,6 +534,9 @@ _.extend(Meteor._LivedataSubscription.prototype, {
flush: function () {
var self = this;
if (self.session.dontFlush)
return;
if (self.stopped)
return;
@@ -546,6 +605,26 @@ _.extend(Meteor._LivedataSubscription.prototype, {
self.pending_complete = false;
},
_teardown: function() {
var self = this;
// tell listeners, so they can clean up
for (var i = 0; i < self.stop_callbacks.length; i++)
(self.stop_callbacks[i])();
// remove our data from the client (possibly unshadowing data from
// lower priority subscriptions)
self.pending_data = {};
self.pending_complete = false;
for (var name in self.snapshot) {
self.pending_data[name] = {};
for (var id in self.snapshot[name]) {
self.pending_data[name][id] = {};
for (var key in self.snapshot[name][id])
self.pending_data[name][id][key] = undefined;
}
}
},
_publishCursor: function (cursor, name) {
var self = this;
var collection = name || cursor.collection_name;
@@ -767,9 +846,19 @@ _.extend(Meteor._LivedataServer.prototype, {
return this.apply(name, args, callback);
},
apply: function (name, args, callback) {
// @param options {Optional Object}
// @param callback {Optional Function}
apply: function (name, args, options, callback) {
var self = this;
// We were passed 3 arguments. They may be either (name, args, options)
// or (name, args, callback)
if (!callback && typeof options === 'function') {
callback = options;
options = {};
}
options = options || {};
if (callback)
// It's not really necessary to do this, since we immediately
// run the callback in this fiber before returning, but we do it
@@ -785,7 +874,26 @@ _.extend(Meteor._LivedataServer.prototype, {
if (!handler)
var exception = new Meteor.Error(404, "Method not found");
else {
var invocation = new Meteor._MethodInvocation(false /* isSimulation */);
// If this is a method call from within another method, get the
// user state from the outer method, otherwise don't allow
// setUserId to be called
var userId = null;
var setUserId = function() {
throw new Error("Can't call setUserId on a server initiated method call");
};
var currentInvocation = Meteor._CurrentInvocation.get();
if (currentInvocation) {
userId = currentInvocation.userId;
setUserId = function(userId) {
currentInvocation.setUserId(userId);
};
}
var invocation = new Meteor._MethodInvocation({
isSimulation: false,
userId: userId, setUserId: setUserId,
sessionData: self.sessionData
});
try {
var ret = Meteor._CurrentInvocation.withValue(invocation, function () {
return handler.apply(invocation, args);

View File

@@ -22,9 +22,45 @@ Meteor.methods({
}
});
// Methods to help test applying methods with `wait: true`: delayedTrue
// returns true 500ms after being run unless makeDelayedTrueImmediatelyReturnFalse
// was run in the meanwhile
if (Meteor.isServer) {
var delayed_true_future;
var delayed_true_times;
Meteor.methods({
delayedTrue: function() {
delayed_true_future = new Future();
delayed_true_times = Meteor.setTimeout(function() {
delayed_true_future['return'](true);
delayed_true_future = null;
delayed_true_times = null;
}, 500);
this.unblock();
return delayed_true_future.wait();
},
makeDelayedTrueImmediatelyReturnFalse: function() {
if (!delayed_true_future)
return; // since delayedTrue's timeout had already run
if (delayed_true_times) clearTimeout(delayed_true_times);
delayed_true_future['return'](false);
delayed_true_future = null;
delayed_true_times = null;
}
});
}
/*****/
Ledger = new Meteor.Collection("ledger");
Ledger.allow({
insert: function() { return true; },
update: function() { return true; },
remove: function() { return true; },
fetch: []
});
Meteor.startup(function () {
if (Meteor.isServer)
@@ -60,4 +96,57 @@ Meteor.methods({
Ledger.update({_id: to._id}, {$inc: {balance: amount}});
Meteor.refresh({collection: 'ledger', world: world});
}
});
});
/*****/
/// Helpers for "livedata - changing userid reruns subscriptions..."
objectsWithUsers = new Meteor.Collection("objectsWithUsers");
if (Meteor.isServer) {
objectsWithUsers.remove({});
objectsWithUsers.insert({name: "owned by none", ownerUserIds: [null]});
objectsWithUsers.insert({name: "owned by one - a", ownerUserIds: [1]});
objectsWithUsers.insert({name: "owned by one/two - a", ownerUserIds: [1, 2]});
objectsWithUsers.insert({name: "owned by one/two - b", ownerUserIds: [1, 2]});
objectsWithUsers.insert({name: "owned by two - a", ownerUserIds: [2]});
objectsWithUsers.insert({name: "owned by two - b", ownerUserIds: [2]});
Meteor.publish("objectsWithUsers", function() {
return objectsWithUsers.find({ownerUserIds: this.userId},
{fields: {ownerUserIds: 0}});
});
userIdWhenStopped = null;
Meteor.publish("recordUserIdOnStop", function() {
var self = this;
self.onStop(function() {
userIdWhenStopped = self.userId;
});
});
Meteor.methods({
setUserId: function(userId) {
this.setUserId(userId);
},
userIdWhenStopped: function() {
return userIdWhenStopped;
}
});
}
/*****/
/// Helper for "livedata - setUserId fails when called on server"
if (Meteor.isServer) {
Meteor.startup(function() {
errorThrownWhenCallingSetUserIdDirectlyOnServer = null;
try {
Meteor.call("setUserId", 1000);
} catch (e) {
errorThrownWhenCallingSetUserIdDirectlyOnServer = e;
}
});
}

View File

@@ -17,7 +17,7 @@ var failure = function (test, code, reason) {
}
}
};
}
};
Tinytest.add("livedata - Meteor.Error", function (test) {
var error = new Meteor.Error(123, "kittens", "puppies");
@@ -105,6 +105,26 @@ testAsyncMulti("livedata - basic method invocation", [
expect(undefined, [12, {x: 13}])), undefined);
},
// test that `wait: false` is respected
function (test, expect) {
if (Meteor.isClient) {
Meteor.apply("delayedTrue", [], {wait: false}, expect(function(err, res) {
test.equal(res, false);
}));
Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", []);
}
},
// test that `wait: true` is respected
function(test, expect) {
if (Meteor.isClient) {
Meteor.apply("delayedTrue", [], {wait: true}, expect(function(err, res) {
test.equal(res, true);
}));
Meteor.apply("makeDelayedTrueImmediatelyReturnFalse", []);
}
},
function (test, expect) {
// No callback
@@ -184,12 +204,14 @@ testAsyncMulti("livedata - basic method invocation", [
]);
var checkBalances = function (test, a, b) {
var alice = Ledger.findOne({name: "alice", world: test.runId()});
var bob = Ledger.findOne({name: "bob", world: test.runId()});
test.equal(alice.balance, a);
test.equal(bob.balance, b);
}
};
var onQuiesce = function (f) {
if (Meteor.isServer)
@@ -204,6 +226,7 @@ testAsyncMulti("livedata - compound methods", [
function (test) {
if (Meteor.isClient)
Meteor.subscribe("ledger", test.runId());
Ledger.insert({name: "alice", balance: 100, world: test.runId()});
Ledger.insert({name: "bob", balance: 50, world: test.runId()});
},
@@ -238,6 +261,127 @@ testAsyncMulti("livedata - compound methods", [
}
]);
// Replaces the LivedataConnection's `_livedata_data` method to push
// incoming messages on a given collection to an array. This can be
// used to verify that the right data is sent on the wire
//
// @param messages {Array} The array to which to append the messages
// @return {Function} A function to call to undo the eavesdropping
var eavesdropOnCollection = function(livedata_connection,
collection_name, messages) {
old_livedata_data = _.bind(
livedata_connection._livedata_data, livedata_connection);
// Kind of gross since all tests past this one will run with this
// hook set up. That's probably fine since we only check a specific
// collection but still...
//
// Should we consider having a separate connection per Tinytest or
// some similar scheme?
livedata_connection._livedata_data = function(msg) {
if (msg.collection && msg.collection === collection_name) {
messages.push(msg);
}
old_livedata_data(msg);
};
return function() {
livedata_connection._livedata_data = old_livedata_data;
};
};
testAsyncMulti("livedata - changing userid reruns subscriptions without flapping data on the wire", [
function(test, expect) {
if (Meteor.isClient) {
var messages = [];
var undoEavesdrop = eavesdropOnCollection(
Meteor.default_connection, "objectsWithUsers", messages);
// A helper for testing incoming set and unset messages
// XXX should this be extracted as a general helper together with
// eavesdropOnCollection?
var testSetAndUnset = function(expectation) {
test.equal(_.map(messages, function(msg) {
var result = {};
if (msg.set)
result.set = msg.set.name;
if (msg.unset)
result.unset = true;
return result;
}), expectation);
messages.length = 0; // clear messages without creating a new object
};
Meteor.subscribe("objectsWithUsers", expect(function() {
testSetAndUnset([{set: "owned by none"}]);
test.equal(objectsWithUsers.find().count(), 1);
Meteor.defer(sendFirstSetUserId);
}));
// Contorted since we need to call expect at the top level of a test
// (see comment at top of async_multi.js)
var sendFirstSetUserId = expect(function() {
Meteor.apply("setUserId", [1], {wait: true});
Meteor.default_connection.onQuiesce(afterFirstSetUserId);
});
var afterFirstSetUserId = expect(function() {
testSetAndUnset([
{unset: true},
{set: "owned by one - a"},
{set: "owned by one/two - a"},
{set: "owned by one/two - b"}]);
test.equal(objectsWithUsers.find().count(), 3);
Meteor.defer(sendSecondSetUserId);
});
var sendSecondSetUserId = expect(function() {
Meteor.apply("setUserId", [2], {wait: true});
Meteor.default_connection.onQuiesce(afterSecondSetUserId);
});
var afterSecondSetUserId = expect(function() {
testSetAndUnset([
{unset: true},
{set: "owned by two - a"},
{set: "owned by two - b"}]);
test.equal(objectsWithUsers.find().count(), 4);
Meteor.defer(sendThirdSetUserId);
});
var sendThirdSetUserId = expect(function() {
Meteor.apply("setUserId", [2], {wait: true});
Meteor.default_connection.onQuiesce(afterThirdSetUserId);
});
var afterThirdSetUserId = expect(function() {
// Nothing should have been sent since the results of the
// query are the same ("don't flap data on the wire")
testSetAndUnset([]);
test.equal(objectsWithUsers.find().count(), 4);
undoEavesdrop();
});
}
}, function(test, expect) {
if (Meteor.isClient) {
Meteor.subscribe("recordUserIdOnStop");
Meteor.apply("setUserId", [100], {wait: true}, expect(function() {}));
Meteor.apply("setUserId", [101], {wait: true}, expect(function() {}));
Meteor.call("userIdWhenStopped", expect(function(err, result) {
test.equal(result, 100);
}));
}
}
]);
Tinytest.add("livedata - setUserId error when called from server", function(test) {
if (Meteor.isServer) {
test.equal(errorThrownWhenCallingSetUserIdDirectlyOnServer.message,
"Can't call setUserId on a server initiated method call");
}
});
// XXX some things to test in greater detail:
// staying in simulation mode
// time warp

View File

@@ -0,0 +1,51 @@
if (!window.localStorage) {
window.localStorage = (function () {
// XXX eliminate dependency on jQuery, detect browsers ourselves
if ($.browser.msie) { // If we are on IE, which support userData
var userdata = document.createElement('span'); // could be anything
userdata.style.behavior = 'url("#default#userData")';
userdata.id = 'localstorage-polyfill-helper';
userdata.style.display = 'none';
document.getElementsByTagName("head")[0].appendChild(userdata);
var userdataKey = 'localStorage';
userdata.load(userdataKey);
return {
setItem: function (key, val) {
userdata.setAttribute(key, val);
userdata.save(userdataKey);
},
removeItem: function (key) {
userdata.removeAttribute(key);
userdata.save(userdataKey);
},
getItem: function (key) {
userdata.load(userdataKey);
return userdata.getAttribute(key);
}
};
} else {
Meteor._debug(
"You are running a browser with no localStorage or userData "
+ "support. Logging in from one tab will not cause another "
+ "tab to be logged in.");
return {
_data: {},
setItem: function (key, val) {
this._data[key] = val;
},
removeItem: function (key) {
delete this._data[key];
},
getItem: function (key) {
return this._data[key];
}
};
};
})();
}

View File

@@ -0,0 +1,9 @@
Tinytest.add("localStorage polyfill", function (test) {
// Doesn't actually test preservation across reloads since that is hard.
// userData should do that for us so it's unlikely this wouldn't work.
localStorage.setItem("key", "value");
test.equal(localStorage.getItem("key"), "value");
localStorage.removeItem("key");
test.equal(localStorage.getItem("key"), null);
});

Some files were not shown because too many files have changed in this diff Show More