mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Merge branch 'release-0.5.0'
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,5 +3,6 @@
|
||||
/dev_bundle
|
||||
/dev_bundle*.tar.gz
|
||||
/dist
|
||||
\#*#
|
||||
\#*\#
|
||||
.\#*
|
||||
.idea
|
||||
|
||||
59
History.md
59
History.md
@@ -1,6 +1,65 @@
|
||||
|
||||
## vNEXT
|
||||
|
||||
## v0.5.0
|
||||
|
||||
* This release introduces Meteor Accounts, a full-featured auth system that supports
|
||||
- fine-grained user-based control over database reads and writes
|
||||
- federated login with any OAuth provider (with built-in support for
|
||||
Facebook, GitHub, Google, Twitter, and Weibo)
|
||||
- secure password login
|
||||
- email validation and password recovery
|
||||
- an optional set of UI widgets implementing standard login/signup/password
|
||||
change/logout flows
|
||||
|
||||
When you upgrade to Meteor 0.5.0, existing apps will lose the ability to write
|
||||
to the database from the client. To restore this, either:
|
||||
- configure each of your collections with
|
||||
[`collection.allow`](http://docs.meteor.com/#allow) and
|
||||
[`collection.deny`](http://docs.meteor.com/#deny) calls to specify which
|
||||
users can perform which write operations, or
|
||||
- add the `insecure` smart package (which is included in new apps by default)
|
||||
to restore the old behavior where anyone can write to any collection which
|
||||
has not been configured with `allow` or `deny`
|
||||
|
||||
For more information on Meteor Accounts, see
|
||||
http://docs.meteor.com/#dataandsecurity and
|
||||
http://docs.meteor.com/#accounts_api
|
||||
|
||||
* The new function `Meteor.autorun` allows you run any code in a reactive
|
||||
context. See http://docs.meteor.com/#meteor_autorun
|
||||
|
||||
* Arrays and objects can now be stored in the `Session`; mutating the value you
|
||||
retrieve with `Session.get` does not affect the value in the session.
|
||||
|
||||
* On the client, `Meteor.apply` takes a new `wait` option, which ensures that no
|
||||
further method calls are sent to the server until this method is finished; it
|
||||
is used for login and logout methods in order to keep the user ID
|
||||
well-defined. You can also specifiy an `onReconnect` handler which is run when
|
||||
re-establishing a connection; Meteor Accounts uses this to log back in on
|
||||
reconnect.
|
||||
|
||||
* Meteor now provides a compatible replacement for the DOM `localStorage`
|
||||
facility that works in IE7, in the `localstorage-polyfill` smart package.
|
||||
|
||||
* Meteor now packages the D3 library for manipulating documents based on data in
|
||||
a smart package called `d3`.
|
||||
|
||||
* `Meteor.Collection` now takes its optional `manager` argument (used to
|
||||
associate a collection with a server you've connected to with
|
||||
`Meteor.connect`) as a named option. (The old call syntax continues to work
|
||||
for now.)
|
||||
|
||||
* Fix a bug where trying to immediately resubscribe to a record set after
|
||||
unsubscribing could fail silently.
|
||||
|
||||
* Better error handling for failed Mongo writes from inside methods; previously,
|
||||
errors here could cause clients to stop processing data from the server.
|
||||
|
||||
|
||||
Patches contributed by GitHub users bradens, dandv, dybskiy, possibilities,
|
||||
zhangcheng, and 75lb.
|
||||
|
||||
|
||||
## v0.4.2
|
||||
|
||||
|
||||
152
LICENSE.txt
152
LICENSE.txt
@@ -356,6 +356,11 @@ Copyright (c) 2011:
|
||||
Tim Koschützki (tim@debuggable.com)
|
||||
Felix Geisendörfer (felix@debuggable.com)
|
||||
|
||||
----------
|
||||
node-form-data: https://github.com/felixge/node-form-data
|
||||
----------
|
||||
|
||||
Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
|
||||
|
||||
|
||||
==============
|
||||
@@ -622,6 +627,11 @@ npmlog: https://github.com/isaacs/npmlog
|
||||
once: https://github.com/isaacs/once
|
||||
osenv: https://github.com/isaacs/osenv
|
||||
mute-stream: https://github.com/isaacs/mute-stream
|
||||
couch-login: https://github.com/isaacs/couch-login
|
||||
npmconf: https://github.com/isaacs/npmconf
|
||||
read-installed: https://github.com/isaacs/read-installed
|
||||
read-package-json: https://github.com/isaacs/read-package-json
|
||||
promzard: https://github.com/isaacs/promzard
|
||||
----------
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter ("Author")
|
||||
@@ -685,6 +695,104 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
----------
|
||||
jsbn: http://www-cs-students.stanford.edu/~tjw/jsbn/
|
||||
----------
|
||||
|
||||
Copyright (c) 2003-2005 Tom Wu
|
||||
All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY
|
||||
WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL,
|
||||
INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER
|
||||
RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF
|
||||
THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
In addition, the following condition applies:
|
||||
|
||||
All redistributions must retain an intact copy of this copyright notice
|
||||
and disclaimer.
|
||||
|
||||
|
||||
----------
|
||||
jsSHA2: http://anmar.eu.org/projects/jssha2/
|
||||
with Unicode support (Utf8Encode function) added by
|
||||
http://www.webtoolkit.info/javascript-sha256.html
|
||||
----------
|
||||
|
||||
Copyright (c) 2003-2004, Angel Marin
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
* Neither the name of the <ORGANIZATION> nor the names of its contributors may
|
||||
be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
----------
|
||||
D3: http://d3js.org/
|
||||
----------
|
||||
|
||||
Copyright (c) 2012, Michael Bostock
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* The name Michael Bostock may not be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL MICHAEL BOSTOCK BE LIABLE FOR ANY DIRECT,
|
||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
|
||||
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
|
||||
=============
|
||||
@@ -775,6 +883,36 @@ By Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
|
||||
|
||||
----------
|
||||
node-stream-buffer: https://github.com/samcday/node-stream-buffer
|
||||
----------
|
||||
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <http://unlicense.org/>
|
||||
|
||||
|
||||
----------
|
||||
mongodb: http://www.mongodb.org/
|
||||
----------
|
||||
@@ -1636,3 +1774,17 @@ maintained libraries. The externally maintained libraries used by Node are:
|
||||
THE SOFTWARE.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
----------
|
||||
OpenStreetMap: http://openstreetmap.org/
|
||||
(San Francisco SOMA map in examples/parties/public/soma.jpeg)
|
||||
----------
|
||||
|
||||
OpenStreetMap is open data, licensed under the Open Data Commons Open Database
|
||||
License (ODbL): http://opendatacommons.org/licenses/odbl/1.0/. Any rights in
|
||||
individual contents of the database are licensed under the Database Contents
|
||||
License: http://opendatacommons.org/licenses/dbcl/1.0/ The cartography is
|
||||
licensed under the under the Creative Commons Attribution-ShareAlike 2.0 license
|
||||
(CC-BY-SA). Full information is available at
|
||||
http://www.openstreetmap.org/copyright
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
meteor (0.4.2-1) unstable; urgency=low
|
||||
meteor (0.5.0-1) unstable; urgency=low
|
||||
|
||||
* Automated debian build.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## example.
|
||||
|
||||
URLBASE="https://d3sqy0vbqsdhku.cloudfront.net"
|
||||
VERSION="0.4.2"
|
||||
VERSION="0.5.0"
|
||||
PKGVERSION="${VERSION}-1"
|
||||
|
||||
UNAME=`uname`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "0.4.2",
|
||||
"deb_version": "0.4.2-1",
|
||||
"rpm_version": "0.4.2-1",
|
||||
"version": "0.5.0",
|
||||
"deb_version": "0.5.0-1",
|
||||
"rpm_version": "0.5.0-1",
|
||||
"urlbase": "https://d3sqy0vbqsdhku.cloudfront.net"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
Summary: Meteor platform and JavaScript application server
|
||||
Vendor: Meteor
|
||||
Name: meteor
|
||||
Version: 0.4.2
|
||||
Version: 0.5.0
|
||||
Release: 1
|
||||
License: MIT
|
||||
Group: Networking/WWW
|
||||
|
||||
@@ -9,6 +9,7 @@ PACKAGES_DIR=`dirname $0`/../packages
|
||||
echo 'Meteor = {};'
|
||||
cat $PACKAGES_DIR/uuid/uuid.js
|
||||
cat $PACKAGES_DIR/deps/deps.js
|
||||
cat $PACKAGES_DIR/deps/deps-utils.js
|
||||
cat $PACKAGES_DIR/liverange/liverange.js
|
||||
cat $PACKAGES_DIR/universal-events/listener.js
|
||||
cat $PACKAGES_DIR/universal-events/events-ie.js
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
exports.CURRENT_VERSION = "0.4.2";
|
||||
exports.CURRENT_VERSION = "0.5.0";
|
||||
|
||||
var fs = require("fs");
|
||||
var http = require("http");
|
||||
|
||||
@@ -2,7 +2,7 @@ try {
|
||||
// XXX can't get this from updater.js because in 0.3.7 and before the
|
||||
// updater didn't have the right NODE_PATH set. At some point we can
|
||||
// remove this and just use updater.CURRENT_VERSION.
|
||||
var VERSION = "0.4.2";
|
||||
var VERSION = "0.5.0";
|
||||
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
# but you can also edit it by hand.
|
||||
|
||||
autopublish
|
||||
insecure
|
||||
preserve-inputs
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
<h1 id="api">The Meteor API</h1>
|
||||
|
||||
Your Javascript code can run in two environments: the <i>client</i>
|
||||
(browser), and the <i>server</i> (a Node.js container on a server). For
|
||||
each function in this API reference, we'll indicate if the function is
|
||||
available just on the client, just on the server, or *Anywhere*.
|
||||
Your Javascript code can run in two environments: the *client* (browser), and
|
||||
the *server* (a [Node.js](http://nodejs.org/) container on a server). For each
|
||||
function in this API reference, we'll indicate if the function is available just
|
||||
on the client, just on the server, or *Anywhere*.
|
||||
|
||||
<h2 id="core"><span>Meteor Core</span></h2>
|
||||
|
||||
@@ -47,18 +47,25 @@ will publish that cursor's documents.
|
||||
|
||||
// server: publish the rooms collection, minus secret info.
|
||||
Meteor.publish("rooms", function () {
|
||||
return Rooms.find({}, {fields: {secretInfo: false}});
|
||||
return Rooms.find({}, {fields: {secretInfo: 0}});
|
||||
});
|
||||
|
||||
Otherwise, the publish function can <i>set</i> and <i>unset</i>
|
||||
individual record attributes on a client, use these methods provided by
|
||||
`this` in your publish function.
|
||||
// ... and publish secret info for rooms where the logged-in user
|
||||
// is an admin. If the client subscribes to both streams, the records
|
||||
// are merged together into the same documents in the Rooms collection.
|
||||
Meteor.publish("adminSecretInfo", function () {
|
||||
return Rooms.find({admin: this.userId}, {fields: {secretInfo: 1}});
|
||||
});
|
||||
|
||||
Otherwise, the publish function can [`set`](#publish_set) and
|
||||
[`unset`](#publish_unset) individual record attributes on a client. These
|
||||
methods are provided by `this` in your publish function.
|
||||
|
||||
<!-- discuss complete -->
|
||||
|
||||
In particular, if you use observe() to watch changes to the database, be
|
||||
sure to call `this.flush` from inside your observe callbacks. Methods
|
||||
that update the database are considered finished when the observe
|
||||
In particular, if you use [`observe`](#observe) to watch changes to the
|
||||
database, be sure to call `this.flush` from inside your observe callbacks.
|
||||
Methods that update the database are considered finished when the `observe`
|
||||
callbacks return.
|
||||
|
||||
Example:
|
||||
@@ -108,6 +115,11 @@ project that includes the `autopublish` package. Your publish function
|
||||
will still work.
|
||||
{{/warning}}
|
||||
|
||||
{{> api_box subscription_userId}}
|
||||
|
||||
This is constant. However, if the logged-in user changes, the publish
|
||||
function is rerun with the new value.
|
||||
|
||||
{{> api_box subscription_set}}
|
||||
{{> api_box subscription_unset}}
|
||||
{{> api_box subscription_complete}}
|
||||
@@ -145,9 +157,9 @@ attribute.)
|
||||
|
||||
If all of the attributes in a document are removed, Meteor
|
||||
will remove the (now empty) document. If you want to publish empty
|
||||
documents, just use a placeholder attribute.
|
||||
documents, just use a placeholder attribute:
|
||||
|
||||
// Clicks.insert({exists: true});
|
||||
Clicks.insert({exists: true});
|
||||
|
||||
{{> api_box autosubscribe}}
|
||||
|
||||
@@ -201,6 +213,8 @@ object, which provides the following:
|
||||
* `isSimulation`: a boolean value, true if this invocation is a stub.
|
||||
* `unblock`: when called, allows the next method from this client to
|
||||
begin running.
|
||||
* `userId`: the id of the current user.
|
||||
* `setUserId`: a function that associates the current client with a user.
|
||||
|
||||
Calling `methods` on the client defines *stub* functions associated with
|
||||
server methods of the same name. You don't have to define a stub for
|
||||
@@ -215,6 +229,29 @@ intended to *simulate* the result of what the server's method will do,
|
||||
but without waiting for the round trip delay. If a stub throws an
|
||||
exception it will be logged to the console.
|
||||
|
||||
|
||||
{{> api_box method_invocation_userId}}
|
||||
|
||||
The user id is an arbitrary string — typically the id of the user record
|
||||
in the database. You can set it with the `setUserId` function. If you're using
|
||||
the [Meteor accounts system](#accounts_api) then this is handled for you.
|
||||
|
||||
{{> api_box method_invocation_setUserId}}
|
||||
|
||||
Call this function to change the currently logged in user on the
|
||||
connection that made this method call. This simply sets the value of
|
||||
`userId` for future method calls received on this connection. Pass
|
||||
`null` to log out the connection.
|
||||
|
||||
If you are using the [built-in Meteor accounts system](#accounts_api) then this
|
||||
should correspond to the `_id` field of a document in the
|
||||
[`Meteor.users`](#meteor_users) collection.
|
||||
|
||||
`setUserId` is not retroactive. It affects the current method call and
|
||||
any future method calls on the connection. Any previous method calls on
|
||||
this connection will still see the value of `userId` that was in effect
|
||||
when they started.
|
||||
|
||||
{{> api_box method_invocation_isSimulation}}
|
||||
|
||||
{{> api_box method_invocation_unblock}}
|
||||
@@ -238,7 +275,7 @@ This is how to invoke a method. It will run the method on the server.
|
||||
If a stub is available, it will also run the stub on the client.
|
||||
|
||||
If you include a callback function as the last argument (which can't be
|
||||
an argument to the method, since functions aren't serializeable), the
|
||||
an argument to the method, since functions aren't serializable), the
|
||||
method will run asynchronously: it will return nothing in particular and
|
||||
will not throw an exception. When the method is complete (which may or
|
||||
may not happen before `Meteor.call` returns), the callback will be
|
||||
@@ -282,8 +319,9 @@ only to the server.)
|
||||
|
||||
{{> api_box meteor_apply}}
|
||||
|
||||
`Meteor.apply` is just like `Meteor.call`, but it allows the
|
||||
arguments to be passed as an array.
|
||||
`Meteor.apply` is just like `Meteor.call`, except that the method arguments are
|
||||
passed as an array rather than directly as arguments, and you can specify
|
||||
options about how the client executes the method.
|
||||
|
||||
<h2 id="connections"><span>Server connections</span></h2>
|
||||
|
||||
@@ -305,9 +343,9 @@ the server. The return value is an object with the following fields:
|
||||
<dt><span class="name">status</span>
|
||||
<span class="type">String</span></dt>
|
||||
<dd>Describes the current reconnection status. The possible
|
||||
values are <code>connected</code> (the connection is up and
|
||||
running), <code>connecting</code> (disconnected and trying to open a
|
||||
new connection), and <code>waiting</code> (failed to connect and
|
||||
values are `connected` (the connection is up and
|
||||
running), `connecting` (disconnected and trying to open a
|
||||
new connection), and `waiting` (failed to connect and
|
||||
waiting to try to reconnect).</dd>
|
||||
|
||||
<dt><span class="name">retryCount</span>
|
||||
@@ -319,15 +357,15 @@ the server. The return value is an object with the following fields:
|
||||
<span class="type">Number or undefined</span></dt>
|
||||
<dd>The estimated time of the next reconnection attempt. To turn this
|
||||
into an interval until the next reconnection, use
|
||||
<code>retryTime - (new Date()).getTime()</code>. This key will
|
||||
be set only when <code>status</code> is <code>waiting</code>.
|
||||
`retryTime - (new Date()).getTime()`. This key will
|
||||
be set only when `status` is `waiting`.
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
Instead of using callbacks to notify you on changes, this is
|
||||
a <a href="#reactivity">reactive</a> data source. You can use it in a
|
||||
<a href="#templates">template</a> or <a href="#meteor_deps">invalidation
|
||||
context</a> to get realtime updates.
|
||||
a [reactive](#reactivity) data source. You can use it in a
|
||||
[template](#templates) or [invalidation](#meteor_deps)
|
||||
context to get realtime updates.
|
||||
|
||||
{{> api_box reconnect}}
|
||||
|
||||
@@ -337,13 +375,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
|
||||
[Meteor.subscribe](#meteor_subscribe).
|
||||
* `call` -
|
||||
Invoke a method. See [Meteor.call](#meteor_call).
|
||||
* `apply` -
|
||||
Invoke a method with an argument array. See
|
||||
[Meteor.apply](#meteor_apply).
|
||||
* `methods` -
|
||||
Define client-only stubs for methods defined on the remote server. See
|
||||
[Meteor.methods](#meteor_methods).
|
||||
* `status` -
|
||||
Get the current connection status. See
|
||||
[Meteor.status](#meteor_status).
|
||||
* `reconnect` -
|
||||
See [Meteor.reconnect](#meteor_reconnect).
|
||||
* `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.
|
||||
@@ -399,7 +452,8 @@ Specifically, when you pass a `name`, here's what happens:
|
||||
|
||||
* On the server, a collection with that name is created on a backend
|
||||
Mongo server. When you call methods on that collection on the server,
|
||||
they translate directly into normal Mongo operations.
|
||||
they translate directly into normal Mongo operations (after checking that
|
||||
they match your [access control rules](#allow)).
|
||||
|
||||
* On the client, a Minimongo instance is
|
||||
created. Minimongo is essentially an in-memory, non-persistent
|
||||
@@ -426,13 +480,8 @@ the package:
|
||||
|
||||
$ meteor remove autopublish
|
||||
|
||||
{{#warning}}
|
||||
Currently the client is given full write access to the collection. They
|
||||
can execute arbitrary Mongo update commands. Once we build
|
||||
authentication, you will be able to limit the client's direct access to
|
||||
insert, update, and remove. We are also considering validators and
|
||||
other ORM-like functionality.
|
||||
{{/warning}}
|
||||
and instead call [`Meteor.publish`](#meteor_publish) to specify which parts of
|
||||
your collection should be published to which users.
|
||||
|
||||
// Create a collection called Posts and put a document in it. The
|
||||
// document will be immediately visible in the local copy of the
|
||||
@@ -501,8 +550,8 @@ those changes may or may not appear in the result set.
|
||||
|
||||
Cursors are a reactive data source. The first time you retrieve a
|
||||
cursor's documents with `fetch`, `map`, or `forEach` inside a
|
||||
reactive context (eg, [`Meteor.render`](#meteor_render),
|
||||
[`Meteor.autosubscribe`](#meteor_autosubscribe), Meteor will register a
|
||||
reactive context (eg, [`Meteor.render`](#meteor_render) or
|
||||
[`Meteor.autosubscribe`](#meteor_autosubscribe)), Meteor will register a
|
||||
dependency on the underlying data. Any change to the collection that
|
||||
changes the documents in a cursor will trigger a recomputation. To
|
||||
disable this behavior, pass `{reactive: false}` as an option to
|
||||
@@ -544,10 +593,9 @@ Example:
|
||||
|
||||
{{> api_box update}}
|
||||
|
||||
Modify documents that match `selector` as
|
||||
given by `modifier` (see <a href="#modifiers">modifier
|
||||
documentation</a>). By default, modify only one matching document.
|
||||
If `multi` is true, modify all matching documents.
|
||||
Modify documents that match `selector` as given by `modifier` (see [modifier
|
||||
documentation](#modifiers)). By default, modify only one matching document. If
|
||||
`multi` is true, modify all matching documents.
|
||||
|
||||
Instead of a selector, you can pass a string, which will be
|
||||
interpreted as an `_id`.
|
||||
@@ -608,6 +656,153 @@ Example:
|
||||
Logs.remove({});
|
||||
|
||||
|
||||
{{> api_box allow}}
|
||||
|
||||
When a client calls `insert`, `update`, or `remove` on a collection, the
|
||||
collection's `allow` and [`deny`](#deny) callbacks are called
|
||||
on the server to determine if the write should be allowed. If at least
|
||||
one `allow` callback allows the write, and no `deny` callbacks deny the
|
||||
write, then the write is allowed to proceed.
|
||||
|
||||
These checks are run only when a client tries to write to the database
|
||||
directly, for example by calling `update` from inside an event
|
||||
handler. Server code is trusted and isn't subject to `allow` and `deny`
|
||||
restrictions. That includes methods that are called with `Meteor.call`
|
||||
— they are expected to do their own access checking rather than
|
||||
relying on `allow` and `deny`.
|
||||
|
||||
You can call `allow` as many times as you like, and each call can
|
||||
include any combination of `insert`, `update`, and `remove`
|
||||
functions. The functions should return `true` if they think the
|
||||
operation should be allowed. Otherwise they should return `false`, or
|
||||
nothing at all (`undefined`). In that case Meteor will continue
|
||||
searching through any other `allow` rules on the collection.
|
||||
|
||||
The available callbacks are:
|
||||
|
||||
<dl class="callbacks">
|
||||
{{#dtdd "insert(userId, doc)"}}
|
||||
The user `userId` wants to insert the document `doc` into the
|
||||
collection. Return `true` if this should be allowed.
|
||||
{{/dtdd}}
|
||||
|
||||
{{#dtdd "update(userId, docs, fields, modifier)"}}
|
||||
The user `userId` wants to update some documents. Meteor has fetched the
|
||||
documents from the database and they are available in `docs` as an
|
||||
array. Return `true` if the user should be allowed to change these
|
||||
documents.
|
||||
|
||||
Additional details about the proposed modification are in `fields` and
|
||||
`modifier`. `fields` is the top-level fields in the document that the
|
||||
client wishes to modify, for example `['name', 'score']`. `modifier` is
|
||||
the raw Mongo modifier that the client wants to execute, for example
|
||||
`{$set: {'name.first': "Alice"}, $inc: {score: 1}}`.
|
||||
|
||||
Only Mongo modifiers are supported (operations like `$set` and `$push`.)
|
||||
If the user tries to replace the entire document rather than use
|
||||
$-modifiers, the request will be denied without checking the `allow`
|
||||
functions.
|
||||
|
||||
{{/dtdd}}
|
||||
|
||||
{{#dtdd "remove(userId, docs)"}}
|
||||
The user `userId` wants to remove some documents. Meteor has fetched the
|
||||
documents from the database and they are available in `docs` as an
|
||||
array. Return `true` if the user should be allowed to remove these
|
||||
documents.
|
||||
{{/dtdd}}
|
||||
|
||||
</dl>
|
||||
|
||||
By default, when Meteor fetches the documents from the database for the
|
||||
`docs` array, it will retrieve all of the fields in the documents. For
|
||||
efficiency you may instead want to retrieve just the fields that are
|
||||
actually needed by your functions. This is enabled by the `fetch`
|
||||
option. Set `fetch` to an array of the field names that should be
|
||||
retrieved.
|
||||
|
||||
Example:
|
||||
|
||||
// Create a collection where users can only modify documents that
|
||||
// they own. Ownership is tracked by an 'owner' field on each
|
||||
// document. All documents must be owned by the user that created
|
||||
// them and ownership can't be changed. Only a document's owner
|
||||
// is allowed to delete it, and the 'locked' attribute can be
|
||||
// set on a document to prevent its accidental deletion.
|
||||
|
||||
Posts = new Meteor.Collection("posts");
|
||||
|
||||
Posts.allow({
|
||||
insert: function (userId, doc) {
|
||||
// the user must be logged in, and the document must be owned by the user
|
||||
return (userId && doc.owner === userId);
|
||||
},
|
||||
update: function (userId, docs, fields, modifier) {
|
||||
// can only change your own documents
|
||||
return _.all(docs, function(doc) {
|
||||
return doc.owner === userId;
|
||||
});
|
||||
},
|
||||
remove: function (userId, docs) {
|
||||
// can only remove your own documents
|
||||
return _.all(docs, function(doc) {
|
||||
return doc.owner === userId;
|
||||
});
|
||||
},
|
||||
fetch: ['owner']
|
||||
});
|
||||
|
||||
Posts.deny({
|
||||
update: function (userId, docs, fields, modifier) {
|
||||
// can't change owners
|
||||
return _.contains(fields, 'owner');
|
||||
},
|
||||
remove: function (userId, docs) {
|
||||
// can't remove locked documents
|
||||
return _.any(docs, function (doc) {
|
||||
return doc.locked;
|
||||
});
|
||||
},
|
||||
fetch: ['locked'] // no need to fetch 'owner'
|
||||
});
|
||||
|
||||
If you never set up any `allow` rules on a collection then all client
|
||||
writes to the collection will be denied, and it will only be possible to
|
||||
write to the collection from server-side code. In this case you will
|
||||
have to create a method for each possible write that clients are allowed
|
||||
to do. You'll then call these methods with `Meteor.call` rather than
|
||||
having the clients call `insert`, `update`, and `remove` directly on the
|
||||
collection.
|
||||
|
||||
Meteor also has a special "insecure mode" for quickly prototyping new
|
||||
applications. In insecure mode, if you haven't set up any `allow` or `deny`
|
||||
rules on a collection, then all users have full write access to the
|
||||
collection. This is the only effect of insecure mode. If you call `allow` or
|
||||
`deny` at all on a collection, even `Posts.allow({})`, then access is checked
|
||||
just like normal on that collection. __New Meteor projects start in insecure
|
||||
mode by default.__ To turn it off just type `meteor remove insecure`.
|
||||
|
||||
{{#note}}
|
||||
For `update` and `remove`, documents will be affected only if they match
|
||||
the selector both at the time the documents are fetched to run the
|
||||
`allow` and `deny` rules, __and__ at the time that the operation is
|
||||
actually executed. This is accomplished by rewriting the selector to
|
||||
`{$and: [(original selector), {$in: {_id: [(ids of documents fetched
|
||||
and checked by allow and deny)]}}]}`.
|
||||
{{/note}}
|
||||
|
||||
{{> api_box deny}}
|
||||
|
||||
This works just like [`allow`](#allow), except it lets you
|
||||
make sure that certain writes are definitely denied, even if there is an
|
||||
`allow` rule that says that they should be permitted.
|
||||
|
||||
When a client tries to write to a collection, the Meteor server first
|
||||
checks the collection's `deny` rules. If none of them return true then
|
||||
it checks the collection's `allow` rules. Meteor allows the write only
|
||||
if no `deny` rules return `true` and at least one `allow` rule returns
|
||||
`true`.
|
||||
|
||||
<h2 id="meteor_collection_cursor"><span>Cursors</span></h2>
|
||||
|
||||
To create a cursor, use [`find`](#find). To access the documents in a
|
||||
@@ -751,7 +946,8 @@ But they can also contain more complicated tests:
|
||||
// Matches documents where fruit is one of three possibilities
|
||||
{fruit: {$in: ["peach", "plum", "pear"]}}
|
||||
|
||||
See the <a href="http://www.mongodb.org/display/DOCS/Advanced+Queries" target="_blank">complete documentation.</a>
|
||||
See the [complete
|
||||
documentation](http://www.mongodb.org/display/DOCS/Advanced+Queries).
|
||||
|
||||
{{/api_box_inline}}
|
||||
|
||||
@@ -767,14 +963,17 @@ place by changing some of its fields. Some examples:
|
||||
// 'supporters' array
|
||||
{$inc: {votes: 2}, $push: {supporters: "Traz"}}
|
||||
|
||||
But if a modifier doesn't contain any $-operators, then it is
|
||||
instead interpreted as a literal document, and completely replaces
|
||||
whatever was previously in the database.
|
||||
But if a modifier doesn't contain any $-operators, then it is instead
|
||||
interpreted as a literal document, and completely replaces whatever was
|
||||
previously in the database. (Literal document modifiers are not currently
|
||||
supported by [validated updates](#allow).)
|
||||
|
||||
// Find the document with id "123", and completely replace it.
|
||||
Users.update({_id: "123"}, {name: "Alice", friends: ["Bob"]});
|
||||
|
||||
See the <a href="http://www.mongodb.org/display/DOCS/Updating#Updating-ModifierOperations" target="_blank">full list of modifiers.</a>
|
||||
See the [full list of
|
||||
modifiers](http://www.mongodb.org/display/DOCS/Updating#Updating-ModifierOperations)
|
||||
full list of modifiers.
|
||||
|
||||
{{/api_box_inline}}
|
||||
|
||||
@@ -858,7 +1057,7 @@ Example:
|
||||
|
||||
{{> api_box equals}}
|
||||
|
||||
These two expressions do the same thing:
|
||||
If value is a scalar, then these two expressions do the same thing:
|
||||
|
||||
(1) Session.get("key") === value
|
||||
(2) Session.equals("key", value)
|
||||
@@ -905,6 +1104,415 @@ Example:
|
||||
// If Session.get had been used instead of Session.equals, then
|
||||
// when the selection changed, all the items would be re-rendered.
|
||||
|
||||
For object and array session values, you cannot use `Session.equals`; instead,
|
||||
you need to use the `underscore` package and write
|
||||
`_.isEqual(Session.get(key), value)`.
|
||||
|
||||
|
||||
|
||||
<h2 id="accounts_api"><span>Accounts</span></h2>
|
||||
|
||||
The Meteor Accounts system builds on top of the `userId` support in
|
||||
[`publish`](#publish_userId) and [`methods`](#method_userId). The core
|
||||
packages add the concept of user documents stored in the database, and
|
||||
additional packages add [secure password
|
||||
authentication](#accounts_passwords), [integration with third party
|
||||
login services](#meteor_loginwithexternalservice), and a [pre-built user
|
||||
interface](#accountsui).
|
||||
|
||||
The basic Accounts system is in the `accounts-base` package, but
|
||||
applications typically include this automatically by adding one of the
|
||||
login provider packages: `accounts-password`, `accounts-facebook`,
|
||||
`accounts-github`, `accounts-google`, `accounts-twitter`, or
|
||||
`accounts-weibo`.
|
||||
|
||||
|
||||
{{> api_box user}}
|
||||
|
||||
Retreives the user record for the current user from
|
||||
the [`Meteor.users`](#meteor_users) collection.
|
||||
|
||||
On the client this will be a subset of the fields in the document, only
|
||||
those that are published from the server are available on the client. By
|
||||
default the server publishes `username`, `emails`, and
|
||||
`profile`. See [`Meteor.users`](#meteor_users) for more on
|
||||
the fields used in user documents.
|
||||
|
||||
If the user is logged in but the user's database record is not fully
|
||||
loaded yet, this returns an object with only the `_id` field set. During
|
||||
this period [`userLoaded`](#meteor_userloaded) will return
|
||||
`false`.
|
||||
|
||||
{{> api_box userId}}
|
||||
|
||||
{{> api_box users}}
|
||||
|
||||
This collection contains one document per registered user. Here's an example
|
||||
user document:
|
||||
|
||||
{
|
||||
_id: "bbca5d6a-2156-41c4-89da-0329e8c99a4f", // Meteor.userId()
|
||||
username: "cool_kid_13", // unique name
|
||||
emails: [
|
||||
// each email address can only belong to one user.
|
||||
{ address: "cool@example.com", verified: true },
|
||||
{ address: "another@different.com", verified: false }
|
||||
],
|
||||
createdAt: 1349761684042,
|
||||
profile: {
|
||||
// The profile is writable by the user by default.
|
||||
name: "Joe Schmoe"
|
||||
},
|
||||
services: {
|
||||
facebook: {
|
||||
id: "709050", // facebook id
|
||||
accessToken: "AAACCgdX7G2...AbV9AZDZD"
|
||||
},
|
||||
resume: {
|
||||
loginTokens: [
|
||||
{ token: "97e8c205-c7e4-47c9-9bea-8e2ccc0694cd",
|
||||
when: 1349761684048 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
A user document can contain any data you want to store about a user. Meteor
|
||||
treats the following fields specially:
|
||||
|
||||
- `username`: a unique String identifying the user.
|
||||
- `emails`: an Array of Objects with keys `address` and `verified`;
|
||||
an email address may belong to at most one user. `verified` is
|
||||
a Boolean which is true if the user has [verified the
|
||||
address](#accounts_verifyemail) with a token sent over email.
|
||||
- `createdAt`: a numeric timestamp (milliseconds since January 1 1970)
|
||||
of the time the user document was created.
|
||||
- `profile`: an Object which (by default) the user can create
|
||||
and update with any data.
|
||||
- `services`: an Object containing data used by particular
|
||||
login services. For example, its `reset` field contains
|
||||
tokens used by [forgot password](#accounts_forgotpassword) links,
|
||||
and its `resume` field contains tokens used to keep you
|
||||
logged in between sessions.
|
||||
|
||||
Like all [Meteor.Collection](#collections)s, you can access all
|
||||
documents on the server, but only those specifically published by the server are
|
||||
available on the client.
|
||||
|
||||
By default, the current user's `username`, `emails` and `profile` are
|
||||
published to the client. You can publish additional fields for the
|
||||
current user with:
|
||||
|
||||
Meteor.publish("userData", function () {
|
||||
return Meteor.users.find({_id: this.userId},
|
||||
{fields: {'other': 1, 'things': 1}});
|
||||
});
|
||||
|
||||
If the `autopublish` package is installed, the `username` and `profile` fields
|
||||
for all users are published to all clients. To publish specific fields from all
|
||||
users:
|
||||
|
||||
Meteor.publish("allUserData", function () {
|
||||
return Meteor.users.find({}, {fields: {'nested.things': 1}});
|
||||
});
|
||||
|
||||
Users are by default allowed to specify their own `profile` field with
|
||||
[`Accounts.createUser`](#accounts_createuser) and modify it with
|
||||
`Meteor.users.update`. To allow users to edit additional fields, use
|
||||
[`Meteor.users.allow`](#allow). To forbid users from making any modifications to
|
||||
their user document:
|
||||
|
||||
Meteor.users.deny({update: function () { return true; }});
|
||||
|
||||
|
||||
{{> api_box userLoaded}}
|
||||
|
||||
There are some cases when the client knows the id of the logged in user
|
||||
but has not yet received the user data from the server. For example, if
|
||||
the user is logged in and reloads the page the user data will be
|
||||
unavailable during initial page load.
|
||||
|
||||
During these periods, `userLoaded` will return false
|
||||
and [`user`](#meteor_user) will return an object with only
|
||||
the `_id` key.
|
||||
|
||||
{{#note}}
|
||||
We realize this is inconvenient. It is a temporary solution. In the
|
||||
future we will either make it unnecessary or fold it into a more
|
||||
general mechanism.
|
||||
{{/note}}
|
||||
|
||||
{{> api_box logout}}
|
||||
|
||||
{{> api_box loginWithPassword}}
|
||||
|
||||
This function is provided by the `accounts-password` package. See the
|
||||
[Passwords](#accounts_passwords) section below.
|
||||
|
||||
|
||||
{{> api_box loginWithExternalService}}
|
||||
|
||||
These functions initiate the login process with an external
|
||||
service (eg: Facebook, Google, etc), using OAuth. When called they open a new pop-up
|
||||
window that loads the provider's login page. Once the user has logged in
|
||||
with the provider, the pop-up window is closed and the Meteor client
|
||||
logs in to the Meteor server with the information provided by the external
|
||||
service.
|
||||
|
||||
<a id="requestpermissions" name="requestpermissions" />
|
||||
|
||||
In addition to identifying the user to your application, some services
|
||||
have APIs that allow you to take action on behalf of the user. To
|
||||
request specific permissions from the user, pass the
|
||||
`requestPermissions` option the login function. This will cause the user
|
||||
to be presented with an additional page in the pop-up dialog to permit
|
||||
access to their data. The user's `accessToken` — with permissions
|
||||
to access the service's API — is stored in the `services` field of
|
||||
the user document. The supported values for `requestPermissions` differ
|
||||
for each login service and are documented on their respective developer
|
||||
sites:
|
||||
|
||||
- Facebook: <http://developers.facebook.com/docs/authentication/permissions/>
|
||||
- GitHub: <http://developer.github.com/v3/oauth/#scopes>
|
||||
- Google: <https://developers.google.com/accounts/docs/OAuth2Login#scopeparameter>
|
||||
- Twitter, Weibo: `requestPermissions` currently not supported
|
||||
|
||||
External login services typically require registering and configuring
|
||||
your application before use. The easiest way to do this is with the
|
||||
[`accounts-ui` package](#accountsui) which presents a step-by-step guide
|
||||
to configuring each service. However, the data can be also be entered
|
||||
manually in the `Accounts.loginServiceConfiguration` collection. For
|
||||
example:
|
||||
|
||||
Accounts.loginServiceConfiguration.insert({
|
||||
service: "weibo",
|
||||
clientId: "1292962797",
|
||||
secret: "75a730b58f5691de5522789070c319bc"
|
||||
});
|
||||
|
||||
|
||||
Each external service has its own login provider package and login function. For
|
||||
example, to support GitHub login, run `$ meteor add accounts-github` and use the
|
||||
`Meteor.loginWithGithub` function:
|
||||
|
||||
Meteor.loginWithGithub({
|
||||
requestPermissions: ['user', 'public_repo']
|
||||
}, function (err) {
|
||||
if (err)
|
||||
Session.set('errorMessage', err.reason || 'Unknown error');
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
{{> api_box accounts_config}}
|
||||
{{> api_box accounts_ui_config}}
|
||||
|
||||
Example:
|
||||
|
||||
Accounts.ui.config({
|
||||
requestPermissions: {
|
||||
facebook: ['user_likes'],
|
||||
github: ['user', 'repo']
|
||||
},
|
||||
passwordSignupFields: 'USERNAME_AND_OPTIONAL_EMAIL'
|
||||
});
|
||||
|
||||
{{> api_box accounts_validateNewUser}}
|
||||
|
||||
This can be called multiple times. If any of the functions return `false` or
|
||||
throw an error, the new user creation is aborted. To set a specific error
|
||||
message (which will be displayed by [`accounts-ui`](#accountsui)), throw a new
|
||||
[`Meteor.Error`](#meteor_error).
|
||||
|
||||
Example:
|
||||
|
||||
// Validate username, sending a specific error message on failure.
|
||||
Accounts.validateNewUser(function (user) {
|
||||
if (user.username && user.username.length >= 3)
|
||||
return true;
|
||||
throw new Meteor.Error(403, "Username must have at least 3 characters");
|
||||
});
|
||||
// Validate username, without a specific error message.
|
||||
Accounts.validateNewUser(function (user) {
|
||||
return user.username !== "root";
|
||||
});
|
||||
|
||||
{{> api_box accounts_onCreateUser}}
|
||||
|
||||
Use this when you need to do more than simply accept or reject new user
|
||||
creation. With this function you can programatically control the
|
||||
contents of new user documents.
|
||||
|
||||
The function you pass will be called with two arguments: `options` and
|
||||
`user`. The `options` argument comes
|
||||
from [`Accounts.createUser`](#accounts_createuser) for
|
||||
password-based users or from an external service login flow. `options` may come
|
||||
from an untrusted client so make sure to validate any values you read from
|
||||
it. The `user` argument is created on the server and contains a
|
||||
proposed user object with all the automatically generated fields
|
||||
required for the user to log in.
|
||||
|
||||
The function should return the user document (either the one passed in or a
|
||||
newly-created object) with whatever modifications are desired. The returned
|
||||
document is inserted directly into the [`Meteor.users`](#meteor_users) collection.
|
||||
|
||||
The default create user function simply copies `options.profile` into
|
||||
the new user document. Calling `onCreateUser` overrides the default
|
||||
hook. This can only be called once.
|
||||
|
||||
Example:
|
||||
|
||||
<!-- XXX replace d6 with _.random once we have underscore 1.4.2 -->
|
||||
|
||||
// Support for playing D&D: Roll 3d6 for dexterity
|
||||
Accounts.onCreateUser(function(options, user) {
|
||||
var d6 = function () { return Math.floor(Math.random() * 6) + 1; };
|
||||
user.dexterity = d6() + d6() + d6();
|
||||
// We still want the default hook's 'profile' behavior.
|
||||
if (options.profile)
|
||||
user.profile = options.profile;
|
||||
return user;
|
||||
});
|
||||
|
||||
|
||||
<h2 id="accounts_passwords"><span>Passwords</span></h2>
|
||||
|
||||
The `accounts-password` package contains a full system for password
|
||||
based authentication. In addition to the basic username and password
|
||||
based sign-in process it also supports email based sign-in including
|
||||
address verification and password recovery emails.
|
||||
|
||||
Unlike most web applications, the Meteor client does not send the user's
|
||||
password directly to the server. It uses the [Secure Remote Password
|
||||
protocol](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol)
|
||||
to ensure the server never sees the user's plain-text password. This
|
||||
helps protect against embarrassing password leaks if the server's
|
||||
database is compromised.
|
||||
|
||||
To add password support to your application, run `$ meteor add
|
||||
accounts-password`. You can construct your own user interface using the
|
||||
functions below, or use the [`accounts-ui` package](#accountsui) to
|
||||
include a turn-key user interface for password based sign-in.
|
||||
|
||||
|
||||
{{> api_box accounts_createUser}}
|
||||
|
||||
On the client this function logs in as the newly created user on
|
||||
successful completion. On the server, it returns the newly created user
|
||||
id.
|
||||
|
||||
On the client, you must pass `password` and one of `username` or `email`
|
||||
— enough information for the user to be able to log in again
|
||||
later. On the server, you can pass any subset of these options, but the
|
||||
user will not be able to log in until it has an identifier and a
|
||||
password.
|
||||
|
||||
To create an account without a password on the server and still let the
|
||||
user pick their own password, call `createUser` with the `email` option
|
||||
and then
|
||||
call [`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail). This
|
||||
will send the user an email with a link to set their initial password.
|
||||
|
||||
By default the `profile` option is added directly to the new user document. To
|
||||
override this behavior, use [`Accounts.onCreateUser`](#accounts_createuser).
|
||||
|
||||
This function is only used for creating users with passwords. The external
|
||||
service login flows do not use this function.
|
||||
|
||||
|
||||
{{> api_box accounts_changePassword}}
|
||||
|
||||
{{> api_box accounts_forgotPassword}}
|
||||
|
||||
This triggers a call
|
||||
to [`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail)
|
||||
on the server. Pass the token the user receives in this email
|
||||
to [`Accounts.resetPassword`](#accounts_resetpassword) to
|
||||
complete the password reset process.
|
||||
|
||||
If you are using the [`accounts-ui` package](#pkg_accounts_ui), this is handled
|
||||
automatically. Otherwise, it is your responsiblity to prompt the user for the
|
||||
new password and call `resetPassword`.
|
||||
|
||||
{{> api_box accounts_resetPassword}}
|
||||
|
||||
This function accepts tokens generated
|
||||
by [`Accounts.sendResetPasswordEmail`](#accounts_sendresetpasswordemail)
|
||||
and
|
||||
[`Accounts.sendEnrollmentEmail`](#accounts_sendenrollmentemail).
|
||||
|
||||
{{> api_box accounts_setPassword}}
|
||||
|
||||
{{> api_box accounts_verifyEmail}}
|
||||
|
||||
This function accepts tokens generated
|
||||
by [`Accounts.sendVerificationEmail`](#accounts_sendverificationemail). It
|
||||
sets the `emails.verified` field in the user record.
|
||||
|
||||
{{> api_box accounts_sendResetPasswordEmail}}
|
||||
|
||||
The token in this email should be passed
|
||||
to [`Accounts.resetPassword`](#accounts_resetpassword).
|
||||
|
||||
To customize the contents of the email, see
|
||||
[`Accounts.emailTemplates`](#accounts_emailtemplates).
|
||||
|
||||
{{> api_box accounts_sendEnrollmentEmail}}
|
||||
|
||||
The token in this email should be passed
|
||||
to [`Accounts.resetPassword`](#accounts_resetpassword).
|
||||
|
||||
To customize the contents of the email, see
|
||||
[`Accounts.emailTemplates`](#accounts_emailtemplates).
|
||||
|
||||
{{> api_box accounts_sendVerificationEmail}}
|
||||
|
||||
The token in this email should be passed
|
||||
to [`Accounts.verifyEmail`](#accounts_verifyemail).
|
||||
|
||||
To customize the contents of the email, see
|
||||
[`Accounts.emailTemplates`](#accounts_emailtemplates).
|
||||
|
||||
{{> api_box accounts_emailTemplates}}
|
||||
|
||||
This is an `Object` with several fields that are used to generate text
|
||||
for the emails by `sendResetPasswordEmail`, `sendEnrollmentEmail`, and
|
||||
`sendVerificationEmail`.
|
||||
|
||||
Override fields of the object by assigning to them:
|
||||
|
||||
- `from`: A `String` with an [RFC5322](http://tools.ietf.org/html/rfc5322) From
|
||||
address. By default email is from `no-reply@meteor.com`. If you wish to
|
||||
receive email from users asking for help with their account, be sure to set
|
||||
this to an email address that you can receive email at.
|
||||
- `siteName`: The public name of your application. Defaults to the DNS name of
|
||||
the application (eg: `awesome.meteor.com`).
|
||||
- `resetPassword`: An `Object` with two fields:
|
||||
- `resetPassword.subject`: A `Function` that takes a user object and returns
|
||||
a `String` for the subject line of a reset password email.
|
||||
- `resetPassword.text`: A `Function` that takes a user object and a url, and
|
||||
returns the body text for a reset password email.
|
||||
- `enrollAccount`: Same as `resetPassword`, but for initial password setup for
|
||||
new accounts.
|
||||
- `verifyEmail`: Same as `resetPassword`, but for verifying the users email
|
||||
address.
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
Accounts.emailTemplates.siteName = "AwesomeSite";
|
||||
Accounts.emailTemplates.from = "AwesomeSite Admin <accounts@example.com>";
|
||||
Accounts.emailTemplates.enrollAccount.subject = function (user) {
|
||||
return "Welcome to Awesome Town, " + user.profile.name;
|
||||
};
|
||||
Accounts.emailTemplates.enrollAccount.text = function (user, url) {
|
||||
return "You have been selected to participate in building a better future!"
|
||||
+ " To activate your account, simply click the link below:\n\n"
|
||||
+ url;
|
||||
};
|
||||
|
||||
|
||||
<h2 id="templates_api"><span>Templates</span></h2>
|
||||
|
||||
A template that you declare as `<{{! }}template name="foo"> ... </{{!
|
||||
@@ -941,7 +1549,9 @@ Otherwise, the HTML is unadorned and static.
|
||||
|
||||
{{> api_box template_rendered}}
|
||||
|
||||
This callback is called once when an instance of Template.*myTemplate* is rendered into DOM nodes and put into the document for the first time, and again each time any part of the template is re-rendered.
|
||||
This callback is called once when an instance of Template.*myTemplate* is
|
||||
rendered into DOM nodes and put into the document for the first time, and again
|
||||
each time any part of the template is re-rendered.
|
||||
|
||||
In the body of the callback, `this` is a [template
|
||||
instance](#template_inst) object that is unique to this occurrence of
|
||||
@@ -983,8 +1593,8 @@ See [Event Maps](#eventmaps) for a detailed description of the event
|
||||
map format and how event handling works in Meteor.
|
||||
|
||||
{{#note}}
|
||||
This syntax replaces the previous syntax: `Template.myTemplate.events = {...}`, but for now, the
|
||||
old syntax still works.
|
||||
This syntax replaces the previous syntax: `Template.myTemplate.events = {...}`,
|
||||
but for now, the old syntax still works.
|
||||
{{/note}}
|
||||
|
||||
|
||||
@@ -1233,11 +1843,12 @@ 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>"}}
|
||||
To handle more than one type of event with the same function, use a comma-separated list.
|
||||
To handle more than one type of event with the same function, use a
|
||||
comma-separated list.
|
||||
{{/dtdd}}
|
||||
</dl>
|
||||
|
||||
@@ -1440,7 +2051,6 @@ sub-template.
|
||||
{{/api_box_inline}}
|
||||
|
||||
|
||||
|
||||
<h2 id="timers"><span>Timers</span></h2>
|
||||
|
||||
Meteor uses global environment variables
|
||||
@@ -1468,7 +2078,7 @@ dependencies — it "just works". The mechanism is simple and
|
||||
efficient. When you call a function that supports reactive updates
|
||||
(say, a database query), it automatically saves the current
|
||||
"invalidation context" object if any (say, the current template being
|
||||
rendered.) Later, when the data changes, it can "invalidates" this
|
||||
rendered.) Later, when the data changes, it can "invalidate" this
|
||||
context (tell the template to rerender itself.) The whole
|
||||
implementation is about 50 lines of code.
|
||||
|
||||
@@ -1628,6 +2238,34 @@ might think of it as a dynamically scoped ("special") variable. (That
|
||||
just means that [`run`](#run) sets it, runs some user-supplied code, and
|
||||
then restores its previous value.)
|
||||
|
||||
{{> api_box autorun }}
|
||||
|
||||
`Meteor.autorun` allows you to set up your own reactive context, where you can
|
||||
perform arbitrary actions when dependencies change. For example, you can monitor
|
||||
a cursor (which is a reactive data source) and aggregate it into a session
|
||||
variable:
|
||||
|
||||
Meteor.autorun(function() {
|
||||
var oldest = _.max(Monkeys.find().fetch(), function (monkey) {
|
||||
return monkey.age;
|
||||
});
|
||||
if (oldest)
|
||||
Session.set("oldest", oldest.name);
|
||||
});
|
||||
|
||||
Or you can wait for a session variable to get a certain value, and do something
|
||||
the first time it does so, using the `stop` handle to prevent further runs:
|
||||
|
||||
Meteor.autorun(function(handle) {
|
||||
if (!Session.equals("shouldAlert", true)) return;
|
||||
handle.stop();
|
||||
alert("Oh no!");
|
||||
});
|
||||
|
||||
The function is invoked immediately and — like all reactive
|
||||
sources — the rerun occurs at the time of the next
|
||||
[`Meteor.flush`](#meteor_flush).
|
||||
|
||||
|
||||
{{> api_box flush }}
|
||||
|
||||
@@ -1661,12 +2299,10 @@ elements are cleaned up by logic that is triggered by context invalidations.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<h2 id="meteor_http"><span>Meteor.http</span></h2>
|
||||
|
||||
`Meteor.http` provides an HTTP API on the client and server. To use
|
||||
these functions, add the HTTP package to your project with `meteor add
|
||||
these functions, add the HTTP package to your project with `$ meteor add
|
||||
http`.
|
||||
|
||||
{{> api_box httpcall}}
|
||||
@@ -1739,7 +2375,7 @@ Example server method:
|
||||
|
||||
Meteor.methods({checkTwitter: function (userId) {
|
||||
this.unblock();
|
||||
var result = Meteor.http.call("GET", "http://api.twitter.com/xxx",
|
||||
var result = Meteor.http.call("GET", "http://api.twitter.com/xyz",
|
||||
{params: {user: userId}});
|
||||
if (result.statusCode === 200)
|
||||
return true
|
||||
@@ -1748,7 +2384,7 @@ Example server method:
|
||||
|
||||
Example asynchronous HTTP call:
|
||||
|
||||
Meteor.http.call("POST", "http://api.twitter.com/xxx",
|
||||
Meteor.http.call("POST", "http://api.twitter.com/xyz",
|
||||
{data: {some: "json", stuff: 1}},
|
||||
function (error, result) {
|
||||
if (result.statusCode === 200) {
|
||||
@@ -1773,9 +2409,9 @@ send mail. Currently, Meteor supports sending mail over SMTP; the `MAIL_URL`
|
||||
environment variable should be of the form
|
||||
`smtp://USERNAME:PASSWORD@HOST:PORT/`. For apps deployed with `meteor deploy`,
|
||||
`MAIL_URL` defaults to an account (provided by
|
||||
<a href="http://www.mailgun.com/" target="_blank">Mailgun</a>) which allows
|
||||
apps to send up to 200 emails per day; you may override this default by
|
||||
assigning to `process.env.MAIL_URL` before your first call to `Email.send`.
|
||||
[Mailgun](http://www.mailgun.com/)) which allows apps to send up to 200 emails
|
||||
per day; you may override this default by assigning to `process.env.MAIL_URL`
|
||||
before your first call to `Email.send`.
|
||||
|
||||
If `MAIL_URL` is not set (eg, when running your application locally),
|
||||
`Email.send` outputs the message to standard output instead.
|
||||
|
||||
@@ -145,6 +145,14 @@ Template.api.subscription_onStop = {
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.subscription_userId = {
|
||||
id: "publish_userId",
|
||||
name: "<i>this</i>.userId",
|
||||
locus: "Server",
|
||||
descr: ["The id of logged-in user, or `null` if no user is logged in."]
|
||||
};
|
||||
|
||||
|
||||
Template.api.subscribe = {
|
||||
id: "meteor_subscribe",
|
||||
name: "Meteor.subscribe(name [, arg1, arg2, ... ] [, onComplete])",
|
||||
@@ -187,6 +195,25 @@ Template.api.methods = {
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.method_invocation_userId = {
|
||||
id: "method_userId",
|
||||
name: "<i>this</i>.userId",
|
||||
locus: "Anywhere",
|
||||
descr: ["The id of the user that made this method call, or `null` if no user was logged in."]
|
||||
};
|
||||
|
||||
Template.api.method_invocation_setUserId = {
|
||||
id: "method_setUserId",
|
||||
name: "<i>this</i>.setUserId(userId)",
|
||||
locus: "Server",
|
||||
descr: ["Set the logged in user."],
|
||||
args: [
|
||||
{name: "userId",
|
||||
type: "String or null",
|
||||
descr: "The value that should be returned by `userId` on this connection."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.method_invocation_unblock = {
|
||||
id: "method_unblock",
|
||||
name: "<i>this</i>.unblock()",
|
||||
@@ -239,7 +266,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 +279,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 +321,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
|
||||
]
|
||||
};
|
||||
|
||||
@@ -364,6 +398,93 @@ Template.api.findone = {
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.insert = {
|
||||
id: "insert",
|
||||
name: "<em>collection</em>.insert(doc, [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Insert a document in the collection. Returns its unique _id."],
|
||||
args: [
|
||||
{name: "doc",
|
||||
type: "Object",
|
||||
descr: "The document to insert. Should not yet have an _id attribute."},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as the first argument and, if no error, the _id as the second."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.update = {
|
||||
id: "update",
|
||||
name: "<em>collection</em>.update(selector, modifier, [options], [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Modify one or more documents in the collection"],
|
||||
args: [
|
||||
{name: "selector",
|
||||
type: "Object: Mongo selector, or String",
|
||||
type_link: "selectors",
|
||||
descr: "Specifies which documents to modify"},
|
||||
{name: "modifier",
|
||||
type: "Object: Mongo modifier",
|
||||
type_link: "modifiers",
|
||||
descr: "Specifies how to modify the documents"},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as its argument."}
|
||||
],
|
||||
options: [
|
||||
{name: "multi",
|
||||
type: "Boolean",
|
||||
descr: "True to modify all matching documents; false to only modify one of the matching documents (the default)."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.remove = {
|
||||
id: "remove",
|
||||
name: "<em>collection</em>.remove(selector, [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Remove documents from the collection"],
|
||||
args: [
|
||||
{name: "selector",
|
||||
type: "Object: Mongo selector, or String",
|
||||
type_link: "selectors",
|
||||
descr: "Specifies which documents to remove"},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as its argument."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.allow = {
|
||||
id: "allow",
|
||||
name: "<em>collection</em>.allow(options)",
|
||||
locus: "Server",
|
||||
descr: ["Allow users to write directly to this collection from client code, subject to limitations you define."],
|
||||
options: [
|
||||
{name: "insert, update, remove",
|
||||
type: "Function",
|
||||
descr: "Functions that look at a proposed modification to the database and return true if it should be allowed."},
|
||||
{name: "fetch",
|
||||
type: "Array of String",
|
||||
descr: "Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.deny = {
|
||||
id: "deny",
|
||||
name: "<em>collection</em>.deny(options)",
|
||||
locus: "Server",
|
||||
descr: ["Override `allow` rules."],
|
||||
options: [
|
||||
{name: "insert, update, remove",
|
||||
type: "Function",
|
||||
descr: "Functions that look at a proposed modification to the database and return true if it should be denied, even if an `allow` rule says otherwise."},
|
||||
{name: "fetch",
|
||||
type: "Array of Strings",
|
||||
descr: "Optional performance enhancement. Limits the fields that will be fetched from the database for inspection by your `update` and `remove` functions."}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
Template.api.cursor_count = {
|
||||
id: "count",
|
||||
name: "<em>cursor</em>.count()",
|
||||
@@ -422,62 +543,6 @@ Template.api.cursor_observe = {
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.insert = {
|
||||
id: "insert",
|
||||
name: "<em>collection</em>.insert(doc, [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Insert a document in the collection. Returns its unique _id."],
|
||||
args: [
|
||||
{name: "doc",
|
||||
type: "Object",
|
||||
descr: "The document to insert. Should not yet have an _id attribute."},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as the first argument and, if no error, the _id as the second."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.update = {
|
||||
id: "update",
|
||||
name: "<em>collection</em>.update(selector, modifier, [options], [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Modify one or more documents in the collection"],
|
||||
args: [
|
||||
{name: "selector",
|
||||
type: "Object: Mongo selector, or String",
|
||||
type_link: "selectors",
|
||||
descr: "Specifies which documents to modify"},
|
||||
{name: "modifier",
|
||||
type: "Object: Mongo modifier",
|
||||
type_link: "modifiers",
|
||||
descr: "Specifies how to modify the documents"},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as its argument."}
|
||||
],
|
||||
options: [
|
||||
{name: "multi",
|
||||
type: "Boolean",
|
||||
descr: "True to modify all matching documents; false to only modify one of the matching documents (the default)."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.remove = {
|
||||
id: "remove",
|
||||
name: "<em>collection</em>.remove(selector, [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Remove documents from the collection"],
|
||||
args: [
|
||||
{name: "selector",
|
||||
type: "Object: Mongo selector, or String",
|
||||
type_link: "selectors",
|
||||
descr: "Specifies which documents to remove"},
|
||||
{name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional. If present, called with an error object as its argument."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.selectors = {
|
||||
id: "selectors",
|
||||
name: "Mongo-style Selectors"
|
||||
@@ -543,6 +608,18 @@ Template.api.current = {
|
||||
descr: ["The current [`invalidation context`](#context), or `null` if not being called from inside [`run`](#run)."]
|
||||
};
|
||||
|
||||
Template.api.autorun = {
|
||||
id: "meteor_autorun",
|
||||
name: "Meteor.autorun(func)",
|
||||
locus: "Client",
|
||||
descr: ["Run a function and rerun it whenever its dependencies change. Returns a handle that provides a `stop` method, which will prevent further reruns."],
|
||||
args: [
|
||||
{name: "func",
|
||||
type: "Function",
|
||||
descr: "The function to run. It receives one argument: the same handle that `Meteor.autorun` returns."}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.flush = {
|
||||
id: "meteor_flush",
|
||||
name: "Meteor.flush()",
|
||||
@@ -603,6 +680,381 @@ Template.api.isolate = {
|
||||
|
||||
|
||||
|
||||
Template.api.user = {
|
||||
id: "meteor_user",
|
||||
name: "Meteor.user()",
|
||||
locus: "Anywhere but publish functions",
|
||||
descr: ["Get the current user record, or `null` if no user is logged in. A reactive data source."]
|
||||
};
|
||||
|
||||
|
||||
Template.api.userId = {
|
||||
id: "meteor_userid",
|
||||
name: "Meteor.userId()",
|
||||
locus: "Anywhere but publish functions",
|
||||
descr: ["Get the current user id, or `null` if no user is logged in. A reactive data source."]
|
||||
};
|
||||
|
||||
|
||||
Template.api.users = {
|
||||
id: "meteor_users",
|
||||
name: "Meteor.users",
|
||||
locus: "Anywhere",
|
||||
descr: ["A [Meteor.Collection](#collections) containing user documents."]
|
||||
};
|
||||
|
||||
Template.api.userLoaded = {
|
||||
id: "meteor_userloaded",
|
||||
name: "Meteor.userLoaded()",
|
||||
locus: "Client",
|
||||
descr: ["Determine if the current user document is fully loaded in [`Meteor.users`](#meteor_users). A reactive data source."]
|
||||
};
|
||||
|
||||
|
||||
|
||||
Template.api.logout = {
|
||||
id: "meteor_logout",
|
||||
name: "Meteor.logout([callback])",
|
||||
locus: "Client",
|
||||
descr: ["Log the user out."],
|
||||
args: [
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
Template.api.loginWithPassword = {
|
||||
id: "meteor_loginwithpassword",
|
||||
name: "Meteor.loginWithPassword(user, password, [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Log the user in with a password."],
|
||||
args: [
|
||||
{
|
||||
name: "user",
|
||||
type: "Object or String",
|
||||
descr: "Either a string interpreted as a username or an email; or an object with a single key: `email`, `username` or `id`."
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
type: "String",
|
||||
descr: "The user's password. This is __not__ sent in plain text over the wire — it is secured with [SRP](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol)."
|
||||
},
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
Template.api.loginWithExternalService = {
|
||||
id: "meteor_loginwithexternalservice",
|
||||
name: "Meteor.loginWith<i>ExternalService</i>([options], [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Log the user in using an external service."],
|
||||
args: [
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: "requestPermissions",
|
||||
type: "Array of Strings",
|
||||
descr: "A list of permissions to request from the user."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
Template.api.accounts_config = {
|
||||
id: "accounts_config",
|
||||
name: "Accounts.config(options)",
|
||||
locus: "Anywhere",
|
||||
descr: ["Set global accounts options."],
|
||||
options: [
|
||||
{
|
||||
name: "sendVerificationEmail",
|
||||
type: "Boolean",
|
||||
descr: "New users with an email address will receive an address verification email."
|
||||
},
|
||||
{
|
||||
name: "forbidClientAccountCreation",
|
||||
type: "Boolean",
|
||||
descr: "[`createUser`](#accounts_createuser) requests from the client will be rejected."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_ui_config = {
|
||||
id: "accounts_ui_config",
|
||||
name: "Accounts.ui.config(options)",
|
||||
locus: "Client",
|
||||
descr: ["Configure the behavior of [`{{loginButtons}}`](#accountsui)."],
|
||||
options: [
|
||||
{
|
||||
name: "requestPermissions",
|
||||
type: "Object",
|
||||
descr: "Which [permissions](#requestpermissions) to request from the user for each external service."
|
||||
},
|
||||
{
|
||||
name: "passwordSignupFields",
|
||||
type: "String",
|
||||
descr: "Which fields to display in the user creation form. One of '`USERNAME_AND_EMAIL`', '`USERNAME_AND_OPTIONAL_EMAIL`', '`USERNAME_ONLY`', or '`EMAIL_ONLY`' (default)."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_validateNewUser = {
|
||||
id: "accounts_validatenewuser",
|
||||
name: "Accounts.validateNewUser(func)",
|
||||
locus: "Server",
|
||||
descr: ["Set restrictions on new user creation."],
|
||||
args: [
|
||||
{
|
||||
name: "func",
|
||||
type: "Function",
|
||||
descr: "Called whenever a new user is created. Takes the new user object, and returns true to allow the creation or false to abort."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_onCreateUser = {
|
||||
id: "accounts_oncreateuser",
|
||||
name: "Accounts.onCreateUser(func)",
|
||||
locus: "Server",
|
||||
descr: ["Customize new user creation."],
|
||||
args: [
|
||||
{
|
||||
name: "func",
|
||||
type: "Function",
|
||||
descr: "Called whenever a new user is created. Return the new user object, or throw an `Error` to abort the creation."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
Template.api.accounts_createUser = {
|
||||
id: "accounts_createuser",
|
||||
name: "Accounts.createUser(options, [callback])",
|
||||
locus: "Anywhere",
|
||||
descr: ["Create a new user."],
|
||||
args: [
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Client only, optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: "username",
|
||||
type: "String",
|
||||
descr: "A unique name for this user."
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "String",
|
||||
descr: "The user's email address."
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
type: "String",
|
||||
descr: "The user's password. This is __not__ sent in plain text over the wire."
|
||||
},
|
||||
{
|
||||
name: "profile",
|
||||
type: "Object",
|
||||
descr: "The user's profile, typically including the `name` field."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_changePassword = {
|
||||
id: "accounts_changepassword",
|
||||
name: "Accounts.changePassword(oldPassword, newPassword, [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Change the current user's password. Must be logged in."],
|
||||
args: [
|
||||
{
|
||||
name: "oldPassword",
|
||||
type: "String",
|
||||
descr: "The user's current password. This is __not__ sent in plain text over the wire."
|
||||
},
|
||||
{
|
||||
name: "newPassword",
|
||||
type: "String",
|
||||
descr: "A new password for the user. This is __not__ sent in plain text over the wire."
|
||||
},
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_forgotPassword = {
|
||||
id: "accounts_forgotpassword",
|
||||
name: "Accounts.forgotPassword(options, [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Request a forgot password email."],
|
||||
args: [
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
],
|
||||
options: [
|
||||
{
|
||||
name: "email",
|
||||
type: "String",
|
||||
descr: "The email address to send a password reset link."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_resetPassword = {
|
||||
id: "accounts_resetpassword",
|
||||
name: "Accounts.resetPassword(token, newPassword, [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Reset the password for a user using a token received in email. Logs the user in afterwards."],
|
||||
args: [
|
||||
{
|
||||
name: "token",
|
||||
type: "String",
|
||||
descr: "The token retrieved from the reset password URL."
|
||||
},
|
||||
{
|
||||
name: "newPassword",
|
||||
type: "String",
|
||||
descr: "A new password for the user. This is __not__ sent in plain text over the wire."
|
||||
},
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
Template.api.accounts_setPassword = {
|
||||
id: "accounts_setpassword",
|
||||
name: "Accounts.setPassword(userId, newPassword)",
|
||||
locus: "Server",
|
||||
descr: ["Forcibly change the password for a user."],
|
||||
args: [
|
||||
{
|
||||
name: "userId",
|
||||
type: "String",
|
||||
descr: "The id of the user to update."
|
||||
},
|
||||
{
|
||||
name: "newPassword",
|
||||
type: "String",
|
||||
descr: "A new password for the user."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_verifyEmail = {
|
||||
id: "accounts_verifyemail",
|
||||
name: "Accounts.verifyEmail(token, [callback])",
|
||||
locus: "Client",
|
||||
descr: ["Marks the user's email address as verified. Logs the user in afterwards."],
|
||||
args: [
|
||||
{
|
||||
name: "token",
|
||||
type: "String",
|
||||
descr: "The token retrieved from the verification URL."
|
||||
},
|
||||
{
|
||||
name: "callback",
|
||||
type: "Function",
|
||||
descr: "Optional callback. Called with no arguments on success, or with a single `Error` argument on failure."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
Template.api.accounts_sendResetPasswordEmail = {
|
||||
id: "accounts_sendresetpasswordemail",
|
||||
name: "Accounts.sendResetPasswordEmail(userId, [email])",
|
||||
locus: "Server",
|
||||
descr: ["Send an email with a link the user can use to reset their password."],
|
||||
args: [
|
||||
{
|
||||
name: "userId",
|
||||
type: "String",
|
||||
descr: "The id of the user to send email to."
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "String",
|
||||
descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_sendEnrollmentEmail = {
|
||||
id: "accounts_sendenrollmentemail",
|
||||
name: "Accounts.sendEnrollmentEmail(userId, [email])",
|
||||
locus: "Server",
|
||||
descr: ["Send an email with a link the user can use to set their initial password."],
|
||||
args: [
|
||||
{
|
||||
name: "userId",
|
||||
type: "String",
|
||||
descr: "The id of the user to send email to."
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "String",
|
||||
descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first email in the list."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Template.api.accounts_sendVerificationEmail = {
|
||||
id: "accounts_sendverificationemail",
|
||||
name: "Accounts.sendVerificationEmail(userId, [email])",
|
||||
locus: "Server",
|
||||
descr: ["Send an email with a link the user can use verify their email address."],
|
||||
args: [
|
||||
{
|
||||
name: "userId",
|
||||
type: "String",
|
||||
descr: "The id of the user to send email to."
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "String",
|
||||
descr: "Optional. Which address of the user's to send the email to. This address must be in the user's `emails` list. Defaults to the first unverified email in the list."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
Template.api.accounts_emailTemplates = {
|
||||
id: "accounts_emailtemplates",
|
||||
name: "Accounts.emailTemplates",
|
||||
locus: "Anywhere",
|
||||
descr: ["Options to customize emails sent from the Accounts system."]
|
||||
};
|
||||
|
||||
|
||||
|
||||
Template.api.setTimeout = {
|
||||
id: "meteor_settimeout",
|
||||
name: "Meteor.setTimeout",
|
||||
@@ -726,7 +1178,7 @@ Template.api.set = {
|
||||
type: "String",
|
||||
descr: "The key to set, eg, `selectedItem`"},
|
||||
{name: "value",
|
||||
type: "String, Number, Boolean, null, or undefined",
|
||||
type: "JSON-able object or undefined",
|
||||
descr: "The new value for `key`"}
|
||||
]
|
||||
};
|
||||
@@ -735,7 +1187,7 @@ Template.api.get = {
|
||||
id: "session_get",
|
||||
name: "Session.get(key)",
|
||||
locus: "Client",
|
||||
descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set)."],
|
||||
descr: ["Get the value of a session variable. If inside a [`Meteor.deps`](#meteor_deps) context, invalidate the context the next time the value of the variable is changed by [`Session.set`](#session_set). This returns a clone of the session value, so if it's an object or an array, mutating the returned value has no effect on the value stored in the session."],
|
||||
args: [
|
||||
{name: "key",
|
||||
type: "String",
|
||||
@@ -760,7 +1212,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: [
|
||||
@@ -951,8 +1403,7 @@ Template.api.template_data = {
|
||||
};
|
||||
|
||||
var rfc = function (descr) {
|
||||
return ('<a href="http://tools.ietf.org/html/rfc5322" target="_blank">RFC5322'
|
||||
+ '</a> ' + descr);
|
||||
return '[RFC5322](http://tools.ietf.org/html/rfc5322) ' + descr;
|
||||
};
|
||||
|
||||
Template.api.email_send = {
|
||||
|
||||
@@ -34,7 +34,7 @@ Run `meteor help run` to see the full list of options.
|
||||
|
||||
<h3 id="meteorcreate">meteor create <i>name</i></h3>
|
||||
|
||||
Create a new Meteor project. Makes a subdirectory named <i>name</i>
|
||||
Create a new Meteor project. Makes a subdirectory named *name*
|
||||
and copies in the template app. You can pass an absolute or relative
|
||||
path.
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ when writing those apps.
|
||||
|
||||
<h2 id="structuringyourapp">Structuring your application</h2>
|
||||
|
||||
A Meteor application is a mix of JavaScript that runs inside a
|
||||
client web browser, JavaScript that runs on the Meteor server inside
|
||||
a Node.js container, and all the supporting HTML fragments, CSS rules,
|
||||
and static assets. Meteor automates the packaging and transmission
|
||||
of these different components. And, it is quite flexible about how
|
||||
you choose to structure those components in your file tree.
|
||||
A Meteor application is a mix of JavaScript that runs inside a client web
|
||||
browser, JavaScript that runs on the Meteor server inside a
|
||||
[Node.js](http://nodejs.org/) container, and all the supporting HTML fragments,
|
||||
CSS rules, and static assets. Meteor automates the packaging and transmission
|
||||
of these different components. And, it is quite flexible about how you choose
|
||||
to structure those components in your file tree.
|
||||
|
||||
The only server asset is JavaScript. Meteor gathers all your JavaScript
|
||||
files, excluding anything under the `client`
|
||||
@@ -83,78 +83,133 @@ for images, `favicon.ico`, `robots.txt`, and anything else.
|
||||
<template name="data">
|
||||
{{#better_markdown}}
|
||||
|
||||
<h2 id="data">Data</h2>
|
||||
<h2 id="dataandsecurity">Data and security</h2>
|
||||
|
||||
Meteor makes writing distributed client code as simple as talking to a
|
||||
local database. It's a clean and simple approach, much easier than
|
||||
building individual RPC endpoints, slow roundtrips to the server, and
|
||||
orchestrating invalidation messages.
|
||||
local database. It's a clean, simple, and secure approach that obviates
|
||||
the need to implement individual RPC endpoints, manually cache data on
|
||||
the client to avoid slow roundtrips to the server, and carefully
|
||||
orchestrate invalidation messages to every client as data changes.
|
||||
|
||||
Every Meteor client includes an in-memory database cache. Each client's
|
||||
cache holds valid copies of some set of documents that are stored in a
|
||||
server's master database. When a matching document in that database
|
||||
changes, Meteor automatically synchronizes that change to every
|
||||
subscribed client.
|
||||
In Meteor, the client and server share the same database API. The same
|
||||
exact application code — like validators and computed properties — can
|
||||
often run in both places. But while code running on the server has
|
||||
direct access to the database, code running on the client does *not*.
|
||||
This distinction is the basis for Meteor's data security model.
|
||||
|
||||
To manage the client caches, your server code <b>publishes</b> sets of
|
||||
documents, and your client code <b>subscribes</b> to those sets. For
|
||||
example, if you are building a chat system, the server might publish two
|
||||
sets: the set of all rooms, and the set of all messages in a given room.
|
||||
Each client would subscribe to the master set of available rooms and the
|
||||
set of messages in the currently-selected room. Once subscribed, the
|
||||
client uses its cache as a fast local database, dramatically simplifying
|
||||
your client model code.
|
||||
{{#note}}
|
||||
By default, a new Meteor app includes the `autopublish` and `insecure`
|
||||
packages, which together mimic the effect of each client having full
|
||||
read/write access to the server's database. These are useful
|
||||
prototyping tools, but typically not appropriate for production
|
||||
applications. When you're ready, just remove the packages.
|
||||
{{/note}}
|
||||
|
||||
Meteor's protocol for distributing document updates is database
|
||||
agnostic. By default, Meteor applications use the
|
||||
familiar <a target="_blank"
|
||||
href="http://www.mongodb.org/display/DOCS/Manual">MongoDB API</a>:
|
||||
servers store documents in MongoDB collections, and clients cache those
|
||||
documents in a client-side cache that implements the same Mongo API for
|
||||
queries and updates.
|
||||
Every Meteor client includes an in-memory database cache. To manage the
|
||||
client cache, the server *publishes* sets of JSON documents, and the
|
||||
client *subscribes* to those sets. As documents in a set change, the
|
||||
server patches each client's cache.
|
||||
|
||||
// server: publish all room documents, and per-room messages
|
||||
Meteor.publish("chatrooms");
|
||||
Each document set is defined by a publish function on the server. The
|
||||
publish function runs each time a new client subscribes to a document
|
||||
set. The data in a document set can come from anywhere, but the common
|
||||
case is to publish a database query.
|
||||
|
||||
// server: publish all room documents
|
||||
Meteor.publish("all-rooms", function () {
|
||||
return Rooms.find(); // everything
|
||||
);
|
||||
|
||||
// server: publish all messages for a given room
|
||||
Meteor.publish("messages", function (roomId) {
|
||||
return Messages.find({room: roomId});
|
||||
});
|
||||
|
||||
// client: subscribe to all rooms, and messages in the first room
|
||||
Meteor.subscribe("chatrooms");
|
||||
Meteor.subscribe("messages", Chatrooms.find()[0]._id);
|
||||
// server: publish the set of parties the logged-in user can see.
|
||||
Meteor.publish("parties", function () {
|
||||
return Parties.find({$or: [{"public": true},
|
||||
{invited: this.userId},
|
||||
{owner: this.userId}]});
|
||||
});
|
||||
|
||||
Document modifications also propagate automatically. Modification
|
||||
instructions like `insert`, `remove`, and `update` are executed
|
||||
immediately on the client's cached data. <i>At the same time</i>, the
|
||||
client sends that instruction up to the server, which executes the same
|
||||
change against the master database. Usually the client and server
|
||||
agree, but should they differ (permissions checking or overlapping with
|
||||
another client, for example), the server's result will publish back down
|
||||
to the client. And of course, all other clients with a matching
|
||||
subscription automatically receive an updated document.
|
||||
Publish functions can provide different results to each client. In the
|
||||
last example, a logged in user can only see `Party` documents that
|
||||
are public, that the user owns, or that the user has been invited to.
|
||||
|
||||
// create new message, executes on both client and server.
|
||||
Messages.insert({room: 2413, text: "hello!"});
|
||||
Once subscribed, the client uses its cache as a fast local database,
|
||||
dramatically simplifying client code. Reads never require a costly
|
||||
round trip to the server. And they're limited to the contents of the
|
||||
cache: a query for every document in a collection on a client will only
|
||||
return documents the server is publishing to that client.
|
||||
|
||||
Putting it all together, these techniques accomplish <i>latency
|
||||
compensation</i>. Clients hold a fresh copy of the data they need, and
|
||||
never need to wait for a roundtrip to the server. And when clients
|
||||
// client: start a parties subscription
|
||||
Meteor.subscribe("parties");
|
||||
|
||||
// client: return array of Parties this client can read
|
||||
return Parties.find().fetch(); // synchronous!
|
||||
|
||||
Sophisticated clients can turn subscriptions on and off to control how
|
||||
much data is kept in the cache and manage network traffic. When a
|
||||
subscription is turned off, all its documents are removed from the cache
|
||||
unless the same document is also provided by another active
|
||||
subscription.
|
||||
|
||||
When the client *changes* one or more documents, it sends a message to
|
||||
the server requesting the change. The server checks the proposed change
|
||||
against a set of allow/deny rules you write as JavaScript functions.
|
||||
The server only accepts the change if all the rules pass.
|
||||
|
||||
// server: don't allow client to insert a party
|
||||
Parties.allow({
|
||||
insert: function (userId, party) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// client: this will fail
|
||||
var party = { ... };
|
||||
Parties.insert(party);
|
||||
|
||||
If the server accepts the change, it applies the change to the database
|
||||
and automatically propagates the change to other clients subscribed to
|
||||
the affected documents. If not, the update fails, the server's database
|
||||
remains untouched, and no other client sees the update.
|
||||
|
||||
Meteor has a cute trick, though. When a client issues a write to the
|
||||
server, it also updates its local cache immediately, without waiting for
|
||||
the server's response. This means the screen will redraw right away.
|
||||
If the server accepted the update — what ought to happen most of the
|
||||
time in a properly behaving client — then the client got a jump on the
|
||||
change and didn't have to wait for the round trip to update its own
|
||||
screen. If the server rejects the change, Meteor patches up the
|
||||
client's cache with the server's result.
|
||||
|
||||
Putting it all together, these techniques accomplish latency
|
||||
compensation. Clients hold a fresh copy of the data they need, and
|
||||
never need to wait for a roundtrip to the server. And when clients
|
||||
modify data, those modifications can run locally without waiting for the
|
||||
confirmation from the server, while still giving the server final say
|
||||
over the requested change.
|
||||
|
||||
You can substitute another database for MongoDB by providing a
|
||||
server-side database driver and/or a client-side cache that implements
|
||||
an alternative API. The `mongo-livedata` is a good starting point for
|
||||
such a project.
|
||||
Meteor includes [Meteor Accounts](#accounts_api), a state-of-the-art
|
||||
authentication system. It features secure password login using the [Secure
|
||||
Remote Password
|
||||
protocol](http://en.wikipedia.org/wiki/Secure_Remote_Password_protocol), and
|
||||
integration with external services including Facebook, GitHub, Google, Twitter,
|
||||
and Weibo. Meteor Accounts defines a [`Meteor.users`](#meteor_users) collection
|
||||
where developers can store application-specific user data.
|
||||
|
||||
Meteor also includes pre-built forms for common tasks like login, signup,
|
||||
password change, and password reset emails. You can add [Accounts
|
||||
UI](#accountsui) to your app with just one line of code. The `accounts-ui` smart
|
||||
package even provides a configuration wizard that walks you through the steps to
|
||||
set up the external login services you're using in your app.
|
||||
|
||||
{{#note}}
|
||||
A pre-release version of Meteor includes a user login system and a set of tools
|
||||
for securing read and write access to data based on the logged-in user. For more
|
||||
information, see the
|
||||
<a target="_blank"
|
||||
href="https://github.com/meteor/meteor/wiki/Getting-Started-with-Auth">Getting
|
||||
Started with Auth</a> wiki page.
|
||||
The current release of Meteor supports MongoDB, the popular document
|
||||
database, and the examples in this section use the
|
||||
[MongoDB API](http://www.mongodb.org/display/DOCS/Manual). Future
|
||||
releases will include support for other databases.
|
||||
{{/note}}
|
||||
|
||||
{{/better_markdown}}
|
||||
@@ -165,11 +220,10 @@ Started with Auth</a> wiki page.
|
||||
|
||||
<h2 id="reactivity">Reactivity</h2>
|
||||
|
||||
Meteor embraces the concept of
|
||||
<a target="_blank" href="http://en.wikipedia.org/wiki/Reactive_programming">
|
||||
reactive programming</a>. This means that you can write your code in a
|
||||
simple imperative style, and the result will be automatically
|
||||
recalculated whenever data changes that your code depends on.
|
||||
Meteor embraces the concept of [reactive
|
||||
programming](http://en.wikipedia.org/wiki/Reactive_programming). This means that
|
||||
you can write your code in a simple imperative style, and the result will be
|
||||
automatically recalculated whenever data changes that your code depends on.
|
||||
|
||||
Meteor.autosubscribe(function () {
|
||||
Meteor.subscribe("messages", Session.get("currentRoomId"));
|
||||
@@ -190,28 +244,34 @@ providers like `Session`, on the other hand, make note of
|
||||
the context they are called from and what data was requested, and they
|
||||
are prepared to send an invalidation signal when the data changes.
|
||||
|
||||
This simple pattern has wide applicability. Above, the programmer is
|
||||
saved from writing unsubscribe/resubscribe calls and making sure they
|
||||
are called at the right time. In general, Meteor can eliminate whole
|
||||
classes of data propagation code which would otherwise clog up your
|
||||
application with error-prone logic.
|
||||
This simple pattern (reactive context + reactive data source) has wide
|
||||
applicability. Above, the programmer is saved from writing
|
||||
unsubscribe/resubscribe calls and making sure they are called at the
|
||||
right time. In general, Meteor can eliminate whole classes of data
|
||||
propagation code which would otherwise clog up your application with
|
||||
error-prone logic.
|
||||
|
||||
These Meteor functions run your code in a reactive context:
|
||||
|
||||
* [Templates](#templates)
|
||||
* [`Meteor.render`](#meteor_render) and [`Meteor.renderList`](#meteor_renderlist)
|
||||
* [`Meteor.autosubscribe`](#meteor_autosubscribe)
|
||||
* [Templates](#templates)
|
||||
* [`Meteor.autorun`](#meteor_autorun)
|
||||
|
||||
And the reactive data sources that can trigger changes are:
|
||||
|
||||
* <a href="#session">Session</a> variables
|
||||
* Database queries on <a href="#find">Collections</a>
|
||||
* <a href="#meteor_status">`Meteor.status`</a>
|
||||
* [`Session`](#session) variables
|
||||
* Database queries on [Collections](#find)
|
||||
* [`Meteor.status`](#meteor_status)
|
||||
* [`Meteor.user`](#meteor_user)
|
||||
* [`Meteor.userId`](#meteor_userid)
|
||||
* [`Meteor.userLoaded`](#meteor_userloaded)
|
||||
|
||||
Meteor's <a href="https://github.com/meteor/meteor/blob/master/packages/deps/deps.js" target="_blank">implementation</a>
|
||||
of reactivity is short and sweet, about 50 lines of code. You can
|
||||
hook into it yourself to add new reactive contexts or data sources,
|
||||
using the <a href="#meteor_deps">Meteor.deps</a> module.
|
||||
Meteor's
|
||||
[implementation](https://github.com/meteor/meteor/blob/master/packages/deps/deps.js)
|
||||
of reactivity is short and sweet, about 50 lines of code. You can hook into it
|
||||
yourself to add new reactive contexts or data sources, using the
|
||||
[`Meteor.deps`](#meteor_deps) module.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
@@ -261,15 +321,14 @@ the new elements using a library like jQuery. In that case, call
|
||||
[`Meteor.flush`](#meteor_flush) to bring the DOM up to date
|
||||
immediately.
|
||||
|
||||
When live-updating DOM elements are taken off the screen, they are
|
||||
automatically cleaned up — their callbacks are torn down, any
|
||||
associated database queries are stopped, and they stop updating. For
|
||||
this reason, you never have to worry about
|
||||
the <a href="http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/"
|
||||
target="_blank">zombie templates</a> that plague hand-written update
|
||||
logic. To protect your elements from cleanup, just make sure that they
|
||||
on-screen before your code returns to the event loop, or before any
|
||||
call you make to [`Meteor.flush`](#meteor_flush).
|
||||
When live-updating DOM elements are taken off the screen, they are automatically
|
||||
cleaned up — their callbacks are torn down, any associated database
|
||||
queries are stopped, and they stop updating. For this reason, you never have to
|
||||
worry about the [zombie
|
||||
templates](http://lostechies.com/derickbailey/2011/09/15/zombies-run-managing-page-transitions-in-backbone-apps/)
|
||||
that plague hand-written update logic. To protect your elements from cleanup,
|
||||
just make sure that they on-screen before your code returns to the event loop,
|
||||
or before any call you make to [`Meteor.flush`](#meteor_flush).
|
||||
|
||||
Another thorny problem in hand-written applications is element
|
||||
preservation. Suppose the user is typing text into an `<input>`
|
||||
@@ -307,10 +366,9 @@ available as a function on the global `Template` object.
|
||||
{{#note}}
|
||||
Today, the only templating system that has been packaged for Meteor is
|
||||
Handlebars. Let us know what templating systems you'd like to use with
|
||||
Meteor. Meanwhile, see
|
||||
the <a href="http://www.handlebarsjs.com/">Handlebars documentation</a>
|
||||
and <a href="https://github.com/meteor/meteor/wiki/Handlebars">Meteor
|
||||
Handlebars extensions</a>.
|
||||
Meteor. Meanwhile, see the [Handlebars
|
||||
documentation](http://www.handlebarsjs.com/) and [Meteor Handlebars
|
||||
extensions](https://github.com/meteor/meteor/wiki/Handlebars).
|
||||
{{/note}}
|
||||
|
||||
A template with a `name` of `hello` is rendered by calling the
|
||||
@@ -444,7 +502,7 @@ discussion.
|
||||
<template name="packages_concept">
|
||||
{{#better_markdown}}
|
||||
|
||||
<h2 id="smartpackages">Smart Packages</h2>
|
||||
<h2 id="smartpackages">Smart packages</h2>
|
||||
|
||||
Meteor has an unusually powerful package system. All of the
|
||||
functionality you've read about so far is implemented as standard
|
||||
@@ -455,30 +513,28 @@ JavaScript programs. They can inject code into the client or the
|
||||
server, or hook new functions into the bundler, so they can extend the
|
||||
Meteor environment in arbitrary ways. Some examples of packages are:
|
||||
|
||||
* The <a href="#coffeescript">coffeescript</a> package extends the
|
||||
bundler, automatically compiling any <code>.coffee</code> files in
|
||||
* The [coffeescript](#coffeescript) package extends the
|
||||
bundler, automatically compiling any `.coffee` files in
|
||||
your tree. Once added, you can write your application in CoffeeScript
|
||||
instead of JavaScript.
|
||||
|
||||
* The <a href="#jquery">jQuery</a>
|
||||
and <a href="#backbone">Backbone</a> packages are examples of using
|
||||
* The [jQuery](#jquery) and [Backbone](#backbone) packages are examples of using
|
||||
Meteor to prepackage client JavaScript libraries. You could get
|
||||
the same result by copying the JavaScript files into your tree, but
|
||||
it's faster to add a package.
|
||||
|
||||
* The <a href="#underscore">underscore</a> package extends both the
|
||||
* The [underscore](#underscore) package extends both the
|
||||
client and server environments. Many of the core Meteor features,
|
||||
including Minimongo, the Session object, and reactive Handlebars
|
||||
templates, are implemented as internal packages automatically
|
||||
included with every Meteor application.
|
||||
|
||||
You can see a list of available packages
|
||||
with <a href="#meteorlist">meteor list</a>,
|
||||
add packages to your project
|
||||
with <a href="#meteoradd">meteor add</a>, and remove them
|
||||
with <a href="#meteorremove">meteor remove</a>.
|
||||
with [`meteor list`](#meteorlist), add packages to your project
|
||||
with [`meteor add`](#meteoradd), and remove them
|
||||
with [`meteor remove`](#meteorremove).
|
||||
|
||||
See the <a href="#packages">Package List</a> section for a description
|
||||
See the [Package List](#packages) section for a description
|
||||
of the existing packages.
|
||||
|
||||
{{#warning}}
|
||||
@@ -489,6 +545,10 @@ make your own packages just yet. Coming soon.
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<template name="deploying">
|
||||
{{#better_markdown}}
|
||||
|
||||
@@ -500,8 +560,8 @@ HTML, and CSS.
|
||||
|
||||
<h3 class="nosection">Running on Meteor's infrastructure</h3>
|
||||
|
||||
The easiest way to deploy your application is to use <b>meteor
|
||||
deploy</b>. We provide it because it's what, personally, we've always
|
||||
The easiest way to deploy your application is to use `meteor
|
||||
deploy`. We provide it because it's what, personally, we've always
|
||||
wanted: an easy way to take an app idea, flesh it out over a weekend,
|
||||
and put it out there for the world to use, with nothing getting in the
|
||||
way of creativity.
|
||||
@@ -515,7 +575,7 @@ update, Meteor will preserve the existing data and just refresh the
|
||||
code.
|
||||
|
||||
You can also deploy to your own domain. Just set up the hostname you
|
||||
want to use as a CNAME to <code>origin.meteor.com</code>,
|
||||
want to use as a CNAME to `origin.meteor.com`,
|
||||
then deploy to that name.
|
||||
|
||||
$ meteor deploy www.myapp.com
|
||||
@@ -548,7 +608,7 @@ package requires a `MAIL_URL` environment variable).
|
||||
For now, bundles will only run on the platform that the bundle was
|
||||
created on. To run on a different platform, you'll need to rebuild
|
||||
the native packages included in the bundle. To do that, make sure you
|
||||
have <code>npm</code> available, and run the following:
|
||||
have `npm` available, and run the following:
|
||||
|
||||
$ cd bundle/server/node_modules
|
||||
$ rm -r fibers
|
||||
|
||||
@@ -265,6 +265,10 @@ dl.callbacks dt .name, dl.methods dt .name {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
dl.callbacks {
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
|
||||
#main dd p {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
@@ -432,11 +436,11 @@ pre {
|
||||
@media (min-width: 1024px) {
|
||||
/* ipad landscape and desktop */
|
||||
#main {
|
||||
width: 600px;
|
||||
margin-left: 310px; /* nav width + padding */
|
||||
width: 610px;
|
||||
margin-left: 330px; /* nav width + padding */
|
||||
}
|
||||
#nav {
|
||||
width: 250px;
|
||||
width: 270px;
|
||||
}
|
||||
.github-ribbon {
|
||||
display: block;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<div id="main">
|
||||
<div id="top"></div>
|
||||
<h1 class="main-headline">Meteor 0.4.2</h1>
|
||||
<h1 class="main-headline">Meteor 0.5.0</h1>
|
||||
{{> introduction }}
|
||||
{{> concepts }}
|
||||
{{> api }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
METEOR_VERSION = "0.4.2";
|
||||
METEOR_VERSION = "0.5.0";
|
||||
|
||||
Meteor.startup(function () {
|
||||
// XXX this is broken by the new multi-page layout. Also, it was
|
||||
@@ -48,18 +48,29 @@ Meteor.startup(function () {
|
||||
}
|
||||
});
|
||||
|
||||
window.onhashchange = function () {
|
||||
scrollToSection(location.hash);
|
||||
};
|
||||
|
||||
var scrollToSection = function (section) {
|
||||
ignore_waypoints = true;
|
||||
Session.set("section", section.substr(1));
|
||||
scroller().animate({
|
||||
scrollTop: $(section).offset().top
|
||||
}, 500, 'swing', function () {
|
||||
window.location.hash = section;
|
||||
ignore_waypoints = false;
|
||||
});
|
||||
};
|
||||
|
||||
$('#main, #nav').delegate("a[href^='#']", 'click', function (evt) {
|
||||
evt.preventDefault();
|
||||
var sel = $(this).attr('href');
|
||||
ignore_waypoints = true;
|
||||
Session.set("section", sel.substr(1));
|
||||
scroller().animate({
|
||||
scrollTop: $(sel).offset().top
|
||||
}, 500, 'swing', function () {
|
||||
window.location.hash = sel;
|
||||
ignore_waypoints = false;
|
||||
});
|
||||
scrollToSection(sel);
|
||||
});
|
||||
|
||||
// Make external links open in a new tab.
|
||||
$('a:not([href^="#"])').attr('target', '_blank');
|
||||
});
|
||||
|
||||
var toc = [
|
||||
@@ -70,11 +81,11 @@ var toc = [
|
||||
],
|
||||
"Concepts", [
|
||||
"Structuring your app",
|
||||
"Data",
|
||||
"Data and security",
|
||||
"Reactivity",
|
||||
"Live HTML",
|
||||
"Templates",
|
||||
"Smart Packages",
|
||||
"Smart packages",
|
||||
"Deploying"
|
||||
],
|
||||
|
||||
@@ -88,6 +99,7 @@ var toc = [
|
||||
|
||||
"Publish and subscribe", [
|
||||
"Meteor.publish", [
|
||||
{instance: "this", name: "userId", id: "publish_userId"},
|
||||
{instance: "this", name: "set", id: "publish_set"},
|
||||
{instance: "this", name: "unset", id: "publish_unset"},
|
||||
{instance: "this", name: "complete", id: "publish_complete"},
|
||||
@@ -101,6 +113,8 @@ var toc = [
|
||||
|
||||
{name: "Methods", id: "methods_header"}, [
|
||||
"Meteor.methods", [
|
||||
{instance: "this", name: "userId", id: "method_userId"},
|
||||
{instance: "this", name: "setUserId", id: "method_setUserId"},
|
||||
{instance: "this", name: "isSimulation", id: "method_issimulation"},
|
||||
{instance: "this", name: "unblock", id: "method_unblock"}
|
||||
],
|
||||
@@ -121,7 +135,9 @@ var toc = [
|
||||
{instance: "collection", name: "findOne"},
|
||||
{instance: "collection", name: "insert"},
|
||||
{instance: "collection", name: "update"},
|
||||
{instance: "collection", name: "remove"}
|
||||
{instance: "collection", name: "remove"},
|
||||
{instance: "collection", name: "allow"},
|
||||
{instance: "collection", name: "deny"}
|
||||
],
|
||||
|
||||
"Meteor.Collection.Cursor", [
|
||||
@@ -135,7 +151,8 @@ var toc = [
|
||||
{type: "spacer"},
|
||||
{name: "Selectors", style: "noncode"},
|
||||
{name: "Modifiers", style: "noncode"},
|
||||
{name: "Sort specifiers", style: "noncode"}
|
||||
{name: "Sort specifiers", style: "noncode"},
|
||||
{name: "Field specifiers", style: "noncode"}
|
||||
],
|
||||
|
||||
"Session", [
|
||||
@@ -144,6 +161,41 @@ var toc = [
|
||||
"Session.equals"
|
||||
],
|
||||
|
||||
{name: "Accounts", id: "accounts_api"}, [
|
||||
"Meteor.user",
|
||||
"Meteor.userId",
|
||||
"Meteor.users",
|
||||
"Meteor.userLoaded",
|
||||
"Meteor.logout",
|
||||
"Meteor.loginWithPassword",
|
||||
{name: "Meteor.loginWithFacebook", id: "meteor_loginwithexternalservice"},
|
||||
{name: "Meteor.loginWithGithub", id: "meteor_loginwithexternalservice"},
|
||||
{name: "Meteor.loginWithGoogle", id: "meteor_loginwithexternalservice"},
|
||||
{name: "Meteor.loginWithTwitter", id: "meteor_loginwithexternalservice"},
|
||||
{name: "Meteor.loginWithWeibo", id: "meteor_loginwithexternalservice"},
|
||||
{type: "spacer"},
|
||||
|
||||
"Accounts.config",
|
||||
"Accounts.ui.config",
|
||||
"Accounts.validateNewUser",
|
||||
"Accounts.onCreateUser"
|
||||
],
|
||||
|
||||
{name: "Passwords", id: "accounts_passwords"}, [
|
||||
"Accounts.createUser",
|
||||
"Accounts.changePassword",
|
||||
"Accounts.forgotPassword",
|
||||
"Accounts.resetPassword",
|
||||
"Accounts.setPassword",
|
||||
"Accounts.verifyEmail",
|
||||
{type: "spacer"},
|
||||
|
||||
"Accounts.sendResetPasswordEmail",
|
||||
"Accounts.sendEnrollmentEmail",
|
||||
"Accounts.sendVerificationEmail",
|
||||
"Accounts.emailTemplates"
|
||||
],
|
||||
|
||||
{name: "Templates", id: "templates_api"}, [
|
||||
{prefix: "Template", instance: "myTemplate", id: "template_call"}, [
|
||||
{name: "rendered", id: "template_rendered"},
|
||||
@@ -182,6 +234,7 @@ var toc = [
|
||||
{instance: "context", name: "invalidate"}
|
||||
],
|
||||
{name: "Meteor.deps.Context.current", id: "current"},
|
||||
"Meteor.autorun",
|
||||
"Meteor.flush"
|
||||
// ],
|
||||
|
||||
@@ -206,10 +259,12 @@ var toc = [
|
||||
],
|
||||
|
||||
"Packages", [ [
|
||||
"accounts-ui",
|
||||
"amplify",
|
||||
"backbone",
|
||||
"bootstrap",
|
||||
"coffeescript",
|
||||
"d3",
|
||||
"force-ssl",
|
||||
"jquery",
|
||||
"less",
|
||||
|
||||
@@ -32,14 +32,15 @@ invalidations to clients.
|
||||
Meteor is a work in progress, but we hope it shows the direction of
|
||||
our thinking. We'd love to hear your feedback.
|
||||
|
||||
— Geoff, Nick, Matt, and David
|
||||
— Geoff, Nick, Matt, David, Avital, and David
|
||||
|
||||
## Quick start!
|
||||
|
||||
<!-- change colors on these. $ and command output in grey, rest in
|
||||
white -->
|
||||
|
||||
The following works on all <a target="_blank" href="https://github.com/meteor/meteor/wiki/Supported-Platforms">supported platforms</a>.
|
||||
The following works on all [supported
|
||||
platforms](https://github.com/meteor/meteor/wiki/Supported-Platforms).
|
||||
|
||||
Install Meteor:
|
||||
|
||||
@@ -106,17 +107,16 @@ clean, classically beautiful APIs.
|
||||
<h2 id="resources">Developer Resources</h2>
|
||||
|
||||
<!-- https://github.com/blog/273-github-ribbons -->
|
||||
<a href="http://github.com/meteor/meteor" target="_blank"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
|
||||
<a href="http://github.com/meteor/meteor"><img class="github-ribbon visible-desktop" style="position: absolute; top: 0; right: 0; border: 0;" src="https://s3.amazonaws.com/github/ribbons/forkme_right_red_aa0000.png" alt="Fork me on GitHub"></a>
|
||||
|
||||
If anything in Meteor catches your interest, we hope you'll get involved
|
||||
with the project!
|
||||
|
||||
<dl class="involved">
|
||||
<dt><span>Stack Overflow</span></dt>
|
||||
<dd>The best place to ask (and answer!) technical questions is
|
||||
on <a href="http://stackoverflow.com/questions/tagged/meteor">Stack
|
||||
Overflow</a>. Be sure to add the <code>meteor</code> tag to your
|
||||
question.
|
||||
<dd>The best place to ask (and answer!) technical questions is on [Stack
|
||||
Overflow](http://stackoverflow.com/questions/tagged/meteor). Be sure to add
|
||||
the <code>meteor</code> tag to your question.
|
||||
</dd>
|
||||
|
||||
<dt><span>Mailing lists</span></dt>
|
||||
@@ -134,7 +134,7 @@ developers hang out here and will answer your questions whenever they
|
||||
can.</dd>
|
||||
|
||||
<dt><span>GitHub</span></dt>
|
||||
<dd>The code is on <a target="_blank" href="http://github.com/meteor/meteor">GitHub</a>. The best way to send a patch is with a GitHub pull request, and the best way to file a bug is in the GitHub bug tracker.</dd>
|
||||
<dd>The code is on <a href="http://github.com/meteor/meteor">GitHub</a>. The best way to send a patch is with a GitHub pull request, and the best way to file a bug is in the GitHub bug tracker.</dd>
|
||||
</dl>
|
||||
|
||||
{{/markdown}}
|
||||
|
||||
@@ -16,10 +16,12 @@ and removed with:
|
||||
|
||||
$ meteor remove <package_name>
|
||||
|
||||
{{> pkg_accounts_ui}}
|
||||
{{> pkg_amplify}}
|
||||
{{> pkg_backbone}}
|
||||
{{> pkg_bootstrap}}
|
||||
{{> pkg_coffeescript}}
|
||||
{{> pkg_d3}}
|
||||
{{> pkg_force_ssl}}
|
||||
{{> pkg_jquery}}
|
||||
{{> pkg_less}}
|
||||
|
||||
32
docs/client/packages/accounts-ui.html
Normal file
32
docs/client/packages/accounts-ui.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<template name="pkg_accounts_ui">
|
||||
{{#better_markdown}}
|
||||
## `accounts-ui`
|
||||
|
||||
A turn-key user interface for Meteor Accounts.
|
||||
|
||||
To add Accounts and a set of login controls to an application add the
|
||||
`accounts-ui` package and at least one login provider package:
|
||||
`accounts-password`, `accounts-facebook`, `accounts-github`,
|
||||
`accounts-google`, `accounts-twitter`, or `accounts-weibo`.
|
||||
|
||||
Then simply add the `{{dstache}}loginButtons}}` helper to an HTML file. This
|
||||
will place a login widget on the page. If there is only one provider configured
|
||||
and it is an external service, this will add a login/logout button. If you use
|
||||
`accounts-password` or use multiple external login services, this will add
|
||||
a "Sign in" link which opens a dropdown menu with login options. To make the
|
||||
login dropdown right aligned (useful if you position the login buttons
|
||||
at the right edge of the screen), use `{{dstache}}loginButtons align=right}}`.
|
||||
|
||||
To configure the behavior of `{{dstache}}loginButtons}}`, use
|
||||
[`Accounts.ui.config`](#accounts_ui_config).
|
||||
|
||||
`accounts-ui` also includes modal popup dialogs to handle links from
|
||||
[`sendResetPasswordEmail`](#accounts_sendresetpasswordemail), [`sendVerificationEmail`](#accounts_sendverificationemail),
|
||||
and [`sendEnrollmentEmail`](#accounts_sendenrollmentemail). These
|
||||
do not have be manually placed in HTML: they are automatically activated
|
||||
when the URLs are loaded.
|
||||
|
||||
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ components, and several useful utility functions.
|
||||
Amplify defines a global namespace `amplify` on the client only. It does
|
||||
not run on the server.
|
||||
|
||||
For more information about Amplify, see <a href="http://amplifyjs.com/">http://amplifyjs.com/</a>.
|
||||
For more information about Amplify, see <http://amplifyjs.com/>.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@ functionality, it also provides an API for HTML5 pushState and
|
||||
client-side URL routing.
|
||||
|
||||
For more information about Backbone, see
|
||||
<a href="http://documentcloud.github.com/backbone/">http://documentcloud.github.com/backbone/</a>.
|
||||
<http://documentcloud.github.com/backbone/>.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -9,7 +9,7 @@ interactions including typography, forms, buttons, tables, grids, and
|
||||
navigation.
|
||||
|
||||
For more information about Bootstrap, see
|
||||
<a href="http://twitter.github.com/bootstrap/">http://twitter.github.com/bootstrap/</a>.
|
||||
<http://twitter.github.com/bootstrap/>.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,7 @@ interpretation at runtime.
|
||||
CoffeeScript is supported on both the client and the server. Files
|
||||
ending with `.coffee` are automatically compiled to JavaScript.
|
||||
|
||||
See <a href="http://jashkenas.github.com/coffee-script/">http://jashkenas.github.com/coffee-script/</a>
|
||||
for more information.
|
||||
See <http://jashkenas.github.com/coffee-script/> for more information.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
17
docs/client/packages/d3.html
Normal file
17
docs/client/packages/d3.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<template name="pkg_d3">
|
||||
{{#better_markdown}}
|
||||
|
||||
## `d3`
|
||||
|
||||
[D3.js](http://d3js.org/) is a JavaScript library for manipulating
|
||||
documents based on data. D3 helps you bring data to life using HTML, SVG
|
||||
and CSS. D3's emphasis on web standards gives you the full capabilities
|
||||
of modern browsers without tying yourself to a proprietary framework,
|
||||
combining powerful visualization components and a data-driven approach
|
||||
to DOM manipulation.
|
||||
|
||||
The `d3` package adds the D3 library to the client JavaScript
|
||||
bundle. It has no effect on the server.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
## `jquery`
|
||||
|
||||
<a href="http://jquery.com/">jQuery</a> is a fast and concise JavaScript
|
||||
[jQuery](http://jquery.com/) is a fast and concise JavaScript
|
||||
Library that simplifies HTML document traversing, event handling,
|
||||
animating, and Ajax interactions for rapid web development.
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ With the `less` package installed, `.less` files in your application are
|
||||
automatically compiled to CSS and the results are included in the client
|
||||
CSS bundle.
|
||||
|
||||
See <a href="http://lesscss.org/">http://lesscss.org/</a> for
|
||||
documentation of the LESS language.
|
||||
See <http://lesscss.org/> for documentation of the LESS language.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -7,10 +7,15 @@ expresions. With the `sass` package installed, `.sass` files in your
|
||||
application are automatically compiled to CSS and the results are
|
||||
included in the client CSS bundle.
|
||||
|
||||
See <a href="https://github.com/visionmedia/sass.js">https://github.com/visionmedia/sass.js</a>
|
||||
for the JavaScript implementation of the Sass language
|
||||
and <a href="http://sass-lang.com/">http://sass-lang.com/</a> for the
|
||||
original project.
|
||||
See <https://github.com/visionmedia/sass.js> for the JavaScript implementation
|
||||
of the Sass language and <http://sass-lang.com/> for the original project.
|
||||
|
||||
{{#warning}}
|
||||
The Sass JavaScript implementation used by Node is unmaintained and doesn't
|
||||
implement the newest language syntax documented at <http://sass-lang.com/>. It
|
||||
may be removed from a future version of Meteor; consider using [Less](#less) or
|
||||
[Stylus](#stylus) instead.
|
||||
{{/warning}}
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -3,36 +3,33 @@
|
||||
## `spiderable`
|
||||
|
||||
|
||||
The `spiderable` package is a temporary solution to allow web search
|
||||
engines to index a Meteor application. It uses the <a target="_blank"
|
||||
href="https://developers.google.com/webmasters/ajax-crawling/">AJAX
|
||||
Crawling specification</a> published by Google to serve HTML to
|
||||
compatible spiders (Google, Bing, Yandex, and more).
|
||||
The `spiderable` package is a temporary solution to allow web search engines to
|
||||
index a Meteor application. It uses the [AJAX Crawling
|
||||
specification](https://developers.google.com/webmasters/ajax-crawling/)
|
||||
published by Google to serve HTML to compatible spiders (Google, Bing, Yandex,
|
||||
and more).
|
||||
|
||||
When a spider requests an HTML snapshot of a page the Meteor server runs
|
||||
the client half of the application inside <a target="_blank"
|
||||
href="http://phantomjs.org/">phantomjs</a>, a headless browser, and
|
||||
returns the full HTML generated by the client code.
|
||||
When a spider requests an HTML snapshot of a page the Meteor server runs the
|
||||
client half of the application inside [phantomjs](http://phantomjs.org/), a
|
||||
headless browser, and returns the full HTML generated by the client code.
|
||||
|
||||
{{#warning}}
|
||||
This is a temporary approach to allow Meteor applications to be
|
||||
searchable. Expect significant changes to this package.
|
||||
{{/warning}}
|
||||
|
||||
In order to have links between multiple pages on a site visible to
|
||||
spiders, apps must use real links (eg `<a href="/about">`) rather than
|
||||
simply re-rendering portions of the page when an element is
|
||||
clicked. Apps should render their content based on the URL of the page
|
||||
and can use HTML5 push-state to alter the URL on the client without
|
||||
triggering a page reload. See the <a target="_blank"
|
||||
href="http://meteor.com/examples/todos">Todos example</a> for a
|
||||
demonstration.
|
||||
In order to have links between multiple pages on a site visible to spiders, apps
|
||||
must use real links (eg `<a href="/about">`) rather than simply re-rendering
|
||||
portions of the page when an element is clicked. Apps should render their
|
||||
content based on the URL of the page and can use [HTML5
|
||||
pushState](https://developer.mozilla.org/en-US/docs/DOM/Manipulating_the_browser_history)
|
||||
to alter the URL on the client without triggering a page reload. See the [Todos
|
||||
example](http://meteor.com/examples/todos) for a demonstration.
|
||||
|
||||
|
||||
{{#warning}}
|
||||
If you deploy your application with `meteor bundle`, you must install
|
||||
`phantomjs` (<a target="_blank"
|
||||
href="http://phantomjs.org/">http://phantomjs.org</a>) somewhere in your
|
||||
`phantomjs` ([http://phantomjs.org](http://phantomjs.org/)) somewhere in your
|
||||
`$PATH`. If you use `meteor deploy` this is already taken care of.
|
||||
{{/warning}}
|
||||
|
||||
|
||||
@@ -14,10 +14,9 @@ The `stylus` package also includes `nib` support. Add `@import 'nib'` to
|
||||
your `.styl` files to enable cross-browser mixins such as
|
||||
`linear-gradient` and `border-radius`.
|
||||
|
||||
See <a href="http://learnboost.github.com/stylus/">http://learnboost.github.com/stylus</a>
|
||||
for documentation of the Stylus language,
|
||||
and <a href="http://visionmedia.github.com/nib/">http://visionmedia.github.com/nib</a>
|
||||
for documentation of the nib extensions.
|
||||
See <http://learnboost.github.com/stylus> for documentation of the Stylus
|
||||
language, and <http://visionmedia.github.com/nib> for documentation of the nib
|
||||
extensions.
|
||||
|
||||
{{/better_markdown}}
|
||||
</template>
|
||||
|
||||
@@ -9,8 +9,8 @@ concise JavaScript in a functional style.
|
||||
The `underscore` package defines the `_` namespace on both the client
|
||||
and the server.
|
||||
|
||||
See <a href="http://documentcloud.github.com/underscore/">http://documentcloud.github.com/underscore/</a>
|
||||
for underscore API documentation.
|
||||
See <http://documentcloud.github.com/underscore/> for underscore API
|
||||
documentation.
|
||||
|
||||
{{#warning}}
|
||||
Currently, underscore is included in all projects, as the Meteor
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
# but you can also edit it by hand.
|
||||
|
||||
autopublish
|
||||
insecure
|
||||
preserve-inputs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Set up a collection to contain player information. On the server,
|
||||
// it is backed by a MongoDB collection named "players."
|
||||
// it is backed by a MongoDB collection named "players".
|
||||
|
||||
Players = new Meteor.Collection("players");
|
||||
|
||||
|
||||
@@ -178,5 +178,6 @@ X={{x}}<br>
|
||||
<input type="button" value="Add" class="add">
|
||||
<input type="button" value="Remove" class="remove" {{{disabled}}}>
|
||||
<input type="button" value="Scram" class="scram">
|
||||
<input type="button" value="Clear" class="clear">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -137,32 +137,6 @@ Template.timer.destroyed = function () {
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Run f(). Record its dependencies. Rerun it whenever the
|
||||
// dependencies change.
|
||||
//
|
||||
// Returns an object with a stop() method. Call stop() to stop the
|
||||
// rerunning.
|
||||
//
|
||||
// XXX this should go into Meteor core as Meteor.autorun
|
||||
var autorun = function (f) {
|
||||
var ctx;
|
||||
var slain = false;
|
||||
var rerun = function () {
|
||||
if (slain)
|
||||
return;
|
||||
ctx = new Meteor.deps.Context;
|
||||
ctx.run(f);
|
||||
ctx.onInvalidate(rerun);
|
||||
};
|
||||
rerun();
|
||||
return {
|
||||
stop: function () {
|
||||
slain = true;
|
||||
ctx.invalidate();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Template.d3Demo.left = function () {
|
||||
return { group: "left" };
|
||||
};
|
||||
@@ -201,6 +175,9 @@ Template.circles.events({
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
'click .clear': function () {
|
||||
Circles.remove({group: this.group});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -230,7 +207,7 @@ Template.circles.rendered = function () {
|
||||
|
||||
if (! self.handle) {
|
||||
d3.select(self.node).append("rect");
|
||||
self.handle = autorun(function () {
|
||||
self.handle = Meteor.autorun(function () {
|
||||
var circle = d3.select(self.node).selectAll("circle")
|
||||
.data(Circles.find({group: data.group}).fetch(),
|
||||
function (d) { return d._id; });
|
||||
|
||||
1
examples/parties/.meteor/.gitignore
vendored
Normal file
1
examples/parties/.meteor/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
13
examples/parties/.meteor/packages
Normal file
13
examples/parties/.meteor/packages
Normal file
@@ -0,0 +1,13 @@
|
||||
# Meteor packages used by this project, one per line.
|
||||
#
|
||||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||
# but you can also edit it by hand.
|
||||
|
||||
preserve-inputs
|
||||
accounts-ui
|
||||
accounts-password
|
||||
d3
|
||||
bootstrap
|
||||
email
|
||||
accounts-facebook
|
||||
accounts-twitter
|
||||
276
examples/parties/client/client.js
Normal file
276
examples/parties/client/client.js
Normal file
@@ -0,0 +1,276 @@
|
||||
// All Tomorrow's Parties -- client
|
||||
|
||||
Meteor.subscribe("directory");
|
||||
Meteor.subscribe("parties");
|
||||
|
||||
// If no party selected, select one.
|
||||
Meteor.startup(function () {
|
||||
Meteor.autorun(function () {
|
||||
if (! Session.get("selected")) {
|
||||
var party = Parties.findOne();
|
||||
if (party)
|
||||
Session.set("selected", party._id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Party details sidebar
|
||||
|
||||
Template.details.party = function () {
|
||||
return Parties.findOne(Session.get("selected"));
|
||||
};
|
||||
|
||||
Template.details.anyParties = function () {
|
||||
return Parties.find().count() > 0;
|
||||
};
|
||||
|
||||
Template.details.creatorName = function () {
|
||||
var owner = Meteor.users.findOne(this.owner);
|
||||
if (owner._id === Meteor.userId())
|
||||
return "me";
|
||||
return displayName(owner);
|
||||
};
|
||||
|
||||
Template.details.canRemove = function () {
|
||||
return this.owner === Meteor.userId() && attending(this) === 0;
|
||||
};
|
||||
|
||||
Template.details.maybeChosen = function (what) {
|
||||
var myRsvp = _.find(this.rsvps, function (r) {
|
||||
return r.user === Meteor.userId();
|
||||
}) || {};
|
||||
|
||||
return what == myRsvp.rsvp ? "chosen btn-inverse" : "";
|
||||
};
|
||||
|
||||
Template.details.events({
|
||||
'click .rsvp_yes': function () {
|
||||
Meteor.call("rsvp", Session.get("selected"), "yes");
|
||||
return false;
|
||||
},
|
||||
'click .rsvp_maybe': function () {
|
||||
Meteor.call("rsvp", Session.get("selected"), "maybe");
|
||||
return false;
|
||||
},
|
||||
'click .rsvp_no': function () {
|
||||
Meteor.call("rsvp", Session.get("selected"), "no");
|
||||
return false;
|
||||
},
|
||||
'click .invite': function () {
|
||||
openInviteDialog();
|
||||
return false;
|
||||
},
|
||||
'click .remove': function () {
|
||||
Parties.remove(this._id);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Party attendance widget
|
||||
|
||||
Template.attendance.rsvpName = function () {
|
||||
var user = Meteor.users.findOne(this.user);
|
||||
return displayName(user);
|
||||
};
|
||||
|
||||
Template.attendance.outstandingInvitations = function () {
|
||||
var party = Parties.findOne(this._id);
|
||||
return Meteor.users.find({$and: [
|
||||
{_id: {$in: party.invited}}, // they're invited
|
||||
{_id: {$nin: _.pluck(party.rsvps, 'user')}} // but haven't RSVP'd
|
||||
]});
|
||||
};
|
||||
|
||||
Template.attendance.invitationName = function () {
|
||||
return displayName(this);
|
||||
};
|
||||
|
||||
Template.attendance.rsvpIs = function (what) {
|
||||
return this.rsvp === what;
|
||||
};
|
||||
|
||||
Template.attendance.nobody = function () {
|
||||
return ! this.public && (this.rsvps.length + this.invited.length === 0);
|
||||
};
|
||||
|
||||
Template.attendance.canInvite = function () {
|
||||
return ! this.public && this.owner === Meteor.userId();
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Map display
|
||||
|
||||
// Use jquery to get the position clicked relative to the map element.
|
||||
var coordsRelativeToElement = function (element, event) {
|
||||
var offset = $(element).offset();
|
||||
var x = event.pageX - offset.left;
|
||||
var y = event.pageY - offset.top;
|
||||
return { x: x, y: y };
|
||||
};
|
||||
|
||||
Template.map.events({
|
||||
'mousedown circle, mousedown text': function (event, template) {
|
||||
Session.set("selected", event.currentTarget.id);
|
||||
},
|
||||
'dblclick .map': function (event, template) {
|
||||
if (! Meteor.userId()) // must be logged in to create events
|
||||
return;
|
||||
var coords = coordsRelativeToElement(event.currentTarget, event);
|
||||
openCreateDialog(coords.x / 500, coords.y / 500);
|
||||
}
|
||||
});
|
||||
|
||||
Template.map.rendered = function () {
|
||||
var self = this;
|
||||
self.node = self.find("svg");
|
||||
|
||||
if (! self.handle) {
|
||||
self.handle = Meteor.autorun(function () {
|
||||
var selected = Session.get('selected');
|
||||
var selectedParty = selected && Parties.findOne(selected);
|
||||
var radius = function (party) {
|
||||
return 10 + Math.sqrt(attending(party)) * 10;
|
||||
};
|
||||
|
||||
// Draw a circle for each party
|
||||
var updateCircles = function (group) {
|
||||
group.attr("id", function (party) { return party._id; })
|
||||
.attr("cx", function (party) { return party.x * 500; })
|
||||
.attr("cy", function (party) { return party.y * 500; })
|
||||
.attr("r", radius)
|
||||
.attr("class", function (party) {
|
||||
return party.public ? "public" : "private";
|
||||
})
|
||||
.style('opacity', function (party) {
|
||||
return selected === party._id ? 1 : 0.6;
|
||||
});
|
||||
};
|
||||
|
||||
var circles = d3.select(self.node).select(".circles").selectAll("circle")
|
||||
.data(Parties.find().fetch(), function (party) { return party._id; });
|
||||
|
||||
updateCircles(circles.enter().append("circle"));
|
||||
updateCircles(circles.transition().duration(250).ease("cubic-out"));
|
||||
circles.exit().transition().duration(250).attr("r", 0).remove();
|
||||
|
||||
// Label each with the current attendance count
|
||||
var updateLabels = function (group) {
|
||||
group.attr("id", function (party) { return party._id; })
|
||||
.text(function (party) {return attending(party) || '';})
|
||||
.attr("x", function (party) { return party.x * 500; })
|
||||
.attr("y", function (party) { return party.y * 500 + radius(party)/2 })
|
||||
.style('font-size', function (party) {
|
||||
return radius(party) * 1.25 + "px";
|
||||
});
|
||||
};
|
||||
|
||||
var labels = d3.select(self.node).select(".labels").selectAll("text")
|
||||
.data(Parties.find().fetch(), function (party) { return party._id; });
|
||||
|
||||
updateLabels(labels.enter().append("text"));
|
||||
updateLabels(labels.transition().duration(250).ease("cubic-out"));
|
||||
labels.exit().remove();
|
||||
|
||||
// Draw a dashed circle around the currently selected party, if any
|
||||
var callout = d3.select(self.node).select("circle.callout")
|
||||
.transition().duration(250).ease("cubic-out");
|
||||
if (selectedParty)
|
||||
callout.attr("cx", selectedParty.x * 500)
|
||||
.attr("cy", selectedParty.y * 500)
|
||||
.attr("r", radius(selectedParty) + 10)
|
||||
.attr("class", "callout")
|
||||
.attr("display", '');
|
||||
else
|
||||
callout.attr("display", 'none');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Template.map.destroyed = function () {
|
||||
this.handle && this.handle.stop();
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Create Party dialog
|
||||
|
||||
var openCreateDialog = function (x, y) {
|
||||
Session.set("createCoords", {x: x, y: y});
|
||||
Session.set("createError", null);
|
||||
Session.set("showCreateDialog", true);
|
||||
};
|
||||
|
||||
Template.page.showCreateDialog = function () {
|
||||
return Session.get("showCreateDialog");
|
||||
};
|
||||
|
||||
Template.createDialog.events({
|
||||
'click .save': function (event, template) {
|
||||
var title = template.find(".title").value;
|
||||
var description = template.find(".description").value;
|
||||
var public = ! template.find(".private").checked;
|
||||
var coords = Session.get("createCoords");
|
||||
|
||||
if (title.length && description.length) {
|
||||
Meteor.call('createParty', {
|
||||
title: title,
|
||||
description: description,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
public: public
|
||||
}, function (error, party) {
|
||||
if (! error) {
|
||||
Session.set("selected", party);
|
||||
if (! public && Meteor.users.find().count() > 1)
|
||||
openInviteDialog();
|
||||
}
|
||||
});
|
||||
Session.set("showCreateDialog", false);
|
||||
} else {
|
||||
Session.set("createError",
|
||||
"It needs a title and a description, or why bother?");
|
||||
}
|
||||
},
|
||||
|
||||
'click .cancel': function () {
|
||||
Session.set("showCreateDialog", false);
|
||||
}
|
||||
});
|
||||
|
||||
Template.createDialog.error = function () {
|
||||
return Session.get("createError");
|
||||
};
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Invite dialog
|
||||
|
||||
var openInviteDialog = function () {
|
||||
Session.set("showInviteDialog", true);
|
||||
};
|
||||
|
||||
Template.page.showInviteDialog = function () {
|
||||
return Session.get("showInviteDialog");
|
||||
};
|
||||
|
||||
Template.inviteDialog.events({
|
||||
'click .invite': function (event, template) {
|
||||
Meteor.call('invite', Session.get("selected"), this._id);
|
||||
},
|
||||
'click .done': function (event, template) {
|
||||
Session.set("showInviteDialog", false);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
Template.inviteDialog.uninvited = function () {
|
||||
var party = Parties.findOne(Session.get("selected"));
|
||||
if (! party)
|
||||
return []; // party hasn't loaded yet
|
||||
return Meteor.users.find({$nor: [{_id: {$in: party.invited}},
|
||||
{_id: party.owner}]});
|
||||
};
|
||||
|
||||
Template.inviteDialog.displayName = function () {
|
||||
return displayName(this);
|
||||
};
|
||||
81
examples/parties/client/parties.css
Normal file
81
examples/parties/client/parties.css
Normal file
@@ -0,0 +1,81 @@
|
||||
.header {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: -18px;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
background-color: #000000;
|
||||
opacity: .4;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.invite-row .invite {
|
||||
margin: 10px 10px 10px 0;
|
||||
}
|
||||
|
||||
.rsvp-buttons {
|
||||
text-align: center;
|
||||
margin: 40px 0 40px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 20px 0 20px 0;
|
||||
}
|
||||
|
||||
.attendance .who {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.attendance .invite {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input.chosen {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.map {
|
||||
position: relative;
|
||||
background-image: url('/soma.jpeg');
|
||||
background-position: -20px -20px;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
.map circle.public {
|
||||
fill: #49AFCD;
|
||||
}
|
||||
|
||||
.map circle.private {
|
||||
fill: #DA4F49;
|
||||
}
|
||||
|
||||
.map text {
|
||||
text-anchor: middle;
|
||||
fill: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.map circle.callout {
|
||||
stroke-width: 5px;
|
||||
stroke-dasharray: 9, 5;
|
||||
stroke-opacity: .8;
|
||||
fill: none;
|
||||
stroke: red;
|
||||
}
|
||||
|
||||
.attribution {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
padding: 3px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
218
examples/parties/client/parties.html
Normal file
218
examples/parties/client/parties.html
Normal file
@@ -0,0 +1,218 @@
|
||||
<head>
|
||||
<title>All Tomorrow's Parties</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{> page}}
|
||||
</body>
|
||||
|
||||
<template name="page">
|
||||
{{#if showCreateDialog}}
|
||||
{{> createDialog}}
|
||||
{{/if}}
|
||||
|
||||
{{#if showInviteDialog}}
|
||||
{{> inviteDialog}}
|
||||
{{/if}}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="span1"> </div>
|
||||
|
||||
<div class="span10">
|
||||
<div class="header row">
|
||||
<div class="span5">
|
||||
<h3 style="margin-bottom: 0px">All Tomorrow's Parties</h3>
|
||||
</div>
|
||||
<div class="span5">
|
||||
<div style="float: right">
|
||||
{{loginButtons align="right"}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="span6">
|
||||
{{> map}}
|
||||
{{#if currentUser}}
|
||||
<div class="pagination-centered">
|
||||
<em><small>Double click the map to post a party!</small></em>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="span4">
|
||||
{{> details}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="span1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template name="map">
|
||||
<div class="map">
|
||||
{{#constant}}
|
||||
<svg width="500" height="500">
|
||||
<circle class="callout" cx=-100 cy=-100></circle>
|
||||
<g class="circles"></g>
|
||||
<g class="labels"></g>
|
||||
</svg>
|
||||
{{/constant}}
|
||||
<div>
|
||||
<small class="attribution muted">©
|
||||
<a href="http://www.openstreetmap.org/?lat=37.78212&lon=-122.40146&zoom=15&layers=M"
|
||||
target="_blank">OpenStreetMap</a> contributors</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template name="details">
|
||||
<div class="details">
|
||||
{{#if party}}
|
||||
{{#with party}}
|
||||
<h1>{{title}}</h1>
|
||||
|
||||
<div class="description">{{description}}</div>
|
||||
|
||||
{{> attendance}}
|
||||
|
||||
<div class="rsvp-buttons">
|
||||
{{#if currentUser}}
|
||||
<input type="button" value="I'm going!"
|
||||
class="btn btn-small rsvp_yes {{maybeChosen "yes"}}">
|
||||
<input type="button" value="Maybe"
|
||||
class="btn btn-small rsvp_maybe {{maybeChosen "maybe"}}">
|
||||
<input type="button" value="No"
|
||||
class="btn btn-small rsvp_no {{maybeChosen "no"}}">
|
||||
{{else}}
|
||||
<i>Sign in to RSVP for this party.</i>
|
||||
{{/if}}
|
||||
<p><small>Posted by {{creatorName}}</small></p>
|
||||
</div>
|
||||
|
||||
{{#if canRemove}}
|
||||
<div class="alert alert-info"><small>
|
||||
You posted this party and nobody is signed up to go, so if
|
||||
you like, you could
|
||||
<b><a href="#" class="remove">delete this listing</a></b>.
|
||||
</small></div>
|
||||
{{/if}}
|
||||
{{/with}}
|
||||
{{else}}
|
||||
<h1 class="muted pagination-centered">
|
||||
{{#if anyParties}}
|
||||
Click a party to select it
|
||||
{{else}}
|
||||
Sign in and double click the map to post a party
|
||||
{{/if}}
|
||||
</h1>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template name="attendance">
|
||||
<div class="attendance well well-small">
|
||||
<div class="muted who"><b>Who</b></div>
|
||||
{{#if public}}
|
||||
<div>
|
||||
<b>Everyone</b>
|
||||
<span class="label label-inverse pull-right">Invited</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each rsvps}}
|
||||
<div>
|
||||
{{rsvpName}}
|
||||
{{#if rsvpIs "yes"}}
|
||||
<span class="label label-success pull-right">Going</span>
|
||||
{{/if}}
|
||||
{{#if rsvpIs "maybe"}}
|
||||
<span class="label label-info pull-right">Maybe</span>
|
||||
{{/if}}
|
||||
{{#if rsvpIs "no"}}
|
||||
<span class="label label pull-right">No</span>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
{{#unless public}}
|
||||
{{#each outstandingInvitations}}
|
||||
<div>
|
||||
{{invitationName}}
|
||||
<span class="label label-inverse pull-right">Invited</span>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/unless}}
|
||||
|
||||
{{#if nobody}}
|
||||
<div>Nobody.</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if canInvite}}
|
||||
<div class="invite">
|
||||
<a href="#" class="btn btn-mini invite">Invite people</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template name="createDialog">
|
||||
<div class="mask"> </div>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close cancel">×</button>
|
||||
<h3>Add party</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{{#if error}}
|
||||
<div class="alert alert-error">{{error}}</div>
|
||||
{{/if}}
|
||||
|
||||
<label>Title</label>
|
||||
<input type="text" class="title span5">
|
||||
|
||||
<label>Description</label>
|
||||
<textarea class="description span5"></textarea>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" class="private">
|
||||
Private party — invitees only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<a href="#" class="btn cancel">Cancel</a>
|
||||
<a href="#" class="btn btn-primary save">Add party</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template name="inviteDialog">
|
||||
<div class="mask"> </div>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close done">×</button>
|
||||
<h3>Invite people</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
{{#each uninvited}}
|
||||
<div class="invite-row">
|
||||
<a href="#" class="btn invite">Invite</a>
|
||||
{{displayName}}
|
||||
</div>
|
||||
{{else}}
|
||||
Everyone on the site has already been invited.
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<a href="#" class="btn btn-primary done">Done</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
162
examples/parties/model.js
Normal file
162
examples/parties/model.js
Normal file
@@ -0,0 +1,162 @@
|
||||
// All Tomorrow's Parties -- data model
|
||||
// Loaded on both the client and the server
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Parties
|
||||
|
||||
/*
|
||||
Each party is represented by a document in the Parties collection:
|
||||
owner: user id
|
||||
x, y: Number (screen coordinates in the interval [0, 1])
|
||||
title, description: String
|
||||
public: Boolean
|
||||
invited: Array of user id's that are invited (only if !public)
|
||||
rsvps: Array of objects like {user: userId, rsvp: "yes"} (or "no"/"maybe")
|
||||
*/
|
||||
Parties = new Meteor.Collection("parties");
|
||||
|
||||
Parties.allow({
|
||||
insert: function (userId, party) {
|
||||
return false; // no cowboy inserts -- use createParty method
|
||||
},
|
||||
update: function (userId, parties, fields, modifier) {
|
||||
return _.all(parties, function (party) {
|
||||
if (userId !== party.owner)
|
||||
return false; // not the owner
|
||||
|
||||
var allowed = ["title", "description", "x", "y"];
|
||||
if (_.difference(fields, allowed).length)
|
||||
return false; // tried to write to forbidden field
|
||||
|
||||
// A good improvement would be to validate the type of the new
|
||||
// value of the field (and if a string, the length.) In the
|
||||
// future Meteor will have a schema system to makes that easier.
|
||||
return true;
|
||||
});
|
||||
},
|
||||
remove: function (userId, parties) {
|
||||
return ! _.any(parties, function (party) {
|
||||
// deny if not the owner, or if other people are going
|
||||
return party.owner !== userId || attending(party) > 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
var attending = function (party) {
|
||||
return (_.groupBy(party.rsvps, 'rsvp').yes || []).length;
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
// options should include: title, description, x, y, public
|
||||
createParty: function (options) {
|
||||
options = options || {};
|
||||
if (! (typeof options.title === "string" && options.title.length &&
|
||||
typeof options.description === "string" &&
|
||||
options.description.length &&
|
||||
typeof options.x === "number" && options.x >= 0 && options.x <= 1 &&
|
||||
typeof options.y === "number" && options.y >= 0 && options.y <= 1))
|
||||
throw new Meteor.Error(400, "Required parameter missing");
|
||||
if (options.title.length > 100)
|
||||
throw new Meteor.Error(413, "Title too long");
|
||||
if (options.description.length > 1000)
|
||||
throw new Meteor.Error(413, "Description too long");
|
||||
if (! this.userId)
|
||||
throw new Meteor.Error(403, "You must be logged in");
|
||||
|
||||
return Parties.insert({
|
||||
owner: this.userId,
|
||||
x: options.x,
|
||||
y: options.y,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
public: !! options.public,
|
||||
invited: [],
|
||||
rsvps: []
|
||||
});
|
||||
},
|
||||
|
||||
invite: function (partyId, userId) {
|
||||
var party = Parties.findOne(partyId);
|
||||
if (! party || party.owner !== this.userId)
|
||||
throw new Meteor.Error(404, "No such party");
|
||||
if (party.public)
|
||||
throw new Meteor.Error(400,
|
||||
"That party is public. No need to invite people.");
|
||||
if (userId !== party.owner && ! _.contains(party.invited, userId)) {
|
||||
Parties.update(partyId, { $addToSet: { invited: userId } });
|
||||
|
||||
var from = contactEmail(Meteor.users.findOne(this.userId));
|
||||
var to = contactEmail(Meteor.users.findOne(userId));
|
||||
if (Meteor.isServer && to) {
|
||||
// This code only runs on the server. If you didn't want clients
|
||||
// to be able to see it, you could move it to a separate file.
|
||||
Email.send({
|
||||
from: "noreply@example.com",
|
||||
to: to,
|
||||
replyTo: from || undefined,
|
||||
subject: "PARTY: " + party.title,
|
||||
text:
|
||||
"Hey, I just invited you to '" + party.title + "' on All Tomorrow's Parties." +
|
||||
"\n\nCome check it out: " + Meteor.absoluteUrl() + "\n"
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
rsvp: function (partyId, rsvp) {
|
||||
if (! this.userId)
|
||||
throw new Meteor.Error(403, "You must be logged in to RSVP");
|
||||
if (! _.contains(['yes', 'no', 'maybe'], rsvp))
|
||||
throw new Meteor.Error(400, "Invalid RSVP");
|
||||
var party = Parties.findOne(partyId);
|
||||
if (! party)
|
||||
throw new Meteor.Error(404, "No such party");
|
||||
if (! party.public && party.owner !== this.userId &&
|
||||
!_.contains(party.invited, this.userId))
|
||||
// private, but let's not tell this to the user
|
||||
throw new Meteor.Error(403, "No such party");
|
||||
|
||||
var rsvpIndex = _.indexOf(_.pluck(party.rsvps, 'user'), this.userId);
|
||||
if (rsvpIndex !== -1) {
|
||||
// update existing rsvp entry
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// update the appropriate rsvp entry with $
|
||||
Parties.update(
|
||||
{_id: partyId, "rsvps.user": this.userId},
|
||||
{$set: {"rsvps.$.rsvp": rsvp}});
|
||||
} else {
|
||||
// minimongo doesn't yet support $ in modifier. as a temporary
|
||||
// workaround, make a modifier that uses an index. this is
|
||||
// safe on the client since there's only one thread.
|
||||
var modifier = {$set: {}};
|
||||
modifier.$set["rsvps." + rsvpIndex + ".rsvp"] = rsvp;
|
||||
Parties.update(partyId, modifier);
|
||||
}
|
||||
|
||||
// Possible improvement: send email to the other people that are
|
||||
// coming to the party.
|
||||
} else {
|
||||
// add new rsvp entry
|
||||
Parties.update(partyId,
|
||||
{$push: {rsvps: {user: this.userId, rsvp: rsvp}}});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Users
|
||||
|
||||
var displayName = function (user) {
|
||||
if (user.profile && user.profile.name)
|
||||
return user.profile.name;
|
||||
return user.emails[0].address;
|
||||
};
|
||||
|
||||
var contactEmail = function (user) {
|
||||
if (user.emails && user.emails.length)
|
||||
return user.emails[0].address;
|
||||
if (user.services && user.services.facebook && user.services.facebook.email)
|
||||
return user.services.facebook.email;
|
||||
return null;
|
||||
};
|
||||
BIN
examples/parties/public/soma.jpeg
Normal file
BIN
examples/parties/public/soma.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
10
examples/parties/server/server.js
Normal file
10
examples/parties/server/server.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// All Tomorrow's Parties -- server
|
||||
|
||||
Meteor.publish("directory", function () {
|
||||
return Meteor.users.find({}, {fields: {emails: 1, profile: 1}});
|
||||
});
|
||||
|
||||
Meteor.publish("parties", function () {
|
||||
return Parties.find(
|
||||
{$or: [{"public": true}, {invited: this.userId}, {owner: this.userId}]});
|
||||
});
|
||||
@@ -8,3 +8,4 @@ backbone
|
||||
spiderable
|
||||
jquery
|
||||
preserve-inputs
|
||||
insecure
|
||||
|
||||
@@ -98,5 +98,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 () {
|
||||
|
||||
1
examples/unfinished/accounts-ui-viewer/.meteor/.gitignore
vendored
Normal file
1
examples/unfinished/accounts-ui-viewer/.meteor/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
local
|
||||
15
examples/unfinished/accounts-ui-viewer/.meteor/packages
Normal file
15
examples/unfinished/accounts-ui-viewer/.meteor/packages
Normal file
@@ -0,0 +1,15 @@
|
||||
# Meteor packages used by this project, one per line.
|
||||
#
|
||||
# 'meteor add' and 'meteor remove' will edit this file for you,
|
||||
# but you can also edit it by hand.
|
||||
|
||||
autopublish
|
||||
insecure
|
||||
preserve-inputs
|
||||
accounts-ui
|
||||
less
|
||||
accounts-google
|
||||
accounts-github
|
||||
accounts-password
|
||||
underscore
|
||||
accounts-facebook
|
||||
123
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html
Normal file
123
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.html
Normal file
@@ -0,0 +1,123 @@
|
||||
<head>
|
||||
<title>accounts-ui-viewer</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{> page}}
|
||||
</body>
|
||||
|
||||
<template name="radio">
|
||||
<span class="radio"><input id="{{key}}:{{value}}" {{maybeChecked}} type="radio" name="{{key}}" value="{{value}}" />{{! no whitespace}}<label for="{{key}}:{{value}}">{{label}}</label></span>
|
||||
</template>
|
||||
|
||||
<template name="button">
|
||||
<button>{{label}}</button>
|
||||
</template>
|
||||
|
||||
<template name="page">
|
||||
<div id="controlpane">
|
||||
<div class="group">
|
||||
<h3>Dropdown align edge:</h3>
|
||||
{{radio "alignRight" "false" "Left"}}
|
||||
{{radio "alignRight" "true" "Right"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Positioning:</h3>
|
||||
{{radio "positioning" "relative" "Relative"}}
|
||||
{{radio "positioning" "absolute" "Absolute"}}
|
||||
{{radio "positioning" "floatRight" "Float:right"}}
|
||||
{{radio "positioning" "inline" "Inline"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>How many third-party services?</h3>
|
||||
{{radio "numServices" "0" "0"}}
|
||||
{{radio "numServices" "1" "1"}}
|
||||
{{radio "numServices" "2" "2"}}
|
||||
{{radio "numServices" "3" "3"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Has password accounts?</h3>
|
||||
{{radio "hasPasswords" "false" "No"}}
|
||||
{{radio "hasPasswords" "true" "Yes"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Password sign-up fields:</h3>
|
||||
{{radio "signupFields" "EMAIL_ONLY" "Email"}}
|
||||
{{radio "signupFields" "USERNAME_ONLY" "Username"}}
|
||||
{{radio "signupFields" "USERNAME_AND_EMAIL" "Username & Email"}}
|
||||
{{radio "signupFields" "USERNAME_AND_OPTIONAL_EMAIL" "Username & Optional Email"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Fake-Configure:</h3>
|
||||
{{button "fakeConfig" "facebook" "Facebook"}}
|
||||
{{button "fakeConfig" "github" "GitHub"}}
|
||||
{{button "fakeConfig" "google" "Google"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Show Configure Dialog:</h3>
|
||||
{{button "showConfig" "facebook" "Facebook"}}
|
||||
{{button "showConfig" "github" "GitHub"}}
|
||||
{{button "showConfig" "google" "Google"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Unconfigure:</h3>
|
||||
{{button "unconfig" "facebook" "Facebook"}}
|
||||
{{button "unconfig" "github" "GitHub"}}
|
||||
{{button "unconfig" "google" "Google"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Messages:</h3>
|
||||
{{button "messages" "error" "Error"}}
|
||||
{{button "messages" "info" "Info"}}
|
||||
{{button "messages" "clear" "Clear"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Signing in/out</h3>
|
||||
{{button "sign" "in" "Fake sign-in"}}
|
||||
{{button "sign" "out" "Sign out"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Logged-out Views</h3>
|
||||
{{button "lov" "signIn" "Sign In"}}
|
||||
{{button "lov" "createAccount" "Create Account"}}
|
||||
{{button "lov" "forgotPassword" "Forgot Password"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Logged-in Views</h3>
|
||||
{{button "liv" "accountButtons" "Account Buttons"}}
|
||||
{{button "liv" "changePassword" "Change Password"}}
|
||||
{{button "liv" "messageOnly" "Message Only"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Other Modals</h3>
|
||||
{{button "modals" "resetPassword" "Reset Password"}}
|
||||
{{button "modals" "enrollAccount" "Enroll Account"}}
|
||||
{{button "modals" "justVerifiedEmail" "Verified Email"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Spinner (must be logged in)</h3>
|
||||
{{radio "fakeUserNotLoaded" "false" "Off"}}
|
||||
{{radio "fakeUserNotLoaded" "true" "Pretend userLoaded=false"}}
|
||||
</div>
|
||||
<div class="group">
|
||||
<h3>Background Color</h3>
|
||||
{{radio "bgcolor" "white" "White"}}
|
||||
{{radio "bgcolor" "black" "Black"}}
|
||||
{{radio "bgcolor" "red" "Red"}}
|
||||
</div>
|
||||
</div>
|
||||
{{#with settings}}
|
||||
<div id="previewpane" class="{{settingsClass}}" style="background:{{bgcolor}}">
|
||||
<div id="preview-wrapper" class="{{outerClass}}">
|
||||
{{#if match "positioning:inline"}}
|
||||
Here is a place to sign in, yay!
|
||||
{{/if}}
|
||||
{{loginButtons align=dropdownAlign}}
|
||||
{{#if match "positioning:inline"}}
|
||||
Isn't that great?
|
||||
{{/if}}
|
||||
</div>
|
||||
<div id="pos-indicator"></div>
|
||||
</div>
|
||||
{{/with}}
|
||||
</template>
|
||||
225
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js
Normal file
225
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.js
Normal file
@@ -0,0 +1,225 @@
|
||||
|
||||
Meteor.users.allow({update: function () { return true; }});
|
||||
|
||||
if (Meteor.isClient) {
|
||||
|
||||
Accounts.STASH = _.extend({}, Accounts);
|
||||
Accounts.STASH.userLoaded = Meteor.userLoaded;
|
||||
|
||||
var handleSetting = function (key, value) {
|
||||
if (key === "numServices") {
|
||||
_.each(['facebook', 'github', 'google'],
|
||||
function (serv, i) {
|
||||
if (i < value)
|
||||
Accounts[serv] = Accounts.STASH[serv];
|
||||
else
|
||||
Accounts[serv] = null;
|
||||
});
|
||||
} else if (key === "hasPasswords") {
|
||||
Accounts.password = value && Accounts.STASH.password || null;
|
||||
var user = Meteor.user();
|
||||
if (user) {
|
||||
if (! value) {
|
||||
// make sure we have no username if "app" has no passwords
|
||||
Meteor.users.update(Meteor.userId(),
|
||||
{ $unset: { username: 1 }});
|
||||
} else {
|
||||
// make sure we have a username
|
||||
Meteor.users.update(Meteor.userId(),
|
||||
{ $set: { username: Meteor.uuid() }});
|
||||
}
|
||||
}
|
||||
} else if (key === "signupFields") {
|
||||
Accounts.ui._options.passwordSignupFields = value;
|
||||
} else if (key === "fakeUserNotLoaded") {
|
||||
Meteor.userLoaded = (value ? function () { return false; } :
|
||||
Accounts.STASH.userLoaded);
|
||||
}
|
||||
};
|
||||
|
||||
if (! Session.get('settings'))
|
||||
Session.set('settings', {
|
||||
alignRight: false,
|
||||
positioning: "relative",
|
||||
numServices: 3,
|
||||
hasPasswords: true,
|
||||
signupFields: 'EMAIL_ONLY',
|
||||
fakeUserNotLoaded: false,
|
||||
bgcolor: 'white'
|
||||
});
|
||||
else
|
||||
_.each(Session.get('settings'), function (v,k) {
|
||||
handleSetting(k, v);
|
||||
});
|
||||
|
||||
Template.page.settings = function () {
|
||||
return Session.get('settings');
|
||||
};
|
||||
|
||||
Template.page.settingsClass = function () {
|
||||
var settings = Session.get('settings');
|
||||
var classes = [];
|
||||
if (settings.positioning)
|
||||
classes.push('positioning-' + settings.positioning.toLowerCase());
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
Template.page.outerClass = function () {
|
||||
var settings = Session.get('settings');
|
||||
var classes = [];
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
var keyValueFromId = function (id) {
|
||||
var match;
|
||||
if (id && (match = /^(.*?):(.*)$/.exec(id))) {
|
||||
var key = match[1];
|
||||
var value = castValue(match[2]);
|
||||
return [key, value];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
var castValue = function (value) {
|
||||
if (value === "false")
|
||||
value = false;
|
||||
else if (value === "true")
|
||||
value = true;
|
||||
else if (/^[0-9]+$/.test(value))
|
||||
value = Number(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
Template.radio.maybeChecked = function () {
|
||||
var curValue = Session.get('settings')[this.key];
|
||||
if (castValue(this.value) === curValue)
|
||||
return 'checked="checked"';
|
||||
return '';
|
||||
};
|
||||
|
||||
Template.page.radio = function (key, value, label) {
|
||||
return new Handlebars.SafeString(
|
||||
Template.radio({key: key, value: value, label: label}));
|
||||
};
|
||||
|
||||
Template.page.button = function (key, value, label) {
|
||||
return new Handlebars.SafeString(
|
||||
Template.button({key: key, value: value, label: label}));
|
||||
};
|
||||
|
||||
Template.page.match = function (kv) {
|
||||
kv = keyValueFromId(kv);
|
||||
if (! kv)
|
||||
return false;
|
||||
|
||||
return Session.get('settings')[kv[0]] === kv[1];
|
||||
};
|
||||
|
||||
Template.page.dropdownAlign = function () {
|
||||
var settings = this;
|
||||
return settings.alignRight ? 'right' : 'left';
|
||||
};
|
||||
|
||||
var fakeLogin = function () {
|
||||
Accounts.createUser(
|
||||
{username: Meteor.uuid(),
|
||||
password: "password",
|
||||
profile: { name: "Joe Schmoe" }},
|
||||
function () {
|
||||
var user = Meteor.user();
|
||||
if (! user)
|
||||
return;
|
||||
// delete our username if we are in a mode
|
||||
// where there aren't usernames/emails/passwords
|
||||
// (only third-party auth) so that there is no
|
||||
// "Change Password" button when signed in
|
||||
if (! Session.get('settings').hasPasswords)
|
||||
Meteor.users.update(Meteor.userId(),
|
||||
{ $unset: { username: 1 }});
|
||||
});
|
||||
};
|
||||
|
||||
var exitFlows = function () {
|
||||
Accounts._loginButtonsSession.set('inSignupFlow', false);
|
||||
Accounts._loginButtonsSession.set('inForgotPasswordFlow', false);
|
||||
Accounts._loginButtonsSession.set('inChangePasswordFlow', false);
|
||||
Accounts._loginButtonsSession.set('inMessageOnlyFlow', false);
|
||||
};
|
||||
|
||||
Template.page.events({
|
||||
'change #controlpane input[type=radio]': function (event) {
|
||||
var input = event.currentTarget;
|
||||
var keyValue;
|
||||
if (input && input.id && (keyValue = keyValueFromId(input.id))) {
|
||||
var key = keyValue[0];
|
||||
var value = keyValue[1];
|
||||
if (value === "false")
|
||||
value = false;
|
||||
else if (value === "true")
|
||||
value = true;
|
||||
var settings = Session.get('settings');
|
||||
settings[key] = value;
|
||||
Session.set('settings', settings);
|
||||
|
||||
handleSetting(key, value);
|
||||
}
|
||||
},
|
||||
'click #controlpane button': function (event) {
|
||||
if (this.key === "fakeConfig") {
|
||||
var service = this.value;
|
||||
if (! Accounts.loginServiceConfiguration.findOne({service: service}))
|
||||
Accounts.loginServiceConfiguration.insert(
|
||||
{service: service, fake: true});
|
||||
} else if (this.key === "unconfig") {
|
||||
var service = this.value;
|
||||
Accounts.loginServiceConfiguration.remove({service: service});
|
||||
} else if (this.key === "messages") {
|
||||
if (this.value === "error") {
|
||||
Accounts._loginButtonsSession.set('errorMessage', 'An error occurred! Gee golly gosh.');
|
||||
} else if (this.value === "info") {
|
||||
Accounts._loginButtonsSession.set('infoMessage', 'Here is some information that is crucial.');
|
||||
} else if (this.value === "clear") {
|
||||
Accounts._loginButtonsSession.resetMessages();
|
||||
}
|
||||
} else if (this.key === "sign") {
|
||||
if (this.value === 'in') {
|
||||
// create a random new user
|
||||
Accounts._loginButtonsSession.closeDropdown();
|
||||
fakeLogin();
|
||||
} else if (this.value === 'out') {
|
||||
Meteor.logout();
|
||||
}
|
||||
} else if (this.key === "showConfig") {
|
||||
Accounts._loginButtonsSession.configureService(this.value);
|
||||
} else if (this.key === "lov") {
|
||||
exitFlows();
|
||||
Accounts._loginButtonsSession.set("dropdownVisible", true);
|
||||
if (Meteor.userId())
|
||||
Meteor.logout();
|
||||
if (this.value === "createAccount")
|
||||
Accounts._loginButtonsSession.set("inSignupFlow", true);
|
||||
else if (this.value === "forgotPassword")
|
||||
Accounts._loginButtonsSession.set("inForgotPasswordFlow", true);
|
||||
} else if (this.key === "liv") {
|
||||
exitFlows();
|
||||
Accounts._loginButtonsSession.set("dropdownVisible", true);
|
||||
if (! Meteor.userId())
|
||||
fakeLogin();
|
||||
if (this.value === "changePassword")
|
||||
Accounts._loginButtonsSession.set("inChangePasswordFlow", true);
|
||||
else if (this.value === "messageOnly")
|
||||
Accounts._loginButtonsSession.set("inMessageOnlyFlow", true);
|
||||
} else if (this.key === "modals") {
|
||||
var value = this.value;
|
||||
_.each([
|
||||
'resetPasswordToken',
|
||||
'enrollAccountToken',
|
||||
'justVerifiedEmail'], function (k) {
|
||||
Accounts._loginButtonsSession.set(
|
||||
k, k.indexOf(value) >= 0 ? 'foo' : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
110
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.less
Normal file
110
examples/unfinished/accounts-ui-viewer/accounts-ui-viewer.less
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
* { padding: 0; margin: 0; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
#controlpane {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 299px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
background: #eee;
|
||||
border-right: 1px solid #999;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
h3 {
|
||||
border-top: 1px solid #999;
|
||||
font-size: 85%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
input[type=radio] {
|
||||
margin-left: 5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
label {
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
#previewpane {
|
||||
position: absolute;
|
||||
left: 300px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
#preview-wrapper {
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.positioning-floatright {
|
||||
#login-buttons {
|
||||
float: right;
|
||||
margin-right: 180px;
|
||||
}
|
||||
|
||||
#pos-indicator {
|
||||
display: block;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.positioning-relative {
|
||||
#login-buttons {
|
||||
position: relative;
|
||||
left: 150px;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
#pos-indicator {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 170px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.positioning-absolute {
|
||||
#login-buttons {
|
||||
position: absolute;
|
||||
left: 170px;
|
||||
top: 40px;
|
||||
}
|
||||
|
||||
#pos-indicator {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 170px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
#pos-indicator {
|
||||
position: absolute;
|
||||
background: #eec;
|
||||
display: none;
|
||||
}
|
||||
|
||||
a { color: blue; }
|
||||
|
||||
button { padding: 4px;
|
||||
margin-bottom: 4px; // for when buttons wrap
|
||||
}
|
||||
@@ -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
|
||||
|
||||
113
packages/accounts-base/accounts_client.js
Normal file
113
packages/accounts-base/accounts_client.js
Normal file
@@ -0,0 +1,113 @@
|
||||
(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()) {
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (user) return user;
|
||||
}
|
||||
// Either the subscription isn't done yet, or for some reason this user has
|
||||
// no published fields (and thus is considered to not exist in
|
||||
// minimongo). 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;
|
||||
};
|
||||
})();
|
||||
69
packages/accounts-base/accounts_common.js
Normal file
69
packages/accounts-base/accounts_common.js
Normal file
@@ -0,0 +1,69 @@
|
||||
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) {
|
||||
// validate option keys
|
||||
var VALID_KEYS = ["sendVerificationEmail", "forbidClientAccountCreation"];
|
||||
_.each(_.keys(options), function (key) {
|
||||
if (!_.contains(VALID_KEYS, key)) {
|
||||
throw new Error("Accounts.config: Invalid key: " + key);
|
||||
}
|
||||
});
|
||||
|
||||
// set values in Accounts._options
|
||||
_.each(VALID_KEYS, 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';
|
||||
|
||||
328
packages/accounts-base/accounts_server.js
Normal file
328
packages/accounts-base/accounts_server.js
Normal file
@@ -0,0 +1,328 @@
|
||||
(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
|
||||
// second "server options" argument.
|
||||
var defaultCreateUserHook = function (options, user) {
|
||||
if (options.profile)
|
||||
user.profile = options.profile;
|
||||
return user;
|
||||
};
|
||||
Accounts.insertUserDoc = function (options, user) {
|
||||
// add created at timestamp (and protect passed in user object from
|
||||
// modification)
|
||||
user = _.extend({createdAt: +(new Date)}, user);
|
||||
|
||||
var result = {};
|
||||
if (options.generateLoginToken) {
|
||||
var stampedToken = Accounts._generateStampedLoginToken();
|
||||
result.token = stampedToken.token;
|
||||
Meteor._ensure(user, 'services', 'resume');
|
||||
if (_.has(user.services.resume, 'loginTokens'))
|
||||
user.services.resume.loginTokens.push(stampedToken);
|
||||
else
|
||||
user.services.resume.loginTokens = [stampedToken];
|
||||
}
|
||||
|
||||
var fullUser;
|
||||
if (onCreateUserHook) {
|
||||
fullUser = onCreateUserHook(options, 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, user);
|
||||
} else {
|
||||
fullUser = defaultCreateUserHook(options, user);
|
||||
}
|
||||
|
||||
_.each(validateNewUserHooks, function (hook) {
|
||||
if (!hook(fullUser))
|
||||
throw new Meteor.Error(403, "User validation failed");
|
||||
});
|
||||
|
||||
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 options {Object, optional} Other options to pass to insertUserDoc
|
||||
// (eg, profile)
|
||||
// @returns {Object} Object with token and id keys, like the result
|
||||
// of the "login" method.
|
||||
Accounts.updateOrCreateUserFromExternalService = function(
|
||||
serviceName, serviceData, options) {
|
||||
options = _.clone(options || {});
|
||||
|
||||
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) {
|
||||
// We *don't* process options (eg, profile) for update, but we do replace
|
||||
// the serviceData (eg, so that we keep an unexpired access token and
|
||||
// don't cache old email addresses in serviceData.email).
|
||||
// XXX provide an onUpdateUser hook which would let apps update
|
||||
// the profile too
|
||||
var stampedToken = Accounts._generateStampedLoginToken();
|
||||
var setAttrs = {};
|
||||
setAttrs["services." + serviceName] = serviceData;
|
||||
// XXX Maybe we should re-use the selector above and notice if the update
|
||||
// touches nothing?
|
||||
Meteor.users.update(
|
||||
user._id,
|
||||
{$set: setAttrs,
|
||||
$push: {'services.resume.loginTokens': stampedToken}});
|
||||
return {token: stampedToken.token, id: user._id};
|
||||
} else {
|
||||
// Create a new user with the service data. Pass other options through to
|
||||
// insertUserDoc.
|
||||
user = {services: {}};
|
||||
user.services[serviceName] = serviceData;
|
||||
options.generateLoginToken = true;
|
||||
return Accounts.insertUserDoc(options, 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, emails: 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;
|
||||
},
|
||||
fetch: ['_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});
|
||||
}) ();
|
||||
|
||||
139
packages/accounts-base/accounts_tests.js
Normal file
139
packages/accounts-base/accounts_tests.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// XXX it'd be cool to also test that the right thing happens if options
|
||||
// *are* validated, but Accounts._options is global state which makes this hard
|
||||
// (impossible?)
|
||||
Tinytest.add('accounts - config validates keys', function (test) {
|
||||
test.throws(function () {
|
||||
Accounts.config({foo: "bar"});
|
||||
});
|
||||
});
|
||||
|
||||
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, monkey: 42}, {profile: {foo: 1}}).id;
|
||||
var users = Meteor.users.find({"services.facebook.id": facebookId}).fetch();
|
||||
test.length(users, 1);
|
||||
test.equal(users[0].profile.foo, 1);
|
||||
test.equal(users[0].services.facebook.monkey, 42);
|
||||
|
||||
// create again with the same id, see that we get the same user.
|
||||
// it should update services.facebook but not profile.
|
||||
var uid2 = Accounts.updateOrCreateUserFromExternalService(
|
||||
'facebook', {id: facebookId, llama: 50},
|
||||
{profile: {foo: 1000, bar: 2}}).id;
|
||||
test.equal(uid1, uid2);
|
||||
users = Meteor.users.find({"services.facebook.id": facebookId}).fetch();
|
||||
test.length(users, 1);
|
||||
test.equal(users[0].profile.foo, 1);
|
||||
test.equal(users[0].profile.bar, undefined);
|
||||
test.equal(users[0].services.facebook.llama, 50);
|
||||
test.equal(users[0].services.facebook.monkey, 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(
|
||||
{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(
|
||||
{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(
|
||||
{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(
|
||||
{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
|
||||
},
|
||||
options: {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');
|
||||
});
|
||||
28
packages/accounts-github/github_client.js
Normal file
28
packages/accounts-github/github_client.js
Normal file
@@ -0,0 +1,28 @@
|
||||
(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 scope = (options && options.requestPermissions) || [];
|
||||
var flatScope = _.map(scope, encodeURIComponent).join('+');
|
||||
|
||||
var loginUrl =
|
||||
'https://github.com/login/oauth/authorize' +
|
||||
'?client_id=' + config.clientId +
|
||||
'&scope=' + flatScope +
|
||||
'&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
|
||||
},
|
||||
options: {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 = {};
|
||||
}
|
||||
30
packages/accounts-google/google_configure.html
Normal file
30
packages/accounts-google/google_configure.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
If necessary, "Create Project"
|
||||
<li>
|
||||
Open the "API Access" tab
|
||||
</li>
|
||||
<li>
|
||||
Create another Client ID
|
||||
</li>
|
||||
<li>
|
||||
Click "Edit settings…"
|
||||
</li>
|
||||
<li>
|
||||
Set Authorized Redirect URIs to: <span class="url">{{siteUrl}}_oauth/google?close</span>
|
||||
</li>
|
||||
<li>
|
||||
Set Authorized 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
|
||||
},
|
||||
options: {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 options:)} 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.options);
|
||||
}
|
||||
}
|
||||
|
||||
// Either close the window, redirect, or render nothing
|
||||
// if all else fails
|
||||
Accounts.oauth._renderOauthResults(res, query);
|
||||
};
|
||||
|
||||
})();
|
||||
137
packages/accounts-oauth1-helper/oauth1_tests.js
Normal file
137
packages/accounts-oauth1-helper/oauth1_tests.js
Normal file
@@ -0,0 +1,137 @@
|
||||
|
||||
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();
|
||||
var state = Meteor.uuid();
|
||||
|
||||
OAuth1Binding.prototype.prepareRequestToken = function() {};
|
||||
OAuth1Binding.prototype.prepareAccessToken = function() {
|
||||
this.accessToken = twitterfooAccessToken;
|
||||
this.accessTokenSecret = twitterfooAccessTokenSecret;
|
||||
};
|
||||
|
||||
if (!Accounts.loginServiceConfiguration.findOne({service: 'twitterfoo'}))
|
||||
Accounts.loginServiceConfiguration.insert({service: 'twitterfoo'});
|
||||
Accounts.twitterfoo = {};
|
||||
|
||||
try {
|
||||
// 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);
|
||||
} finally {
|
||||
delete Accounts.oauth._services.twitterfoo;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
// register a failing login service
|
||||
Accounts.oauth.registerService("twitterfail", 1, function (query) {
|
||||
return {
|
||||
serviceData: {
|
||||
id: twitterfailId,
|
||||
screenName: twitterfailName,
|
||||
accessToken: twitterfailAccessToken,
|
||||
accessTokenSecret: twitterfailAccessTokenSecret
|
||||
},
|
||||
options: {
|
||||
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}}]);
|
||||
});
|
||||
} finally {
|
||||
delete Accounts.oauth._services.twitterfail;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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.options);
|
||||
}
|
||||
|
||||
// Either close the window, redirect, or render nothing
|
||||
// if all else fails
|
||||
Accounts.oauth._renderOauthResults(res, query);
|
||||
};
|
||||
|
||||
})();
|
||||
94
packages/accounts-oauth2-helper/oauth2_tests.js
Normal file
94
packages/accounts-oauth2-helper/oauth2_tests.js
Normal file
@@ -0,0 +1,94 @@
|
||||
Tinytest.add("oauth2 - loginResultForState is stored", function (test) {
|
||||
var http = __meteor_bootstrap__.require('http');
|
||||
var foobookId = Meteor.uuid();
|
||||
var state = Meteor.uuid();
|
||||
|
||||
if (!Accounts.loginServiceConfiguration.findOne({service: 'foobook'}))
|
||||
Accounts.loginServiceConfiguration.insert({service: 'foobook'});
|
||||
Accounts.foobook = {};
|
||||
|
||||
try {
|
||||
// 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);
|
||||
} finally {
|
||||
delete Accounts.oauth._services.foobook;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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 = {};
|
||||
|
||||
try {
|
||||
// register a failing login service
|
||||
Accounts.oauth.registerService("failbook", 2, function (query) {
|
||||
return {
|
||||
serviceData: {
|
||||
id: failbookId
|
||||
},
|
||||
options: {
|
||||
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}}]);
|
||||
});
|
||||
} finally {
|
||||
delete Accounts.oauth._services.failbook;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
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 things like verification tokens!
|
||||
test.isFalse(_.has(Meteor.user(), 'services'));
|
||||
},
|
||||
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');
|
||||
});
|
||||
185
packages/accounts-password/passwords_client.js
Normal file
185
packages/accounts-password/passwords_client.js
Normal file
@@ -0,0 +1,185 @@
|
||||
(function () {
|
||||
Accounts.createUser = function (options, callback) {
|
||||
options = _.clone(options); // we'll be modifying options
|
||||
|
||||
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], {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 = {};
|
||||
461
packages/accounts-password/passwords_server.js
Normal file
461
packages/accounts-password/passwords_server.js
Normal file
@@ -0,0 +1,461 @@
|
||||
(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(
|
||||
{'services.email.verificationTokens.token': token});
|
||||
if (!user)
|
||||
throw new Meteor.Error(403, "Verify email link expired");
|
||||
|
||||
var tokenRecord = _.find(user.services.email.verificationTokens,
|
||||
function (t) {
|
||||
return t.token == token;
|
||||
});
|
||||
if (!tokenRecord)
|
||||
throw new Meteor.Error(403, "Verify email link expired");
|
||||
|
||||
var emailsRecord = _.find(user.emails, function (e) {
|
||||
return e.address == tokenRecord.address;
|
||||
});
|
||||
if (!emailsRecord)
|
||||
throw new Meteor.Error(403, "Verify email link is for unknown address");
|
||||
|
||||
// Log the user in with a new login token.
|
||||
var stampedLoginToken = Accounts._generateStampedLoginToken();
|
||||
|
||||
// By including the address 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.address': tokenRecord.address},
|
||||
{$set: {'emails.$.verified': true},
|
||||
$pull: {'services.email.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, address) {
|
||||
// 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 address is one of their addresses.
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (!user)
|
||||
throw new Error("Can't find user");
|
||||
// pick the first unverified address if we weren't passed an address.
|
||||
if (!address) {
|
||||
var email = _.find(user.emails || [],
|
||||
function (e) { return !e.verified; });
|
||||
address = (email || {}).address;
|
||||
}
|
||||
// make sure we have a valid address
|
||||
if (!address || !_.contains(_.pluck(user.emails || [], 'address'), address))
|
||||
throw new Error("No such email address for user.");
|
||||
|
||||
|
||||
var tokenRecord = {
|
||||
token: Meteor.uuid(),
|
||||
address: address,
|
||||
when: +(new Date)};
|
||||
Meteor.users.update(
|
||||
{_id: userId},
|
||||
{$push: {'services.email.verificationTokens': tokenRecord}});
|
||||
|
||||
var verifyEmailUrl = Accounts.urls.verifyEmail(tokenRecord.token);
|
||||
Email.send({
|
||||
to: address,
|
||||
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) {
|
||||
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, user);
|
||||
};
|
||||
|
||||
// method for create user. Requests come from the client.
|
||||
Meteor.methods({
|
||||
createUser: function (options) {
|
||||
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);
|
||||
// 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, callback) {
|
||||
options = _.clone(options);
|
||||
options.generateLoginToken = false;
|
||||
|
||||
// XXX allow an optional callback?
|
||||
if (callback) {
|
||||
throw new Error("Accounts.createUser with callback not supported on the server yet.");
|
||||
}
|
||||
|
||||
var userId = createUser(options).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});
|
||||
})();
|
||||
341
packages/accounts-password/passwords_tests.js
Normal file
341
packages/accounts-password/passwords_tests.js
Normal file
@@ -0,0 +1,341 @@
|
||||
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);
|
||||
}));
|
||||
},
|
||||
function(test, expect) {
|
||||
Meteor.call('clearUsernameAndProfile');
|
||||
Meteor.default_connection.onQuiesce(expect(function() {
|
||||
test.isTrue(Meteor.userId());
|
||||
var user = Meteor.user();
|
||||
test.equal(user, {_id: Meteor.userId()});
|
||||
}));
|
||||
},
|
||||
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})
|
||||
}) ();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user