mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Merge branch 'auth' into devel
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,5 +3,6 @@
|
||||
/dev_bundle
|
||||
/dev_bundle*.tar.gz
|
||||
/dist
|
||||
\#*#
|
||||
\#*\#
|
||||
.\#*
|
||||
.idea
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
# but you can also edit it by hand.
|
||||
|
||||
autopublish
|
||||
insecure
|
||||
preserve-inputs
|
||||
|
||||
@@ -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>"}}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
# but you can also edit it by hand.
|
||||
|
||||
autopublish
|
||||
insecure
|
||||
preserve-inputs
|
||||
|
||||
@@ -6,5 +6,12 @@
|
||||
underscore
|
||||
backbone
|
||||
spiderable
|
||||
accounts-ui
|
||||
accounts-weibo
|
||||
accounts-google
|
||||
accounts-facebook
|
||||
accounts-password
|
||||
accounts-twitter
|
||||
jquery
|
||||
preserve-inputs
|
||||
accounts-github
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
19
examples/todos/server/access_control.js
Normal file
19
examples/todos/server/access_control.js
Normal 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
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
109
packages/accounts-base/accounts_client.js
Normal file
109
packages/accounts-base/accounts_client.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
59
packages/accounts-base/accounts_common.js
Normal file
59
packages/accounts-base/accounts_common.js
Normal 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';
|
||||
|
||||
338
packages/accounts-base/accounts_server.js
Normal file
338
packages/accounts-base/accounts_server.js
Normal 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});
|
||||
}) ();
|
||||
|
||||
130
packages/accounts-base/accounts_tests.js
Normal file
130
packages/accounts-base/accounts_tests.js
Normal 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);
|
||||
});
|
||||
97
packages/accounts-base/localstorage_token.js
Normal file
97
packages/accounts-base/localstorage_token.js
Normal 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);
|
||||
25
packages/accounts-base/package.js
Normal file
25
packages/accounts-base/package.js
Normal 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');
|
||||
});
|
||||
35
packages/accounts-facebook/facebook_client.js
Normal file
35
packages/accounts-facebook/facebook_client.js
Normal 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);
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
|
||||
|
||||
3
packages/accounts-facebook/facebook_common.js
Normal file
3
packages/accounts-facebook/facebook_common.js
Normal file
@@ -0,0 +1,3 @@
|
||||
if (!Accounts.facebook) {
|
||||
Accounts.facebook = {};
|
||||
}
|
||||
19
packages/accounts-facebook/facebook_configure.html
Normal file
19
packages/accounts-facebook/facebook_configure.html
Normal 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>
|
||||
10
packages/accounts-facebook/facebook_configure.js
Normal file
10
packages/accounts-facebook/facebook_configure.js
Normal 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'}
|
||||
];
|
||||
};
|
||||
76
packages/accounts-facebook/facebook_server.js
Normal file
76
packages/accounts-facebook/facebook_server.js
Normal 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;
|
||||
};
|
||||
}) ();
|
||||
18
packages/accounts-facebook/package.js
Normal file
18
packages/accounts-facebook/package.js
Normal 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');
|
||||
});
|
||||
29
packages/accounts-github/github_client.js
Normal file
29
packages/accounts-github/github_client.js
Normal 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});
|
||||
};
|
||||
}) ();
|
||||
3
packages/accounts-github/github_common.js
Normal file
3
packages/accounts-github/github_common.js
Normal file
@@ -0,0 +1,3 @@
|
||||
if (!Accounts.github) {
|
||||
Accounts.github = {};
|
||||
}
|
||||
16
packages/accounts-github/github_configure.html
Normal file
16
packages/accounts-github/github_configure.html
Normal 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>
|
||||
10
packages/accounts-github/github_configure.js
Normal file
10
packages/accounts-github/github_configure.js
Normal 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'}
|
||||
];
|
||||
};
|
||||
46
packages/accounts-github/github_server.js
Normal file
46
packages/accounts-github/github_server.js
Normal 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;
|
||||
};
|
||||
}) ();
|
||||
18
packages/accounts-github/package.js
Normal file
18
packages/accounts-github/package.js
Normal 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');
|
||||
});
|
||||
39
packages/accounts-google/google_client.js
Normal file
39
packages/accounts-google/google_client.js
Normal 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);
|
||||
};
|
||||
|
||||
}) ();
|
||||
3
packages/accounts-google/google_common.js
Normal file
3
packages/accounts-google/google_common.js
Normal file
@@ -0,0 +1,3 @@
|
||||
if (!Accounts.google) {
|
||||
Accounts.google = {};
|
||||
}
|
||||
28
packages/accounts-google/google_configure.html
Normal file
28
packages/accounts-google/google_configure.html
Normal 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>
|
||||
10
packages/accounts-google/google_configure.js
Normal file
10
packages/accounts-google/google_configure.js
Normal 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'}
|
||||
];
|
||||
};
|
||||
48
packages/accounts-google/google_server.js
Normal file
48
packages/accounts-google/google_server.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
18
packages/accounts-google/package.js
Normal file
18
packages/accounts-google/package.js
Normal 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');
|
||||
});
|
||||
83
packages/accounts-oauth-helper/oauth_client.js
Normal file
83
packages/accounts-oauth-helper/oauth_client.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
1
packages/accounts-oauth-helper/oauth_common.js
Normal file
1
packages/accounts-oauth-helper/oauth_common.js
Normal file
@@ -0,0 +1 @@
|
||||
Accounts.oauth = {};
|
||||
180
packages/accounts-oauth-helper/oauth_server.js
Normal file
180
packages/accounts-oauth-helper/oauth_server.js
Normal 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');
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
12
packages/accounts-oauth-helper/package.js
Normal file
12
packages/accounts-oauth-helper/package.js
Normal 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');
|
||||
});
|
||||
137
packages/accounts-oauth1-helper/oauth1_binding.js
Normal file
137
packages/accounts-oauth1-helper/oauth1_binding.js
Normal 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(', ');
|
||||
};
|
||||
1
packages/accounts-oauth1-helper/oauth1_common.js
Normal file
1
packages/accounts-oauth1-helper/oauth1_common.js
Normal file
@@ -0,0 +1 @@
|
||||
Accounts.oauth1 = {};
|
||||
67
packages/accounts-oauth1-helper/oauth1_server.js
Normal file
67
packages/accounts-oauth1-helper/oauth1_server.js
Normal 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);
|
||||
};
|
||||
|
||||
})();
|
||||
134
packages/accounts-oauth1-helper/oauth1_tests.js
Normal file
134
packages/accounts-oauth1-helper/oauth1_tests.js
Normal 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}}]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
18
packages/accounts-oauth1-helper/package.js
Normal file
18
packages/accounts-oauth1-helper/package.js
Normal 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');
|
||||
});
|
||||
1
packages/accounts-oauth2-helper/oauth2_common.js
Normal file
1
packages/accounts-oauth2-helper/oauth2_common.js
Normal file
@@ -0,0 +1 @@
|
||||
Accounts.oauth2 = {};
|
||||
25
packages/accounts-oauth2-helper/oauth2_server.js
Normal file
25
packages/accounts-oauth2-helper/oauth2_server.js
Normal 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);
|
||||
};
|
||||
|
||||
})();
|
||||
91
packages/accounts-oauth2-helper/oauth2_tests.js
Normal file
91
packages/accounts-oauth2-helper/oauth2_tests.js
Normal 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}}]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
17
packages/accounts-oauth2-helper/package.js
Normal file
17
packages/accounts-oauth2-helper/package.js
Normal 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');
|
||||
});
|
||||
53
packages/accounts-password/email_templates.js
Normal file
53
packages/accounts-password/email_templates.js
Normal 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";
|
||||
}
|
||||
}
|
||||
};
|
||||
234
packages/accounts-password/email_tests.js
Normal file
234
packages/accounts-password/email_tests.js
Normal 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);
|
||||
}));
|
||||
}
|
||||
]);
|
||||
}) ();
|
||||
40
packages/accounts-password/email_tests_setup.js
Normal file
40
packages/accounts-password/email_tests_setup.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}) ();
|
||||
22
packages/accounts-password/package.js
Normal file
22
packages/accounts-password/package.js
Normal 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');
|
||||
});
|
||||
190
packages/accounts-password/passwords_client.js
Normal file
190
packages/accounts-password/passwords_client.js
Normal 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();
|
||||
});
|
||||
};
|
||||
})();
|
||||
|
||||
1
packages/accounts-password/passwords_common.js
Normal file
1
packages/accounts-password/passwords_common.js
Normal file
@@ -0,0 +1 @@
|
||||
Accounts.password = {};
|
||||
446
packages/accounts-password/passwords_server.js
Normal file
446
packages/accounts-password/passwords_server.js
Normal 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});
|
||||
})();
|
||||
334
packages/accounts-password/passwords_tests.js
Normal file
334
packages/accounts-password/passwords_tests.js
Normal 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})
|
||||
}) ();
|
||||
39
packages/accounts-password/passwords_tests_setup.js
Normal file
39
packages/accounts-password/passwords_tests_setup.js
Normal 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(); }
|
||||
});
|
||||
18
packages/accounts-twitter/package.js
Normal file
18
packages/accounts-twitter/package.js
Normal 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');
|
||||
});
|
||||
34
packages/accounts-twitter/twitter_client.js
Normal file
34
packages/accounts-twitter/twitter_client.js
Normal 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);
|
||||
};
|
||||
|
||||
})();
|
||||
10
packages/accounts-twitter/twitter_common.js
Normal file
10
packages/accounts-twitter/twitter_common.js
Normal 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"
|
||||
};
|
||||
13
packages/accounts-twitter/twitter_configure.html
Normal file
13
packages/accounts-twitter/twitter_configure.html
Normal 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>
|
||||
11
packages/accounts-twitter/twitter_configure.js
Normal file
11
packages/accounts-twitter/twitter_configure.js
Normal 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'}
|
||||
];
|
||||
};
|
||||
20
packages/accounts-twitter/twitter_server.js
Normal file
20
packages/accounts-twitter/twitter_server.js
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}) ();
|
||||
41
packages/accounts-ui-unstyled/accounts_ui.js
Normal file
41
packages/accounts-ui-unstyled/accounts_ui.js
Normal 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";
|
||||
};
|
||||
|
||||
50
packages/accounts-ui-unstyled/login_buttons.html
Normal file
50
packages/accounts-ui-unstyled/login_buttons.html
Normal 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>
|
||||
146
packages/accounts-ui-unstyled/login_buttons.js
Normal file
146
packages/accounts-ui-unstyled/login_buttons.js
Normal 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;
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
122
packages/accounts-ui-unstyled/login_buttons_dialogs.html
Normal file
122
packages/accounts-ui-unstyled/login_buttons_dialogs.html
Normal 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>
|
||||
|
||||
|
||||
235
packages/accounts-ui-unstyled/login_buttons_dialogs.js
Normal file
235
packages/accounts-ui-unstyled/login_buttons_dialogs.js
Normal 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);
|
||||
};
|
||||
|
||||
}) ();
|
||||
168
packages/accounts-ui-unstyled/login_buttons_dropdown.html
Normal file
168
packages/accounts-ui-unstyled/login_buttons_dropdown.html
Normal 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"> </span>
|
||||
<span class="or-text">or</span>
|
||||
<span class="hline"> </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>
|
||||
494
packages/accounts-ui-unstyled/login_buttons_dropdown.js
Normal file
494
packages/accounts-ui-unstyled/login_buttons_dropdown.js
Normal 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;
|
||||
};
|
||||
|
||||
|
||||
}) ();
|
||||
21
packages/accounts-ui-unstyled/login_buttons_images.css
Normal file
21
packages/accounts-ui-unstyled/login_buttons_images.css
Normal 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=);
|
||||
}
|
||||
62
packages/accounts-ui-unstyled/login_buttons_session.js
Normal file
62
packages/accounts-ui-unstyled/login_buttons_session.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}) ();
|
||||
11
packages/accounts-ui-unstyled/login_buttons_single.html
Normal file
11
packages/accounts-ui-unstyled/login_buttons_single.html
Normal 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>
|
||||
48
packages/accounts-ui-unstyled/login_buttons_single.js
Normal file
48
packages/accounts-ui-unstyled/login_buttons_single.js
Normal 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);
|
||||
};
|
||||
}) ();
|
||||
23
packages/accounts-ui-unstyled/package.js
Normal file
23
packages/accounts-ui-unstyled/package.js
Normal 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');
|
||||
});
|
||||
317
packages/accounts-ui/login_buttons.less
Normal file
317
packages/accounts-ui/login_buttons.less
Normal file
File diff suppressed because one or more lines are too long
10
packages/accounts-ui/package.js
Normal file
10
packages/accounts-ui/package.js
Normal 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');
|
||||
});
|
||||
9
packages/accounts-urls/package.js
Normal file
9
packages/accounts-urls/package.js
Normal 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');
|
||||
});
|
||||
47
packages/accounts-urls/url_client.js
Normal file
47
packages/accounts-urls/url_client.js
Normal 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 = '';
|
||||
}
|
||||
})();
|
||||
17
packages/accounts-urls/url_server.js
Normal file
17
packages/accounts-urls/url_server.js
Normal 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);
|
||||
};
|
||||
18
packages/accounts-weibo/package.js
Normal file
18
packages/accounts-weibo/package.js
Normal 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');
|
||||
});
|
||||
28
packages/accounts-weibo/weibo_client.js
Normal file
28
packages/accounts-weibo/weibo_client.js
Normal 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);
|
||||
};
|
||||
|
||||
}) ();
|
||||
3
packages/accounts-weibo/weibo_common.js
Normal file
3
packages/accounts-weibo/weibo_common.js
Normal file
@@ -0,0 +1,3 @@
|
||||
if (!Accounts.weibo) {
|
||||
Accounts.weibo = {};
|
||||
}
|
||||
25
packages/accounts-weibo/weibo_configure.html
Normal file
25
packages/accounts-weibo/weibo_configure.html
Normal 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>
|
||||
11
packages/accounts-weibo/weibo_configure.js
Normal file
11
packages/accounts-weibo/weibo_configure.js
Normal 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'}
|
||||
];
|
||||
};
|
||||
50
packages/accounts-weibo/weibo_server.js
Normal file
50
packages/accounts-weibo/weibo_server.js
Normal 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;
|
||||
};
|
||||
})();
|
||||
@@ -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
|
||||
|
||||
1
packages/insecure/insecure.js
Normal file
1
packages/insecure/insecure.js
Normal file
@@ -0,0 +1 @@
|
||||
Meteor.Collection.insecure = true;
|
||||
8
packages/insecure/package.js
Normal file
8
packages/insecure/package.js
Normal 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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
51
packages/localstorage-polyfill/localstorage_polyfill.js
Normal file
51
packages/localstorage-polyfill/localstorage_polyfill.js
Normal 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];
|
||||
}
|
||||
};
|
||||
};
|
||||
})();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user