├── .meteor ├── .gitignore ├── release ├── platforms ├── .id ├── .finished-upgraders ├── packages └── versions ├── tests ├── __mocks__ │ ├── meteor │ │ ├── email.js │ │ ├── mdg_validated-method.js │ │ ├── random.js │ │ ├── themeteorchef_bert.js │ │ ├── meteorhacks_picker.js │ │ ├── percolate_migrations.js │ │ ├── http.js │ │ ├── alanning_roles.js │ │ ├── meteor.js │ │ └── mongo.js │ └── fileMock.js ├── helpers │ ├── unit.js │ ├── shared.js │ ├── integration.js │ └── e2e.js └── fixtures │ ├── comments.js │ ├── documents.js │ └── users.js ├── .gitattributes ├── public ├── robots.txt ├── favicon.png ├── apple-touch-icon-precomposed.png ├── manifest.json ├── facebook.svg └── google.svg ├── ui ├── queries │ ├── OAuth.gql │ ├── UserSettings.gql │ ├── Documents.gql │ └── Users.gql ├── fragments │ ├── Documents.gql │ └── Users.gql ├── pages │ ├── ExamplePage │ │ ├── content.js │ │ └── index.js │ ├── Terms │ │ ├── index.js │ │ └── content.js │ ├── Privacy │ │ ├── index.js │ │ └── content.js │ ├── Login │ │ ├── index.e2e.js │ │ └── styles.js │ ├── AdminUsers │ │ ├── styles.js │ │ ├── index.e2e.js │ │ └── index.js │ ├── NotFound │ │ └── index.js │ ├── Signup │ │ └── styles.js │ ├── ResetPassword │ │ ├── styles.js │ │ └── index.js │ ├── RecoverPassword │ │ ├── styles.js │ │ └── index.js │ ├── AdminUser │ │ ├── styles.js │ │ └── index.js │ ├── ViewDocument │ │ ├── styles.js │ │ └── index.js │ ├── Page │ │ ├── styles.js │ │ └── index.js │ ├── EditDocument │ │ └── index.js │ ├── Index │ │ ├── index.js │ │ └── styles.js │ ├── Profile │ │ └── styles.js │ ├── Logout │ │ ├── styles.js │ │ └── index.js │ ├── VerifyEmail │ │ └── index.js │ └── Documents │ │ ├── styles.js │ │ └── index.js ├── components │ ├── InputHint │ │ ├── styles.js │ │ └── index.js │ ├── AccountPageFooter │ │ ├── styles.js │ │ └── index.js │ ├── GDPRConsentModal │ │ ├── styles.js │ │ └── index.js │ ├── SearchInput │ │ ├── styles.js │ │ └── index.js │ ├── Content │ │ ├── index.js │ │ └── styles.js │ ├── UserSettings │ │ ├── styles.js │ │ └── index.js │ ├── Icon │ │ └── index.js │ ├── PublicNavigation │ │ └── index.js │ ├── PageHeader │ │ ├── index.js │ │ └── styles.js │ ├── VerifyEmailAlert │ │ ├── styles.js │ │ └── index.js │ ├── OAuthLoginButtons │ │ ├── styles.js │ │ └── index.js │ ├── CommentComposer │ │ ├── styles.js │ │ └── index.js │ ├── BlankState │ │ ├── styles.js │ │ └── index.js │ ├── Footer │ │ ├── styles.js │ │ └── index.js │ ├── Public │ │ └── index.js │ ├── Navigation │ │ └── index.js │ ├── Validation │ │ └── index.js │ ├── Comments │ │ ├── styles.js │ │ └── index.js │ ├── OAuthLoginButton │ │ ├── styles.js │ │ └── index.js │ ├── ToggleSwitch │ │ ├── styles.js │ │ └── index.js │ ├── Authenticated │ │ └── index.js │ ├── AdminUsersList │ │ ├── styles.js │ │ └── index.js │ ├── Loading │ │ └── index.js │ ├── AuthenticatedNavigation │ │ └── index.js │ ├── DocumentEditor │ │ └── styles.js │ ├── Authorized │ │ └── index.js │ └── SEO │ │ └── index.js ├── mutations │ ├── Comments.gql │ ├── UserSettings.gql │ ├── Users.gql │ └── Documents.gql └── layouts │ └── App │ └── styles.js ├── api ├── Comments │ ├── Comments.js │ ├── types.js │ ├── queries.js │ └── mutations.js ├── Documents │ ├── Documents.js │ ├── server │ │ └── indexes.js │ ├── types.js │ ├── queries.js │ └── mutations.js ├── UserSettings │ ├── UserSettings.js │ ├── queries.js │ ├── types.js │ ├── actions │ │ ├── addSettingToUsers.js │ │ └── updateSettingOnUsers.js │ └── mutations.js ├── App │ └── server │ │ └── publications.js ├── OAuth │ └── queries.js ├── webhooks │ ├── handleWebhook.js │ ├── index.js │ └── curl.js └── Users │ ├── types.js │ ├── queries.js │ ├── mutations.js │ └── actions │ ├── isOAuthUser.js │ ├── queryUser.js │ ├── mapMeteorUserToSchema.js │ ├── sendWelcomeEmail.js │ ├── exportUserData.js │ ├── removeUser.js │ ├── checkIfAuthorized.js │ ├── queryUsers.js │ ├── normalizeMeteorUserData.js │ └── updateUser.js ├── .gitignore ├── modules ├── server │ ├── getPrivateFile.js │ ├── checkIfBlacklisted.js │ ├── createIndex.js │ ├── handlebarsEmailToText.js │ ├── checkIfBlacklisted.test.js │ ├── sendEmail.js │ └── handlebarsEmailToHtml.js ├── delay.js ├── getUserName.js ├── validate.js ├── withTrackerSsr.js ├── parseMarkdown.js ├── parseMarkdown.test.js ├── unfreezeApolloCacheValue.js ├── getUserName.test.js ├── getUserSetting.js ├── unfreezeApolloCacheValue.test.js └── dates.js ├── startup ├── server │ ├── accounts │ │ ├── index.js │ │ ├── userSettings.js │ │ ├── oauth.js │ │ ├── onCreateUser.js │ │ └── emailTemplates.js │ ├── index.js │ ├── email.js │ ├── graphql.js │ ├── browserPolicy.js │ ├── sitemap.js │ ├── ssr.js │ ├── fixtures.js │ └── api.js └── client │ ├── index.js │ ├── apollo.js │ └── GlobalStyle.js ├── .babelrc ├── private └── email-templates │ ├── verify-email.txt │ ├── welcome.txt │ ├── reset-password.txt │ ├── verify-email.html │ ├── welcome.html │ └── reset-password.html ├── jest.config.js ├── settings-development.json ├── .githooks └── pre-commit.sh ├── client └── main.html └── .circleci └── config.yml /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@2.7.1 2 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/email.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /tests/helpers/unit.js: -------------------------------------------------------------------------------- 1 | // NOTE: Place unit test helpers in this file. 2 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/mdg_validated-method.js: -------------------------------------------------------------------------------- 1 | module.exports = jest.fn(); 2 | -------------------------------------------------------------------------------- /tests/helpers/shared.js: -------------------------------------------------------------------------------- 1 | // NOTE: Place shared test helpers in this file. 2 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/random.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | id: jest.fn(), 3 | }; 4 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleverbeagle/pup/HEAD/public/favicon.png -------------------------------------------------------------------------------- /tests/helpers/integration.js: -------------------------------------------------------------------------------- 1 | // NOTE: Place integration test helpers in this file. 2 | -------------------------------------------------------------------------------- /ui/queries/OAuth.gql: -------------------------------------------------------------------------------- 1 | query oAuthServices($services: [String]) { 2 | oAuthServices(services: $services) 3 | } 4 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/themeteorchef_bert.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Bert: { 3 | alert: jest.fn(), 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /api/Comments/Comments.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo'; 2 | 3 | export default new Mongo.Collection('Comments'); 4 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/meteorhacks_picker.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Picker: { 3 | route: jest.fn(), 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | settings-demo.json 3 | settings-staging.json 4 | settings-production.json 5 | .vscode 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cleverbeagle/pup/HEAD/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /tests/__mocks__/meteor/percolate_migrations.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Migrations: { 3 | add: jest.fn(), 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /modules/server/getPrivateFile.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | export default (path) => fs.readFileSync(`assets/app/${path}`, 'utf8'); 4 | -------------------------------------------------------------------------------- /startup/server/accounts/index.js: -------------------------------------------------------------------------------- 1 | import './userSettings'; 2 | import './oauth'; 3 | import './emailTemplates'; 4 | import './onCreateUser'; 5 | -------------------------------------------------------------------------------- /ui/fragments/Documents.gql: -------------------------------------------------------------------------------- 1 | fragment DocumentAttributes on Document { 2 | _id 3 | isPublic 4 | title 5 | body 6 | updatedAt 7 | createdAt 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "lodash" 4 | ], 5 | "presets": [ 6 | ["@babel/preset-env"], 7 | ["@babel/preset-react"] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /api/Documents/Documents.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import { Mongo } from 'meteor/mongo'; 4 | 5 | export default new Mongo.Collection('Documents'); 6 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/http.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | call: jest.fn(), 3 | get: jest.fn(), 4 | post: jest.fn(), 5 | put: jest.fn(), 6 | delete: jest.fn(), 7 | }; 8 | -------------------------------------------------------------------------------- /ui/queries/UserSettings.gql: -------------------------------------------------------------------------------- 1 | query userSettings { 2 | userSettings { 3 | _id 4 | isGDPR 5 | key 6 | label 7 | type 8 | value 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /api/UserSettings/UserSettings.js: -------------------------------------------------------------------------------- 1 | import { Mongo } from 'meteor/mongo'; 2 | 3 | const UserSettings = new Mongo.Collection('UserSettings'); 4 | 5 | export default UserSettings; 6 | -------------------------------------------------------------------------------- /api/App/server/publications.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | 3 | Meteor.publish('app', function app() { 4 | return [Meteor.users.find({ _id: this.userId })]; 5 | }); 6 | -------------------------------------------------------------------------------- /api/Documents/server/indexes.js: -------------------------------------------------------------------------------- 1 | import createIndex from '../../../modules/server/createIndex'; 2 | import Documents from '../Documents'; 3 | 4 | createIndex(Documents, { owner: 1 }); 5 | -------------------------------------------------------------------------------- /modules/delay.js: -------------------------------------------------------------------------------- 1 | export default (() => { 2 | let timer = 0; 3 | return (callback, ms) => { 4 | clearTimeout(timer); 5 | timer = setTimeout(callback, ms); 6 | }; 7 | })(); 8 | -------------------------------------------------------------------------------- /modules/getUserName.js: -------------------------------------------------------------------------------- 1 | const getUserName = (name) => 2 | ({ 3 | string: name, 4 | object: `${name.first} ${name.last}`, 5 | }[typeof name]); 6 | 7 | export default getUserName; 8 | -------------------------------------------------------------------------------- /tests/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | // NOTE: This exists to tell Jest to ignore static files. 2 | // https://jestjs.io/docs/en/webpack.html#handling-static-assets 3 | 4 | module.exports = 'test-file-stub'; 5 | -------------------------------------------------------------------------------- /api/Comments/types.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Comment { 3 | _id: String 4 | user: User 5 | documentId: String 6 | comment: String 7 | createdAt: String 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /startup/server/index.js: -------------------------------------------------------------------------------- 1 | import './accounts'; 2 | import './api'; 3 | import './browserPolicy'; 4 | import './fixtures'; 5 | import './email'; 6 | import './sitemap'; 7 | import './graphql'; 8 | import './ssr'; 9 | -------------------------------------------------------------------------------- /private/email-templates/verify-email.txt: -------------------------------------------------------------------------------- 1 | Hey, {{firstName}}! 2 | 3 | Can you do us a favor and verify your email address? 4 | 5 | [Verify Email Address]({{verifyUrl}}) 6 | 7 | Thanks! 8 | {{productName}} Team 9 | -------------------------------------------------------------------------------- /modules/validate.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | export default (form, options) => { 4 | import('jquery-validation') 5 | .then(() => { 6 | $(form).validate(options); 7 | }) 8 | .catch(() => {}); 9 | }; 10 | -------------------------------------------------------------------------------- /ui/pages/ExamplePage/content.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | ### This is my Markdown page 3 | I can type **any** Markdown I want into this file and it will ultimately be parsed into HTML by the <Page /> component in the source of this page. 4 | `; 5 | -------------------------------------------------------------------------------- /private/email-templates/welcome.txt: -------------------------------------------------------------------------------- 1 | Hey, {{firstName}}! 2 | 3 | Welcome to {{productName}}. We're excited to have you on board. 4 | 5 | Ready to get started? 6 | 7 | [View Your Documents]({{welcomeUrl}}) 8 | 9 | Cheers, 10 | {{productName}} Team 11 | -------------------------------------------------------------------------------- /api/Documents/types.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Document { 3 | _id: String 4 | isPublic: Boolean 5 | title: String 6 | createdAt: String 7 | updatedAt: String 8 | body: String 9 | owner: String 10 | comments(sortBy: String): [Comment] 11 | } 12 | `; 13 | -------------------------------------------------------------------------------- /api/OAuth/queries.js: -------------------------------------------------------------------------------- 1 | import { ServiceConfiguration } from 'meteor/service-configuration'; 2 | 3 | export default { 4 | oAuthServices: () => 5 | ServiceConfiguration.configurations 6 | .find({ enabled: true }, { sort: { service: 1 } }) 7 | .map((document) => document.service), 8 | }; 9 | -------------------------------------------------------------------------------- /modules/withTrackerSsr.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { withTracker } from 'meteor/react-meteor-data'; 3 | 4 | export default (container) => (Component) => 5 | withTracker((props) => { 6 | if (Meteor.isClient) return container(props); 7 | return {}; 8 | })(Component); 9 | -------------------------------------------------------------------------------- /ui/components/InputHint/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const InputHint = styled.div` 4 | display: block; 5 | margin-top: 8px; 6 | font-style: italic; 7 | color: var(--gray-light); 8 | font-size: 13px; 9 | `; 10 | 11 | export default { 12 | InputHint, 13 | }; 14 | -------------------------------------------------------------------------------- /api/Comments/queries.js: -------------------------------------------------------------------------------- 1 | import Comments from './Comments'; 2 | 3 | export default { 4 | comments: (parent, args) => { 5 | return Comments.find( 6 | { documentId: parent && parent._id }, 7 | { sort: { createdAt: args.sortBy === 'newestFirst' ? -1 : 1 } }, 8 | ).fetch(); 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /private/email-templates/reset-password.txt: -------------------------------------------------------------------------------- 1 | Hey, {{firstName}}! 2 | 3 | A password reset has been requested for the account related to this email address ({{emailAddress}}). 4 | 5 | To reset the password, visit the following link: 6 | 7 | [Reset Password]({{resetUrl}}) 8 | 9 | Cheers, 10 | {{productName}} Team 11 | -------------------------------------------------------------------------------- /modules/parseMarkdown.js: -------------------------------------------------------------------------------- 1 | import { Parser, HtmlRenderer } from 'commonmark'; 2 | 3 | export default (markdown, options) => { 4 | const reader = new Parser(); 5 | const writer = options ? new HtmlRenderer(options) : new HtmlRenderer(); 6 | const parsed = reader.parse(markdown); 7 | return writer.render(parsed); 8 | }; 9 | -------------------------------------------------------------------------------- /ui/pages/Terms/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Page from '../Page'; 3 | import content from './content'; 4 | 5 | const Terms = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | export default Terms; 12 | -------------------------------------------------------------------------------- /ui/components/AccountPageFooter/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const AccountPageFooter = styled.div` 4 | margin: 25px 0 0; 5 | padding-top: 20px; 6 | border-top: 1px solid var(--gray-lighter); 7 | 8 | p { 9 | margin: 0; 10 | } 11 | `; 12 | 13 | export default { 14 | AccountPageFooter, 15 | }; 16 | -------------------------------------------------------------------------------- /ui/mutations/Comments.gql: -------------------------------------------------------------------------------- 1 | mutation addComment($documentId: String!, $comment: String!) { 2 | addComment(documentId: $documentId, comment: $comment) { 3 | _id 4 | documentId 5 | comment 6 | createdAt 7 | user { 8 | _id 9 | name { 10 | first 11 | last 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/pages/Privacy/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Page from '../Page'; 3 | import content from './content'; 4 | 5 | const Privacy = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | export default Privacy; 12 | -------------------------------------------------------------------------------- /ui/fragments/Users.gql: -------------------------------------------------------------------------------- 1 | fragment UserAttributes on User { 2 | _id 3 | name { 4 | first 5 | last 6 | } 7 | username 8 | emailAddress 9 | oAuthProvider 10 | roles { 11 | _id 12 | name 13 | inRole 14 | } 15 | settings { 16 | _id 17 | key 18 | label 19 | type 20 | value 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 16be20efyo0qb53r01o 8 | -------------------------------------------------------------------------------- /ui/components/InputHint/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Styles from './styles'; 4 | 5 | const InputHint = ({ children }) => {children}; 6 | 7 | InputHint.propTypes = { 8 | children: PropTypes.node.isRequired, 9 | }; 10 | 11 | export default InputHint; 12 | -------------------------------------------------------------------------------- /modules/server/checkIfBlacklisted.js: -------------------------------------------------------------------------------- 1 | import UrlPattern from 'url-pattern'; 2 | 3 | export default (url) => { 4 | let isBlacklisted = false; 5 | ['/documents(/:id)'].forEach((blacklistedPattern) => { 6 | const pattern = new UrlPattern(blacklistedPattern); 7 | isBlacklisted = !!pattern.match(url); 8 | }); 9 | return isBlacklisted; 10 | }; 11 | -------------------------------------------------------------------------------- /ui/pages/ExamplePage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Page from '../Page'; 3 | import content from './content'; 4 | 5 | const ExamplePage = () => ( 6 |
7 | 8 |
9 | ); 10 | 11 | export default ExamplePage; 12 | -------------------------------------------------------------------------------- /ui/components/GDPRConsentModal/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Modal } from 'react-bootstrap'; 3 | 4 | const GDPRConsentModal = styled(Modal)` 5 | .modal-body > p { 6 | margin-bottom: 15px; 7 | } 8 | 9 | .list-group { 10 | margin-bottom: 0; 11 | } 12 | `; 13 | 14 | export default { 15 | GDPRConsentModal, 16 | }; 17 | -------------------------------------------------------------------------------- /modules/parseMarkdown.test.js: -------------------------------------------------------------------------------- 1 | import parseMarkdown from './parseMarkdown'; 2 | 3 | describe('parseMarkdown.js', () => { 4 | test('it returns HTML when passed a string of Markdown', () => { 5 | const html = parseMarkdown('### Testing\n**Markdown** is working.'); 6 | expect(html).toBe('

Testing

\n

Markdown is working.

\n'); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /api/webhooks/handleWebhook.js: -------------------------------------------------------------------------------- 1 | import curl from './curl'; 2 | 3 | export default { 4 | curl({ params, request }) { 5 | const { method } = request; // NOTE: method is one of: HEAD, OPTIONS, GET, POST, PUT, DELETE, PATCH 6 | const handler = curl[method]; 7 | if (handler) return handler({ params, request }); 8 | return `${method} is not supported.`; 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /ui/components/SearchInput/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const SearchInput = styled.div` 4 | position: relative; 5 | 6 | i { 7 | position: absolute; 8 | left: 12px; 9 | top: 10px; 10 | color: var(--gray-light); 11 | } 12 | 13 | .form-control { 14 | padding-left: 30px; 15 | } 16 | `; 17 | 18 | export default { 19 | SearchInput, 20 | }; 21 | -------------------------------------------------------------------------------- /ui/components/Content/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | 3 | import React from 'react'; 4 | import PropTypes from 'prop-types'; 5 | import Styles from './styles'; 6 | 7 | const Content = ({ content }) => ; 8 | 9 | Content.propTypes = { 10 | content: PropTypes.string.isRequired, 11 | }; 12 | 13 | export default Content; 14 | -------------------------------------------------------------------------------- /ui/components/AccountPageFooter/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Styles from './styles'; 4 | 5 | const AccountPageFooter = ({ children }) => ( 6 | {children} 7 | ); 8 | 9 | AccountPageFooter.propTypes = { 10 | children: PropTypes.node.isRequired, 11 | }; 12 | 13 | export default AccountPageFooter; 14 | -------------------------------------------------------------------------------- /ui/mutations/UserSettings.gql: -------------------------------------------------------------------------------- 1 | mutation addUserSetting($setting: UserSettingInput) { 2 | addUserSetting(setting: $setting) { 3 | _id 4 | } 5 | } 6 | 7 | mutation updateUserSetting($setting: UserSettingInput) { 8 | updateUserSetting(setting: $setting) { 9 | _id 10 | } 11 | } 12 | 13 | mutation removeUserSetting($_id: String!) { 14 | removeUserSetting(_id: $_id) { 15 | _id 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /private/email-templates/verify-email.html: -------------------------------------------------------------------------------- 1 |

Hey, {{firstName}}!

2 | 3 |

Can you do us a favor and verify your email address?

4 | 5 |

Verify Email Address

6 | 7 |

8 | Thanks!
9 | {{productName}} Team 10 |

11 | -------------------------------------------------------------------------------- /modules/unfreezeApolloCacheValue.js: -------------------------------------------------------------------------------- 1 | export default (value) => { 2 | let unfrozenValue = JSON.parse(JSON.stringify(value)); 3 | 4 | if (unfrozenValue instanceof Array) { 5 | unfrozenValue = unfrozenValue.map(({ __typename, ...rest }) => ({ ...rest })); 6 | } 7 | 8 | if (unfrozenValue instanceof Object) { 9 | delete unfrozenValue.__typename; // eslint-disable-line 10 | } 11 | 12 | return unfrozenValue; 13 | }; 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Pup - The Ultimate Boilerplate for Products", 3 | "short_name": "Pup", 4 | "start_url": "/", 5 | "display": "fullscreen", 6 | "background_color": "#4285F4", 7 | "theme_color": "#4285F4", 8 | "icons": [ 9 | { 10 | "src": "/apple-touch-icon-precomposed.png", 11 | "sizes": "120x120", 12 | "type": "image/png", 13 | "density": 1 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /ui/pages/Login/index.e2e.js: -------------------------------------------------------------------------------- 1 | import { login, getPageUrl } from '../../../tests/helpers/e2e'; 2 | 3 | fixture('/login').page('http://localhost:3000/login'); 4 | 5 | test('should allow users to login and see their documents', async (browser) => { 6 | await login({ 7 | email: 'user+1@test.com', 8 | password: 'password', 9 | browser, 10 | }); 11 | 12 | await browser.expect(getPageUrl()).contains('/documents'); 13 | }); 14 | -------------------------------------------------------------------------------- /api/UserSettings/queries.js: -------------------------------------------------------------------------------- 1 | import { Roles } from 'meteor/alanning:roles'; 2 | import UserSettings from './UserSettings'; 3 | 4 | export default { 5 | userSettings: (parent, args, { user }) => { 6 | if (!user || !Roles.userIsInRole(user._id, 'admin')) { 7 | throw new Error('Sorry, you need to be an administrator to do this.'); 8 | } 9 | 10 | return UserSettings.find({}, { sort: { key: 1 } }).fetch(); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/alanning_roles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Roles: { 3 | addUsersToRoles: jest.fn(), 4 | createRole: jest.fn(), 5 | deleteRole: jest.fn(), 6 | getAllRoles: jest.fn(), 7 | getGroupsForUser: jest.fn(), 8 | getRolesForUser: jest.fn(), 9 | getUsersInRole: jest.fn(), 10 | removeUsersFromRoles: jest.fn(), 11 | setUserRoles: jest.fn(), 12 | userIsInRole: jest.fn(), 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /modules/server/createIndex.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import { Mongo } from 'meteor/mongo'; 4 | 5 | export default (Collection, index, options) => { 6 | if (Collection && Collection instanceof Mongo.Collection) { 7 | Collection.rawCollection().createIndex(index, options); 8 | } else { 9 | console.warn( 10 | '[/modules/server/createIndex.js] Must pass a MongoDB collection instance to define index on.', 11 | ); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /ui/pages/AdminUsers/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.div` 4 | h4 span { 5 | display: inline-block; 6 | padding: 2px 6px; 7 | border-radius: 3px; 8 | font-weight: bold; 9 | font-size: 15px; 10 | margin-left: 3px; 11 | background: var(--gray-lighter); 12 | color: var(--gray); 13 | } 14 | 15 | .SearchInput { 16 | float: right; 17 | width: 200px; 18 | } 19 | `; 20 | -------------------------------------------------------------------------------- /private/email-templates/welcome.html: -------------------------------------------------------------------------------- 1 |

Hey, {{firstName}}!

2 | 3 |

Welcome to {{productName}}. We're excited to have you on board.

4 | 5 |

Ready to get started?

6 | 7 |

View Your Documents

8 | 9 |

10 | Cheers,
11 | {{productName}} Team 12 |

13 | -------------------------------------------------------------------------------- /startup/server/email.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | 3 | if (Meteor.isDevelopment) { 4 | if (Meteor.settings.private && Meteor.settings.private.MAIL_URL) { 5 | process.env.MAIL_URL = Meteor.settings.private.MAIL_URL; 6 | } else { 7 | console.warn( 8 | '[Pup] Woof! Email settings are not configured. Emails will not be sent. See https://cleverbeagle.com/pup/v2/extras/email for configuration instructions.', 9 | ); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /modules/server/handlebarsEmailToText.js: -------------------------------------------------------------------------------- 1 | import handlebars from 'handlebars'; 2 | 3 | export default (handlebarsMarkup, context) => { 4 | if (handlebarsMarkup && context) { 5 | const template = handlebars.compile(handlebarsMarkup); 6 | return template(context); 7 | } 8 | 9 | throw new Error( 10 | 'Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template.', 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /api/webhooks/index.js: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser'; 2 | import { Picker } from 'meteor/meteorhacks:picker'; 3 | import handleWebhook from './handleWebhook'; 4 | 5 | Picker.middleware(bodyParser.json()); 6 | 7 | Picker.route('/webhooks/:service', (params, request, response) => { 8 | const service = handleWebhook[params.service]; 9 | if (service) service({ params, request }); 10 | response.writeHead(200); 11 | response.end('[200] Webhook received.'); 12 | }); 13 | -------------------------------------------------------------------------------- /modules/getUserName.test.js: -------------------------------------------------------------------------------- 1 | import getUserName from './getUserName'; 2 | 3 | describe('getUserName.js', () => { 4 | test('it returns a string when passed an object', () => { 5 | const name = getUserName({ first: 'Andy', last: 'Warhol' }); 6 | expect(name).toBe('Andy Warhol'); 7 | }); 8 | 9 | test('it returns a string when passed a string', () => { 10 | const name = getUserName('Andy Warhol'); 11 | expect(name).toBe('Andy Warhol'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /ui/mutations/Users.gql: -------------------------------------------------------------------------------- 1 | #import "../queries/Users.gql" 2 | 3 | mutation updateUser($user: UserInput) { 4 | updateUser(user: $user) { 5 | ...UserAttributes 6 | } 7 | } 8 | 9 | mutation removeUser($_id: String) { 10 | removeUser(_id: $_id) { 11 | _id 12 | } 13 | } 14 | 15 | mutation sendVerificationEmail { 16 | sendVerificationEmail { 17 | _id 18 | } 19 | } 20 | 21 | mutation sendWelcomeEmail { 22 | sendWelcomeEmail { 23 | _id 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ui/components/UserSettings/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { ListGroupItem as BListGroupItem } from 'react-bootstrap'; 3 | 4 | const Setting = styled(BListGroupItem)` 5 | display: flexbox; 6 | flex-direction: row; 7 | justify-content: center; 8 | align-items: center; 9 | 10 | p { 11 | flex-grow: 1; 12 | margin: 0 5px 0 0; 13 | } 14 | 15 | > div { 16 | flex-grow: 0; 17 | } 18 | `; 19 | 20 | export default { 21 | Setting, 22 | }; 23 | -------------------------------------------------------------------------------- /startup/server/graphql.js: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-express'; 2 | import { WebApp } from 'meteor/webapp'; 3 | import { getUser } from 'meteor/apollo'; 4 | import schema from './api'; 5 | 6 | const server = new ApolloServer({ 7 | schema, 8 | context: async ({ req }) => ({ 9 | user: await getUser(req.headers.authorization), 10 | }), 11 | uploads: false, 12 | }); 13 | 14 | server.applyMiddleware({ 15 | app: WebApp.connectHandlers, 16 | path: '/graphql', 17 | }); 18 | -------------------------------------------------------------------------------- /api/Documents/queries.js: -------------------------------------------------------------------------------- 1 | import Documents from './Documents'; 2 | 3 | export default { 4 | documents: (parent, args, context) => 5 | context.user && context.user._id ? Documents.find({ owner: context.user._id }).fetch() : [], 6 | document: (parent, args, context) => 7 | Documents.findOne({ 8 | $or: [ 9 | { _id: args._id, owner: context.user && context.user._id ? context.user._id : null }, 10 | { _id: args._id, isPublic: true }, 11 | ], 12 | }), 13 | }; 14 | -------------------------------------------------------------------------------- /ui/pages/NotFound/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert } from 'react-bootstrap'; 3 | import { Meteor } from 'meteor/meteor'; 4 | 5 | const NotFound = () => ( 6 |
7 | 8 |

9 | Error [404] 10 | {': '} 11 | {Meteor.isClient ? window.location.pathname : ''} 12 | {' does not exist.'} 13 |

14 |
15 |
16 | ); 17 | 18 | export default NotFound; 19 | -------------------------------------------------------------------------------- /tests/helpers/e2e.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction, Selector } from 'testcafe'; 2 | 3 | export const login = async ({ email, password, browser }) => { 4 | await browser.typeText('[data-test="emailAddress"]', email); 5 | await browser.typeText('[data-test="password"]', password); 6 | await browser.click('button[type=submit]'); 7 | await Selector('[data-test="user-nav-dropdown"]')(); // NOTE: If this exists, users was logged in. 8 | }; 9 | 10 | export const getPageUrl = ClientFunction(() => window.location.href); 11 | -------------------------------------------------------------------------------- /private/email-templates/reset-password.html: -------------------------------------------------------------------------------- 1 |

Hey, {{firstName}}!

2 | 3 |

A password reset has been requested for the account related to this email address ({{emailAddress}}).

4 | 5 |

To reset the password, visit the following link:

6 | 7 |

Reset Password

8 | 9 |

10 | Cheers,
11 | {{productName}} Team 12 |

13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePaths: ['/node_modules/', '/tests/__mocks__/'], 3 | moduleNameMapper: { 4 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 5 | '/__mocks__/fileMock.js', 6 | '\\.(css|scss)$': 'identity-obj-proxy', // NOTE: https://jestjs.io/docs/en/webpack#mocking-css-modules 7 | '^meteor/(.*):(.*)$': '/tests/__mocks__/meteor/$1_$2', 8 | }, 9 | unmockedModulePathPatterns: ['/^node_modules/'], 10 | }; 11 | -------------------------------------------------------------------------------- /modules/server/checkIfBlacklisted.test.js: -------------------------------------------------------------------------------- 1 | import checkIfBlacklisted from './checkIfBlacklisted'; 2 | 3 | describe('checkIfBlacklisted.js', () => { 4 | test('it returns true if url is blacklisted', () => { 5 | const isBlacklisted = checkIfBlacklisted('/documents/abc123'); 6 | expect(isBlacklisted).toBe(true); 7 | }); 8 | 9 | test('it returns false if url is not blacklisted', () => { 10 | const isBlacklisted = checkIfBlacklisted('/admin/users'); 11 | expect(isBlacklisted).toBe(false); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /ui/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const getIconStyle = (iconStyle) => 5 | ({ 6 | regular: 'far', 7 | solid: 'fas', 8 | light: 'fal', 9 | brand: 'fab', 10 | }[iconStyle]); 11 | 12 | const Icon = ({ icon, iconStyle }) => ; 13 | 14 | Icon.defaultProps = { 15 | iconStyle: 'regular', 16 | }; 17 | 18 | Icon.propTypes = { 19 | icon: PropTypes.string.isRequired, 20 | iconStyle: PropTypes.string, 21 | }; 22 | 23 | export default Icon; 24 | -------------------------------------------------------------------------------- /ui/components/PublicNavigation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LinkContainer } from 'react-router-bootstrap'; 3 | import { Nav, NavItem } from 'react-bootstrap'; 4 | 5 | const PublicNavigation = () => ( 6 | 18 | ); 19 | 20 | export default PublicNavigation; 21 | -------------------------------------------------------------------------------- /ui/components/PageHeader/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Styles from './styles'; 4 | 5 | const PageHeader = ({ title, subtitle }) => ( 6 | 7 | 8 |

{title}

9 | {subtitle &&

{subtitle}

} 10 |
11 |
12 | ); 13 | 14 | PageHeader.defaultProps = { 15 | subtitle: '', 16 | }; 17 | 18 | PageHeader.propTypes = { 19 | title: PropTypes.string.isRequired, 20 | subtitle: PropTypes.string, 21 | }; 22 | 23 | export default PageHeader; 24 | -------------------------------------------------------------------------------- /ui/components/VerifyEmailAlert/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const VerifyEmailAlert = styled.div` 4 | .alert { 5 | margin-bottom: 0; 6 | padding: 0; 7 | border-top: none; 8 | border-bottom: 1px solid #e7e7e7; 9 | background: #fff; 10 | color: var(--gray-dark); 11 | border-radius: 0; 12 | 13 | p { 14 | padding: 19px; 15 | } 16 | 17 | .btn { 18 | padding: 0; 19 | text-decoration: underline; 20 | margin-left: 5px; 21 | margin-top: -2px; 22 | } 23 | } 24 | `; 25 | 26 | export default { 27 | VerifyEmailAlert, 28 | }; 29 | -------------------------------------------------------------------------------- /ui/pages/Signup/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledSignup = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | padding: 25px; 6 | max-width: 425px; 7 | margin: 0 auto; 8 | border-radius: 3px; 9 | 10 | .page-header { 11 | margin-top: 0; 12 | } 13 | 14 | > .row { 15 | margin: 0 !important; 16 | } 17 | 18 | button[type='submit'] { 19 | height: 41px; 20 | margin-top: 20px; 21 | } 22 | 23 | @media screen and (min-width: 768px) { 24 | margin-top: 0px; 25 | padding: 40px 25px; 26 | } 27 | `; 28 | 29 | export default StyledSignup; 30 | -------------------------------------------------------------------------------- /startup/server/accounts/userSettings.js: -------------------------------------------------------------------------------- 1 | // https://cleverbeagle.com/pup/v2/admin/user-settings 2 | 3 | import UserSettings from '../../../api/UserSettings/UserSettings'; 4 | 5 | const defaultUserSettings = [ 6 | { 7 | isGDPR: true, 8 | key: 'canSendMarketingEmails', 9 | label: 'Can we send you marketing emails?', 10 | type: 'boolean', 11 | value: 'false', // Pass a string and allow schema to convert to a Boolean for us. 12 | }, 13 | ]; 14 | 15 | defaultUserSettings.forEach((setting) => { 16 | if (!UserSettings.findOne({ key: setting.key })) { 17 | UserSettings.insert(setting); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /ui/pages/ResetPassword/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledResetPassword = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | padding: 25px; 6 | max-width: 425px; 7 | margin: 0 auto; 8 | border-radius: 3px; 9 | 10 | .page-header { 11 | margin-top: 0; 12 | } 13 | 14 | > .row { 15 | margin: 0 !important; 16 | } 17 | 18 | button[type='submit'] { 19 | height: 41px; 20 | margin-top: 20px; 21 | } 22 | 23 | @media screen and (min-width: 768px) { 24 | margin-top: 0px; 25 | padding: 40px 25px; 26 | } 27 | `; 28 | 29 | export default StyledResetPassword; 30 | -------------------------------------------------------------------------------- /ui/pages/RecoverPassword/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledRecoverPassword = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | padding: 25px; 6 | max-width: 425px; 7 | margin: 0 auto; 8 | border-radius: 3px; 9 | 10 | .page-header { 11 | margin-top: 0; 12 | } 13 | 14 | > .row { 15 | margin: 0 !important; 16 | } 17 | 18 | button[type='submit'] { 19 | height: 41px; 20 | margin-top: 20px; 21 | } 22 | 23 | @media screen and (min-width: 768px) { 24 | margin-top: 0px; 25 | padding: 40px 25px; 26 | } 27 | `; 28 | 29 | export default StyledRecoverPassword; 30 | -------------------------------------------------------------------------------- /ui/components/Content/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Content = styled.div` 4 | max-width: 700px; 5 | margin: 0 auto; 6 | font-size: 14px; 7 | line-height: 22px; 8 | 9 | h1, 10 | h2, 11 | h3, 12 | h4, 13 | h5, 14 | h6 { 15 | margin: 30px 0 20px; 16 | } 17 | 18 | p { 19 | margin-bottom: 20px; 20 | } 21 | 22 | > *:first-child { 23 | margin-top: 0px; 24 | } 25 | 26 | > *:last-child { 27 | margin-bottom: 0px; 28 | } 29 | 30 | @media screen and (min-width: 768px) { 31 | font-size: 16px; 32 | line-height: 22px; 33 | } 34 | `; 35 | 36 | export default { 37 | Content, 38 | }; 39 | -------------------------------------------------------------------------------- /ui/mutations/Documents.gql: -------------------------------------------------------------------------------- 1 | mutation addDocument($title: String, $body: String) { 2 | addDocument(title: $title, body: $body) { 3 | _id 4 | isPublic 5 | title 6 | body 7 | createdAt 8 | updatedAt 9 | owner 10 | } 11 | } 12 | 13 | mutation updateDocument($_id: String!, $title: String, $body: String, $isPublic: Boolean) { 14 | updateDocument(_id: $_id, title: $title, body: $body, isPublic: $isPublic) { 15 | _id 16 | isPublic 17 | title 18 | body 19 | createdAt 20 | updatedAt 21 | owner 22 | } 23 | } 24 | 25 | mutation removeDocument($_id: String!) { 26 | removeDocument(_id: $_id) { 27 | _id 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ui/queries/Documents.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/Documents.gql" 2 | 3 | query documents { 4 | documents { 5 | _id 6 | isPublic 7 | title 8 | updatedAt 9 | createdAt 10 | } 11 | } 12 | 13 | query editDocument($_id: String) { 14 | document(_id: $_id) { 15 | ...DocumentAttributes 16 | } 17 | } 18 | 19 | query document($_id: String, $sortBy: String) { 20 | document(_id: $_id) { 21 | ...DocumentAttributes 22 | comments(sortBy: $sortBy) { 23 | _id 24 | comment 25 | createdAt 26 | user { 27 | _id 28 | name { 29 | first 30 | last 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /api/webhooks/curl.js: -------------------------------------------------------------------------------- 1 | const GET = ({ params }) => { 2 | console.log('Handle GET Request', { 3 | params, 4 | }); 5 | }; 6 | 7 | const POST = ({ params, request }) => { 8 | console.log('Handle POST Request', { 9 | params, 10 | body: request.body, 11 | }); 12 | }; 13 | 14 | const PUT = ({ params, request }) => { 15 | console.log('Handle PUT Request', { 16 | params, 17 | body: request.body, 18 | }); 19 | }; 20 | 21 | const DELETE = ({ params, request }) => { 22 | console.log('Handle DELETE Request', { 23 | params, 24 | body: request.body, 25 | }); 26 | }; 27 | 28 | export default { 29 | GET, 30 | POST, 31 | PUT, 32 | DELETE, 33 | }; 34 | -------------------------------------------------------------------------------- /startup/server/accounts/oauth.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { ServiceConfiguration } from 'meteor/service-configuration'; 3 | 4 | if (Meteor.settings.private && Meteor.settings.private.OAuth) { 5 | const OAuthSettings = Meteor.settings.private.OAuth; 6 | 7 | Object.keys(OAuthSettings).forEach((service) => { 8 | ServiceConfiguration.configurations.upsert({ service }, { $set: OAuthSettings[service] }); 9 | }); 10 | } else { 11 | console.warn( 12 | '[Pup] Woof! OAuth settings are not configured. OAuth login will not function. See https://cleverbeagle.com/pup/v2/accounts/oauth#setting-oauth-credentials for configuration instructions.', 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /ui/layouts/App/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const App = styled.div` 4 | visibility: ${(props) => (props.ready && props.loading !== 'true' ? 'visible' : 'hidden')}; 5 | 6 | > .container { 7 | margin-bottom: 80px; 8 | padding-bottom: 20px; 9 | } 10 | 11 | .verify-email { 12 | margin-bottom: 0; 13 | padding: 0; 14 | border-top: none; 15 | border-bottom: 1px solid #e7e7e7; 16 | background: #fff; 17 | color: var(--gray-dark); 18 | border-radius: 0; 19 | 20 | p { 21 | padding: 19px; 22 | } 23 | 24 | .btn { 25 | padding: 0; 26 | } 27 | } 28 | `; 29 | 30 | export default { 31 | App, 32 | }; 33 | -------------------------------------------------------------------------------- /.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | 1.3.0-split-minifiers-package 14 | 1.4.0-remove-old-dev-bundle-link 15 | 1.4.1-add-shell-server-package 16 | 1.4.3-split-account-service-packages 17 | 1.5-add-dynamic-import-package 18 | 1.7-split-underscore-from-meteor-base 19 | 1.8.3-split-jquery-from-blaze 20 | -------------------------------------------------------------------------------- /startup/server/browserPolicy.js: -------------------------------------------------------------------------------- 1 | import { BrowserPolicy } from 'meteor/browser-policy-common'; 2 | 3 | // Bootstrap 4 | BrowserPolicy.content.allowOriginForAll('*.bootstrapcdn.com'); 5 | 6 | // FontAwesome 7 | BrowserPolicy.content.allowOriginForAll('use.fontawesome.com'); 8 | 9 | // GraphQL Playground 10 | BrowserPolicy.content.allowOriginForAll('graphcool-playground.netlify.com'); 11 | BrowserPolicy.content.allowOriginForAll('cdn.jsdelivr.net'); 12 | 13 | // Replace these with your own content URLs 14 | BrowserPolicy.content.allowOriginForAll('cleverbeagle-assets.s3.amazonaws.com'); 15 | BrowserPolicy.content.allowOriginForAll('s3-us-west-2.amazonaws.com'); 16 | BrowserPolicy.content.allowFontOrigin('data:'); 17 | -------------------------------------------------------------------------------- /ui/components/OAuthLoginButtons/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const OAuthLoginButtons = styled.div` 4 | margin-bottom: 25px; 5 | 6 | ${(props) => 7 | props.emailMessage 8 | ? ` 9 | position: relative; 10 | border-bottom: 1px solid var(--gray-lighter); 11 | padding-bottom: 30px; 12 | margin-bottom: 30px; 13 | ` 14 | : ''}; 15 | `; 16 | 17 | const EmailMessage = styled.p` 18 | display: inline-block; 19 | background: #fff; 20 | padding: 0 10px; 21 | position: absolute; 22 | bottom: -19px; 23 | left: 50%; 24 | margin-left: -${(props) => props.offset}px; 25 | `; 26 | 27 | export default { 28 | OAuthLoginButtons, 29 | EmailMessage, 30 | }; 31 | -------------------------------------------------------------------------------- /api/UserSettings/types.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | enum AllowedSettingType { 3 | boolean 4 | string 5 | number 6 | } 7 | 8 | input UserSettingInput { 9 | _id: String 10 | isGDPR: Boolean 11 | key: String 12 | label: String 13 | type: String 14 | value: String 15 | lastUpdatedByUser: String 16 | } 17 | 18 | type UserSetting { 19 | _id: String 20 | isGDPR: Boolean 21 | key: String # What is the key value you'll access this setting with? 22 | label: String # The user-facing label for the setting. 23 | type: AllowedSettingType 24 | value: String 25 | lastUpdatedByUser: String 26 | } 27 | 28 | type GDPRComplete { 29 | complete: Boolean 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /ui/components/CommentComposer/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const StyledCommentComposer = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | border-radius: 3px; 6 | 7 | header { 8 | padding: 20px; 9 | border-bottom: 1px solid var(--gray-lighter); 10 | font-size: 15px; 11 | font-weight: 600; 12 | } 13 | 14 | .form-control { 15 | border: none !important; 16 | box-shadow: none !important; 17 | padding: 20px !important; 18 | min-height: 130px !important; 19 | resize: none !important; 20 | font-size: 15px; 21 | line-height: 23px; 22 | } 23 | 24 | .btn { 25 | margin: 20px; 26 | } 27 | `; 28 | 29 | export default StyledCommentComposer; 30 | -------------------------------------------------------------------------------- /ui/pages/AdminUser/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Tabs } from 'react-bootstrap'; 3 | 4 | const AdminUserHeader = styled.h4` 5 | .label { 6 | position: relative; 7 | top: -2px; 8 | font-size: 10px; 9 | } 10 | 11 | .label-Facebook { 12 | background: var(--facebook); 13 | color: #fff; 14 | } 15 | 16 | .label-Google { 17 | background: var(--google); 18 | color: #fff; 19 | } 20 | 21 | .label-GitHub { 22 | background: var(--github); 23 | color: #fff; 24 | } 25 | `; 26 | 27 | const AdminUserTabs = styled(Tabs)` 28 | .nav.nav-tabs { 29 | margin-bottom: 20px; 30 | } 31 | `; 32 | 33 | export default { 34 | AdminUserHeader, 35 | AdminUserTabs, 36 | }; 37 | -------------------------------------------------------------------------------- /ui/queries/Users.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/Users.gql" 2 | 3 | query users($currentPage: Int, $perPage: Int, $search: String) { 4 | users(currentPage: $currentPage, perPage: $perPage, search: $search) { 5 | total 6 | users { 7 | _id 8 | name { 9 | first 10 | last 11 | } 12 | emailAddress 13 | oAuthProvider 14 | } 15 | } 16 | } 17 | 18 | query user($_id: String) { 19 | user(_id: $_id) { 20 | ...UserAttributes 21 | } 22 | } 23 | 24 | query userSettings { 25 | user { 26 | settings { 27 | _id 28 | isGDPR 29 | key 30 | label 31 | type 32 | value 33 | lastUpdatedByUser 34 | } 35 | } 36 | } 37 | 38 | query exportUserData { 39 | exportUserData { 40 | zip 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/components/BlankState/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const BlankState = styled.div` 4 | padding: 40px 0; 5 | text-align: center; 6 | 7 | img { 8 | max-width: 300px; 9 | margin-bottom: 20px; 10 | } 11 | 12 | i { 13 | font-size: 100px; 14 | color: var(--gray-lighter); 15 | margin-bottom: 20px; 16 | } 17 | 18 | h4 { 19 | font-size: 18px; 20 | font-weight: 600; 21 | color: #666; 22 | margin-bottom: 0; 23 | } 24 | 25 | p { 26 | font-size: 15px; 27 | font-weight: normal; 28 | color: #aaa; 29 | margin-top: 10px !important; 30 | margin-bottom: 0; 31 | } 32 | 33 | .btn { 34 | margin-top: 20px; 35 | margin-bottom: 0 !important; 36 | } 37 | `; 38 | 39 | export default { 40 | BlankState, 41 | }; 42 | -------------------------------------------------------------------------------- /ui/pages/ViewDocument/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledViewDocument = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | padding: 25px; 6 | border-radius: 3px; 7 | max-width: 750px; 8 | margin: 0 auto 20px; 9 | 10 | h1 { 11 | margin: 0 0 25px; 12 | font-size: 22px; 13 | line-height: 28px; 14 | } 15 | 16 | @media screen and (min-width: 768px) { 17 | margin-top: 20px; 18 | padding: 50px; 19 | } 20 | `; 21 | 22 | export const DocumentBody = styled.div` 23 | font-size: 16px; 24 | line-height: 22px; 25 | 26 | > * { 27 | margin-bottom: 20px; 28 | white-space: pre-line; 29 | } 30 | 31 | > *:first-child { 32 | margin-top: 0; 33 | } 34 | 35 | > *:last-child { 36 | margin-bottom: 0; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /modules/getUserSetting.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-this-in-sfc */ 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | 5 | export default (key, valueOnly, notCurrentUserId) => { 6 | if (!key) { 7 | console.warn('[Pup] Please pass a setting key to retrieve.'); 8 | return null; 9 | } 10 | 11 | const userId = Meteor.isClient 12 | ? notCurrentUserId || Meteor.userId() 13 | : notCurrentUserId || this.userId; 14 | 15 | if (userId && key) { 16 | const user = Meteor.users.findOne({ _id: userId }); 17 | const foundSetting = 18 | user && user.settings ? user.settings.find((userSetting) => userSetting.key === key) : null; 19 | 20 | if (foundSetting) { 21 | return valueOnly ? foundSetting.value : foundSetting; 22 | } 23 | 24 | return null; 25 | } 26 | 27 | return null; 28 | }; 29 | -------------------------------------------------------------------------------- /ui/pages/AdminUsers/index.e2e.js: -------------------------------------------------------------------------------- 1 | import { login, getPageUrl } from '../../../tests/helpers/e2e'; 2 | 3 | fixture('/admin/users').page('http://localhost:3000/login'); 4 | 5 | test('should allow users in admin role to access /admin/users', async (browser) => { 6 | await login({ 7 | email: 'admin@admin.com', 8 | password: 'password', 9 | browser, 10 | }); 11 | 12 | await browser.navigateTo('/admin/users'); 13 | await browser.expect(getPageUrl()).contains('/admin/users'); 14 | }); 15 | 16 | test('should block users in users role from accessing /admin/users', async (browser) => { 17 | await login({ 18 | email: 'user+1@test.com', 19 | password: 'password', 20 | browser, 21 | }); 22 | 23 | await browser.navigateTo('/admin/users'); 24 | await browser.expect(getPageUrl()).eql('http://localhost:3000/'); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/fixtures/comments.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | comments: [ 3 | { 4 | _id: 'comment123', 5 | userId: 'abc123', // NOTE: Matches userId of admin@admin.com user in /tests/fixtures/users.js fixture. 6 | documentId: 'document123', // NOTE: Matches _id of first document in /tests/fixtures/documents.js fixture. 7 | comment: 'This is a comment on a document. Hello!', 8 | createdAt: '2018-11-05T20:34:54.225Z', 9 | }, 10 | { 11 | _id: 'comment456', 12 | userId: 'def123', // NOTE: Matches userId of user+1@test.com user in /tests/fixtures/users.js fixture. 13 | documentId: 'document456', // NOTE: Matches _id of second document in /tests/fixtures/documents.js fixture. 14 | comment: 'This is another comment on a document. Howdy!', 15 | createdAt: '2018-11-05T20:34:54.225Z', 16 | }, 17 | ], 18 | }; 19 | -------------------------------------------------------------------------------- /ui/components/SearchInput/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Icon from '../Icon'; 4 | import Styles from './styles'; 5 | 6 | const SearchInput = ({ placeholder, value, onChange }) => ( 7 | 8 | 9 | 17 | 18 | ); 19 | 20 | SearchInput.defaultProps = { 21 | placeholder: 'Search...', 22 | value: '', 23 | }; 24 | 25 | SearchInput.propTypes = { 26 | placeholder: PropTypes.string, 27 | value: PropTypes.string, 28 | onChange: PropTypes.func.isRequired, 29 | }; 30 | 31 | export default SearchInput; 32 | -------------------------------------------------------------------------------- /startup/server/accounts/onCreateUser.js: -------------------------------------------------------------------------------- 1 | import { Accounts } from 'meteor/accounts-base'; 2 | import sendWelcomeEmail from '../../../api/Users/actions/sendWelcomeEmail'; 3 | import UserSettings from '../../../api/UserSettings/UserSettings'; 4 | import isOAuthUser from '../../../api/Users/actions/isOAuthUser'; 5 | 6 | Accounts.onCreateUser((options, user) => { 7 | const userToCreate = user; 8 | if (options.profile) userToCreate.profile = options.profile; 9 | if (isOAuthUser({ user: userToCreate })) sendWelcomeEmail({ user: userToCreate }); // NOTE: Sent for OAuth accounts only here. Sent for password accounts after email verification (https://cleverbeagle.com/pup/v2/accounts/email-verification). 10 | 11 | userToCreate.roles = ['user']; 12 | 13 | const settings = UserSettings.find().fetch(); 14 | userToCreate.settings = settings; 15 | 16 | return userToCreate; 17 | }); 18 | -------------------------------------------------------------------------------- /ui/pages/Page/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Page = styled.div` 4 | margin-bottom: 0px; 5 | 6 | @include media screen and (min-width: 768px) { 7 | margin-bottom: 30px; 8 | } 9 | `; 10 | 11 | const Content = styled.div` 12 | max-width: 700px; 13 | margin: 0 auto; 14 | font-size: 14px; 15 | line-height: 22px; 16 | 17 | h1, 18 | h2, 19 | h3, 20 | h4, 21 | h5, 22 | h6 { 23 | margin: 30px 0 20px; 24 | } 25 | 26 | p { 27 | margin-bottom: 20px; 28 | } 29 | 30 | > *:first-child { 31 | margin-top: 0px; 32 | } 33 | 34 | > *:last-child { 35 | margin-bottom: 0px; 36 | } 37 | 38 | @media screen and (min-width: 768px) { 39 | max-width: 700px; 40 | margin: 0 auto; 41 | font-size: 16px; 42 | line-height: 22px; 43 | } 44 | `; 45 | 46 | export default { 47 | Page, 48 | Content, 49 | }; 50 | -------------------------------------------------------------------------------- /tests/fixtures/documents.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | documents: [ 3 | { 4 | _id: 'document123', 5 | isPublic: true, 6 | userId: 'abc123', // NOTE: Matches userId of admin@admin.com user in /tests/fixtures/users.js fixture. 7 | title: 'Document Title #1', 8 | body: 'This is my document. There are many like it, but this one is mine.', 9 | createdAt: '2018-11-05T20:34:54.225Z', 10 | updatedAt: '2018-11-05T20:34:54.225Z', 11 | }, 12 | { 13 | _id: 'document456', 14 | isPublic: false, 15 | userId: 'def123', // NOTE: Matches userId of user+1@test.com user in /tests/fixtures/users.js fixture. 16 | title: 'Document Title #2', 17 | body: 'This is my document. There are many like it, but this one is mine.', 18 | createdAt: '2018-11-05T20:34:54.225Z', 19 | updatedAt: '2018-11-05T20:34:54.225Z', 20 | }, 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /ui/pages/Page/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactMarkdown from 'react-markdown'; 4 | import { Meteor } from 'meteor/meteor'; 5 | import PageHeader from '../../components/PageHeader'; 6 | import Styles from './styles'; 7 | 8 | const Page = ({ title, subtitle, content }) => { 9 | if (Meteor.isClient) window.scrollTo(0, 0); // Force window to top of page. 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | Page.defaultProps = { 21 | subtitle: '', 22 | }; 23 | 24 | Page.propTypes = { 25 | title: PropTypes.string.isRequired, 26 | subtitle: PropTypes.string, 27 | content: PropTypes.string.isRequired, 28 | }; 29 | 30 | export default Page; 31 | -------------------------------------------------------------------------------- /tests/fixtures/users.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | users: [ 3 | { 4 | _id: 'abc123', 5 | emails: [{ verified: true, address: 'admin@admin.com' }], 6 | profile: { 7 | name: { 8 | first: 'Andy', 9 | last: 'Warhol', 10 | }, 11 | }, 12 | roles: ['admin'], 13 | }, 14 | { 15 | _id: 'def123', 16 | emails: [{ verified: true, address: 'user+1@test.com' }], 17 | profile: { 18 | name: { 19 | first: 'Hieronymus', 20 | last: 'Bosch', 21 | }, 22 | }, 23 | roles: ['user'], 24 | }, 25 | { 26 | _id: 'ghi123', 27 | emails: [{ verified: true, address: 'user+2@test.com' }], 28 | profile: { 29 | name: { 30 | first: 'Jean-Michel', 31 | last: 'Basquiat', 32 | }, 33 | }, 34 | roles: ['user'], 35 | }, 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /ui/components/Footer/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Footer = styled.div` 4 | position: absolute; 5 | bottom: 0; 6 | width: 100%; 7 | height: 60px; 8 | background-color: #fff; 9 | border-top: 1px solid var(--gray-lighter); 10 | padding: 20px 0; 11 | 12 | p { 13 | color: var(--gray-light); 14 | font-size: 14px; 15 | } 16 | 17 | ul { 18 | list-style: none; 19 | padding: 0; 20 | } 21 | 22 | ul li { 23 | float: left; 24 | 25 | &:first-child { 26 | margin-right: 15px; 27 | } 28 | 29 | a { 30 | color: var(--gray-light); 31 | } 32 | 33 | a:hover, 34 | a:active, 35 | a:focus { 36 | text-decoration: none; 37 | color: var(--gray); 38 | } 39 | } 40 | 41 | @media screen and (min-width: 768px) { 42 | ul li:first-child { 43 | margin-right: 30px; 44 | } 45 | } 46 | `; 47 | 48 | export default { 49 | Footer, 50 | }; 51 | -------------------------------------------------------------------------------- /ui/components/PageHeader/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const PageHeader = styled.div` 4 | border-bottom: 1px solid var(--gray-lighter); 5 | padding: 0px 0 20px; 6 | margin-bottom: 20px; 7 | 8 | h1 { 9 | font-size: 20px; 10 | font-weight: 600; 11 | margin: 0; 12 | } 13 | 14 | p { 15 | font-size: 14px; 16 | margin-top: 10px; 17 | margin-bottom: 0; 18 | color: var(--gray-light); 19 | } 20 | 21 | @media screen and (min-width: 768px) { 22 | padding: 10px 0 30px; 23 | margin-bottom: 30px; 24 | 25 | h1 { 26 | font-size: 24px; 27 | } 28 | 29 | p { 30 | font-size: 16px; 31 | } 32 | } 33 | 34 | @media screen and (min-width: 992px) { 35 | padding: 20px 0 40px; 36 | margin-bottom: 40px; 37 | } 38 | `; 39 | 40 | const PageHeaderContainer = styled.div` 41 | text-align: center; 42 | `; 43 | 44 | export default { 45 | PageHeader, 46 | PageHeaderContainer, 47 | }; 48 | -------------------------------------------------------------------------------- /api/Users/types.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Name { 3 | first: String 4 | last: String 5 | } 6 | 7 | input NameInput { 8 | first: String 9 | last: String 10 | } 11 | 12 | type Role { 13 | _id: String 14 | name: String 15 | inRole: Boolean 16 | } 17 | 18 | input ProfileInput { 19 | name: NameInput 20 | } 21 | 22 | input UserInput { 23 | _id: String, 24 | email: String, 25 | password: String, 26 | profile: ProfileInput, 27 | roles: [String], 28 | settings: [UserSettingInput] # From /api/UserSettings/types.js 29 | } 30 | 31 | type User { 32 | _id: String 33 | name: Name 34 | username: String 35 | emailAddress: String 36 | oAuthProvider: String 37 | roles: [Role] 38 | settings: [UserSetting] # From /api/UserSettings/types.js 39 | } 40 | 41 | type Users { 42 | total: Int 43 | users: [User] 44 | } 45 | 46 | type UserDataExport { 47 | zip: String 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /settings-development.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": { 3 | "graphQL": { 4 | "httpUri": "http://localhost:3000/graphql" 5 | }, 6 | "productName": "Product Name", 7 | "copyrightStartYear": "2018", 8 | "twitterUsername": "product", 9 | "facebookUsername": "product", 10 | "productAddress": "1658 N. Milwaukee Ave., #393, Chicago, IL 60647" 11 | }, 12 | "private": { 13 | "MAIL_URL": "", 14 | "OAuth": { 15 | "facebook": { 16 | "enabled": true, 17 | "appId": "", 18 | "secret": "", 19 | "loginStyle": "popup" 20 | }, 21 | "google": { 22 | "enabled": true, 23 | "clientId": "", 24 | "secret": "", 25 | "loginStyle": "popup" 26 | }, 27 | "github": { 28 | "enabled": true, 29 | "clientId": "", 30 | "secret": "", 31 | "loginStyle": "popup" 32 | } 33 | }, 34 | "supportEmail": "Customer Support " 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/Users/queries.js: -------------------------------------------------------------------------------- 1 | import queryUsers from './actions/queryUsers'; 2 | import queryUser from './actions/queryUser'; 3 | import exportUserData from './actions/exportUserData'; 4 | 5 | export default { 6 | users: (parent, args, context) => 7 | queryUsers({ 8 | currentUser: context.user, 9 | search: args.search ? new RegExp(args.search, 'i') : null, 10 | limit: args.perPage, 11 | skip: args.currentPage * args.perPage - args.perPage, 12 | sort: { 13 | 'profile.name.last': 1, 14 | 'services.facebook.first_name': 1, 15 | 'services.google.name': 1, 16 | 'services.github.username': 1, 17 | }, 18 | }), 19 | user: (parent, args, context) => { 20 | const userIdFromParentQuery = parent && parent.userId; 21 | return queryUser({ 22 | userIdToQuery: userIdFromParentQuery || args._id || context.user._id, 23 | }); 24 | }, 25 | exportUserData: (parent, args, { user }) => 26 | exportUserData({ 27 | user, 28 | }), 29 | }; 30 | -------------------------------------------------------------------------------- /ui/pages/EditDocument/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import DocumentEditor from '../../components/DocumentEditor'; 5 | import Loading from '../../components/Loading'; 6 | import NotFound from '../NotFound'; 7 | import { editDocument as editDocumentQuery } from '../../queries/Documents.gql'; 8 | 9 | const EditDocument = ({ data, history }) => ( 10 | 11 | {!data.loading ? ( 12 | 13 | {data.document ? : } 14 | 15 | ) : ( 16 | 17 | )} 18 | 19 | ); 20 | 21 | EditDocument.propTypes = { 22 | data: PropTypes.object.isRequired, 23 | history: PropTypes.object.isRequired, 24 | }; 25 | 26 | export default graphql(editDocumentQuery, { 27 | options: ({ match }) => ({ 28 | variables: { 29 | _id: match.params._id, 30 | }, 31 | }), 32 | })(EditDocument); 33 | -------------------------------------------------------------------------------- /ui/components/BlankState/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Button } from 'react-bootstrap'; 4 | import Icon from '../Icon'; 5 | import Styles from './styles'; 6 | 7 | const BlankState = ({ image, icon, title, subtitle, action }) => ( 8 | 9 | {image && {title}} 10 | {icon && } 11 |

{title}

12 |

{subtitle}

13 | {action && ( 14 | 17 | )} 18 |
19 | ); 20 | 21 | BlankState.defaultProps = { 22 | image: null, 23 | icon: null, 24 | action: null, 25 | }; 26 | 27 | BlankState.propTypes = { 28 | image: PropTypes.string, 29 | icon: PropTypes.object, 30 | title: PropTypes.string.isRequired, 31 | subtitle: PropTypes.string.isRequired, 32 | action: PropTypes.object, 33 | }; 34 | 35 | export default BlankState; 36 | -------------------------------------------------------------------------------- /ui/pages/Index/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import Styles from './styles'; 4 | 5 | const Index = () => ( 6 | 7 | Clever Beagle 11 |

Pup

12 |

The Ultimate Boilerplate for Products.

13 |
14 | 15 | 19 |
20 | 29 |
30 | ); 31 | 32 | export default Index; 33 | -------------------------------------------------------------------------------- /ui/components/Public/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | 5 | const Public = ({ loggingIn, authenticated, afterLoginPath, component, path, exact, ...rest }) => ( 6 | 10 | !authenticated ? ( 11 | React.createElement(component, { 12 | ...props, 13 | ...rest, 14 | loggingIn, 15 | authenticated, 16 | }) 17 | ) : ( 18 | 19 | ) 20 | } 21 | /> 22 | ); 23 | 24 | Public.defaultProps = { 25 | loggingIn: false, 26 | path: '', 27 | exact: false, 28 | afterLoginPath: null, 29 | }; 30 | 31 | Public.propTypes = { 32 | loggingIn: PropTypes.bool, 33 | authenticated: PropTypes.bool.isRequired, 34 | component: PropTypes.func.isRequired, 35 | afterLoginPath: PropTypes.string, 36 | path: PropTypes.string, 37 | exact: PropTypes.bool, 38 | }; 39 | 40 | export default Public; 41 | -------------------------------------------------------------------------------- /.githooks/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep ".jsx\{0,1\}$") 4 | ESLINT="$(git rev-parse --show-toplevel)/node_modules/.bin/eslint" 5 | 6 | if [[ "$STAGED_FILES" = "" ]]; then 7 | exit 0 8 | fi 9 | 10 | PASS=true 11 | 12 | printf "\nValidating Javascript:\n" 13 | 14 | # Check for eslint 15 | if [[ ! -x "$ESLINT" ]]; then 16 | printf "\t\033[41mPlease install ESlint\033[0m (npm i --save-dev eslint)" 17 | exit 1 18 | fi 19 | 20 | for FILE in $STAGED_FILES 21 | do 22 | "$ESLINT" --fix "$FILE" 23 | 24 | if [[ "$?" == 0 ]]; then 25 | printf "\t\033[32mESLint Passed: $FILE\033[0m" 26 | else 27 | printf "\t\033[41mESLint Failed: $FILE\033[0m" 28 | PASS=false 29 | fi 30 | done 31 | 32 | printf "\nJavascript validation completed!\n" 33 | 34 | if ! $PASS; then 35 | printf "\033[41mCOMMIT FAILED:\033[0m Your commit contains files that should pass ESLint but do not. Please fix the ESLint errors and try again.\n" 36 | exit 1 37 | else 38 | printf "\033[42mCOMMIT SUCCEEDED\033[0m\n" 39 | fi 40 | 41 | exit $? 42 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Pup 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /ui/components/Navigation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Navbar } from 'react-bootstrap'; 4 | import { Link } from 'react-router-dom'; 5 | import { Meteor } from 'meteor/meteor'; 6 | import PublicNavigation from '../PublicNavigation'; 7 | import AuthenticatedNavigation from '../AuthenticatedNavigation'; 8 | 9 | const Navigation = (props) => { 10 | const { authenticated } = props; 11 | return ( 12 | 13 | 14 | 15 | {Meteor.settings.public.productName} 16 | 17 | 18 | 19 | 20 | {!authenticated ? : } 21 | 22 | 23 | ); 24 | }; 25 | 26 | Navigation.defaultProps = { 27 | name: '', 28 | }; 29 | 30 | Navigation.propTypes = { 31 | authenticated: PropTypes.bool.isRequired, 32 | name: PropTypes.string, 33 | }; 34 | 35 | export default Navigation; 36 | -------------------------------------------------------------------------------- /api/Users/mutations.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Accounts } from 'meteor/accounts-base'; 3 | import updateUser from './actions/updateUser'; 4 | import queryUser from './actions/queryUser'; 5 | import removeUser from './actions/removeUser'; 6 | import sendWelcomeEmail from './actions/sendWelcomeEmail'; 7 | 8 | export default { 9 | updateUser: async (parent, args, context) => { 10 | await updateUser({ 11 | currentUser: context.user, 12 | user: args.user, 13 | }); 14 | 15 | return queryUser({ userIdToQuery: args.user._id || context.user._id }); 16 | }, 17 | removeUser: (parent, args, { user }) => 18 | removeUser({ 19 | currentUser: user, 20 | user: args, 21 | }), 22 | sendVerificationEmail: (parent, args, context) => { 23 | Accounts.sendVerificationEmail(context.user._id); 24 | 25 | return { 26 | _id: context.user._id, 27 | }; 28 | }, 29 | sendWelcomeEmail: async (parent, args, context) => { 30 | await sendWelcomeEmail({ user: Meteor.users.findOne(context.user._id) }); 31 | 32 | return { 33 | _id: context.user._id, 34 | }; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /ui/components/Validation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import validate from '../../../modules/validate'; 4 | 5 | class Validation extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.form = React.createRef(); 9 | } 10 | 11 | componentDidMount() { 12 | const { children, ...rest } = this.props; 13 | validate(this.form, { ...rest }); 14 | } 15 | 16 | render() { 17 | const { children } = this.props; 18 | 19 | if (!React.Children.only(children) || children.type !== 'form') { 20 | console.warn( 21 | '[Pup] A single
element is the only allowed child of the Validation component.', 22 | ); 23 | return null; 24 | } 25 | 26 | return ( 27 | 28 | {React.cloneElement(children, { ref: (form) => (this.form = form) })} 29 | 30 | ); 31 | } 32 | } 33 | 34 | Validation.propTypes = { 35 | children: PropTypes.node.isRequired, 36 | rules: PropTypes.object.isRequired, 37 | messages: PropTypes.object.isRequired, 38 | }; 39 | 40 | export default Validation; 41 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/meteor.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Meteor: { 3 | absoluteUrl: jest.fn(() => 'http://localhost:3000/'), 4 | Error: jest.fn(), 5 | isServer: true, 6 | loginWithPassword: jest.fn(), 7 | loginWithFacebook: jest.fn(), 8 | methods: jest.fn(), 9 | call: jest.fn(), 10 | publish: jest.fn(), 11 | subscribe: jest.fn(), 12 | user: jest.fn(), 13 | users: { 14 | findOne: jest.fn(), 15 | find: jest.fn(), 16 | }, 17 | userId: jest.fn().mockReturnValue('abc123'), 18 | startup: jest.fn(), 19 | bindEnvironment: jest.fn(), 20 | wrapAsync: jest.fn(), 21 | settings: { 22 | private: { 23 | mailChimp: { 24 | apiKey: '123456', 25 | lists: { 26 | Pupgrades: '123abc', 27 | Waitlist: '123abc', 28 | 'Mentorship Subscribers': '123abc', 29 | }, 30 | }, 31 | postmark: { 32 | apiKey: '123456', 33 | }, 34 | slack: { 35 | hooks: { 36 | 'cb-app-log': 'https://webhook.site/267b1e98-8c7d-4955-a6e8-e76f99b3848b', 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /ui/pages/Profile/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Profile = styled.div` 4 | .nav.nav-tabs { 5 | margin-bottom: 20px; 6 | } 7 | 8 | .LoggedInWith { 9 | padding: 20px; 10 | border-radius: 3px; 11 | color: #fff; 12 | border: 1px solid var(--gray-lighter); 13 | text-align: center; 14 | 15 | img { 16 | width: 100px; 17 | } 18 | 19 | &.github img { 20 | width: 125px; 21 | } 22 | 23 | p { 24 | margin: 20px 0 0 0; 25 | color: var(--gray-light); 26 | } 27 | 28 | .btn { 29 | margin-top: 20px; 30 | 31 | &.btn-facebook { 32 | background: var(--facebook); 33 | border-color: var(--facebook); 34 | color: #fff; 35 | } 36 | 37 | &.btn-google { 38 | background: var(--google); 39 | border-color: var(--google); 40 | color: #fff; 41 | } 42 | 43 | &.btn-github { 44 | background: var(--github); 45 | border-color: var(--github); 46 | color: #fff; 47 | } 48 | } 49 | } 50 | 51 | .btn-export { 52 | padding: 0; 53 | } 54 | `; 55 | 56 | export default { 57 | Profile, 58 | }; 59 | -------------------------------------------------------------------------------- /api/Users/actions/isOAuthUser.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | const checkForOAuthServices = (user) => { 4 | try { 5 | let hasOAuthService = false; 6 | const oAuthServices = ['facebook', 'google', 'github', 'twitter', 'meetup', 'meteor-developer']; 7 | Object.keys(user.services).forEach((serviceName) => { 8 | hasOAuthService = oAuthServices.includes(serviceName); // NOTE: Sets hasOAuthService to true if any oAuthServices match. 9 | }); 10 | return hasOAuthService; 11 | } catch (exception) { 12 | throw new Error(`[isOAuthUser.checkForOAuthServices] ${exception.message}`); 13 | } 14 | }; 15 | 16 | const validateOptions = (options) => { 17 | try { 18 | if (!options) throw new Error('options object is required.'); 19 | if (!options.user) throw new Error('options.user is required.'); 20 | } catch (exception) { 21 | throw new Error(`[isOAuthUser.validateOptions] ${exception.message}`); 22 | } 23 | }; 24 | 25 | export default (options) => { 26 | try { 27 | validateOptions(options); 28 | return checkForOAuthServices(options.user); 29 | } catch (exception) { 30 | throw new Error(`[isOAuthUser] ${exception.message}`); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /api/Comments/mutations.js: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | import Comments from './Comments'; 3 | import { isAdmin } from '../Users/actions/checkIfAuthorized'; 4 | 5 | export default { 6 | addComment(root, args, context) { 7 | if (!context.user) throw new Error('Sorry, you must be logged in to add a new comment.'); 8 | 9 | const date = new Date().toISOString(); 10 | const commentToInsert = { 11 | documentId: args.documentId, 12 | comment: sanitizeHtml(args.comment), 13 | userId: context.user._id, 14 | createdAt: date, 15 | }; 16 | 17 | const commentId = Comments.insert(commentToInsert); 18 | return { _id: commentId, ...commentToInsert }; 19 | }, 20 | removeComment(root, args, context) { 21 | if (!context.user) throw new Error('Sorry, you must be logged in to remove a comment.'); 22 | 23 | const comment = Comments.findOne({ _id: args._id }, { fields: { userId: 1 } }); 24 | 25 | if (!isAdmin(context.user._id) || comment.userId !== context.user._id) { 26 | throw new Error('Sorry, you must be logged in to remove a comment.'); 27 | } 28 | 29 | Comments.remove(args._id); 30 | 31 | return { _id: args._id }; 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /api/Users/actions/queryUser.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | import mapMeteorUserToSchema from './mapMeteorUserToSchema'; 5 | 6 | let action; 7 | 8 | const getUser = (userId) => { 9 | try { 10 | return Meteor.users.findOne(userId); 11 | } catch (exception) { 12 | throw new Error(`[queryUser.getUser] ${exception.message}`); 13 | } 14 | }; 15 | 16 | const validateOptions = (options) => { 17 | try { 18 | if (!options) throw new Error('options object is required.'); 19 | if (!options.userIdToQuery) throw new Error('options.userIdToQuery is required.'); 20 | } catch (exception) { 21 | throw new Error(`[queryUser.validateOptions] ${exception.message}`); 22 | } 23 | }; 24 | 25 | const queryUser = (options) => { 26 | try { 27 | validateOptions(options); 28 | const user = getUser(options.userIdToQuery); 29 | action.resolve(mapMeteorUserToSchema({ user })); 30 | } catch (exception) { 31 | action.reject(`[queryUser] ${exception.message}`); 32 | } 33 | }; 34 | 35 | export default (options) => 36 | new Promise((resolve, reject) => { 37 | action = { resolve, reject }; 38 | queryUser(options); 39 | }); 40 | -------------------------------------------------------------------------------- /ui/components/Comments/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // eslint-disable-next-line import/prefer-default-export 4 | export const StyledComments = styled.div` 5 | max-width: 750px; 6 | margin: 0 auto 20px; 7 | `; 8 | 9 | export const CommentsList = styled.ol` 10 | margin-top: 40px; 11 | padding: 0; 12 | list-style: none; 13 | 14 | h3 { 15 | font-size: 18px; 16 | margin: 0 0 40px; 17 | } 18 | `; 19 | 20 | export const CommentsListHeader = styled.header` 21 | display: flex; 22 | align-items: center; 23 | margin-bottom: 30px; 24 | 25 | h3 { 26 | margin: 0; 27 | } 28 | 29 | select { 30 | margin-left: auto; 31 | display: inline-block; 32 | width: auto; 33 | min-width: 120px; 34 | } 35 | `; 36 | 37 | export const Comment = styled.li` 38 | border: 1px solid var(--gray-lighter); 39 | padding: 20px; 40 | border-radius: 3px; 41 | 42 | header span { 43 | display: inline-block; 44 | margin-left: 5px; 45 | color: var(--gray-light); 46 | } 47 | 48 | > div { 49 | margin-top: 20px; 50 | 51 | p:last-child { 52 | margin-bottom: 0; 53 | } 54 | } 55 | 56 | &:not(:last-child) { 57 | margin-bottom: 20px; 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /modules/unfreezeApolloCacheValue.test.js: -------------------------------------------------------------------------------- 1 | import unfreezeApolloCacheValue from './unfreezeApolloCacheValue'; 2 | import { documents } from '../tests/fixtures/documents'; 3 | 4 | describe('unfreezeApolloCacheValue.js', () => { 5 | test('it unfreezes a frozen JavaScript Object', () => { 6 | const frozenObject = Object.freeze({ 7 | ...documents[0], 8 | __typename: 'Document', 9 | }); 10 | 11 | const unfrozenCacheValue = unfreezeApolloCacheValue(frozenObject); 12 | 13 | expect(unfrozenCacheValue).toEqual({ 14 | _id: 'document123', 15 | isPublic: true, 16 | userId: 'abc123', 17 | title: 'Document Title #1', 18 | body: 'This is my document. There are many like it, but this one is mine.', 19 | createdAt: '2018-11-05T20:34:54.225Z', 20 | updatedAt: '2018-11-05T20:34:54.225Z', 21 | }); 22 | }); 23 | 24 | test('it removes __typename field from objects', () => { 25 | const frozenObject = Object.freeze({ 26 | ...documents[0], 27 | __typename: 'Document', 28 | }); 29 | 30 | const unfrozenCacheValue = unfreezeApolloCacheValue(frozenObject); 31 | 32 | expect(unfrozenCacheValue.__typename).toBe(undefined); //eslint-disable-line 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /startup/client/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle, no-unused-expressions */ 2 | 3 | import React from 'react'; 4 | import { hydrate, render } from 'react-dom'; 5 | import { BrowserRouter, Switch } from 'react-router-dom'; 6 | import { ThemeProvider } from 'styled-components'; 7 | import { ApolloProvider } from 'react-apollo'; 8 | import { Accounts } from 'meteor/accounts-base'; 9 | import { Meteor } from 'meteor/meteor'; 10 | import { Bert } from 'meteor/themeteorchef:bert'; 11 | import App from '../../ui/layouts/App'; 12 | import apolloClient from './apollo'; 13 | import GlobalStyle from './GlobalStyle'; 14 | 15 | Bert.defaults.style = 'growl-bottom-right'; 16 | 17 | Accounts.onLogout(() => apolloClient.resetStore()); 18 | 19 | Meteor.startup(() => { 20 | const target = document.getElementById('react-root'); 21 | const app = ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | 34 | return !window.noSSR ? hydrate(app, target) : render(app, target); 35 | }); 36 | -------------------------------------------------------------------------------- /api/Users/actions/mapMeteorUserToSchema.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import { Roles } from 'meteor/alanning:roles'; 4 | import normalizeMeteorUserData from './normalizeMeteorUserData'; 5 | 6 | const getActiveRoles = (userId) => { 7 | try { 8 | return ( 9 | Roles.getAllRoles().map((role) => ({ 10 | ...role, 11 | inRole: Roles.userIsInRole(userId, role.name), 12 | })) || [] 13 | ); 14 | } catch (exception) { 15 | throw new Error(`[mapMeteorUserToSchema.getActiveRoles] ${exception.message}`); 16 | } 17 | }; 18 | 19 | export default (options) => { 20 | try { 21 | const normalizedMeteorUserData = normalizeMeteorUserData(options); 22 | 23 | return { 24 | _id: normalizedMeteorUserData._id, 25 | name: normalizedMeteorUserData.profile.name, 26 | emailAddress: normalizedMeteorUserData.emails[0].address, 27 | roles: getActiveRoles(normalizedMeteorUserData._id), 28 | oAuthProvider: 29 | normalizedMeteorUserData.service !== 'password' ? normalizedMeteorUserData.service : null, 30 | settings: normalizedMeteorUserData.settings, 31 | }; 32 | } catch (exception) { 33 | throw new Error(`[mapMeteorUserToSchema] ${exception.message}`); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /ui/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Grid } from 'react-bootstrap'; 4 | import { Meteor } from 'meteor/meteor'; 5 | import { year } from '../../../modules/dates'; 6 | import Styles from './styles'; 7 | 8 | const { productName, copyrightStartYear } = Meteor.settings.public; 9 | const copyrightYear = () => { 10 | const currentYear = year(); 11 | return currentYear === copyrightStartYear 12 | ? copyrightStartYear 13 | : `${copyrightStartYear}-${currentYear}`; 14 | }; 15 | 16 | const Footer = () => ( 17 | 18 | 19 |

20 | © 21 | {` ${copyrightYear()} ${productName}`} 22 |

23 |
    24 |
  • 25 | 26 | Terms 27 | of Service 28 | 29 |
  • 30 |
  • 31 | 32 | Privacy 33 | Policy 34 | 35 |
  • 36 |
37 |
38 |
39 | ); 40 | 41 | Footer.propTypes = {}; 42 | 43 | export default Footer; 44 | -------------------------------------------------------------------------------- /ui/components/OAuthLoginButton/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { darken } from 'polished'; 3 | 4 | const OAuthLoginButton = styled.button` 5 | display: block; 6 | width: 100%; 7 | padding: 10px 15px; 8 | border: none; 9 | background: var(--gray-lighter); 10 | border-radius: 3px; 11 | 12 | i { 13 | margin-right: 3px; 14 | font-size: 18px; 15 | position: relative; 16 | top: 1px; 17 | } 18 | 19 | &.OAuthLoginButton-facebook { 20 | background: var(--facebook); 21 | color: #fff; 22 | 23 | &:hover { 24 | background: ${darken(0.05, '#3b5998')}; 25 | } 26 | } 27 | 28 | &.OAuthLoginButton-google { 29 | background: var(--google); 30 | color: #fff; 31 | 32 | &:hover { 33 | background: ${darken(0.05, '#ea4335')}; 34 | } 35 | } 36 | 37 | &.OAuthLoginButton-github { 38 | background: var(--github); 39 | color: #fff; 40 | 41 | &:hover { 42 | background: ${darken(0.05, '#333333')}; 43 | } 44 | } 45 | 46 | &:active { 47 | position: relative; 48 | top: 1px; 49 | } 50 | 51 | &:active, 52 | &:focus { 53 | outline: 0; 54 | } 55 | 56 | &:not(:last-child) { 57 | margin-top: 10px; 58 | } 59 | `; 60 | 61 | export default { 62 | OAuthLoginButton, 63 | }; 64 | -------------------------------------------------------------------------------- /ui/pages/Logout/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { lighten } from 'polished'; 3 | 4 | const Logout = styled.div` 5 | padding: 20px; 6 | background: var(--cb-blue); 7 | text-align: center; 8 | border-radius: 3px; 9 | color: #fff; 10 | 11 | img { 12 | width: 100px; 13 | height: auto; 14 | } 15 | 16 | h1 { 17 | font-size: 24px; 18 | } 19 | 20 | p { 21 | font-size: 16px; 22 | line-height: 22px; 23 | color: ${lighten(0.25, '#4285F4')}; 24 | } 25 | 26 | ul { 27 | list-style: none; 28 | display: inline-block; 29 | padding: 0; 30 | margin: 10px 0 0; 31 | } 32 | 33 | ul li { 34 | float: left; 35 | font-size: 28px; 36 | line-height: 28px; 37 | 38 | a { 39 | color: #fff; 40 | } 41 | 42 | &:not(:last-child) { 43 | margin-right: 15px; 44 | } 45 | } 46 | 47 | @media screen and (min-width: 768px) { 48 | padding: 30px; 49 | 50 | h1 { 51 | font-size: 26px; 52 | } 53 | } 54 | 55 | @media screen and (min-width: 992px) { 56 | padding: 40px; 57 | 58 | h1 { 59 | font-size: 28px; 60 | } 61 | 62 | p { 63 | font-size: 18px; 64 | line-height: 24px; 65 | } 66 | } 67 | `; 68 | 69 | export default { 70 | Logout, 71 | }; 72 | -------------------------------------------------------------------------------- /ui/pages/AdminUsers/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role, jsx-a11y/anchor-is-valid */ 2 | 3 | import React from 'react'; 4 | import SearchInput from '../../components/SearchInput'; 5 | import AdminUsersList from '../../components/AdminUsersList'; 6 | 7 | import AdminUsersHeader from './styles'; 8 | 9 | class AdminUsers extends React.Component { 10 | state = { 11 | currentPage: 1, 12 | }; 13 | 14 | render() { 15 | const { search, currentPage } = this.state; 16 | 17 | return ( 18 |
19 | 20 |

Users

21 | this.setState({ search: event.target.value })} 25 | /> 26 |
27 | this.setState({ currentPage: newPage })} 32 | /> 33 |
34 | ); 35 | } 36 | } 37 | 38 | AdminUsers.propTypes = { 39 | // prop: PropTypes.string.isRequired, 40 | }; 41 | 42 | export default AdminUsers; 43 | -------------------------------------------------------------------------------- /ui/components/ToggleSwitch/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ToggleSwitch = styled.div` 4 | display: inline-block; 5 | min-width: 58px; 6 | background: ${(props) => (props.toggled ? 'var(--success)' : '#ccc')}; 7 | border-radius: 100px; 8 | transition: background 200ms ease, padding 200ms ease; 9 | text-align: left; 10 | padding: ${(props) => (props.toggled ? '4px 10px 4px 4px' : '4px 4px 4px 10px')}; 11 | 12 | &:hover { 13 | cursor: pointer; 14 | } 15 | 16 | .handle { 17 | display: inline-block; 18 | min-width: 45px; 19 | height: 26px; 20 | background: #fff; 21 | border-radius: 100px; 22 | font-size: 12px; 23 | text-transform: uppercase; 24 | color: var(--success); 25 | text-align: center; 26 | padding: 0 10px; 27 | 28 | .handle-label { 29 | display: inline-block; 30 | margin-top: 3px; 31 | font-size: 16px; 32 | font-weight: 600; 33 | white-space: nowrap; 34 | -webkit-touch-callout: none; 35 | -webkit-user-select: none; 36 | -khtml-user-select: none; 37 | -moz-user-select: none; 38 | -ms-user-select: none; 39 | user-select: none; 40 | color: ${(props) => (props.toggled ? 'var(--success)' : '#ccc')}; 41 | } 42 | } 43 | `; 44 | 45 | export default { 46 | ToggleSwitch, 47 | }; 48 | -------------------------------------------------------------------------------- /modules/server/sendEmail.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import { Email } from 'meteor/email'; 3 | import getPrivateFile from './getPrivateFile'; 4 | import templateToText from './handlebarsEmailToText'; 5 | import templateToHtml from './handlebarsEmailToHtml'; 6 | 7 | const sendEmail = (options, { resolve, reject }) => { 8 | try { 9 | Meteor.defer(() => { 10 | Email.send(options); 11 | resolve(); 12 | }); 13 | } catch (exception) { 14 | reject(exception); 15 | } 16 | }; 17 | 18 | export default ({ text, html, template, templateVars, ...rest }) => { 19 | if (text || html || template) { 20 | return new Promise((resolve, reject) => { 21 | const textTemplate = template && getPrivateFile(`email-templates/${template}.txt`); 22 | const htmlTemplate = template && getPrivateFile(`email-templates/${template}.html`); 23 | const context = templateVars || {}; 24 | 25 | sendEmail( 26 | { 27 | ...rest, 28 | text: template ? templateToText(textTemplate, context) : text, 29 | html: template ? templateToHtml(htmlTemplate, context) : html, 30 | }, 31 | { resolve, reject }, 32 | ); 33 | }); 34 | } 35 | throw new Error( 36 | "Please pass an HTML string, text, or template name to compile for your message's body.", 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /startup/client/apollo.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | import { ApolloClient } from 'apollo-client'; 5 | import { ApolloLink } from 'apollo-link'; 6 | import { onError } from 'apollo-link-error'; 7 | import { HttpLink } from 'apollo-link-http'; 8 | import { InMemoryCache } from 'apollo-cache-inmemory'; 9 | import { MeteorAccountsLink } from 'meteor/apollo'; 10 | 11 | const errorLink = onError(({ graphQLErrors, networkError }) => { 12 | if (graphQLErrors) 13 | graphQLErrors.map(({ message, location, path }) => 14 | console.log(`[GraphQL error]: Message: ${message}, Location: ${location}, Path: ${path}`), 15 | ); 16 | 17 | if (networkError) console.log(`[Network error]: ${networkError}`); 18 | }); 19 | 20 | const queryOrMutationLink = () => 21 | // NOTE: createPersistedQueryLink ensures that queries are cached if they have not 22 | // changed (reducing unnecessary load on the client). 23 | new HttpLink({ 24 | uri: Meteor.settings.public.graphQL.httpUri, 25 | credentials: 'same-origin', 26 | }); 27 | 28 | const apolloClient = new ApolloClient({ 29 | connectToDevTools: true, 30 | link: ApolloLink.from([MeteorAccountsLink(), errorLink, queryOrMutationLink()]), 31 | cache: new InMemoryCache().restore(window.__APOLLO_STATE__), 32 | }); 33 | 34 | export default apolloClient; 35 | -------------------------------------------------------------------------------- /modules/dates.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import 'moment-timezone'; 3 | 4 | export const monthDayYear = (timestamp, timezone) => 5 | !timezone 6 | ? moment(timestamp).format('MMMM Do, YYYY') 7 | : moment(timestamp) 8 | .tz(timezone) 9 | .format('MMMM Do, YYYY'); 10 | 11 | export const monthDayYearAtTime = (timestamp, timezone) => 12 | !timezone 13 | ? moment(timestamp).format('MMMM Do, YYYY [at] hh:mm a') 14 | : moment(timestamp) 15 | .tz(timezone) 16 | .format('MMMM Do, YYYY [at] hh:mm a'); 17 | 18 | export const timeago = (timestamp, timezone) => 19 | !timezone 20 | ? moment(timestamp).fromNow() 21 | : moment(timestamp) 22 | .tz(timezone) 23 | .fromNow(); 24 | 25 | export const add = (timestamp, amount, range, timezone) => 26 | !timezone 27 | ? moment(timestamp) 28 | .add(amount, range) 29 | .format() 30 | : moment(timestamp) 31 | .tz(timezone) 32 | .add(amount, range) 33 | .format(); 34 | 35 | export const year = (timestamp, timezone) => 36 | !timezone 37 | ? moment(timestamp).format('YYYY') 38 | : moment(timestamp) 39 | .tz(timezone) 40 | .format('YYYY'); 41 | 42 | export const iso = (timestamp, timezone) => 43 | !timezone 44 | ? moment(timestamp).format() 45 | : moment(timestamp) 46 | .tz(timezone) 47 | .format(); 48 | -------------------------------------------------------------------------------- /modules/server/handlebarsEmailToHtml.js: -------------------------------------------------------------------------------- 1 | import handlebars from 'handlebars'; 2 | import juice from 'juice'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import getPrivateFile from './getPrivateFile'; 5 | 6 | export default (handlebarsMarkup, context, options) => { 7 | if (handlebarsMarkup && context) { 8 | const template = handlebars.compile(handlebarsMarkup); 9 | const content = template(context); 10 | const { 11 | productName, 12 | twitterUsername, 13 | facebookUsername, 14 | productAddress, 15 | } = Meteor.settings.public; 16 | 17 | if (options && options.noBaseTemplate) { 18 | // Use juice to inline CSS styles from unless disabled. 19 | return options && !options.inlineCss ? content : juice(content); 20 | } 21 | 22 | const base = handlebars.compile(getPrivateFile('email-templates/base.html')); 23 | 24 | const baseContext = { 25 | ...context, 26 | content, 27 | productName, 28 | twitterUsername, 29 | facebookUsername, 30 | productAddress, 31 | }; 32 | 33 | return options && !options.inlineCss ? base(baseContext) : juice(base(baseContext)); 34 | } 35 | 36 | throw new Error( 37 | '[Pup] Please pass Handlebars markup to compile and a context object with data mapping to the Handlebars expressions used in your template (e.g., {{expressionToReplace}}).', 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /public/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | facebook-icon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/__mocks__/meteor/mongo.js: -------------------------------------------------------------------------------- 1 | const Collection = jest.fn(); 2 | 3 | Collection.prototype.attachSchema = jest.fn(); 4 | Collection.prototype.insert = jest.fn(); 5 | Collection.prototype.update = jest.fn(); 6 | Collection.prototype.remove = jest.fn(); 7 | Collection.prototype.findOne = jest.fn(); 8 | Collection.prototype.allow = jest.fn(); 9 | Collection.prototype.deny = jest.fn(); 10 | Collection.prototype.helpers = jest.fn(); 11 | 12 | Collection.prototype.find = jest.fn(() => ({ 13 | count: jest.fn(), 14 | fetch: jest.fn(), 15 | })); 16 | 17 | Collection.prototype.before = { 18 | insert: jest.fn(), 19 | update: jest.fn(), 20 | }; 21 | 22 | Collection.prototype.after = { 23 | insert: jest.fn(), 24 | update: jest.fn(), 25 | }; 26 | 27 | Collection.prototype.rawCollection = jest.fn(() => ({ 28 | createIndex: jest.fn(), 29 | })); 30 | 31 | const RemoteCollectionDriver = jest.fn(); 32 | 33 | RemoteCollectionDriver.prototype.open = jest.fn().mockReturnThis(); 34 | RemoteCollectionDriver.prototype.insert = jest.fn(); 35 | RemoteCollectionDriver.prototype.update = jest.fn(); 36 | RemoteCollectionDriver.prototype.remove = jest.fn(); 37 | RemoteCollectionDriver.prototype.findOne = jest.fn(); 38 | 39 | RemoteCollectionDriver.prototype.find = jest.fn(() => ({ 40 | count: jest.fn(), 41 | fetch: jest.fn(), 42 | })); 43 | 44 | module.exports = { 45 | Mongo: { 46 | Collection, 47 | }, 48 | MongoInternals: { 49 | RemoteCollectionDriver, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/Authenticated/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Route, Redirect } from 'react-router-dom'; 4 | import { Meteor } from 'meteor/meteor'; 5 | 6 | class Authenticated extends React.Component { 7 | componentWillMount() { 8 | if (Meteor.isClient) { 9 | const { setAfterLoginPath } = this.props; 10 | setAfterLoginPath(`${window.location.pathname}${window.location.search}`); 11 | } 12 | } 13 | 14 | render() { 15 | const { loggingIn, authenticated, component, path, exact, ...rest } = this.props; 16 | 17 | return ( 18 | 22 | authenticated ? ( 23 | React.createElement(component, { 24 | ...props, 25 | ...rest, 26 | loggingIn, 27 | authenticated, 28 | }) 29 | ) : ( 30 | 31 | ) 32 | } 33 | /> 34 | ); 35 | } 36 | } 37 | 38 | Authenticated.defaultProps = { 39 | loggingIn: false, 40 | path: '', 41 | exact: false, 42 | }; 43 | 44 | Authenticated.propTypes = { 45 | loggingIn: PropTypes.bool, 46 | authenticated: PropTypes.bool.isRequired, 47 | component: PropTypes.func.isRequired, 48 | setAfterLoginPath: PropTypes.func.isRequired, 49 | path: PropTypes.string, 50 | exact: PropTypes.bool, 51 | }; 52 | 53 | export default Authenticated; 54 | -------------------------------------------------------------------------------- /ui/components/OAuthLoginButtons/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import OAuthLoginButton from '../OAuthLoginButton'; 5 | import Loading from '../Loading'; 6 | import oAuthServicesQuery from '../../queries/OAuth.gql'; 7 | import Styles from './styles'; 8 | 9 | const OAuthLoginButtons = ({ emailMessage, data: { oAuthServices, loading } }) => ( 10 | 11 | {loading ? ( 12 | 13 | ) : ( 14 | 15 | {oAuthServices.length ? ( 16 | 17 | {oAuthServices.map((service) => ( 18 | 19 | ))} 20 | {emailMessage && ( 21 | 22 | {emailMessage.text} 23 | 24 | )} 25 | 26 | ) : ( 27 | 28 | )} 29 | 30 | )} 31 | 32 | ); 33 | 34 | OAuthLoginButtons.propTypes = { 35 | data: PropTypes.object.isRequired, 36 | emailMessage: PropTypes.object.isRequired, 37 | }; 38 | 39 | export default graphql(oAuthServicesQuery, { 40 | options: ({ services }) => ({ 41 | variables: { 42 | services, 43 | }, 44 | }), 45 | })(OAuthLoginButtons); 46 | -------------------------------------------------------------------------------- /api/UserSettings/actions/addSettingToUsers.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | 5 | const addSetting = (userIds, settingToAdd) => { 6 | try { 7 | userIds.forEach((userId) => { 8 | Meteor.users.update( 9 | { _id: userId }, 10 | { 11 | $addToSet: { 12 | settings: settingToAdd, 13 | }, 14 | }, 15 | ); 16 | }); 17 | } catch (exception) { 18 | throw new Error(`[addSettingToUsers.addSettingToUsers] ${exception.message}`); 19 | } 20 | }; 21 | 22 | const getUserIds = () => { 23 | try { 24 | return Meteor.users 25 | .find({}, { fields: { _id: 1 } }) 26 | .fetch() 27 | .map(({ _id }) => _id); 28 | } catch (exception) { 29 | throw new Error(`[addSettingToUsers.getUserIds] ${exception.message}`); 30 | } 31 | }; 32 | 33 | const validateOptions = (options) => { 34 | try { 35 | if (!options) throw new Error('options object is required.'); 36 | if (!options.setting) throw new Error('options.setting is required.'); 37 | } catch (exception) { 38 | throw new Error(`[addSettingToUsers.validateOptions] ${exception.message}`); 39 | } 40 | }; 41 | 42 | export default (options) => { 43 | try { 44 | validateOptions(options); 45 | const userIds = getUserIds(); 46 | return addSetting(userIds, options.setting); 47 | } catch (exception) { 48 | throw new Error(`[addSettingToUsers] ${exception.message}`); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base@1.5.1 # Packages every Meteor app needs to have 8 | mobile-experience@1.1.0 # Packages for a great mobile UX 9 | mongo@1.14.6 # The database Meteor supports right now 10 | reactive-var@1.0.11 # Reactive variable for tracker 11 | tracker@1.2.0 # Meteor's client-side reactive programming library 12 | 13 | standard-minifier-css@1.8.1 # CSS minifier run for production mode 14 | standard-minifier-js@2.8.0 # JS minifier run for production mode 15 | es5-shim@4.8.0 # ECMAScript 5 compatibility for older browsers. 16 | ecmascript@0.16.2 # Enable ECMAScript2015+ syntax in app code 17 | shell-server@0.5.0 # Server-side component of the `meteor shell` command 18 | 19 | react-meteor-data@2.3.3 20 | alanning:roles 21 | accounts-base@2.2.2 22 | accounts-password@2.3.1 23 | service-configuration@1.3.0 24 | accounts-facebook@1.3.3 25 | accounts-github@1.5.0 26 | accounts-google@1.4.0 27 | themeteorchef:bert 28 | fortawesome:fontawesome 29 | audit-argument-checks@1.0.7 30 | ddp-rate-limiter@1.1.0 31 | dynamic-import@0.7.2 32 | static-html@1.3.2 33 | server-render@0.4.0 34 | meteorhacks:picker 35 | swydo:graphql 36 | browser-policy@1.1.0 37 | apollo 38 | -------------------------------------------------------------------------------- /ui/pages/Index/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { lighten, darken } from 'polished'; 3 | 4 | const textColor = '#4285F4'; 5 | 6 | const Index = styled.div` 7 | padding: 20px; 8 | background: var(--cb-blue); 9 | text-align: center; 10 | border-radius: 3px; 11 | color: #fff; 12 | 13 | img { 14 | width: 100px; 15 | height: auto; 16 | } 17 | 18 | h1 { 19 | font-size: 28px; 20 | } 21 | 22 | p { 23 | font-size: 18px; 24 | color: ${lighten(0.25, textColor)}; 25 | } 26 | 27 | > div { 28 | display: inline-block; 29 | margin: 10px 0 0; 30 | 31 | .btn:first-child { 32 | margin-right: 10px; 33 | } 34 | 35 | .btn { 36 | border: none; 37 | } 38 | } 39 | 40 | footer { 41 | margin: 20px -20px -20px; 42 | border-top: 1px solid ${darken(0.1, textColor)}; 43 | padding: 20px; 44 | 45 | p { 46 | font-size: 14px; 47 | line-height: 22px; 48 | color: ${lighten(0.35, textColor)}; 49 | margin: 0; 50 | } 51 | 52 | p a { 53 | color: ${lighten(0.35, textColor)}; 54 | text-decoration: underline; 55 | } 56 | } 57 | 58 | @media screen and (min-width: 768px) { 59 | padding: 30px; 60 | 61 | footer { 62 | margin: 30px -30px -30px; 63 | } 64 | } 65 | 66 | @media screen and (min-width: 992px) { 67 | padding: 40px; 68 | 69 | footer { 70 | margin: 40px -40px -40px; 71 | } 72 | } 73 | `; 74 | 75 | export default { 76 | Index, 77 | }; 78 | -------------------------------------------------------------------------------- /ui/pages/VerifyEmail/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import { Alert } from 'react-bootstrap'; 5 | import { Accounts } from 'meteor/accounts-base'; 6 | import { Bert } from 'meteor/themeteorchef:bert'; 7 | import { sendWelcomeEmail as sendWelcomeEmailMutation } from '../../mutations/Users.gql'; 8 | 9 | class VerifyEmail extends React.Component { 10 | state = { error: null }; 11 | 12 | componentDidMount() { 13 | const { match, history, sendWelcomeEmail } = this.props; 14 | Accounts.verifyEmail(match.params.token, (error) => { 15 | if (error) { 16 | Bert.alert(error.reason, 'danger'); 17 | this.setState({ error: `${error.reason}. Please try again.` }); 18 | } else { 19 | setTimeout(() => { 20 | Bert.alert('All set, thanks!', 'success'); 21 | sendWelcomeEmail(); 22 | history.push('/documents'); 23 | }, 1500); 24 | } 25 | }); 26 | } 27 | 28 | render() { 29 | const { error } = this.state; 30 | return ( 31 |
32 | {!error ? 'Verifying...' : error} 33 |
34 | ); 35 | } 36 | } 37 | 38 | VerifyEmail.propTypes = { 39 | match: PropTypes.object.isRequired, 40 | history: PropTypes.object.isRequired, 41 | sendWelcomeEmail: PropTypes.func.isRequired, 42 | }; 43 | 44 | export default graphql(sendWelcomeEmailMutation, { 45 | name: 'sendWelcomeEmail', 46 | })(VerifyEmail); 47 | -------------------------------------------------------------------------------- /ui/components/AdminUsersList/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { ListGroup, ListGroupItem } from 'react-bootstrap'; 3 | 4 | export const StyledListGroup = styled(ListGroup)` 5 | margin-bottom: 0; 6 | `; 7 | 8 | export const StyledListGroupItem = styled(ListGroupItem)` 9 | padding: 15px; 10 | position: relative; 11 | overflow: hidden; 12 | 13 | &:after { 14 | content: ''; 15 | position: absolute; 16 | top: 0; 17 | right: 0; 18 | bottom: 0; 19 | width: 75px; 20 | display: block; 21 | background: rgb(2, 0, 36); 22 | background: linear-gradient(90deg, rgba(2, 0, 36, 0) 0%, rgba(255, 255, 255, 1) 100%); 23 | } 24 | 25 | &:hover { 26 | background: #fafafa; 27 | 28 | &:after { 29 | display: none; 30 | } 31 | } 32 | 33 | a { 34 | display: block; 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | right: 0; 39 | bottom: 0; 40 | } 41 | 42 | p { 43 | margin: 0; 44 | white-space: nowrap; 45 | 46 | span:not(.label) { 47 | color: var(--gray-light); 48 | margin-left: 5px; 49 | } 50 | 51 | .label { 52 | display: inline-block; 53 | position: relative; 54 | top: -1px; 55 | margin-left: 3px; 56 | } 57 | 58 | .label-facebook { 59 | background: var(--facebook); 60 | color: #fff; 61 | } 62 | 63 | .label-google { 64 | background: var(--google); 65 | color: #fff; 66 | } 67 | 68 | .label-github { 69 | background: var(--github); 70 | color: #fff; 71 | } 72 | } 73 | `; 74 | -------------------------------------------------------------------------------- /ui/pages/Documents/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledDocuments = styled.div` 4 | header { 5 | margin: 0px 0 20px; 6 | } 7 | 8 | @media screen and (min-width: 768px) { 9 | header { 10 | margin: 0 0 20px; 11 | } 12 | } 13 | `; 14 | 15 | export const DocumentsList = styled.div` 16 | @media screen and (min-width: 768px) { 17 | display: grid; 18 | grid-template-columns: 1fr 1fr 1fr; 19 | grid-auto-rows: 1fr; 20 | grid-column-gap: 20px; 21 | grid-row-gap: 20px; 22 | } 23 | 24 | @media screen and (min-width: 992px) { 25 | grid-template-columns: 1fr 1fr 1fr 1fr; 26 | } 27 | `; 28 | 29 | export const Document = styled.div` 30 | position: relative; 31 | border: 1px solid var(--gray-lighter); 32 | border-top: 5px solid var(--gray-lighter); 33 | padding: 20px; 34 | border-radius: 3px; 35 | min-height: 180px; 36 | transition: transform 0.3s ease-in-out; 37 | 38 | a { 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | right: 0; 43 | bottom: 0; 44 | } 45 | 46 | &:not(:last-child) { 47 | margin-bottom: 20px; 48 | } 49 | 50 | header { 51 | margin: 0; 52 | } 53 | 54 | h2 { 55 | margin: 10px 0 0; 56 | font-size: 16px; 57 | line-height: 24px; 58 | } 59 | 60 | p { 61 | margin: 10px 0 0; 62 | color: var(--gray-light); 63 | } 64 | 65 | &:hover { 66 | transform: scale(1.02, 1.02); 67 | cursor: pointer; 68 | } 69 | 70 | @media screen and (min-width: 768px) { 71 | &:not(:last-child) { 72 | margin-bottom: 0; 73 | } 74 | } 75 | `; 76 | -------------------------------------------------------------------------------- /ui/pages/Logout/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import Icon from '../../components/Icon'; 5 | import Styles from './styles'; 6 | 7 | const { productName, twitterUsername, facebookUsername } = Meteor.settings.public; 8 | 9 | class Logout extends React.Component { 10 | componentDidMount() { 11 | const { setAfterLoginPath } = this.props; 12 | Meteor.logout(() => setAfterLoginPath(null)); 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | Clever Beagle 22 |

Stay safe out there.

23 |

{`Don't forget to like and follow ${productName} elsewhere on the web:`}

24 | 40 |
41 | ); 42 | } 43 | } 44 | 45 | Logout.propTypes = { 46 | setAfterLoginPath: PropTypes.func.isRequired, 47 | }; 48 | 49 | export default Logout; 50 | -------------------------------------------------------------------------------- /api/UserSettings/mutations.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | import UserSettings from './UserSettings'; 3 | import { isAdmin } from '../Users/actions/checkIfAuthorized'; 4 | import updateSettingOnUsers from './actions/updateSettingOnUsers'; 5 | import addSettingToUsers from './actions/addSettingToUsers'; 6 | 7 | export default { 8 | addUserSetting(parent, args, context) { 9 | if (!isAdmin(context.user._id)) { 10 | throw new Error('Sorry, you must be an admin to do this.'); 11 | } 12 | 13 | if (UserSettings.findOne({ key: args.setting.key })) { 14 | throw new Error('Sorry, this user setting already exists.'); 15 | } 16 | 17 | const settingId = UserSettings.insert(args.setting); 18 | addSettingToUsers({ setting: { _id: settingId, ...args.setting } }); 19 | 20 | return { 21 | _id: settingId, 22 | ...args.setting, 23 | }; 24 | }, 25 | updateUserSetting(parent, args, context) { 26 | if (!isAdmin(context.user._id)) { 27 | throw new Error('Sorry, you must be an admin to do this.'); 28 | } 29 | 30 | UserSettings.update( 31 | { _id: args.setting._id }, 32 | { 33 | $set: args.setting, 34 | }, 35 | () => { 36 | updateSettingOnUsers({ setting: args.setting }); 37 | }, 38 | ); 39 | }, 40 | removeUserSetting(parent, args, context) { 41 | if (!isAdmin(context.user._id)) { 42 | throw new Error('Sorry, you must be an admin to do this.'); 43 | } 44 | 45 | Meteor.users.update({}, { $pull: { settings: { _id: args._id } } }, { multi: true }, () => { 46 | UserSettings.remove({ _id: args._id }); 47 | }); 48 | 49 | return { _id: args._id }; 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/VerifyEmailAlert/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Alert, Button } from 'react-bootstrap'; 4 | import { graphql } from 'react-apollo'; 5 | import { Bert } from 'meteor/themeteorchef:bert'; 6 | import { sendVerificationEmail as sendVerificationEmailMutation } from '../../mutations/Users.gql'; 7 | import Styles from './styles'; 8 | 9 | const handleResendVerificationEmail = (emailAddress, sendVerificationEmail) => { 10 | sendVerificationEmail(); 11 | Bert.alert(`Check ${emailAddress} for a verification link!`, 'success'); 12 | }; 13 | 14 | const VerifyEmailAlert = ({ userId, emailVerified, emailAddress, sendVerificationEmail }) => { 15 | return userId && !emailVerified ? ( 16 | 17 | 18 |

19 | {'Hey friend! Can you '} 20 | verify your email address 21 | {` (${emailAddress}) `} 22 | for us? 23 | 30 |

31 |
32 |
33 | ) : null; 34 | }; 35 | 36 | VerifyEmailAlert.propTypes = { 37 | userId: PropTypes.string.isRequired, 38 | emailVerified: PropTypes.bool.isRequired, 39 | emailAddress: PropTypes.string.isRequired, 40 | sendVerificationEmail: PropTypes.func.isRequired, 41 | }; 42 | 43 | export default graphql(sendVerificationEmailMutation, { 44 | name: 'sendVerificationEmail', 45 | })(VerifyEmailAlert); 46 | -------------------------------------------------------------------------------- /ui/components/ToggleSwitch/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Icon from '../Icon'; 4 | import Styles from './styles'; 5 | 6 | class ToggleSwitch extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | const { toggled } = props; 10 | this.state = { toggled }; 11 | } 12 | 13 | componentWillReceiveProps(nextProps) { 14 | this.setState({ toggled: nextProps.toggled }); 15 | } 16 | 17 | toggleSwitch = (event) => { 18 | const { id, onToggle } = this.props; 19 | event.stopPropagation(); 20 | this.setState( 21 | ({ toggled }) => ({ 22 | toggled: !toggled, 23 | }), 24 | () => { 25 | const { toggled } = this.state; 26 | if (onToggle) onToggle(id, toggled); 27 | }, 28 | ); 29 | }; 30 | 31 | render() { 32 | const { onLabel, offLabel } = this.props; 33 | const { toggled } = this.state; 34 | return ( 35 | 36 |
37 | 38 | {toggled 39 | ? onLabel || 40 | : offLabel || } 41 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | ToggleSwitch.propTypes = { 49 | id: PropTypes.string, 50 | toggled: PropTypes.bool, 51 | onLabel: PropTypes.string, 52 | offLabel: PropTypes.string, 53 | onToggle: PropTypes.func, 54 | }; 55 | 56 | ToggleSwitch.defaultProps = { 57 | id: '', 58 | toggled: false, 59 | onLabel: '', 60 | offLabel: '', 61 | onToggle: () => {}, 62 | }; 63 | 64 | export default ToggleSwitch; 65 | -------------------------------------------------------------------------------- /api/Users/actions/sendWelcomeEmail.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import { Meteor } from 'meteor/meteor'; 4 | import normalizeMeteorUserData from './normalizeMeteorUserData'; 5 | import sendEmail from '../../../modules/server/sendEmail'; 6 | 7 | const getEmailOptions = (user) => { 8 | try { 9 | const firstName = user.profile.name.first; 10 | const { productName } = Meteor.settings.public; 11 | 12 | return { 13 | to: user.emails[0].address, 14 | from: Meteor.settings.private.supportEmail, 15 | subject: `[${Meteor.settings.public.productName}] Welcome, ${firstName}!`, 16 | template: 'welcome', 17 | templateVars: { 18 | title: `Welcome, ${firstName}!`, 19 | subtitle: `Here's how to get started with ${productName}.`, 20 | productName, 21 | firstName, 22 | welcomeUrl: Meteor.absoluteUrl('documents'), // e.g., returns http://localhost:3000/documents 23 | }, 24 | }; 25 | } catch (exception) { 26 | throw new Error(`[sendWelcomeEmail.getEmailOptions] ${exception.message}`); 27 | } 28 | }; 29 | 30 | const validateOptions = (options) => { 31 | try { 32 | if (!options) throw new Error('options object is required.'); 33 | if (!options.user) throw new Error('options.user is required.'); 34 | } catch (exception) { 35 | throw new Error(`[sendWelcomeEmail.validateOptions] ${exception.message}`); 36 | } 37 | }; 38 | 39 | export default (options) => { 40 | try { 41 | validateOptions(options); 42 | const user = normalizeMeteorUserData({ user: options.user }); 43 | const emailOptions = getEmailOptions(user); 44 | 45 | sendEmail(emailOptions).catch((error) => { 46 | throw new Error(error); 47 | }); 48 | } catch (exception) { 49 | throw new Error(`[sendWelcomeEmail] ${exception.message}`); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /ui/components/Loading/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = () => ( 4 |
5 | 12 | 13 | 14 | 24 | 34 | 35 | 36 | 46 | 56 | 57 | 58 | 59 |
60 | ); 61 | 62 | export default Loading; 63 | -------------------------------------------------------------------------------- /api/Users/actions/exportUserData.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return */ 2 | 3 | import JSZip from 'jszip'; 4 | import Documents from '../../Documents/Documents'; 5 | 6 | let action; 7 | 8 | const generateZip = (zip) => { 9 | try { 10 | zip.generateAsync({ type: 'base64' }).then((content) => action.resolve({ zip: content })); 11 | } catch (exception) { 12 | throw new Error(`[exportUserData.generateZip] ${exception.message}`); 13 | } 14 | }; 15 | 16 | const addDocumentsToZip = (documents, zip) => { 17 | try { 18 | documents.forEach((document) => { 19 | zip.file(`${document.title}.txt`, `${document.title}\n\n${document.body}`); 20 | }); 21 | } catch (exception) { 22 | throw new Error(`[exportUserData.addDocumentsToZip] ${exception.message}`); 23 | } 24 | }; 25 | 26 | const getDocuments = ({ _id }) => { 27 | try { 28 | return Documents.find({ owner: _id }).fetch(); 29 | } catch (exception) { 30 | throw new Error(`[exportUserData.getDocuments] ${exception.message}`); 31 | } 32 | }; 33 | 34 | const validateOptions = (options) => { 35 | try { 36 | if (!options) throw new Error('options object is required.'); 37 | if (!options.user) throw new Error('options.user is required.'); 38 | } catch (exception) { 39 | throw new Error(`[exportUserData.validateOptions] ${exception.message}`); 40 | } 41 | }; 42 | 43 | const exportUserData = (options) => { 44 | try { 45 | validateOptions(options); 46 | const zip = new JSZip(); 47 | const documents = getDocuments(options.user); 48 | addDocumentsToZip(documents, zip); 49 | generateZip(zip); 50 | } catch (exception) { 51 | action.reject(`[exportUserData] ${exception.message}`); 52 | } 53 | }; 54 | 55 | export default (options) => 56 | new Promise((resolve, reject) => { 57 | action = { resolve, reject }; 58 | exportUserData(options); 59 | }); 60 | -------------------------------------------------------------------------------- /api/Documents/mutations.js: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | import Documents from './Documents'; 3 | 4 | export default { 5 | addDocument: (root, args, context) => { 6 | if (!context.user) throw new Error('Sorry, you must be logged in to add a new document.'); 7 | const date = new Date().toISOString(); 8 | const documentId = Documents.insert({ 9 | isPublic: args.isPublic || false, 10 | title: 11 | args.title || 12 | `Untitled Document #${Documents.find({ owner: context.user._id }).count() + 1}`, 13 | body: args.body 14 | ? sanitizeHtml(args.body) 15 | : 'This is my document. There are many like it, but this one is mine.', 16 | owner: context.user._id, 17 | createdAt: date, 18 | updatedAt: date, 19 | }); 20 | const doc = Documents.findOne(documentId); 21 | return doc; 22 | }, 23 | updateDocument: (root, args, context) => { 24 | if (!context.user) throw new Error('Sorry, you must be logged in to update a document.'); 25 | if (!Documents.findOne({ _id: args._id, owner: context.user._id })) 26 | throw new Error('Sorry, you need to be the owner of this document to update it.'); 27 | Documents.update( 28 | { _id: args._id }, 29 | { 30 | $set: { 31 | ...args, 32 | body: sanitizeHtml(args.body), 33 | updatedAt: new Date().toISOString(), 34 | }, 35 | }, 36 | ); 37 | const doc = Documents.findOne(args._id); 38 | return doc; 39 | }, 40 | removeDocument: (root, args, context) => { 41 | if (!context.user) throw new Error('Sorry, you must be logged in to remove a document.'); 42 | if (!Documents.findOne({ _id: args._id, owner: context.user._id })) 43 | throw new Error('Sorry, you need to be the owner of this document to remove it.'); 44 | Documents.remove(args); 45 | return args; 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /ui/components/Comments/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import CommentComposer from '../CommentComposer'; 4 | import { timeago } from '../../../modules/dates'; 5 | 6 | import { StyledComments, CommentsList, CommentsListHeader, Comment } from './styles'; 7 | 8 | const Comments = ({ sortBy, onChangeSortBy, documentId, comments }) => ( 9 | 10 | 11 | {comments.length > 0 && ( 12 | 13 | 14 |

{comments.length === 1 ? '1 Comment' : `${comments.length} Comments`}

15 | 19 |
20 | {comments.map(({ _id, user, createdAt, comment }) => { 21 | const name = user && user.name; 22 | return ( 23 | 24 |
25 |

26 | {`${name && name.first} ${name && name.last}`} 27 | {timeago(createdAt)} 28 |

29 |
30 |
31 | {comment.split('\n').map((item, key) => ( 32 |

{item}

33 | ))} 34 |
35 |
36 | ); 37 | })} 38 |
39 | )} 40 |
41 | ); 42 | 43 | Comments.propTypes = { 44 | documentId: PropTypes.string.isRequired, 45 | comments: PropTypes.array.isRequired, 46 | sortBy: PropTypes.string.isRequired, 47 | onChangeSortBy: PropTypes.func.isRequired, 48 | }; 49 | 50 | export default Comments; 51 | -------------------------------------------------------------------------------- /ui/pages/Privacy/content.js: -------------------------------------------------------------------------------- 1 | import { Meteor } from 'meteor/meteor'; 2 | 3 | const { productName } = Meteor.settings.public; 4 | 5 | const content = ` 6 | Your privacy is important to us. 7 | 8 | It is ${productName}'s policy to respect your privacy regarding any information we may collect while operating our website. Accordingly, we have developed this privacy policy in order for you to understand how we collect, use, communicate, disclose and otherwise make use of personal information. We have outlined our privacy policy below. 9 | 10 | We will collect personal information by lawful and fair means and, where appropriate, with the knowledge or consent of the individual concerned. 11 | 12 | Before or at the time of collecting personal information, we will identify the purposes for which information is being collected. 13 | 14 | We will collect and use personal information solely for fulfilling those purposes specified by us and for other ancillary purposes, unless we obtain the consent of the individual concerned or as required by law. 15 | 16 | Personal data should be relevant to the purposes for which it is to be used, and, to the extent necessary for those purposes, should be accurate, complete, and up-to-date. 17 | 18 | We will protect personal information by using reasonable security safeguards against loss or theft, as well as unauthorized access, disclosure, copying, use or modification. 19 | 20 | We will make readily available to customers information about our policies and practices relating to the management of personal information. 21 | 22 | We will only retain personal information for as long as necessary for the fulfillment of those purposes. 23 | 24 | We are committed to conducting our business in accordance with these principles in order to ensure that the confidentiality of personal information is protected and maintained. ${productName} may change this privacy policy from time to time at ${productName}'s sole discretion. 25 | `; 26 | 27 | export default content; 28 | -------------------------------------------------------------------------------- /ui/components/AuthenticatedNavigation/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { LinkContainer } from 'react-router-bootstrap'; 5 | import { Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap'; 6 | import { Roles } from 'meteor/alanning:roles'; 7 | 8 | const AuthenticatedNavigation = ({ name, history, userId }) => ( 9 |
10 | 31 | 44 |
45 | ); 46 | 47 | AuthenticatedNavigation.propTypes = { 48 | name: PropTypes.string.isRequired, 49 | history: PropTypes.object.isRequired, 50 | userId: PropTypes.string.isRequired, 51 | }; 52 | 53 | export default withRouter(AuthenticatedNavigation); 54 | -------------------------------------------------------------------------------- /public/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | google-icon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /startup/server/sitemap.js: -------------------------------------------------------------------------------- 1 | import xml from 'xml'; 2 | import { Meteor } from 'meteor/meteor'; 3 | import { Picker } from 'meteor/meteorhacks:picker'; 4 | import Documents from '../../api/Documents/Documents'; 5 | import { iso } from '../../modules/dates.js'; 6 | 7 | const baseUrl = Meteor.absoluteUrl(); 8 | 9 | // NOTE: Slashes are omitted at front because it comes with baseUrl. 10 | const routes = [ 11 | { base: 'signup' }, 12 | { base: 'login' }, 13 | 14 | { base: 'verify-email' }, 15 | { base: 'recover-password' }, 16 | { base: 'reset-password' }, 17 | 18 | { base: 'terms' }, 19 | { base: 'privacy' }, 20 | { base: 'example-page' }, 21 | 22 | { 23 | base: 'documents', 24 | collection: Documents, 25 | // NOTE: Edit this query to limit what you publish. 26 | query: {}, 27 | projection: { fields: { _id: 1, createdAt: 1 }, sort: { createdAt: -1 } }, 28 | }, 29 | ]; 30 | 31 | const sitemap = { 32 | urlset: [{ _attr: { xmlns: 'http://www.sitemaps.org/schemas/sitemap/0.9' } }], 33 | }; 34 | 35 | routes.forEach(({ base, collection, query, projection }) => { 36 | const currentDateTime = new Date().toISOString(); 37 | const urlTemplate = (path, date, priority) => ({ 38 | url: [ 39 | { loc: `${baseUrl}${path}` }, 40 | { lastmod: iso(date) }, 41 | { changefreq: 'monthly' }, 42 | { priority }, 43 | ], 44 | }); 45 | 46 | sitemap.urlset.push(urlTemplate(base, currentDateTime, '1.0')); 47 | 48 | if (collection) { 49 | const items = collection.find(query, projection).fetch(); 50 | if (items.length > 0) { 51 | items.forEach(({ _id, createdAt }) => { 52 | sitemap.urlset.push(urlTemplate(`${base}/${_id}`, createdAt, 0.5)); 53 | }); 54 | } 55 | } 56 | }); 57 | 58 | Picker.route('/sitemap.xml', (params, request, response) => { 59 | response.writeHead(200, { 'Content-Type': 'application/xml' }); 60 | response.end(xml(sitemap, { declaration: { standalone: 'yes', encoding: 'utf-8' } })); 61 | }); 62 | -------------------------------------------------------------------------------- /startup/client/GlobalStyle.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | const GlobalStyle = createGlobalStyle` 4 | :root { 5 | --primary: #337ab7; 6 | --success: #5cb85c; 7 | --info: #5bc0de; 8 | --warning: #f0ad4e; 9 | --danger: #d9534f; 10 | 11 | --gray-darker: #222; 12 | --gray-dark: #333; 13 | --gray: #555; 14 | --gray-light: #777; 15 | --gray-lighter: #eee; 16 | 17 | --facebook: #3b5998; 18 | --google: #ea4335; 19 | --github: var(--gray-dark); 20 | 21 | --cb-blue: #4285F4; 22 | --cb-green: #00D490; 23 | --cb-yellow: #FFCF50; 24 | --cb-red: #DA5847; 25 | } 26 | 27 | html { 28 | position: relative; 29 | min-height: 100%; 30 | } 31 | 32 | body { 33 | margin-bottom: 80px; 34 | margin: 0; 35 | padding: 0; 36 | font-size: 14px; 37 | line-height: 20px; 38 | } 39 | 40 | body.isViewDocument { 41 | padding-top: 20px; 42 | } 43 | 44 | body.isViewDocument .navbar { 45 | display: none; 46 | } 47 | 48 | .navbar { 49 | border-radius: 0; 50 | border-left: none; 51 | border-right: none; 52 | border-top: none; 53 | } 54 | 55 | form label { 56 | display: block; 57 | } 58 | 59 | form .control-label { 60 | display: block; 61 | margin-bottom: 7px; 62 | } 63 | 64 | form label.error { 65 | display: block; 66 | margin-top: 8px; 67 | font-size: 13px; 68 | font-weight: normal; 69 | color: var(--danger); 70 | } 71 | 72 | .page-header { 73 | margin-top: 0; 74 | } 75 | 76 | .table tr td { 77 | vertical-align: middle !important; 78 | } 79 | 80 | /* Removes unnecessary bottom padding on .container */ 81 | body > #react-root > div > .container { 82 | padding-bottom: 0; 83 | } 84 | 85 | @media screen and (min-width: 768px) { 86 | body.isViewDocument { 87 | padding-top: 40px; 88 | } 89 | 90 | .page-header { 91 | margin-top: 20px; 92 | } 93 | } 94 | `; 95 | 96 | export default GlobalStyle; 97 | -------------------------------------------------------------------------------- /ui/components/OAuthLoginButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Meteor } from 'meteor/meteor'; 4 | import { Bert } from 'meteor/themeteorchef:bert'; 5 | import Icon from '../Icon'; 6 | import Styles from './styles'; 7 | 8 | const handleLogin = (service, callback) => { 9 | const options = { 10 | facebook: { 11 | requestPermissions: ['email'], 12 | loginStyle: 'popup', 13 | }, 14 | github: { 15 | requestPermissions: ['user:email'], 16 | loginStyle: 'popup', 17 | }, 18 | google: { 19 | requestPermissions: ['email', 'profile'], 20 | requestOfflineToken: true, 21 | loginStyle: 'popup', 22 | }, 23 | }[service]; 24 | 25 | return { 26 | facebook: Meteor.loginWithFacebook, 27 | github: Meteor.loginWithGithub, 28 | google: Meteor.loginWithGoogle, 29 | }[service](options, callback); 30 | }; 31 | 32 | const serviceLabel = { 33 | facebook: ( 34 | 35 | 36 | {' Log In with Facebook'} 37 | 38 | ), 39 | github: ( 40 | 41 | 42 | {' Log In with GitHub'} 43 | 44 | ), 45 | google: ( 46 | 47 | 48 | {' Log In with Google'} 49 | 50 | ), 51 | }; 52 | 53 | const OAuthLoginButton = ({ service, callback }) => ( 54 | handleLogin(service, callback)} 58 | > 59 | {serviceLabel[service]} 60 | 61 | ); 62 | 63 | OAuthLoginButton.defaultProps = { 64 | callback: (error) => { 65 | if (error) Bert.alert(error.message, 'danger'); 66 | }, 67 | }; 68 | 69 | OAuthLoginButton.propTypes = { 70 | service: PropTypes.string.isRequired, 71 | callback: PropTypes.func, 72 | }; 73 | 74 | export default OAuthLoginButton; 75 | -------------------------------------------------------------------------------- /ui/pages/Login/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const StyledLogin = styled.div` 4 | border: 1px solid var(--gray-lighter); 5 | padding: 25px; 6 | border-radius: 3px; 7 | max-width: 768px; 8 | margin: 0 auto; 9 | 10 | .page-header { 11 | margin-top: 0; 12 | } 13 | 14 | > .row { 15 | margin: 0 !important; 16 | } 17 | 18 | button[type='submit'] { 19 | height: 41px; 20 | margin-top: 20px; 21 | } 22 | 23 | @media screen and (min-width: 768px) { 24 | padding: 0; 25 | margin-top: 0px; 26 | display: flex; 27 | flex-direction: row; 28 | 29 | > .row { 30 | width: 55%; 31 | padding: 40px 25px; 32 | } 33 | } 34 | 35 | @media screen and (min-width: 992px) { 36 | max-width: 900px; 37 | 38 | > .row { 39 | width: 50%; 40 | } 41 | } 42 | `; 43 | 44 | export const LoginPromo = styled.div` 45 | background: var(--cb-blue) 46 | url('http://cleverbeagle-assets.s3.amazonaws.com/graphics/pup-login-promo-background.png'); 47 | display: none; 48 | 49 | @media screen and (min-width: 768px) { 50 | display: flex; 51 | margin: 0; 52 | width: 45%; 53 | padding: 25px; 54 | border-radius: 3px 0 0 3px; 55 | margin: -1px 0 -1px -1px; 56 | text-align: center; 57 | justify-content: center; 58 | align-items: center; 59 | 60 | img { 61 | width: 100px; 62 | height: auto; 63 | margin: 0 0 25px; 64 | border-radius: 3px; 65 | } 66 | 67 | h4 { 68 | margin: 0; 69 | color: #fff; 70 | font-size: 24px; 71 | line-height: 32px; 72 | } 73 | 74 | p { 75 | color: #fff; 76 | font-size: 18px; 77 | line-height: 26px; 78 | margin-top: 10px; 79 | opacity: 0.6; 80 | } 81 | } 82 | 83 | @media screen and (min-width: 992px) { 84 | padding: 0; 85 | width: 50%; 86 | min-height: 400px; 87 | 88 | p { 89 | display: inline-block; 90 | font-size: 20px; 91 | line-height: 28px; 92 | max-width: 70%; 93 | margin: 10 auto 0; 94 | } 95 | } 96 | `; 97 | -------------------------------------------------------------------------------- /ui/components/CommentComposer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { graphql } from 'react-apollo'; 4 | import { Button } from 'react-bootstrap'; 5 | import { Meteor } from 'meteor/meteor'; 6 | import { Bert } from 'meteor/themeteorchef:bert'; 7 | import Validation from '../Validation'; 8 | import { document as documentQuery } from '../../queries/Documents.gql'; 9 | import addCommentMutation from '../../mutations/Comments.gql'; 10 | import StyledCommentComposer from './styles'; 11 | 12 | const CommentComposer = ({ mutate, documentId }) => ( 13 | 14 |
Add a Comment
15 | { 27 | if (Meteor.userId()) { 28 | mutate({ 29 | variables: { 30 | documentId, 31 | comment: form.comment.value.trim(), 32 | }, 33 | refetchQueries: [ 34 | { query: documentQuery, variables: { _id: documentId, sortBy: 'newestFirst' } }, 35 | ], 36 | }); 37 | 38 | document.querySelector('[name="comment"]').value = ''; 39 | } else { 40 | Bert.alert('Sorry, you need to be logged in to comment!', 'danger'); 41 | } 42 | }} 43 | > 44 |
event.preventDefault()}> 45 |