diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da6ed88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +settings-demo.json +settings-staging.json +settings-production.json diff --git a/.meteor/.finished-upgraders b/.meteor/.finished-upgraders new file mode 100644 index 0000000..910574c --- /dev/null +++ b/.meteor/.finished-upgraders @@ -0,0 +1,17 @@ +# This file contains information which helps Meteor properly upgrade your +# app when you run 'meteor update'. You should check it into version control +# with your project. + +notices-for-0.9.0 +notices-for-0.9.1 +0.9.4-platform-file +notices-for-facebook-graph-api-2 +1.2.0-standard-minifiers-package +1.2.0-meteor-platform-split +1.2.0-cordova-changes +1.2.0-breaking-changes +1.3.0-split-minifiers-package +1.4.0-remove-old-dev-bundle-link +1.4.1-add-shell-server-package +1.4.3-split-account-service-packages +1.5-add-dynamic-import-package diff --git a/.meteor/.gitignore b/.meteor/.gitignore new file mode 100644 index 0000000..4083037 --- /dev/null +++ b/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/.meteor/.id b/.meteor/.id new file mode 100644 index 0000000..9c23f12 --- /dev/null +++ b/.meteor/.id @@ -0,0 +1,7 @@ +# This file contains a token that is unique to your project. +# Check it into your repository along with the rest of this directory. +# It can be used for purposes such as: +# - ensuring you don't accidentally deploy one app on top of another +# - providing package authors with aggregated statistics + +16be20efyo0qb53r01o diff --git a/.meteor/packages b/.meteor/packages new file mode 100644 index 0000000..62200af --- /dev/null +++ b/.meteor/packages @@ -0,0 +1,35 @@ +# Meteor packages used by this project, one per line. +# Check this file (and the other files in this directory) into your repository. +# +# 'meteor add' and 'meteor remove' will edit this file for you, +# but you can also edit it by hand. + +meteor-base@1.1.0 # Packages every Meteor app needs to have +mobile-experience@1.0.4 # Packages for a great mobile UX +mongo@1.2.0 # The database Meteor supports right now +reactive-var@1.0.11 # Reactive variable for tracker +tracker@1.1.3 # Meteor's client-side reactive programming library + +standard-minifier-css@1.3.4 # CSS minifier run for production mode +standard-minifier-js@2.1.1 # JS minifier run for production mode +es5-shim@4.6.15 # ECMAScript 5 compatibility for older browsers. +ecmascript@0.8.2 # Enable ECMAScript2015+ syntax in app code +shell-server@0.2.4 # Server-side component of the `meteor shell` command + +react-meteor-data +alanning:roles +fourseven:scss +twbs:bootstrap +accounts-base@1.3.2 +accounts-password@1.4.0 +service-configuration@1.0.11 +accounts-facebook@1.2.1 +accounts-github@1.3.0 +accounts-google@1.2.0 +themeteorchef:bert +fortawesome:fontawesome +aldeed:collection2-core@2.0.1 +audit-argument-checks@1.0.7 +ddp-rate-limiter@1.0.7 +dynamic-import@0.1.1 +static-html diff --git a/.meteor/platforms b/.meteor/platforms new file mode 100644 index 0000000..efeba1b --- /dev/null +++ b/.meteor/platforms @@ -0,0 +1,2 @@ +server +browser diff --git a/.meteor/release b/.meteor/release new file mode 100644 index 0000000..47c31ab --- /dev/null +++ b/.meteor/release @@ -0,0 +1 @@ +METEOR@1.5.2 diff --git a/.meteor/versions b/.meteor/versions new file mode 100644 index 0000000..87e5bde --- /dev/null +++ b/.meteor/versions @@ -0,0 +1,104 @@ +accounts-base@1.3.3 +accounts-facebook@1.2.1 +accounts-github@1.3.0 +accounts-google@1.2.0 +accounts-oauth@1.1.15 +accounts-password@1.4.0 +alanning:roles@1.2.16 +aldeed:collection2-core@2.0.1 +allow-deny@1.0.9 +audit-argument-checks@1.0.7 +autoupdate@1.3.12 +babel-compiler@6.20.0 +babel-runtime@1.0.1 +base64@1.0.10 +binary-heap@1.0.10 +blaze@2.3.2 +blaze-tools@1.0.10 +boilerplate-generator@1.2.0 +caching-compiler@1.1.9 +caching-html-compiler@1.1.2 +callback-hook@1.0.10 +check@1.2.5 +ddp@1.3.1 +ddp-client@2.1.3 +ddp-common@1.2.9 +ddp-rate-limiter@1.0.7 +ddp-server@2.0.2 +deps@1.0.12 +diff-sequence@1.0.7 +dynamic-import@0.1.3 +ecmascript@0.8.2 +ecmascript-runtime@0.4.1 +ecmascript-runtime-client@0.4.3 +ecmascript-runtime-server@0.4.1 +ejson@1.0.14 +email@1.2.3 +es5-shim@4.6.15 +facebook-oauth@1.3.2 +fastclick@1.0.13 +fortawesome:fontawesome@4.7.0 +fourseven:scss@4.5.4 +geojson-utils@1.0.10 +github-oauth@1.2.0 +google-oauth@1.2.4 +hot-code-push@1.0.4 +html-tools@1.0.11 +htmljs@1.0.11 +http@1.2.12 +id-map@1.0.9 +jquery@1.11.10 +launch-screen@1.1.1 +livedata@1.0.18 +localstorage@1.1.1 +logging@1.1.17 +meteor@1.7.2 +meteor-base@1.1.0 +minifier-css@1.2.16 +minifier-js@2.1.3 +minimongo@1.3.1 +mobile-experience@1.0.4 +mobile-status-bar@1.0.14 +modules@0.10.0 +modules-runtime@0.8.0 +mongo@1.2.2 +mongo-dev-server@1.0.1 +mongo-id@1.0.6 +npm-bcrypt@0.9.3 +npm-mongo@2.2.30 +oauth@1.1.13 +oauth2@1.1.11 +observe-sequence@1.0.16 +ordered-dict@1.0.9 +promise@0.9.0 +raix:eventemitter@0.1.3 +random@1.0.10 +rate-limit@1.0.8 +react-meteor-data@0.2.13 +reactive-dict@1.1.9 +reactive-var@1.0.11 +reload@1.1.11 +retry@1.0.9 +routepolicy@1.0.12 +service-configuration@1.0.11 +session@1.1.7 +sha@1.0.9 +shell-server@0.2.4 +spacebars@1.0.15 +spacebars-compiler@1.1.3 +srp@1.0.10 +standard-minifier-css@1.3.5 +standard-minifier-js@2.1.1 +static-html@1.2.2 +templating@1.3.2 +templating-compiler@1.3.3 +templating-runtime@1.3.2 +templating-tools@1.1.2 +themeteorchef:bert@2.1.3 +tmeasday:check-npm-versions@0.3.1 +tracker@1.1.3 +twbs:bootstrap@3.3.6 +underscore@1.0.10 +url@1.1.0 +webapp@1.3.19 +webapp-hashing@1.0.9 diff --git a/History.md b/History.md new file mode 100644 index 0000000..f64a6d7 --- /dev/null +++ b/History.md @@ -0,0 +1,69 @@ +## v.NEXT + +- Improve [#96](https://github.com/cleverbeagle/pup/issues/96). Fix miscellaneous ESLint errors (h/t [@cdolek](https://github.com/cdolek) for sniffing these out). +- Improve [#95](https://github.com/cleverbeagle/pup/issues/95). Migrate from `createContainer` method to `withTracker` for data containers (h/t [@cdolek](https://github.com/cdolek) for sniffing these out). +- Improve [#94](https://github.com/cleverbeagle/pup/issues/94). Bump Meteor to v1.6 and bump Atmosphere and NPM deps. +- Improve [#53](https://github.com/cleverbeagle/pup/issues/53). Move method imports to be on both the client and the server to leverage Meteor's latency compensation by default (h/t [@zodern](https://github.com/zodern) for refactoring this). +- Improve [#78](https://github.com/cleverbeagle/pup/issues/78). Deprecate `@cleverbeagle/dates` package in favor of a local file of date helpers (h/t [@zodern](https://github.com/zodern) for refactoring this). +- Improve [#79](https://github.com/cleverbeagle/pup/issues/79). Add `babel-plugin-lodash` to reduce footprint of Lodash to only methods that are used in the project (h/t [@ninjaPixel](https://github.com/ninjaPixel) for finding/implementing this). +- Improve [#80](https://github.com/cleverbeagle/pup/issues/80). Add exception to disable `max-len` ESLint rule (h/t [@ninjaPixel](https://github.com/ninjaPixel) for implementing this). +- Improve [#60](https://github.com/cleverbeagle/pup/issues/60). Move to `babel-preset-env` from `babel-preset-es2015` (h/t [@pilarArr](https://github.com/pilarArr) for implementing this). +- Fix [#59](https://github.com/cleverbeagle/pup/issues/59). Fix ESLint throwing a tantrum about line endings on Windows (h/t [@pilarArr](https://github.com/pilarArr) for implementing this). +- Improve [#47](https://github.com/cleverbeagle/pup/issues/47). Move `getUserName()` function in `/imports/ui/layouts/App/App.js` into its own file (h/t [@pilarArr](https://github.com/pilarArr) for implementing this). +- Improve [#46](https://github.com/cleverbeagle/pup/issues/46). Move the resend verification email bar into its own component (h/t [@pilarArr](https://github.com/pilarArr) for implementing this). + +## v1.2.2, September 30th, 2017 + +- Fix [#66](https://github.com/cleverbeagle/pup/issues/66). Remove unnecessary `callback` argument causing trouble in `/imports/modules/server/send-email.js`. + +- Fix [#74](https://github.com/cleverbeagle/pup/issues/74). Fix typo in `/imports/ui/components/Public/Public.js`. + +## v1.2.1, September 24th, 2017 + +- Fix [#68](https://github.com/cleverbeagle/pup/issues/68). Update Meteor to v1.5.2 and bump Atmosphere dependencies to latest versions. + +## v1.2.0, September 7th, 2017 + +- Fix [#31](https://github.com/cleverbeagle/pup/issues/31). Fix name not rendering for users logged in via OAuth with Google. +- Fix [#32](https://github.com/cleverbeagle/pup/issues/32). Fix route props not being passed to rendered component in `` and ``. h/t @pilarArr for the fix. +- Improve [#37](https://github.com/cleverbeagle/pup/issues/37). Bump Atmosphere and NPM dependencies to latest versions. h/t @pilarArr for the refactor. +- Improve [#40](https://github.com/cleverbeagle/pup/issues/40). Move `Meteor.logout()` call to be triggered by visiting the `/logout` route. +- Add [#44](https://github.com/cleverbeagle/pup/issues/44). Add support for transactional emails with HTML and text templates via Handlebars. +- Improve [#45](https://github.com/cleverbeagle/pup/issues/45). Clean up the UI/UX of the "Edit Profile" view for users who are logged in via OAuth. + +## v1.1.1, August 6th, 2017 + +- Fix [#27](https://github.com/cleverbeagle/pup/issues/27). Fix incorrect method name in rate limiter list in `/imports/api/Users/server/methods.js`. +- Fix [#28](https://github.com/cleverbeagle/pup/issues/28). Fix verify email message displaying for users who log in via OAuth. +- Fix [#29](https://github.com/cleverbeagle/pup/issues/29). Fix `@cleverbeagle/seeder` and `@cleverbeagle/dates` dependencies breaking initial startup. Also add `core-js` dependency as it was breaking some apps on startup. + +## v1.1.0, August 1st, 2017 + +- Improve [#16](https://github.com/cleverbeagle/pup/issues/16). Adds missing import for `/imports/startup/server/accounts/on-create-user.js` in `/imports/startup/server/accounts/index.js`. + [Add documentation on usage of `Accounts.onCreateUser()`](/pup/v1/accounts/on-create-user-hook). + +- Improve [#17](https://github.com/cleverbeagle/pup/issues/17). Move setting of user password in editing profile to client to ensure the user's current password is actually checked (implements Meteor's `Accounts.changePassword()` method). + +- Add [#20](https://github.com/cleverbeagle/pup/issues/20). Add support for `lodash` package via NPM and swap usage of `capitalize()` method from `@cleverbeagle/strings` package with lodash's `_.capitalize()` method. Remove `@cleverbeagle/strings` from NPM dependencies. + +- Add [#21](https://github.com/cleverbeagle/pup/issues/21). Add support for (non-blocking) email verification on user signup. [Add documentation on email verification process](/pup/v1/accounts/email-verification). + +- Fix [#23](https://github.com/cleverbeagle/pup/issues/23). Fixes a React PropTypes warning showing up when accessing a non-existing document on `/documents/:_id/edit`. Also improve 404 handling on `/documents/:_id/edit` and `/documents/:_id` when no document is passed. + +- Fix [#24](https://github.com/cleverbeagle/pup/issues/24). Remove unnecessary `_.pluck()` call obscuring the method names passed to the `rateLimit()` options object. + +## v1.0.3, July 9th, 2017 + +- Fix [#14](https://github.com/cleverbeagle/pup/issues/14). Remove unnecessary `loggingIn` check that was forcing the page to re-render. + +## v1.0.2, June 27th, 2017 + +- Fix [#12](https://github.com/cleverbeagle/pup/issues/12). Fixes prop type warning being thrown when transitioning to the `ViewDocument` page component from the `NewDocument` page component. + +## v1.0.1, June 14th, 2017 + +- Fix [#1](https://github.com/cleverbeagle/pup/issues/1). h/t to [@sczk](https://github.com/sczk) for pointing out the fix. + +## v1.0.0, June 13th, 2017 + +- Initial release of Pup. diff --git a/README.md b/README.md index e69de29..5d2adc6 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,8 @@ +### Pup +A boilerplate for products. + +[Read the Documentation](http://cleverbeagle.com/pup) + +--- + +Need help and want to stay accountable building your product? [Check out Clever Beagle](http://cleverbeagle.com). diff --git a/client/main.html b/client/main.html new file mode 100644 index 0000000..db2b85f --- /dev/null +++ b/client/main.html @@ -0,0 +1,13 @@ + + + Pup + + + + + + + + +
+ diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..1412f30 --- /dev/null +++ b/client/main.js @@ -0,0 +1 @@ +import '../imports/startup/client'; diff --git a/imports/api/Documents/Documents.js b/imports/api/Documents/Documents.js new file mode 100644 index 0000000..4974aab --- /dev/null +++ b/imports/api/Documents/Documents.js @@ -0,0 +1,51 @@ +/* eslint-disable consistent-return */ + +import { Mongo } from 'meteor/mongo'; +import SimpleSchema from 'simpl-schema'; + +const Documents = new Mongo.Collection('Documents'); + +Documents.allow({ + insert: () => false, + update: () => false, + remove: () => false, +}); + +Documents.deny({ + insert: () => true, + update: () => true, + remove: () => true, +}); + +Documents.schema = new SimpleSchema({ + owner: { + type: String, + label: 'The ID of the user this document belongs to.', + }, + createdAt: { + type: String, + label: 'The date this document was created.', + autoValue() { + if (this.isInsert) return (new Date()).toISOString(); + }, + }, + updatedAt: { + type: String, + label: 'The date this document was last updated.', + autoValue() { + if (this.isInsert || this.isUpdate) return (new Date()).toISOString(); + }, + }, + title: { + type: String, + label: 'The title of the document.', + }, + body: { + type: String, + label: 'The body of the document.', + }, +}); + +Documents.attachSchema(Documents.schema); + +export default Documents; diff --git a/imports/api/Documents/methods.js b/imports/api/Documents/methods.js new file mode 100644 index 0000000..29fbd8b --- /dev/null +++ b/imports/api/Documents/methods.js @@ -0,0 +1,53 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Documents from './Documents'; +import rateLimit from '../../modules/rate-limit'; + +Meteor.methods({ + 'documents.insert': function documentsInsert(doc) { + check(doc, { + title: String, + body: String, + }); + + try { + return Documents.insert({ owner: this.userId, ...doc }); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, + 'documents.update': function documentsUpdate(doc) { + check(doc, { + _id: String, + title: String, + body: String, + }); + + try { + const documentId = doc._id; + Documents.update(documentId, { $set: doc }); + return documentId; // Return _id so we can redirect to document after update. + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, + 'documents.remove': function documentsRemove(documentId) { + check(documentId, String); + + try { + return Documents.remove(documentId); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, +}); + +rateLimit({ + methods: [ + 'documents.insert', + 'documents.update', + 'documents.remove', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Documents/server/publications.js b/imports/api/Documents/server/publications.js new file mode 100644 index 0000000..f667a8b --- /dev/null +++ b/imports/api/Documents/server/publications.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Documents from '../Documents'; + +Meteor.publish('documents', function documents() { + return Documents.find({ owner: this.userId }); +}); + +// Note: documents.view is also used when editing an existing document. +Meteor.publish('documents.view', function documentsView(documentId) { + check(documentId, String); + return Documents.find({ _id: documentId, owner: this.userId }); +}); diff --git a/imports/api/OAuth/server/methods.js b/imports/api/OAuth/server/methods.js new file mode 100644 index 0000000..6696da4 --- /dev/null +++ b/imports/api/OAuth/server/methods.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { ServiceConfiguration } from 'meteor/service-configuration'; +import rateLimit from '../../../modules/rate-limit'; + +Meteor.methods({ + 'oauth.verifyConfiguration': function oauthVerifyConfiguration(services) { + check(services, Array); + + try { + const verifiedServices = []; + services.forEach((service) => { + if (ServiceConfiguration.configurations.findOne({ service })) { + verifiedServices.push(service); + } + }); + return verifiedServices.sort(); + } catch (exception) { + throw new Meteor.Error('500', exception); + } + }, +}); + +rateLimit({ + methods: [ + 'oauth.verifyConfiguration', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Users/server/edit-profile.js b/imports/api/Users/server/edit-profile.js new file mode 100644 index 0000000..fc44093 --- /dev/null +++ b/imports/api/Users/server/edit-profile.js @@ -0,0 +1,32 @@ +/* eslint-disable consistent-return */ + +import { Meteor } from 'meteor/meteor'; + +let action; + +const updateUser = (userId, { emailAddress, profile }) => { + try { + Meteor.users.update(userId, { + $set: { + 'emails.0.address': emailAddress, + profile, + }, + }); + } catch (exception) { + action.reject(`[editProfile.updateUser] ${exception}`); + } +}; + +const editProfile = ({ userId, profile }, promise) => { + try { + action = promise; + updateUser(userId, profile); + action.resolve(); + } catch (exception) { + action.reject(`[editProfile.handler] ${exception}`); + } +}; + +export default options => +new Promise((resolve, reject) => +editProfile(options, { resolve, reject })); diff --git a/imports/api/Users/server/methods.js b/imports/api/Users/server/methods.js new file mode 100644 index 0000000..130c6ee --- /dev/null +++ b/imports/api/Users/server/methods.js @@ -0,0 +1,37 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import { Accounts } from 'meteor/accounts-base'; +import editProfile from './edit-profile'; +import rateLimit from '../../../modules/rate-limit'; + +Meteor.methods({ + 'users.sendVerificationEmail': function usersSendVerificationEmail() { + return Accounts.sendVerificationEmail(this.userId); + }, + 'users.editProfile': function usersEditProfile(profile) { + check(profile, { + emailAddress: String, + profile: { + name: { + first: String, + last: String, + }, + }, + }); + + return editProfile({ userId: this.userId, profile }) + .then(response => response) + .catch((exception) => { + throw new Meteor.Error('500', exception); + }); + }, +}); + +rateLimit({ + methods: [ + 'users.sendVerificationEmail', + 'users.editProfile', + ], + limit: 5, + timeRange: 1000, +}); diff --git a/imports/api/Users/server/publications.js b/imports/api/Users/server/publications.js new file mode 100644 index 0000000..7728b4b --- /dev/null +++ b/imports/api/Users/server/publications.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; + +Meteor.publish('users.editProfile', function usersProfile() { + return Meteor.users.find(this.userId, { + fields: { + emails: 1, + profile: 1, + services: 1, + }, + }); +}); diff --git a/imports/api/Users/server/send-welcome-email.js b/imports/api/Users/server/send-welcome-email.js new file mode 100644 index 0000000..d15298d --- /dev/null +++ b/imports/api/Users/server/send-welcome-email.js @@ -0,0 +1,25 @@ +import sendEmail from '../../../modules/server/send-email'; +import getOAuthProfile from '../../../modules/get-oauth-profile'; + +export default (options, user) => { + const OAuthProfile = getOAuthProfile(options, user); + + const applicationName = 'Application Name'; + const firstName = OAuthProfile ? OAuthProfile.name.first : options.profile.name.first; + const emailAddress = OAuthProfile ? OAuthProfile.email : options.email; + + return sendEmail({ + to: emailAddress, + from: `${applicationName} `, + subject: `[${applicationName}] Welcome, ${firstName}!`, + template: 'welcome', + templateVars: { + applicationName, + firstName, + welcomeUrl: Meteor.absoluteUrl('documents'), // e.g., returns http://localhost:3000/documents + }, + }) + .catch((error) => { + throw new Meteor.Error('500', `${error}`); + }); +}; diff --git a/imports/api/Utility/server/methods.js b/imports/api/Utility/server/methods.js new file mode 100644 index 0000000..498caae --- /dev/null +++ b/imports/api/Utility/server/methods.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import getPrivateFile from '../../../modules/server/get-private-file'; +import parseMarkdown from '../../../modules/parse-markdown'; + +Meteor.methods({ + 'utility.getPage': function utilityGetPage(fileName) { + check(fileName, String); + return parseMarkdown(getPrivateFile(`pages/${fileName}.md`)); + }, +}); diff --git a/imports/modules/get-oauth-profile.js b/imports/modules/get-oauth-profile.js new file mode 100644 index 0000000..91866dc --- /dev/null +++ b/imports/modules/get-oauth-profile.js @@ -0,0 +1,42 @@ +const parseGoogleData = service => { + return { + email: service.email, + name: { + first: service.given_name, + last: service.family_name, + }, + }; +}; + +const parseGithubData = (profile, service) => { + const name = profile.name.split(' '); + return { + email: service.email, + name: { + first: name[0], + last: name[1], + }, + }; +}; + +const parseFacebookData = service => { + return { + email: service.email, + name: { + first: service.first_name, + last: service.last_name, + }, + }; +}; + +const getDataForService = (profile, services) => { + if (services.facebook) return parseFacebookData(services.facebook); + if (services.github) return parseGithubData(profile, services.github); + if (services.google) return parseGoogleData(services.google); +}; + +export default (options, user) => { + const isOAuth = !options.password; + const serviceData = isOAuth ? getDataForService(options.profile, user.services) : null; + return isOAuth ? serviceData : null; +}; diff --git a/imports/modules/parse-markdown.js b/imports/modules/parse-markdown.js new file mode 100644 index 0000000..9ccaffd --- /dev/null +++ b/imports/modules/parse-markdown.js @@ -0,0 +1,8 @@ +import { Parser, HtmlRenderer } from 'commonmark'; + +export default (markdown, options) => { + const reader = new Parser(); + const writer = options ? new HtmlRenderer(options) : new HtmlRenderer(); + const parsed = reader.parse(markdown); + return writer.render(parsed); +}; diff --git a/imports/modules/rate-limit.js b/imports/modules/rate-limit.js new file mode 100644 index 0000000..d98096f --- /dev/null +++ b/imports/modules/rate-limit.js @@ -0,0 +1,11 @@ +import { Meteor } from 'meteor/meteor'; +import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; + +export default ({ methods, limit, timeRange }) => { + if (Meteor.isServer) { + DDPRateLimiter.addRule({ + name(name) { return methods.indexOf(name) > -1; }, + connectionId() { return true; }, + }, limit, timeRange); + } +}; diff --git a/imports/modules/server/get-private-file.js b/imports/modules/server/get-private-file.js new file mode 100644 index 0000000..d2204d8 --- /dev/null +++ b/imports/modules/server/get-private-file.js @@ -0,0 +1,3 @@ +import fs from 'fs'; + +export default path => fs.readFileSync(`assets/app/${path}`, 'utf8'); diff --git a/imports/modules/server/handlebars-email-to-html.js b/imports/modules/server/handlebars-email-to-html.js new file mode 100644 index 0000000..4d7e809 --- /dev/null +++ b/imports/modules/server/handlebars-email-to-html.js @@ -0,0 +1,11 @@ +import handlebars from 'handlebars'; +import juice from 'juice'; + +export default (handlebarsMarkup, context, options) => { + if (handlebarsMarkup && context) { + const template = handlebars.compile(handlebarsMarkup); + return options && !options.inlineCss ? template(context) : juice(template(context)); // Use juice to inline CSS styles from unless disabled. + } + + throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.'); +}; diff --git a/imports/modules/server/handlebars-email-to-text.js b/imports/modules/server/handlebars-email-to-text.js new file mode 100644 index 0000000..c348901 --- /dev/null +++ b/imports/modules/server/handlebars-email-to-text.js @@ -0,0 +1,10 @@ +import handlebars from 'handlebars'; + +export default (handlebarsMarkup, context) => { + if (handlebarsMarkup && context) { + const template = handlebars.compile(handlebarsMarkup); + return template(context); + } + + throw new Error('Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.'); +}; diff --git a/imports/modules/server/send-email.js b/imports/modules/server/send-email.js new file mode 100644 index 0000000..08b731d --- /dev/null +++ b/imports/modules/server/send-email.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Email } from 'meteor/email'; +import getPrivateFile from './get-private-file'; +import templateToText from './handlebars-email-to-text'; +import templateToHTML from './handlebars-email-to-html'; + +const sendEmail = (options, { resolve, reject }) => { + try { + Meteor.defer(() => { + Email.send(options); + resolve(); + }); + } catch (exception) { + reject(exception); + } +}; + +export default ({ text, html, template, templateVars, ...rest }) => { + if (text || html || template) { + return new Promise((resolve, reject) => { + sendEmail({ + ...rest, + text: template ? templateToText(getPrivateFile(`email-templates/${template}.txt`), (templateVars || {})) : text, + html: template ? templateToHTML(getPrivateFile(`email-templates/${template}.html`), (templateVars || {})) : html, + }, { resolve, reject }); + }); + } + throw new Error('Please pass an HTML string, text, or template name to compile for your message\'s body.'); +}; diff --git a/imports/modules/validate.js b/imports/modules/validate.js new file mode 100644 index 0000000..33f6175 --- /dev/null +++ b/imports/modules/validate.js @@ -0,0 +1,4 @@ +import $ from 'jquery'; +import 'jquery-validation'; + +export default (form, options) => $(form).validate(options); diff --git a/imports/startup/client/index.js b/imports/startup/client/index.js new file mode 100644 index 0000000..ccebfda --- /dev/null +++ b/imports/startup/client/index.js @@ -0,0 +1,8 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { Meteor } from 'meteor/meteor'; +import App from '../../ui/layouts/App/App'; + +import '../../ui/stylesheets/app.scss'; + +Meteor.startup(() => render(, document.getElementById('react-root'))); diff --git a/imports/startup/server/accounts/email-templates.js b/imports/startup/server/accounts/email-templates.js new file mode 100644 index 0000000..40a2f18 --- /dev/null +++ b/imports/startup/server/accounts/email-templates.js @@ -0,0 +1,59 @@ +import { Meteor } from 'meteor/meteor'; +import { Accounts } from 'meteor/accounts-base'; +import getPrivateFile from '../../../modules/server/get-private-file'; +import templateToHTML from '../../../modules/server/handlebars-email-to-html'; +import templateToText from '../../../modules/server/handlebars-email-to-text'; + +const name = 'Application Name'; +const email = ''; +const from = `${name} ${email}`; +const emailTemplates = Accounts.emailTemplates; + +emailTemplates.siteName = name; +emailTemplates.from = from; + +emailTemplates.verifyEmail = { + subject() { + return `[${name}] Verify Your Email Address`; + }, + html(user, url) { + return templateToHTML(getPrivateFile('email-templates/verify-email.html'), { + applicationName: name, + firstName: user.profile.name.first, + verifyUrl: url.replace('#/', '') + }); + }, + text(user, url) { + const urlWithoutHash = url.replace('#/', ''); + if (Meteor.isDevelopment) console.info(`Verify Email Link: ${urlWithoutHash}`); // eslint-disable-line + return templateToText(getPrivateFile('email-templates/verify-email.txt'), { + applicationName: name, + firstName: user.profile.name.first, + verifyUrl: urlWithoutHash, + }); + }, +}; + +emailTemplates.resetPassword = { + subject() { + return `[${name}] Reset Your Password`; + }, + html(user, url) { + return templateToHTML(getPrivateFile('email-templates/reset-password.html'), { + firstName: user.profile.name.first, + applicationName: name, + emailAddress: user.emails[0].address, + resetUrl: url.replace('#/', ''), + }); + }, + text(user, url) { + const urlWithoutHash = url.replace('#/', ''); + if (Meteor.isDevelopment) console.info(`Reset Password Link: ${urlWithoutHash}`); // eslint-disable-line + return templateToText(getPrivateFile('email-templates/reset-password.txt'), { + firstName: user.profile.name.first, + applicationName: name, + emailAddress: user.emails[0].address, + resetUrl: urlWithoutHash, + }); + }, +}; diff --git a/imports/startup/server/accounts/index.js b/imports/startup/server/accounts/index.js new file mode 100644 index 0000000..f9400a8 --- /dev/null +++ b/imports/startup/server/accounts/index.js @@ -0,0 +1,3 @@ +import './oauth'; +import './email-templates'; +import './on-create-user.js'; diff --git a/imports/startup/server/accounts/oauth.js b/imports/startup/server/accounts/oauth.js new file mode 100644 index 0000000..545519a --- /dev/null +++ b/imports/startup/server/accounts/oauth.js @@ -0,0 +1,13 @@ +import { Meteor } from 'meteor/meteor'; +import { ServiceConfiguration } from 'meteor/service-configuration'; + +const OAuthSettings = Meteor.settings.private.OAuth; + +if (OAuthSettings) { + Object.keys(OAuthSettings).forEach((service) => { + ServiceConfiguration.configurations.upsert( + { service }, + { $set: OAuthSettings[service] }, + ); + }); +} diff --git a/imports/startup/server/accounts/on-create-user.js b/imports/startup/server/accounts/on-create-user.js new file mode 100644 index 0000000..92d8b40 --- /dev/null +++ b/imports/startup/server/accounts/on-create-user.js @@ -0,0 +1,9 @@ +import { Accounts } from 'meteor/accounts-base'; +import sendWelcomeEmail from '../../../api/Users/server/send-welcome-email'; + +Accounts.onCreateUser((options, user) => { + const userToCreate = user; + if (options.profile) userToCreate.profile = options.profile; + sendWelcomeEmail(options, user); + return userToCreate; +}); diff --git a/imports/startup/server/api.js b/imports/startup/server/api.js new file mode 100644 index 0000000..e351317 --- /dev/null +++ b/imports/startup/server/api.js @@ -0,0 +1,9 @@ +import '../../api/Documents/methods'; +import '../../api/Documents/server/publications'; + +import '../../api/OAuth/server/methods'; + +import '../../api/Users/server/methods'; +import '../../api/Users/server/publications'; + +import '../../api/Utility/server/methods'; diff --git a/imports/startup/server/email.js b/imports/startup/server/email.js new file mode 100644 index 0000000..377599c --- /dev/null +++ b/imports/startup/server/email.js @@ -0,0 +1,3 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isDevelopment) process.env.MAIL_URL = Meteor.settings.private.MAIL_URL; diff --git a/imports/startup/server/fixtures.js b/imports/startup/server/fixtures.js new file mode 100644 index 0000000..d608ab1 --- /dev/null +++ b/imports/startup/server/fixtures.js @@ -0,0 +1,54 @@ +import seeder from '@cleverbeagle/seeder'; +import { Meteor } from 'meteor/meteor'; +import Documents from '../../api/Documents/Documents'; + +const documentsSeed = userId => ({ + collection: Documents, + environments: ['development', 'staging'], + noLimit: true, + modelCount: 5, + model(dataIndex) { + return { + owner: userId, + title: `Document #${dataIndex + 1}`, + body: `This is the body of document #${dataIndex + 1}`, + }; + }, +}); + +seeder(Meteor.users, { + environments: ['development', 'staging'], + noLimit: true, + data: [{ + email: 'admin@admin.com', + password: 'password', + profile: { + name: { + first: 'Andy', + last: 'Warhol', + }, + }, + roles: ['admin'], + data(userId) { + return documentsSeed(userId); + }, + }], + modelCount: 5, + model(index, faker) { + const userCount = index + 1; + return { + email: `user+${userCount}@test.com`, + password: 'password', + profile: { + name: { + first: faker.name.firstName(), + last: faker.name.lastName(), + }, + }, + roles: ['user'], + data(userId) { + return documentsSeed(userId); + }, + }; + }, +}); diff --git a/imports/startup/server/index.js b/imports/startup/server/index.js new file mode 100644 index 0000000..09fb6b3 --- /dev/null +++ b/imports/startup/server/index.js @@ -0,0 +1,4 @@ +import './accounts'; +import './api'; +import './fixtures'; +import './email'; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.js b/imports/ui/components/AccountPageFooter/AccountPageFooter.js new file mode 100644 index 0000000..dd5a1e7 --- /dev/null +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import './AccountPageFooter.scss'; + +const AccountPageFooter = ({ children }) => ( +
+ {children} +
+); + +AccountPageFooter.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default AccountPageFooter; diff --git a/imports/ui/components/AccountPageFooter/AccountPageFooter.scss b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss new file mode 100644 index 0000000..0e6b0df --- /dev/null +++ b/imports/ui/components/AccountPageFooter/AccountPageFooter.scss @@ -0,0 +1,7 @@ +@import '../../stylesheets/colors'; + +.AccountPageFooter { + margin: 25px 0 0; + padding-top: 20px; + border-top: 1px solid $gray-lighter; +} diff --git a/imports/ui/components/Authenticated/Authenticated.js b/imports/ui/components/Authenticated/Authenticated.js new file mode 100644 index 0000000..f34da2f --- /dev/null +++ b/imports/ui/components/Authenticated/Authenticated.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Route, Redirect } from 'react-router-dom'; + +const Authenticated = ({ loggingIn, authenticated, component, path, exact, ...rest }) => ( + ( + authenticated ? + (React.createElement(component, { ...props, ...rest, loggingIn, authenticated })) : + () + )} + /> +); + +Authenticated.propTypes = { + loggingIn: PropTypes.bool.isRequired, + authenticated: PropTypes.bool.isRequired, + component: PropTypes.func.isRequired, +}; + +export default Authenticated; diff --git a/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js new file mode 100644 index 0000000..c8f6b18 --- /dev/null +++ b/imports/ui/components/AuthenticatedNavigation/AuthenticatedNavigation.js @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withRouter } from 'react-router-dom'; +import { LinkContainer } from 'react-router-bootstrap'; +import { Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap'; +import { Meteor } from 'meteor/meteor'; + +const AuthenticatedNavigation = ({ name, history }) => ( +
+ + +
+); + +AuthenticatedNavigation.propTypes = { + name: PropTypes.string.isRequired, +}; + +export default withRouter(AuthenticatedNavigation); diff --git a/imports/ui/components/Content/Content.js b/imports/ui/components/Content/Content.js new file mode 100644 index 0000000..b38d9ca --- /dev/null +++ b/imports/ui/components/Content/Content.js @@ -0,0 +1,16 @@ +/* eslint-disable react/no-danger */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import './Content.scss'; + +const Content = ({ content }) => ( +
+); + +Content.propTypes = { + content: PropTypes.string.isRequired, +}; + +export default Content; diff --git a/imports/ui/components/Content/Content.scss b/imports/ui/components/Content/Content.scss new file mode 100644 index 0000000..a1573ea --- /dev/null +++ b/imports/ui/components/Content/Content.scss @@ -0,0 +1,36 @@ +@import '../../stylesheets/mixins'; + +.Content { + max-width: 700px; + margin: 0 auto; + font-size: 14px; + line-height: 22px; + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 30px 0 20px; + } + + p { + margin-bottom: 20px; + } + + > *:first-child { + margin-top: 0px; + } + + > *:last-child { + margin-bottom: 0px; + } +} + +@include breakpoint(tablet) { + .Content { + font-size: 16px; + line-height: 22px; + } +} diff --git a/imports/ui/components/DocumentEditor/DocumentEditor.js b/imports/ui/components/DocumentEditor/DocumentEditor.js new file mode 100644 index 0000000..4a42df7 --- /dev/null +++ b/imports/ui/components/DocumentEditor/DocumentEditor.js @@ -0,0 +1,97 @@ +/* eslint-disable max-len, no-return-assign */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormGroup, ControlLabel, Button } from 'react-bootstrap'; +import { Meteor } from 'meteor/meteor'; +import { Bert } from 'meteor/themeteorchef:bert'; +import validate from '../../../modules/validate'; + +class DocumentEditor extends React.Component { + componentDidMount() { + const component = this; + validate(component.form, { + rules: { + title: { + required: true, + }, + body: { + required: true, + }, + }, + messages: { + title: { + required: 'Need a title in here, Seuss.', + }, + body: { + required: 'This thneeds a body, please.', + }, + }, + submitHandler() { component.handleSubmit(); }, + }); + } + + handleSubmit() { + const { history } = this.props; + const existingDocument = this.props.doc && this.props.doc._id; + const methodToCall = existingDocument ? 'documents.update' : 'documents.insert'; + const doc = { + title: this.title.value.trim(), + body: this.body.value.trim(), + }; + + if (existingDocument) doc._id = existingDocument; + + Meteor.call(methodToCall, doc, (error, documentId) => { + if (error) { + Bert.alert(error.reason, 'danger'); + } else { + const confirmation = existingDocument ? 'Document updated!' : 'Document added!'; + this.form.reset(); + Bert.alert(confirmation, 'success'); + history.push(`/documents/${documentId}`); + } + }); + } + + render() { + const { doc } = this.props; + return (
(this.form = form)} onSubmit={event => event.preventDefault()}> + + Title + (this.title = title)} + defaultValue={doc && doc.title} + placeholder="Oh, The Places You'll Go!" + /> + + + Body +