├── .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 |
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 |
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 |
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 \nMarkdown 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 |
7 |
8 |
9 | Sign Up
10 |
11 |
12 |
13 |
14 | Log In
15 |
16 |
17 |
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 && }
10 | {icon && }
11 | {title}
12 | {subtitle}
13 | {action && (
14 |
15 | {action.label}
16 |
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 |
11 | Pup
12 | The Ultimate Boilerplate for Products.
13 |
14 | Read the Docs
15 |
16 |
17 | {' Star on GitHub'}
18 |
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 | JavaScript is required to use this app
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 |
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 | handleResendVerificationEmail(emailAddress, sendVerificationEmail)}
26 | href="#"
27 | >
28 | Re-send verification email
29 |
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 |
16 | Newest First
17 | Oldest First
18 |
19 |
20 | {comments.map(({ _id, user, createdAt, comment }) => {
21 | const name = user && user.name;
22 | return (
23 |
24 |
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 |
11 |
12 |
13 | Documents
14 |
15 |
16 | {Roles.userIsInRole(userId, 'admin') && (
17 |
18 |
19 |
20 | Users
21 |
22 |
23 |
24 |
25 | User Settings
26 |
27 |
28 |
29 | )}
30 |
31 |
32 |
33 |
34 |
35 | Profile
36 |
37 |
38 |
39 | history.push('/logout')}>
40 | Logout
41 |
42 |
43 |
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 |
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 |
54 |
55 |
56 | );
57 |
58 | CommentComposer.propTypes = {
59 | documentId: PropTypes.string.isRequired,
60 | mutate: PropTypes.func.isRequired,
61 | };
62 |
63 | export default graphql(addCommentMutation)(CommentComposer);
64 |
--------------------------------------------------------------------------------
/startup/server/ssr.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ApolloProvider, renderToStringWithData } from 'react-apollo';
3 | import { ApolloClient } from 'apollo-client';
4 | import { createHttpLink } from 'apollo-link-http';
5 | import { InMemoryCache } from 'apollo-cache-inmemory';
6 | import 'isomorphic-fetch';
7 | import { onPageLoad } from 'meteor/server-render';
8 | import { StaticRouter } from 'react-router';
9 | import { Helmet } from 'react-helmet';
10 | import { ServerStyleSheet } from 'styled-components';
11 | import { Meteor } from 'meteor/meteor';
12 | import App from '../../ui/layouts/App';
13 | import checkIfBlacklisted from '../../modules/server/checkIfBlacklisted';
14 |
15 | onPageLoad(async (sink) => {
16 | if (checkIfBlacklisted(sink.request.url.path)) {
17 | sink.appendToBody(`
18 |
21 | `);
22 |
23 | return;
24 | }
25 |
26 | const apolloClient = new ApolloClient({
27 | ssrMode: true,
28 | link: createHttpLink({
29 | uri: Meteor.settings.public.graphQL.httpUri,
30 | }),
31 | cache: new InMemoryCache(),
32 | });
33 |
34 | const stylesheet = new ServerStyleSheet();
35 | const app = stylesheet.collectStyles(
36 |
37 |
38 |
39 |
40 | ,
41 | );
42 |
43 | // NOTE: renderToStringWithData pre-fetches all queries in the component tree. This allows the data
44 | // from our GraphQL queries to be ready at render time.
45 | const content = await renderToStringWithData(app);
46 | const initialState = apolloClient.extract();
47 | const helmet = Helmet.renderStatic();
48 |
49 | sink.appendToHead(helmet.meta.toString());
50 | sink.appendToHead(helmet.title.toString());
51 | sink.appendToHead(stylesheet.getStyleTags());
52 | sink.renderIntoElementById('react-root', content);
53 | sink.appendToBody(`
54 |
57 | `);
58 | });
59 |
--------------------------------------------------------------------------------
/api/Users/actions/removeUser.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 |
3 | import { Meteor } from 'meteor/meteor';
4 | import Documents from '../../Documents/Documents';
5 | import checkIfAuthorized, { isAdmin } from './checkIfAuthorized';
6 |
7 | let action;
8 |
9 | const deleteUser = ({ _id }) => {
10 | try {
11 | return Meteor.users.remove(_id);
12 | } catch (exception) {
13 | throw new Error(`[removeUser.deleteUser] ${exception.message}`);
14 | }
15 | };
16 |
17 | const deleteDocuments = ({ _id }) => {
18 | try {
19 | return Documents.remove({ owner: _id });
20 | } catch (exception) {
21 | throw new Error(`[removeUser.deleteDocuments] ${exception.message}`);
22 | }
23 | };
24 |
25 | const validateOptions = (options) => {
26 | try {
27 | if (!options) throw new Error('options object is required.');
28 | if (!options.currentUser) throw new Error('options.currentUser is required.');
29 | if (!options.user) throw new Error('options.user is required.');
30 | } catch (exception) {
31 | throw new Error(`[removeUser.validateOptions] ${exception.message}`);
32 | }
33 | };
34 |
35 | const removeUser = (options) => {
36 | try {
37 | validateOptions(options);
38 | checkIfAuthorized({
39 | as: ['admin', () => !options.user._id],
40 | userId: options.currentUser._id,
41 | errorMessage: 'Sorry, you need to be an admin or the passed user to do this.',
42 | });
43 |
44 | const userToRemove = options.user;
45 |
46 | if (!userToRemove._id) userToRemove._id = options.currentUser._id;
47 |
48 | if (userToRemove && !userToRemove._id && !isAdmin(options.currentUser._id)) {
49 | // NOTE: If passed user doesn't have an _id, we know we're updating the
50 | // currently logged in user (i.e., via the /profile page).
51 | userToRemove._id = options.currentUser._id;
52 | }
53 |
54 | deleteDocuments(userToRemove);
55 | deleteUser(userToRemove);
56 |
57 | action.resolve();
58 | } catch (exception) {
59 | action.reject(`[removeUser] ${exception.message}`);
60 | }
61 | };
62 |
63 | export default (options) =>
64 | new Promise((resolve, reject) => {
65 | action = { resolve, reject };
66 | removeUser(options);
67 | });
68 |
--------------------------------------------------------------------------------
/api/UserSettings/actions/updateSettingOnUsers.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 |
3 | import { Meteor } from 'meteor/meteor';
4 |
5 | const getUpdatedUserSettings = (existingUserSettings, newSetting) => {
6 | try {
7 | const userSettings = [...existingUserSettings];
8 | const userSettingToUpdate = userSettings.find(
9 | (settingOnUser) => settingOnUser._id === newSetting._id,
10 | ); // eslint-disable-line
11 |
12 | if (userSettingToUpdate) {
13 | userSettingToUpdate.isGDPR = newSetting.isGDPR;
14 | userSettingToUpdate.type = newSetting.type;
15 | userSettingToUpdate.value = newSetting.value;
16 | userSettingToUpdate.key = newSetting.key;
17 | userSettingToUpdate.label = newSetting.label;
18 | }
19 |
20 | return userSettings;
21 | } catch (exception) {
22 | throw new Error(`[updateSettingOnUsers.getUpdatedUserSettings] ${exception.message}`);
23 | }
24 | };
25 |
26 | const updateUsersSettings = (users, updatedSetting) => {
27 | try {
28 | users.forEach(({ _id, settings }) => {
29 | const userSettings = getUpdatedUserSettings(settings, updatedSetting);
30 | Meteor.users.update({ _id }, { $set: { settings: userSettings } });
31 | });
32 | } catch (exception) {
33 | throw new Error(`[updateSettingOnUsers.updateUsersSettings] ${exception.message}`);
34 | }
35 | };
36 |
37 | const getUsers = () => {
38 | try {
39 | return Meteor.users.find({}, { fields: { _id: 1, settings: 1 } }).fetch();
40 | } catch (exception) {
41 | throw new Error(`[updateSettingOnUsers.getUsers] ${exception.message}`);
42 | }
43 | };
44 |
45 | const validateOptions = (options) => {
46 | try {
47 | if (!options) throw new Error('options object is required.');
48 | if (!options.setting) throw new Error('options.setting is required.');
49 | } catch (exception) {
50 | throw new Error(`[updateSettingOnUsers.validateOptions] ${exception.message}`);
51 | }
52 | };
53 |
54 | export default (options) => {
55 | try {
56 | validateOptions(options);
57 | const users = getUsers();
58 | updateUsersSettings(users, options.setting);
59 | } catch (exception) {
60 | throw new Error(`[updateSettingOnUsers] ${exception.message}`);
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/ui/components/DocumentEditor/styles.js:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const DocumentEditorHeader = styled.div`
4 | p {
5 | float: right;
6 | margin-top: 6px;
7 | color: var(--gray-light);
8 | }
9 |
10 | .dropdown {
11 | float: left;
12 | }
13 |
14 | .dropdown > button i,
15 | .dropdown-menu > li > a > i {
16 | display: inline-block;
17 | margin-right: 5px;
18 | color: var(--gray-light);
19 | }
20 |
21 | .dropdown-menu > li.active > a > i {
22 | color: #fff;
23 | }
24 | `;
25 |
26 | export const StyledDocumentEditor = styled.div`
27 | border: 1px solid var(--gray-lighter);
28 | height: calc(100vh - 207px);
29 | border-radius: 3px;
30 | margin-top: 20px;
31 |
32 | .control-label {
33 | display: none;
34 | }
35 |
36 | .form-control {
37 | border: none;
38 | box-shadow: none;
39 | padding: 25px;
40 | }
41 |
42 | form {
43 | height: 100%;
44 | }
45 |
46 | @media screen and (min-width: 768px) {
47 | height: calc(100vh - 258px);
48 | margin-top: 20px;
49 | }
50 | `;
51 |
52 | export const DocumentEditorTitle = styled.div`
53 | border-bottom: 1px solid var(--gray-lighter);
54 |
55 | .form-control {
56 | height: 60px;
57 | font-size: 16px;
58 | line-height: 22px;
59 | }
60 | `;
61 |
62 | export const DocumentEditorBody = styled.div`
63 | height: calc(100% - 60px);
64 | overflow: hidden;
65 |
66 | .form-control {
67 | height: calc(100% - 1px);
68 | font-size: 16px;
69 | line-height: 26px;
70 | resize: none;
71 | background: transparent; /* Ensures this input doesn't overflow when resizing browser vertically. */
72 | }
73 | `;
74 |
75 | export const DocumentEditorFooter = styled.div`
76 | padding: 15px 25px;
77 | border: 1px solid var(--gray-lighter);
78 | border-top: none;
79 | border-radius: 0 0 3px 3px;
80 |
81 | svg {
82 | float: left;
83 | width: 25px;
84 | height: auto;
85 | margin-right: 10px;
86 | position: relative;
87 | top: 2px;
88 | }
89 |
90 | p {
91 | float: left;
92 | margin: 0;
93 | }
94 |
95 | p a {
96 | text-decoration: underline;
97 | color: var(--gray-light);
98 | }
99 | `;
100 |
--------------------------------------------------------------------------------
/ui/pages/Documents/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Button } from 'react-bootstrap';
4 | import { Link } from 'react-router-dom';
5 | import { compose, graphql } from 'react-apollo';
6 | import { timeago } from '../../../modules/dates';
7 | import BlankState from '../../components/BlankState';
8 | import { StyledDocuments, DocumentsList, Document } from './styles';
9 | import { documents } from '../../queries/Documents.gql';
10 | import { addDocument } from '../../mutations/Documents.gql';
11 |
12 | const Documents = ({ data, mutate }) => (
13 |
14 |
15 |
16 | New Document
17 |
18 |
19 | {data.documents && data.documents.length ? (
20 |
21 | {data.documents.map(({ _id, isPublic, title, updatedAt }) => (
22 |
23 |
24 |
25 | {isPublic ? (
26 | Public
27 | ) : (
28 | Private
29 | )}
30 | {title}
31 | {timeago(updatedAt)}
32 |
33 |
34 | ))}
35 |
36 | ) : (
37 |
47 | )}
48 |
49 | );
50 |
51 | Documents.propTypes = {
52 | data: PropTypes.object.isRequired,
53 | mutate: PropTypes.func.isRequired,
54 | };
55 |
56 | export default compose(
57 | graphql(documents),
58 | graphql(addDocument, {
59 | options: ({ history }) => ({
60 | refetchQueries: [{ query: documents }],
61 | onCompleted: (mutation) => {
62 | history.push(`/documents/${mutation.addDocument._id}/edit`);
63 | },
64 | }),
65 | }),
66 | )(Documents);
67 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | defaults: &defaults
2 | docker:
3 | - image: circleci/node:8.11.3-browsers
4 |
5 | aliases:
6 | - &restore-cache
7 | keys:
8 | - v1-dependencies-{{ checksum "package.json" }}
9 | - v1-dependencies-
10 |
11 | - &save-cache
12 | paths:
13 | - node_modules
14 | - ~/.meteor
15 | - ~/.npm
16 | key: v1-dependencies-{{ checksum "package.json" }}
17 |
18 | - &install-meteor |
19 | curl https://install.meteor.com | /bin/sh
20 |
21 | - &run-e2e-tests |
22 | sleep 100 # Wait for app to run before starting e2e tests.
23 | meteor npm run test-e2e
24 |
25 | version: 2
26 | jobs:
27 | build:
28 | <<: *defaults
29 | steps:
30 | - checkout
31 | - restore_cache: *restore-cache
32 | - run: npm install
33 | - save_cache: *save-cache
34 | - run: *install-meteor
35 | - run: npm test
36 | - run:
37 | command: meteor npm run dev
38 | background: true
39 | - run: *run-e2e-tests
40 | stage:
41 | <<: *defaults
42 | steps:
43 | - checkout
44 | - restore_cache: *restore-cache
45 | - run: npm install
46 | - save_cache: *save-cache
47 | - run: *install-meteor
48 | - run: npm test
49 | - run:
50 | command: meteor npm run dev
51 | background: true
52 | - run: *run-e2e-tests
53 | # - run: npm run staging
54 | deploy:
55 | <<: *defaults
56 | steps:
57 | - checkout
58 | - restore_cache: *restore-cache
59 | - run: npm install
60 | - save_cache: *save-cache
61 | - run: *install-meteor
62 | - run: npm test
63 | - run:
64 | command: meteor npm run dev
65 | background: true
66 | - run: *run-e2e-tests
67 | # - run: npm run production
68 |
69 | workflows:
70 | version: 2
71 | build:
72 | jobs:
73 | - build:
74 | filters:
75 | branches:
76 | only:
77 | - /feature\/.*/
78 | - /refactor\/.*/
79 | - /bug\/.*/
80 | - /chore\/.*/
81 | - stage:
82 | filters:
83 | branches:
84 | only:
85 | - development
86 | - deploy:
87 | filters:
88 | branches:
89 | only:
90 | - master
91 |
--------------------------------------------------------------------------------
/startup/server/accounts/emailTemplates.js:
--------------------------------------------------------------------------------
1 | import { Meteor } from 'meteor/meteor';
2 | import { Accounts } from 'meteor/accounts-base';
3 | import getPrivateFile from '../../../modules/server/getPrivateFile';
4 | import templateToHtml from '../../../modules/server/handlebarsEmailToHtml';
5 | import templateToText from '../../../modules/server/handlebarsEmailToText';
6 |
7 | const { emailTemplates } = Accounts;
8 | const { productName } = Meteor.settings.public;
9 |
10 | emailTemplates.siteName = productName;
11 | emailTemplates.from = Meteor.settings.private.supportEmail;
12 |
13 | emailTemplates.verifyEmail = {
14 | subject() {
15 | return `[${productName}] Verify Your Email Address`;
16 | },
17 | html(user, url) {
18 | return templateToHtml(getPrivateFile('email-templates/verify-email.html'), {
19 | title: "Let's Verify Your Email",
20 | subtitle: `Verify your email to start using ${productName}.`,
21 | productName,
22 | firstName: user.profile.name.first,
23 | verifyUrl: url.replace('#/', ''),
24 | });
25 | },
26 | text(user, url) {
27 | const urlWithoutHash = url.replace('#/', '');
28 | if (Meteor.isDevelopment) console.info(`[Pup] Verify Email Link: ${urlWithoutHash}`); // eslint-disable-line
29 | return templateToText(getPrivateFile('email-templates/verify-email.txt'), {
30 | productName,
31 | firstName: user.profile.name.first,
32 | verifyUrl: urlWithoutHash,
33 | });
34 | },
35 | };
36 |
37 | emailTemplates.resetPassword = {
38 | subject() {
39 | return `[${productName}] Reset Your Password`;
40 | },
41 | html(user, url) {
42 | return templateToHtml(getPrivateFile('email-templates/reset-password.html'), {
43 | title: "Let's Reset Your Password",
44 | subtitle: 'A password reset was requested for this email address.',
45 | firstName: user.profile.name.first,
46 | productName,
47 | emailAddress: user.emails[0].address,
48 | resetUrl: url.replace('#/', ''),
49 | });
50 | },
51 | text(user, url) {
52 | const urlWithoutHash = url.replace('#/', '');
53 | if (Meteor.isDevelopment) console.info(`Reset Password Link: ${urlWithoutHash}`); // eslint-disable-line
54 | return templateToText(getPrivateFile('email-templates/reset-password.txt'), {
55 | firstName: user.profile.name.first,
56 | productName,
57 | emailAddress: user.emails[0].address,
58 | resetUrl: urlWithoutHash,
59 | });
60 | },
61 | };
62 |
--------------------------------------------------------------------------------
/startup/server/fixtures.js:
--------------------------------------------------------------------------------
1 | import seeder from '@cleverbeagle/seeder';
2 | import { Meteor } from 'meteor/meteor';
3 | import Documents from '../../api/Documents/Documents';
4 | import Comments from '../../api/Comments/Comments';
5 |
6 | const commentsSeed = (userId, date, documentId) => {
7 | seeder(Comments, {
8 | seedIfExistingData: true,
9 | environments: ['development', 'staging', 'production'],
10 | data: {
11 | dynamic: {
12 | count: 3,
13 | seed(commentIteration, faker) {
14 | return {
15 | userId,
16 | documentId,
17 | comment: faker.hacker.phrase(),
18 | createdAt: date,
19 | };
20 | },
21 | },
22 | },
23 | });
24 | };
25 |
26 | const documentsSeed = (userId) => {
27 | seeder(Documents, {
28 | seedIfExistingData: true,
29 | environments: ['development', 'staging', 'production'],
30 | data: {
31 | dynamic: {
32 | count: 5,
33 | seed(iteration) {
34 | const date = new Date().toISOString();
35 | return {
36 | isPublic: false,
37 | createdAt: date,
38 | updatedAt: date,
39 | owner: userId,
40 | title: `Document #${iteration + 1}`,
41 | body: `This is the body of document #${iteration + 1}`,
42 | dependentData(documentId) {
43 | commentsSeed(userId, date, documentId);
44 | },
45 | };
46 | },
47 | },
48 | },
49 | });
50 | };
51 |
52 | seeder(Meteor.users, {
53 | seedIfExistingData: true,
54 | environments: ['development', 'staging', 'production'],
55 | data: {
56 | static: [
57 | {
58 | email: 'admin@admin.com',
59 | password: 'password',
60 | profile: {
61 | name: {
62 | first: 'Andy',
63 | last: 'Warhol',
64 | },
65 | },
66 | roles: ['admin'],
67 | dependentData(userId) {
68 | documentsSeed(userId);
69 | },
70 | },
71 | ],
72 | dynamic: {
73 | count: 5,
74 | seed(iteration, faker) {
75 | const userCount = iteration + 1;
76 | return {
77 | email: `user+${userCount}@test.com`,
78 | password: 'password',
79 | profile: {
80 | name: {
81 | first: faker.name.firstName(),
82 | last: faker.name.lastName(),
83 | },
84 | },
85 | roles: ['user'],
86 | dependentData(userId) {
87 | documentsSeed(userId);
88 | },
89 | };
90 | },
91 | },
92 | },
93 | });
94 |
--------------------------------------------------------------------------------
/.meteor/versions:
--------------------------------------------------------------------------------
1 | accounts-base@2.2.2
2 | accounts-facebook@1.3.3
3 | accounts-github@1.5.0
4 | accounts-google@1.4.0
5 | accounts-oauth@1.4.1
6 | accounts-password@2.3.1
7 | alanning:roles@1.3.0
8 | allow-deny@1.1.1
9 | apollo@4.1.0
10 | audit-argument-checks@1.0.7
11 | autoupdate@1.8.0
12 | babel-compiler@7.9.0
13 | babel-runtime@1.5.0
14 | base64@1.0.12
15 | binary-heap@1.0.11
16 | blaze@2.5.0
17 | blaze-tools@1.1.2
18 | boilerplate-generator@1.7.1
19 | browser-policy@1.1.0
20 | browser-policy-common@1.0.11
21 | browser-policy-content@1.1.1
22 | browser-policy-framing@1.1.0
23 | caching-compiler@1.2.2
24 | caching-html-compiler@1.2.1
25 | callback-hook@1.4.0
26 | check@1.3.1
27 | ddp@1.4.0
28 | ddp-client@2.5.0
29 | ddp-common@1.4.0
30 | ddp-rate-limiter@1.1.0
31 | ddp-server@2.5.0
32 | diff-sequence@1.1.1
33 | dynamic-import@0.7.2
34 | ecmascript@0.16.2
35 | ecmascript-runtime@0.8.0
36 | ecmascript-runtime-client@0.12.1
37 | ecmascript-runtime-server@0.11.0
38 | ejson@1.1.2
39 | email@2.2.1
40 | es5-shim@4.8.0
41 | facebook-oauth@1.11.0
42 | fetch@0.1.1
43 | fortawesome:fontawesome@4.7.0
44 | fourseven:scss@4.12.0
45 | geojson-utils@1.0.10
46 | github-oauth@1.4.0
47 | google-oauth@1.4.2
48 | hot-code-push@1.0.4
49 | html-tools@1.1.2
50 | htmljs@1.1.0
51 | http@2.0.0
52 | id-map@1.1.1
53 | inter-process-messaging@0.1.1
54 | jquery@1.11.11
55 | launch-screen@1.3.0
56 | localstorage@1.2.0
57 | logging@1.3.1
58 | meteor@1.10.0
59 | meteor-base@1.5.1
60 | meteorhacks:picker@1.0.3
61 | minifier-css@1.6.0
62 | minifier-js@2.7.4
63 | minimongo@1.8.0
64 | mobile-experience@1.1.0
65 | mobile-status-bar@1.1.0
66 | modern-browsers@0.1.7
67 | modules@0.18.0
68 | modules-runtime@0.13.0
69 | mongo@1.14.6
70 | mongo-decimal@0.1.2
71 | mongo-dev-server@1.1.0
72 | mongo-id@1.0.8
73 | npm-mongo@4.3.1
74 | oauth@2.1.2
75 | oauth2@1.3.1
76 | observe-sequence@1.0.16
77 | ordered-dict@1.1.0
78 | promise@0.12.0
79 | random@1.2.0
80 | rate-limit@1.0.9
81 | react-fast-refresh@0.2.3
82 | react-meteor-data@2.3.3
83 | reactive-dict@1.3.0
84 | reactive-var@1.0.11
85 | reload@1.3.1
86 | retry@1.1.0
87 | routepolicy@1.1.1
88 | server-render@0.4.0
89 | service-configuration@1.3.0
90 | session@1.2.0
91 | sha@1.0.9
92 | shell-server@0.5.0
93 | socket-stream-client@0.4.0
94 | spacebars@1.2.0
95 | spacebars-compiler@1.3.0
96 | standard-minifier-css@1.8.1
97 | standard-minifier-js@2.8.0
98 | static-html@1.3.2
99 | swydo:graphql@0.4.0
100 | templating@1.4.1
101 | templating-compiler@1.4.1
102 | templating-runtime@1.5.0
103 | templating-tools@1.2.1
104 | themeteorchef:bert@2.2.2
105 | tracker@1.2.0
106 | typescript@4.5.4
107 | underscore@1.0.10
108 | url@1.3.2
109 | webapp@1.13.1
110 | webapp-hashing@1.1.0
111 |
--------------------------------------------------------------------------------
/api/Users/actions/checkIfAuthorized.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 |
3 | import { Roles } from 'meteor/alanning:roles';
4 |
5 | export const isUser = (userId) => {
6 | try {
7 | return Roles.userIsInRole(userId, 'user');
8 | } catch (exception) {
9 | throw new Error(`[checkIfAuthorized.isUser] ${exception.message}`);
10 | }
11 | };
12 |
13 | export const isAdmin = (userId) => {
14 | try {
15 | return Roles.userIsInRole(userId, 'admin');
16 | } catch (exception) {
17 | throw new Error(`[checkIfAuthorized.isAdmin] ${exception.message}`);
18 | }
19 | };
20 |
21 | const getAuthorizationMethods = (methods) => {
22 | try {
23 | const authorizationMethods = [];
24 |
25 | const authorizationMethodsMap = {
26 | admin: isAdmin,
27 | user: isUser,
28 | };
29 |
30 | methods.forEach((method) => {
31 | if (typeof method === 'string') {
32 | const authorizationMethod = authorizationMethodsMap[method];
33 | if (authorizationMethod) {
34 | authorizationMethods.push(authorizationMethod);
35 | } else {
36 | throw new Error(`${method} is not defined as an authorization method.`);
37 | }
38 | }
39 |
40 | if (typeof method === 'function') authorizationMethods.push(method);
41 | });
42 |
43 | return authorizationMethods;
44 | } catch (exception) {
45 | throw new Error(`[checkIfAuthorized.getAuthorizationMethods] ${exception.message}`);
46 | }
47 | };
48 |
49 | const validateOptions = (options) => {
50 | try {
51 | if (!options) throw new Error('options object is required.');
52 | if (!options.as) throw new Error('options.as is required.');
53 | if (!(options.as instanceof Array)) {
54 | throw new Error('options.as must be passed as an array of strings or functions.');
55 | }
56 | if (!options.userId) throw new Error('options.userId is required.');
57 | } catch (exception) {
58 | throw new Error(`[checkIfAuthorized.validateOptions] ${exception.message}`);
59 | }
60 | };
61 |
62 | export default (options) => {
63 | try {
64 | validateOptions(options);
65 | const authorizationMethods = getAuthorizationMethods(options.as);
66 | const authorizations = [];
67 |
68 | authorizationMethods.forEach((authorized) => {
69 | if (authorized(options.userId)) {
70 | authorizations.push(true);
71 | } else {
72 | authorizations.push(false);
73 | }
74 | });
75 |
76 | if (!authorizations.includes(true)) {
77 | throw new Error(options.errorMessage || "Sorry, you're not authorized to do this.");
78 | }
79 |
80 | return true;
81 | } catch (exception) {
82 | throw new Error(`[checkIfAuthorized] ${exception.message}`);
83 | }
84 | };
85 |
--------------------------------------------------------------------------------
/ui/components/Authorized/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Route } from 'react-router-dom';
4 | import { withRouter } from 'react-router';
5 | import { Roles } from 'meteor/alanning:roles';
6 | import { Meteor } from 'meteor/meteor';
7 | import { withTracker } from 'meteor/react-meteor-data';
8 |
9 | class Authorized extends React.Component {
10 | state = { authorized: false };
11 |
12 | componentDidMount() {
13 | this.checkIfAuthorized();
14 | }
15 |
16 | componentDidUpdate() {
17 | this.checkIfAuthorized();
18 | }
19 |
20 | checkIfAuthorized = () => {
21 | const { history, loading, userId, userRoles, userIsInRoles, pathAfterFailure } = this.props;
22 |
23 | if (!userId) history.push(pathAfterFailure || '/');
24 |
25 | if (!loading && userRoles.length > 0) {
26 | if (!userIsInRoles) {
27 | history.push(pathAfterFailure || '/');
28 | } else {
29 | // Check to see if authorized is still false before setting. This prevents an infinite loop
30 | // when this is used within componentDidUpdate.
31 | if (!this.state.authorized) this.setState({ authorized: true }); // eslint-disable-line
32 | }
33 | }
34 | };
35 |
36 | render() {
37 | const { component, path, exact, ...rest } = this.props;
38 | const { authorized } = this.state;
39 |
40 | return authorized ? (
41 | React.createElement(component, { ...rest, ...props })}
45 | />
46 | ) : (
47 |
48 | );
49 | }
50 | }
51 |
52 | Authorized.defaultProps = {
53 | allowedGroup: null,
54 | userId: null,
55 | exact: false,
56 | userRoles: [],
57 | userIsInRoles: false,
58 | pathAfterFailure: '/login',
59 | };
60 |
61 | Authorized.propTypes = {
62 | loading: PropTypes.bool.isRequired,
63 | allowedRoles: PropTypes.array.isRequired,
64 | allowedGroup: PropTypes.string,
65 | userId: PropTypes.string,
66 | component: PropTypes.func.isRequired,
67 | path: PropTypes.string.isRequired,
68 | exact: PropTypes.bool,
69 | history: PropTypes.object.isRequired,
70 | userRoles: PropTypes.array,
71 | userIsInRoles: PropTypes.bool,
72 | pathAfterFailure: PropTypes.string,
73 | };
74 |
75 | export default withRouter(
76 | withTracker(({ allowedRoles, allowedGroup }) =>
77 | // eslint-disable-line
78 | Meteor.isClient
79 | ? {
80 | loading: Meteor.isClient ? !Roles.subscription.ready() : true,
81 | userId: Meteor.userId(),
82 | userRoles: Roles.getRolesForUser(Meteor.userId()),
83 | userIsInRoles: Roles.userIsInRole(Meteor.userId(), allowedRoles, allowedGroup),
84 | }
85 | : {},
86 | )(Authorized),
87 | );
88 |
--------------------------------------------------------------------------------
/startup/server/api.js:
--------------------------------------------------------------------------------
1 | import gql from 'graphql-tag';
2 | import { makeExecutableSchema } from 'graphql-tools';
3 |
4 | import UserTypes from '../../api/Users/types';
5 | import UserQueries from '../../api/Users/queries';
6 | import UserMutations from '../../api/Users/mutations';
7 |
8 | import UserSettingsTypes from '../../api/UserSettings/types';
9 | import UserSettingsQueries from '../../api/UserSettings/queries';
10 | import UserSettingsMutations from '../../api/UserSettings/mutations';
11 |
12 | import DocumentTypes from '../../api/Documents/types';
13 | import DocumentQueries from '../../api/Documents/queries';
14 | import DocumentMutations from '../../api/Documents/mutations';
15 |
16 | import CommentTypes from '../../api/Comments/types';
17 | import CommentQueries from '../../api/Comments/queries';
18 | import CommentMutations from '../../api/Comments/mutations';
19 |
20 | import OAuthQueries from '../../api/OAuth/queries';
21 |
22 | import '../../api/Documents/server/indexes';
23 | import '../../api/webhooks';
24 |
25 | const schema = {
26 | typeDefs: gql`
27 | ${UserTypes}
28 | ${DocumentTypes}
29 | ${CommentTypes}
30 | ${UserSettingsTypes}
31 |
32 | type Query {
33 | documents: [Document]
34 | document(_id: String): Document
35 | user(_id: String): User
36 | users(currentPage: Int, perPage: Int, search: String): Users
37 | userSettings: [UserSetting]
38 | exportUserData: UserDataExport
39 | oAuthServices(services: [String]): [String]
40 | }
41 |
42 | type Mutation {
43 | addDocument(title: String, body: String): Document
44 | updateDocument(_id: String!, title: String, body: String, isPublic: Boolean): Document
45 | removeDocument(_id: String!): Document
46 | addComment(documentId: String!, comment: String!): Comment
47 | removeComment(commentId: String!): Comment
48 | updateUser(user: UserInput): User
49 | removeUser(_id: String): User
50 | addUserSetting(setting: UserSettingInput): UserSetting
51 | updateUserSetting(setting: UserSettingInput): UserSetting
52 | removeUserSetting(_id: String!): UserSetting
53 | sendVerificationEmail: User
54 | sendWelcomeEmail: User
55 | }
56 |
57 | type Subscription {
58 | commentAdded(documentId: String!): Comment
59 | }
60 | `,
61 | resolvers: {
62 | Query: {
63 | ...DocumentQueries,
64 | ...UserQueries,
65 | ...UserSettingsQueries,
66 | ...OAuthQueries,
67 | },
68 | Mutation: {
69 | ...DocumentMutations,
70 | ...CommentMutations,
71 | ...UserMutations,
72 | ...UserSettingsMutations,
73 | },
74 | Document: {
75 | comments: CommentQueries.comments,
76 | },
77 | Comment: {
78 | user: UserQueries.user,
79 | },
80 | },
81 | };
82 |
83 | export default makeExecutableSchema(schema);
84 |
--------------------------------------------------------------------------------
/ui/pages/RecoverPassword/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Row, Col, Alert, FormGroup, ControlLabel, Button } from 'react-bootstrap';
3 | import PropTypes from 'prop-types';
4 | import { Link } from 'react-router-dom';
5 | import { Accounts } from 'meteor/accounts-base';
6 | import { Bert } from 'meteor/themeteorchef:bert';
7 | import Validation from '../../components/Validation';
8 | import AccountPageFooter from '../../components/AccountPageFooter';
9 | import StyledRecoverPassword from './styles';
10 |
11 | class RecoverPassword extends React.Component {
12 | handleSubmit = (form) => {
13 | const { history } = this.props;
14 | const email = form.emailAddress.value;
15 |
16 | Accounts.forgotPassword({ email }, (error) => {
17 | if (error) {
18 | Bert.alert(error.reason, 'danger');
19 | } else {
20 | Bert.alert(`Check ${email} for a reset link!`, 'success');
21 | history.push('/login');
22 | }
23 | });
24 | };
25 |
26 | render() {
27 | return (
28 |
29 |
30 |
31 | Recover Password
32 |
33 | Enter your email address below to receive a link to reset your password.
34 |
35 | {
49 | this.handleSubmit(form);
50 | }}
51 | >
52 | (this.form = form)} onSubmit={(event) => event.preventDefault()}>
53 |
54 | Email Address
55 |
61 |
62 |
63 | Recover Password
64 |
65 |
66 |
67 | {'Remember your password? '}
68 | Log In
69 | {'.'}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 | );
78 | }
79 | }
80 |
81 | RecoverPassword.propTypes = {
82 | history: PropTypes.object.isRequired,
83 | };
84 |
85 | export default RecoverPassword;
86 |
--------------------------------------------------------------------------------
/api/Users/actions/queryUsers.js:
--------------------------------------------------------------------------------
1 | import { Meteor } from 'meteor/meteor';
2 | import checkIfAuthorized from './checkIfAuthorized';
3 | import mapMeteorUserToSchema from './mapMeteorUserToSchema';
4 |
5 | /* eslint-disable consistent-return */
6 |
7 | let action;
8 |
9 | const getTotalUserCount = (currentUserId) => {
10 | try {
11 | return Meteor.users.find({ _id: { $ne: currentUserId } }).count();
12 | } catch (exception) {
13 | throw new Error(`[queryUsers.getTotalUserCount] ${exception.message}`);
14 | }
15 | };
16 |
17 | const getProjection = (options) => {
18 | try {
19 | return options.search
20 | ? { sort: options.sort }
21 | : { limit: options.limit, skip: options.skip, sort: options.sort };
22 | } catch (exception) {
23 | throw new Error(`[queryUsers.getProjection] ${exception.message}`);
24 | }
25 | };
26 |
27 | const getQuery = (options) => {
28 | try {
29 | return options.search
30 | ? {
31 | _id: { $ne: options.currentUser._id },
32 | $or: [
33 | { 'profile.name.first': options.search },
34 | { 'profile.name.last': options.search },
35 | { 'emails.address': options.search },
36 | { 'services.facebook.first_name': options.search },
37 | { 'services.facebook.last_name': options.search },
38 | { 'services.facebook.email': options.search },
39 | { 'services.google.name': options.search },
40 | { 'services.google.email': options.search },
41 | { 'services.github.email': options.search },
42 | { 'services.github.username': options.search },
43 | ],
44 | }
45 | : { _id: { $ne: options.currentUser._id } };
46 | } catch (exception) {
47 | throw new Error(`[queryUsers.getQuery] ${exception.message}`);
48 | }
49 | };
50 |
51 | const getUsers = (options) => {
52 | try {
53 | const query = getQuery(options);
54 | const projection = getProjection(options);
55 | return Meteor.users
56 | .find(query, projection)
57 | .fetch()
58 | .map((user) => mapMeteorUserToSchema({ user }));
59 | } catch (exception) {
60 | throw new Error(`[queryUsers.getUsers] ${exception.message}`);
61 | }
62 | };
63 |
64 | const validateOptions = (options) => {
65 | try {
66 | if (!options) throw new Error('options object is required.');
67 | if (!options.currentUser) throw new Error('options.currentUser is required.');
68 | } catch (exception) {
69 | throw new Error(`[queryUsers.validateOptions] ${exception.message}`);
70 | }
71 | };
72 |
73 | const queryUsers = (options) => {
74 | try {
75 | validateOptions(options);
76 | checkIfAuthorized({ as: ['admin'], userId: options.currentUser._id });
77 |
78 | action.resolve({
79 | total: getTotalUserCount(options.currentUser._id),
80 | users: getUsers(options),
81 | });
82 | } catch (exception) {
83 | action.reject(`[queryUsers] ${exception.message}`);
84 | }
85 | };
86 |
87 | export default (options) =>
88 | new Promise((resolve, reject) => {
89 | action = { resolve, reject };
90 | queryUsers(options);
91 | });
92 |
--------------------------------------------------------------------------------
/ui/pages/ViewDocument/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { graphql } from 'react-apollo';
4 | import { Meteor } from 'meteor/meteor';
5 | import SEO from '../../components/SEO';
6 | import BlankState from '../../components/BlankState';
7 | import Comments from '../../components/Comments';
8 | import { document as documentQuery } from '../../queries/Documents.gql';
9 | import parseMarkdown from '../../../modules/parseMarkdown';
10 |
11 | import { StyledViewDocument, DocumentBody } from './styles';
12 |
13 | class ViewDocument extends React.Component {
14 | state = {
15 | sortBy: 'newestFirst',
16 | };
17 |
18 | componentWillMount() {
19 | const { data } = this.props;
20 | if (Meteor.isClient && Meteor.userId()) data.refetch();
21 | }
22 |
23 | handleChangeCommentSort = (event) => {
24 | const { data } = this.props;
25 | event.persist();
26 |
27 | this.setState({ sortBy: event.target.value }, () => {
28 | data.refetch({ sortBy: event.target.value });
29 | });
30 | };
31 |
32 | render() {
33 | const { data } = this.props;
34 | const { sortBy } = this.state;
35 |
36 | if (!data.loading && data.document) {
37 | return (
38 |
39 |
40 |
49 |
50 | {data.document && data.document.title}
51 |
56 |
57 |
58 |
64 |
65 | );
66 | }
67 |
68 | if (!data.loading && !data.document) {
69 | return (
70 |
75 | );
76 | }
77 |
78 | return null;
79 | }
80 | }
81 |
82 | ViewDocument.propTypes = {
83 | data: PropTypes.object.isRequired,
84 | };
85 |
86 | export default graphql(documentQuery, {
87 | options: ({ match }) => ({
88 | variables: {
89 | _id: match.params._id,
90 | sortBy: 'newestFirst',
91 | },
92 | }),
93 | })(ViewDocument);
94 |
--------------------------------------------------------------------------------
/ui/components/AdminUsersList/index.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 |
3 | import React from 'react';
4 | import PropTypes from 'prop-types';
5 | import autoBind from 'react-autobind';
6 | import { Link } from 'react-router-dom';
7 | import { graphql } from 'react-apollo';
8 | import Loading from '../Loading';
9 | import { users as usersQuery } from '../../queries/Users.gql';
10 |
11 | import { StyledListGroup, StyledListGroupItem } from './styles';
12 |
13 | class AdminUsersList extends React.Component {
14 | constructor(props) {
15 | super(props);
16 | autoBind(this);
17 | }
18 |
19 | renderPagination = () => {
20 | const { data, perPage, currentPage, onChangePage } = this.props;
21 | const pages = [];
22 | const pagesToGenerate = Math.ceil(data.users.total / perPage);
23 |
24 | for (let pageNumber = 1; pageNumber <= pagesToGenerate; pageNumber += 1) {
25 | pages.push(
26 | onChangePage(pageNumber)}
31 | onKeyDown={() => onChangePage(pageNumber)}
32 | >
33 | event.preventDefault()}>
34 | {pageNumber}
35 |
36 | ,
37 | );
38 | }
39 |
40 | return ;
41 | };
42 |
43 | render() {
44 | const { data, search, perPage } = this.props;
45 |
46 | if (data.loading) return ;
47 | return (
48 |
49 |
50 | {data.users &&
51 | data.users.users &&
52 | data.users.users.map(({ _id, emailAddress, name, username, oAuthProvider }) => (
53 |
54 |
55 |
56 | {name ? `${name.first} ${name.last}` : username}
57 | {emailAddress}
58 | {oAuthProvider && (
59 | {oAuthProvider}
60 | )}
61 |
62 |
63 | ))}
64 |
65 | {data.users &&
66 | data.users.total &&
67 | search.trim() === '' &&
68 | data.users.total > perPage &&
69 | this.renderPagination()}
70 |
71 | );
72 | }
73 | }
74 |
75 | AdminUsersList.defaultProps = {
76 | search: '',
77 | };
78 |
79 | AdminUsersList.propTypes = {
80 | data: PropTypes.object.isRequired,
81 | search: PropTypes.string,
82 | perPage: PropTypes.number.isRequired,
83 | currentPage: PropTypes.number.isRequired,
84 | onChangePage: PropTypes.func.isRequired,
85 | };
86 |
87 | export default graphql(usersQuery, {
88 | options: ({ perPage, currentPage, search }) => ({
89 | variables: {
90 | perPage,
91 | currentPage,
92 | search,
93 | },
94 | }),
95 | })(AdminUsersList);
96 |
--------------------------------------------------------------------------------
/ui/components/SEO/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Helmet } from 'react-helmet';
4 | import { Meteor } from 'meteor/meteor';
5 | import { sample } from 'lodash';
6 |
7 | const seoImages = {
8 | facebook: ['open-graph-facebook.png'],
9 | twitter: ['open-graph-twitter.png'],
10 | google: ['open-graph-google.png'],
11 | };
12 |
13 | const seoImageURL = (file) =>
14 | `https://s3-us-west-2.amazonaws.com/cleverbeagle-assets/graphics/${file}`;
15 | const seoURL = (path) => Meteor.absoluteUrl(path);
16 |
17 | const SEO = ({
18 | schema,
19 | title,
20 | description,
21 | images,
22 | path,
23 | contentType,
24 | published,
25 | updated,
26 | category,
27 | tags,
28 | twitter,
29 | }) => (
30 |
31 |
32 |
33 | {title}
34 |
35 |
36 |
37 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
51 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
63 |
64 | {published && }
65 | {updated && }
66 | {category && }
67 | {tags && }
68 |
69 | );
70 |
71 | SEO.defaultProps = {
72 | schema: null,
73 | path: null,
74 | updated: null,
75 | category: null,
76 | tags: [],
77 | twitter: null,
78 | images: {},
79 | };
80 |
81 | SEO.propTypes = {
82 | schema: PropTypes.string,
83 | title: PropTypes.string.isRequired,
84 | description: PropTypes.string.isRequired,
85 | path: PropTypes.string,
86 | contentType: PropTypes.string.isRequired,
87 | published: PropTypes.string.isRequired,
88 | updated: PropTypes.string,
89 | category: PropTypes.string,
90 | tags: PropTypes.array,
91 | twitter: PropTypes.string,
92 | images: PropTypes.object,
93 | };
94 |
95 | export default SEO;
96 |
--------------------------------------------------------------------------------
/api/Users/actions/normalizeMeteorUserData.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 |
3 | const normalizeGoogleData = (service) => {
4 | try {
5 | return {
6 | service: 'google',
7 | emails: [{ address: service.email }],
8 | profile: {
9 | name: {
10 | first: service.given_name,
11 | last: service.family_name,
12 | },
13 | },
14 | };
15 | } catch (exception) {
16 | throw new Error(`[normalizeMeteorUserData.normalizeGoogleData] ${exception.message}`);
17 | }
18 | };
19 |
20 | const normalizeGithubData = (service) => {
21 | try {
22 | return {
23 | service: 'github',
24 | emails: [{ address: service.email }],
25 | username: service.username,
26 | };
27 | } catch (exception) {
28 | throw new Error(`[normalizeMeteorUserData.normalizeGithubData] ${exception.message}`);
29 | }
30 | };
31 |
32 | const normalizeFacebookData = (service) => {
33 | try {
34 | return {
35 | service: 'facebook',
36 | emails: [{ address: service.email }],
37 | profile: {
38 | name: {
39 | first: service.first_name,
40 | last: service.last_name,
41 | },
42 | },
43 | };
44 | } catch (exception) {
45 | throw new Error(`[normalizeMeteorUserData.normalizeFacebookData] ${exception.message}`);
46 | }
47 | };
48 |
49 | const normalizeOAuthUserData = (services) => {
50 | try {
51 | if (services.facebook) return normalizeFacebookData(services.facebook);
52 | if (services.github) return normalizeGithubData(services.github);
53 | if (services.google) return normalizeGoogleData(services.google);
54 | return {};
55 | } catch (exception) {
56 | throw new Error(`[normalizeMeteorUserData.normalizeOAuthUserData] ${exception.message}`);
57 | }
58 | };
59 |
60 | const getNormalizedMeteorUserData = (isOAuthUser, user) => {
61 | try {
62 | return isOAuthUser
63 | ? { _id: user._id, ...normalizeOAuthUserData(user.services), settings: user.settings }
64 | : { service: 'password', ...user };
65 | } catch (exception) {
66 | throw new Error(`[normalizeMeteorUserData.getNormalizedMeteorUserData] ${exception.message}`);
67 | }
68 | };
69 |
70 | const checkIfOAuthUser = (services) => {
71 | try {
72 | // NOTE: If services does not exist, we assume it's the current user being passed on the client.
73 | return !services ? false : !services.password;
74 | } catch (exception) {
75 | throw new Error(`[normalizeMeteorUserData.checkIfOAuthUser] ${exception.message}`);
76 | }
77 | };
78 |
79 | const validateOptions = (options) => {
80 | try {
81 | if (!options) throw new Error('options object is required.');
82 | if (!options.user) throw new Error('options.user is required.');
83 | } catch (exception) {
84 | throw new Error(`[normalizeMeteorUserData.validateOptions] ${exception.message}`);
85 | }
86 | };
87 |
88 | export default (options) => {
89 | try {
90 | validateOptions(options);
91 |
92 | const isOAuthUser = checkIfOAuthUser(options.user.services);
93 | const normalizedMeteorUserData = getNormalizedMeteorUserData(isOAuthUser, options.user);
94 |
95 | return normalizedMeteorUserData;
96 | } catch (exception) {
97 | throw new Error(`[normalizeMeteorUserData] ${exception.message}`);
98 | }
99 | };
100 |
--------------------------------------------------------------------------------
/api/Users/actions/updateUser.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable consistent-return */
2 |
3 | import { Meteor } from 'meteor/meteor';
4 | import { Accounts } from 'meteor/accounts-base';
5 | import { Roles } from 'meteor/alanning:roles';
6 | import checkIfAuthorized, { isAdmin } from './checkIfAuthorized';
7 |
8 | let action;
9 |
10 | const updateUserSettings = ({ _id, settings }) => {
11 | try {
12 | return Meteor.users.update(_id, {
13 | $set: { settings },
14 | });
15 | } catch (exception) {
16 | throw new Error(`[updateUser.updateUserSettings] ${exception.message}`);
17 | }
18 | };
19 |
20 | const updateUserProfile = ({ _id, profile }) => {
21 | try {
22 | return Meteor.users.update(_id, {
23 | $set: { profile },
24 | });
25 | } catch (exception) {
26 | throw new Error(`[updateUser.updateUserProfile] ${exception.message}`);
27 | }
28 | };
29 |
30 | const updateUserEmail = ({ _id, email }) => {
31 | try {
32 | return Meteor.users.update(_id, {
33 | $set: {
34 | 'emails.0.address': email,
35 | },
36 | });
37 | } catch (exception) {
38 | throw new Error(`[updateUser.updateUserEmail] ${exception.message}`);
39 | }
40 | };
41 |
42 | const updateUserRoles = ({ _id, roles }) => {
43 | try {
44 | return Roles.setUserRoles(_id, roles);
45 | } catch (exception) {
46 | throw new Error(`[updateUser.updateUserRoles] ${exception.message}`);
47 | }
48 | };
49 |
50 | const updateUserPassword = ({ _id, password }) => {
51 | try {
52 | return Accounts.setPassword(_id, password);
53 | } catch (exception) {
54 | throw new Error(`[updateUser.updateUserPassword] ${exception.message}`);
55 | }
56 | };
57 |
58 | const validateOptions = (options) => {
59 | try {
60 | if (!options) throw new Error('options object is required.');
61 | if (!options.currentUser) throw new Error('options.currentUser is required.');
62 | if (!options.user) throw new Error('options.user is required.');
63 | } catch (exception) {
64 | throw new Error(`[updateUser.validateOptions] ${exception.message}`);
65 | }
66 | };
67 |
68 | const updateUser = (options) => {
69 | try {
70 | validateOptions(options);
71 | checkIfAuthorized({
72 | as: ['admin', () => !options.user._id],
73 | userId: options.currentUser._id,
74 | errorMessage: 'Sorry, you need to be an admin or the passed user to do this.',
75 | });
76 |
77 | const userToUpdate = options.user;
78 |
79 | if (userToUpdate && !userToUpdate._id) {
80 | // NOTE: If passed user doesn't have an _id, we know we're updating the
81 | // currently logged in user (i.e., via the /profile page).
82 | userToUpdate._id = options.currentUser._id;
83 | }
84 |
85 | if (userToUpdate.password) updateUserPassword(userToUpdate);
86 | if (userToUpdate.roles && isAdmin(options.currentUser._id)) updateUserRoles(userToUpdate);
87 | if (userToUpdate.email) updateUserEmail(userToUpdate);
88 | if (userToUpdate.profile) updateUserProfile(userToUpdate);
89 | if (userToUpdate.settings) updateUserSettings(userToUpdate);
90 |
91 | action.resolve();
92 | } catch (exception) {
93 | action.reject(`[updateUser] ${exception.message}`);
94 | }
95 | };
96 |
97 | export default (options) =>
98 | new Promise((resolve, reject) => {
99 | action = { resolve, reject };
100 | updateUser(options);
101 | });
102 |
--------------------------------------------------------------------------------
/ui/components/GDPRConsentModal/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Button, Modal } from 'react-bootstrap';
4 | import { compose, graphql } from 'react-apollo';
5 | import UserSettings from '../UserSettings';
6 | import { userSettings as userSettingsQuery } from '../../queries/Users.gql';
7 | import { updateUser as updateUserMutation } from '../../mutations/Users.gql';
8 | import unfreezeApolloCacheValue from '../../../modules/unfreezeApolloCacheValue';
9 | import Styles from './styles';
10 |
11 | class GDPRConsentModal extends React.Component {
12 | state = { show: false };
13 |
14 | componentWillReceiveProps(nextProps) {
15 | if (nextProps.data && nextProps.data.user && nextProps.data.user.settings) {
16 | let gdprComplete = true;
17 | const gdprSettings = nextProps.data.user.settings.filter(
18 | (setting) => setting.isGDPR === true,
19 | );
20 | gdprSettings.forEach(({ lastUpdatedByUser }) => {
21 | if (!lastUpdatedByUser) gdprComplete = false;
22 | });
23 | this.setState({ show: !gdprComplete });
24 | }
25 | }
26 |
27 | handleSaveSettings = () => {
28 | const { data, updateUser } = this.props;
29 | if (data && data.user && data.user.settings) {
30 | updateUser({
31 | variables: {
32 | user: {
33 | settings: unfreezeApolloCacheValue(data && data.user && data.user.settings).map(
34 | (setting) => {
35 | const settingToUpdate = setting;
36 | settingToUpdate.lastUpdatedByUser = new Date().toISOString();
37 | return settingToUpdate;
38 | },
39 | ),
40 | },
41 | },
42 | refetchQueries: [{ query: userSettingsQuery }],
43 | });
44 | }
45 | };
46 |
47 | render() {
48 | const { data, updateUser } = this.props;
49 | const { show } = this.state;
50 |
51 | return (
52 |
53 |
this.setState({ show: false })}
57 | >
58 |
59 | GDPR Consent
60 |
61 |
62 |
63 | {"In cooperation with the European Union's (EU) "}
64 |
65 | {'General Data Protection Regulation'}
66 |
67 | {
68 | ' (GDPR), we need to obtain your consent for how we make use of your data. Please review each of the settings below to customize your experience.'
69 | }
70 |
71 |
72 |
73 |
74 | {
77 | this.handleSaveSettings();
78 | this.setState({ show: false });
79 | }}
80 | >
81 | Save Settings
82 |
83 |
84 |
85 |
86 | );
87 | }
88 | }
89 |
90 | GDPRConsentModal.propTypes = {
91 | data: PropTypes.object.isRequired,
92 | updateUser: PropTypes.func.isRequired,
93 | };
94 |
95 | export default compose(
96 | graphql(userSettingsQuery),
97 | graphql(updateUserMutation, {
98 | name: 'updateUser',
99 | }),
100 | )(GDPRConsentModal);
101 |
--------------------------------------------------------------------------------
/ui/components/UserSettings/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { ListGroup } from 'react-bootstrap';
4 | import ToggleSwitch from '../ToggleSwitch';
5 | import BlankState from '../BlankState';
6 | import unfreezeApolloCacheValue from '../../../modules/unfreezeApolloCacheValue';
7 | import delay from '../../../modules/delay';
8 | import Styles from './styles';
9 |
10 | class UserSettings extends React.Component {
11 | constructor(props) {
12 | super(props);
13 | const { settings } = props;
14 | this.state = { settings: unfreezeApolloCacheValue([...settings]) };
15 | }
16 |
17 | handleUpdateSetting = (setting) => {
18 | const { userId, updateUser } = this.props;
19 | const { settings } = this.state;
20 | const settingsUpdate = [...settings];
21 | const settingToUpdate = settingsUpdate.find(({ _id }) => _id === setting._id);
22 |
23 | settingToUpdate.value = setting.value;
24 |
25 | if (!userId) settingToUpdate.lastUpdatedByUser = new Date().toISOString();
26 |
27 | this.setState({ settings }, () => {
28 | delay(() => {
29 | updateUser({
30 | variables: {
31 | user: {
32 | _id: userId,
33 | settings: settingsUpdate,
34 | },
35 | },
36 | });
37 | }, 750);
38 | });
39 | };
40 |
41 | renderSettingValue = (type, key, value, onChange) =>
42 | ({
43 | boolean: () => (
44 | onChange({ key, value: `${toggled}` })}
48 | />
49 | ),
50 | number: () => (
51 | onChange({ key, value: parseInt(event.target.value, 10) })}
56 | />
57 | ),
58 | string: () => (
59 | onChange({ key, value: event.target.value })}
64 | />
65 | ),
66 | }[type]());
67 |
68 | render() {
69 | const { isAdmin } = this.props;
70 | const { settings } = this.state;
71 | return (
72 |
73 |
74 | {settings.length > 0 ? (
75 | settings.map(({ _id, key, label, type, value }) => (
76 |
77 | {label}
78 |
79 | {this.renderSettingValue(type, key, value, (update) =>
80 | this.handleUpdateSetting({ ...update, _id }),
81 | )}
82 |
83 |
84 | ))
85 | ) : (
86 |
93 | )}
94 |
95 |
96 | );
97 | }
98 | }
99 |
100 | UserSettings.defaultProps = {
101 | userId: null,
102 | isAdmin: false,
103 | settings: [],
104 | updateUser: null,
105 | };
106 |
107 | UserSettings.propTypes = {
108 | userId: PropTypes.string,
109 | isAdmin: PropTypes.bool,
110 | settings: PropTypes.array,
111 | updateUser: PropTypes.func,
112 | };
113 |
114 | export default UserSettings;
115 |
--------------------------------------------------------------------------------
/ui/pages/ResetPassword/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Row, Col, Alert, FormGroup, ControlLabel, Button } from 'react-bootstrap';
4 | import { Link } from 'react-router-dom';
5 | import { Accounts } from 'meteor/accounts-base';
6 | import { Bert } from 'meteor/themeteorchef:bert';
7 | import Validation from '../../components/Validation';
8 | import AccountPageFooter from '../../components/AccountPageFooter';
9 | import StyledResetPassword from './styles';
10 |
11 | class ResetPassword extends React.Component {
12 | handleSubmit = (form) => {
13 | const { match, history } = this.props;
14 | const { token } = match.params;
15 |
16 | Accounts.resetPassword(token, form.newPassword.value, (error) => {
17 | if (error) {
18 | Bert.alert(error.reason, 'danger');
19 | } else {
20 | history.push('/documents');
21 | }
22 | });
23 | };
24 |
25 | render() {
26 | return (
27 |
28 |
29 |
30 | Reset Password
31 |
32 | To reset your password, enter a new one below. You will be logged in with your new
33 | password.
34 |
35 | {
58 | this.handleSubmit(form);
59 | }}
60 | >
61 | (this.form = form)} onSubmit={(event) => event.preventDefault()}>
62 |
63 | New Password
64 |
70 |
71 |
72 | Repeat New Password
73 |
79 |
80 |
81 | Reset Password & Login
82 |
83 |
84 |
85 | {"Not sure why you're here? "}
86 | Log In
87 | {'.'}
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | }
97 | }
98 |
99 | ResetPassword.propTypes = {
100 | match: PropTypes.object.isRequired,
101 | history: PropTypes.object.isRequired,
102 | };
103 |
104 | export default ResetPassword;
105 |
--------------------------------------------------------------------------------
/ui/pages/Terms/content.js:
--------------------------------------------------------------------------------
1 | import { Meteor } from 'meteor/meteor';
2 |
3 | const { productName } = Meteor.settings.public;
4 |
5 | const content = `
6 | #### Terms
7 | By accessing the website at ${Meteor.absoluteUrl()}, you are agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using or accessing this site. The materials contained in this website are protected by applicable copyright and trademark law.
8 | #### Use License
9 | Permission is granted to temporarily download one copy of the materials (information or software) on ${productName}'s website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title, and under this license you may not:
10 | modify or copy the materials;
11 | use the materials for any commercial purpose, or for any public display (commercial or non-commercial);
12 | attempt to decompile or reverse engineer any software contained on ${productName}'s website;
13 | remove any copyright or other proprietary notations from the materials; or
14 | transfer the materials to another person or "mirror" the materials on any other server.
15 | This license shall automatically terminate if you violate any of these restrictions and may be terminated by ${productName} at any time. Upon terminating your viewing of these materials or upon the termination of this license, you must destroy any downloaded materials in your possession whether in electronic or printed format.
16 | #### Disclaimer
17 | The materials on ${productName}'s website are provided on an 'as is' basis. ${productName} makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights.
18 | Further, ${productName} does not warrant or make any representations concerning the accuracy, likely results, or reliability of the use of the materials on its website or otherwise relating to such materials or on any sites linked to this site.
19 | #### Limitations
20 | In no event shall ${productName} or its suppliers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on ${productName}'s website, even if ${productName} or a ${productName} authorized representative has been notified orally or in writing of the possibility of such damage. Because some jurisdictions do not allow limitations on implied warranties, or limitations of liability for consequential or incidental damages, these limitations may not apply to you.
21 | #### Accuracy of materials
22 | The materials appearing on ${productName}'s website could include technical, typographical, or photographic errors. ${productName} does not warrant that any of the materials on its website are accurate, complete or current. ${productName} may make changes to the materials contained on its website at any time without notice. However ${productName} does not make any commitment to update the materials.
23 | #### Links
24 | ${productName} has not reviewed all of the sites linked to its website and is not responsible for the contents of any such linked site. The inclusion of any link does not imply endorsement by ${productName} of the site. Use of any such linked website is at the user's own risk.
25 | #### Modifications
26 | ${productName} may revise these terms of service for its website at any time without notice. By using this website you are agreeing to be bound by the then current version of these terms of service.
27 | #### Governing Law
28 | These terms and conditions are governed by and construed in accordance with the laws of Illinois and you irrevocably submit to the exclusive jurisdiction of the courts in that State or location.
29 | `;
30 |
31 | export default content;
32 |
--------------------------------------------------------------------------------
/ui/pages/AdminUser/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { compose, graphql } from 'react-apollo';
4 | import { Breadcrumb, Tab } from 'react-bootstrap';
5 | import { Link } from 'react-router-dom';
6 | import { Bert } from 'meteor/themeteorchef:bert';
7 | import AdminUserProfile from '../../components/AdminUserProfile';
8 | import UserSettings from '../../components/UserSettings';
9 | import { user as userQuery, users as usersQuery } from '../../queries/Users.gql';
10 | import {
11 | updateUser as updateUserMutation,
12 | removeUser as removeUserMutation,
13 | } from '../../mutations/Users.gql';
14 |
15 | import Styles from './styles';
16 |
17 | class AdminUser extends React.Component {
18 | state = { activeTab: 'profile' };
19 |
20 | render() {
21 | const { data, updateUser, removeUser } = this.props;
22 | const { activeTab } = this.state;
23 | const name = data.user && data.user.name;
24 | const username = data.user && data.user.username;
25 |
26 | return data.user ? (
27 |
28 |
29 |
30 | Users
31 |
32 | {name ? `${name.first} ${name.last}` : username}
33 |
34 |
35 | {name ? `${name.first} ${name.last}` : username}
36 | {data.user.oAuthProvider && (
37 |
38 | {data.user.oAuthProvider}
39 |
40 | )}
41 |
42 |
this.setState({ activeTab: selectedTab })}
46 | id="admin-user-tabs"
47 | >
48 |
49 | {
52 | // NOTE: Do this to allow us to perform work inside of AdminUserProfile
53 | // after a successful update. Not ideal, but hey, c'est la vie.
54 | updateUser(options);
55 | if (callback) callback();
56 | }}
57 | removeUser={removeUser}
58 | />
59 |
60 |
61 |
67 |
68 |
69 |
70 | ) : (
71 |
72 | );
73 | }
74 | }
75 |
76 | AdminUser.propTypes = {
77 | data: PropTypes.object.isRequired,
78 | updateUser: PropTypes.func.isRequired,
79 | removeUser: PropTypes.func.isRequired,
80 | };
81 |
82 | export default compose(
83 | graphql(userQuery, {
84 | options: ({ match }) => ({
85 | // NOTE: This ensures cache isn't too aggressive when moving between users.
86 | // Forces Apollo to perform userQuery as a user is loaded instead of falling
87 | // back to the cache. Users share similar data which gets cached and ends up
88 | // breaking the UI.
89 | fetchPolicy: 'no-cache',
90 | variables: {
91 | _id: match.params._id,
92 | },
93 | }),
94 | }),
95 | graphql(updateUserMutation, {
96 | name: 'updateUser',
97 | options: ({ match }) => ({
98 | refetchQueries: [{ query: userQuery, variables: { _id: match.params._id } }],
99 | onCompleted: () => {
100 | Bert.alert('User updated!', 'success');
101 | },
102 | }),
103 | }),
104 | graphql(removeUserMutation, {
105 | name: 'removeUser',
106 | options: ({ history }) => ({
107 | refetchQueries: [{ query: usersQuery }],
108 | onCompleted: () => {
109 | Bert.alert('User deleted!', 'success');
110 | history.push('/admin/users');
111 | },
112 | }),
113 | }),
114 | )(AdminUser);
115 |
--------------------------------------------------------------------------------