diff --git a/.editorconfig b/.editorconfig index 9c378bd19..986314661 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,7 @@ root = true [*] -indent_style = tab +indent_style = space end_of_line = lf charset = utf-8 trim_trailing_whitespace = true diff --git a/Dockerfile b/Dockerfile index 3be5916a8..6776e2f04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Setup the environment +ENV NODE_ENV production ENV PATH /usr/src/app/bin:$PATH ENV TALK_PORT 5000 EXPOSE 5000 diff --git a/README.md b/README.md index f4d8b31de..0f217d89c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,19 @@ Run it once to install the dependencies. `npm start` Runs Talk. +### Configuration + +The Talk application requires specific configuration options to be available +inside the environment in order to run, those variables are listed here: + +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to + secure cookies +- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook + Login enabled app. +- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your + Facebook Login enabled app. +- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. + ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` diff --git a/app.js b/app.js index 22f343221..6512680aa 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,11 @@ const express = require('express'); const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); +const helmet = require('helmet'); +const passport = require('./passport'); +const session = require('express-session'); +const RedisStore = require('connect-redis')(session); +const redis = require('./redis'); const app = express(); @@ -12,12 +17,62 @@ if (app.get('env') !== 'test') { app.use(morgan('dev')); } +//============================================================================== +// APP MIDDLEWARE +//============================================================================== + +app.set('trust proxy', 'loopback'); +app.use(helmet()); app.use(bodyParser.json()); +app.use('/client', express.static(path.join(__dirname, 'dist'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -// Routes. -app.use('/client', express.static(path.join(__dirname, 'dist'))); +//============================================================================== +// SESSION MIDDLEWARE +//============================================================================== + +const session_opts = { + secret: process.env.TALK_SESSION_SECRET, + httpOnly: true, + rolling: true, + saveUninitialized: false, + resave: false, + name: 'talk.sid', + cookie: { + secure: false, + maxAge: 18000000, // 30 minutes for expiry. + }, + store: new RedisStore({ + ttl: 1800, + client: redis, + }) +}; + +if (app.get('env') === 'production') { + + // Enable the secure cookie when we are in production mode. + session_opts.cookie.secure = true; +} else if (app.get('env') === 'test') { + + // Add in the secret during tests. + session_opts.secret = 'keyboard cat'; +} + +app.use(session(session_opts)); + +//============================================================================== +// PASSPORT MIDDLEWARE +//============================================================================== + +// Setup the PassportJS Middleware. +app.use(passport.initialize()); +app.use(passport.session()); + +//============================================================================== +// ROUTES +//============================================================================== + app.use('/', require('./routes')); //============================================================================== @@ -34,37 +89,27 @@ app.use((req, res, next) => { // General error handler. Respond with the message and error if we have it while // returning a status code that makes sense. -if (app.get('env') === 'development') { - app.use('/api', (err, req, res, next) => { - res.status(err.status || 500); - res.json({ - message: err.message, - error: err - }); - }); - - app.use('/', (err, req, res, next) => { - res.status(err.status || 500); - res.render('error', { - message: err.message, - error: err - }); - }); -} - app.use('/api', (err, req, res, next) => { + if (err !== ErrNotFound) { + console.error(err); + } + res.status(err.status || 500); res.json({ message: err.message, - error: {} + error: app.get('env') === 'development' ? err : null }); }); app.use('/', (err, req, res, next) => { + if (err !== ErrNotFound) { + console.error(err); + } + res.status(err.status || 500); res.render('error', { message: err.message, - error: {} + error: app.get('env') === 'development' ? err : null }); }); diff --git a/bin/cli-users b/bin/cli-users index b27c41e14..54d7f2927 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -206,7 +206,7 @@ function listUsers() { const mongoose = require('../mongoose'); User - .find() + .all() .then((users) => { let table = new Table({ head: [ diff --git a/cache.js b/cache.js new file mode 100644 index 000000000..efe689f9c --- /dev/null +++ b/cache.js @@ -0,0 +1,97 @@ +const redis = require('./redis'); + +const cache = module.exports = {}; + +/** + * This collects a key that may either be an array or a string and creates a + * unified key out of it. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {String} A string that represents a key + */ +const keyfunc = (key) => { + if (Array.isArray(key)) { + return `cache[${key.join(':')}]`; + } + + return `cache[${key}]`; +}; + +/** + * This wraps a complicated function with a cache, in the event that the item is + * not inside the cache, it will perform the work to get it and then set it + * followed by returning the value. + * @param {Mixed} key Either an array of items or string represening this + * work + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @param {Function} work A function that returns a promise that can be + * resolved as the value to cache. + * @return {Promise} Resolves to the value either retrieved from cache + */ +cache.wrap = (key, expiry, work) => { + return cache + .get(key) + .then((value) => { + if (value !== null) { + return value; + } + + return work() + .then((value) => { + return cache + .set(key, value, expiry) + .then(() => value); + }); + }); +}; + +/** + * This returns a promise that returns a promise that resolves with the value + * from the cache or null if it does not exist in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.get = (key) => new Promise((resolve, reject) => { + redis.get(keyfunc(key), (err, reply) => { + if (err) { + return reject(err); + } + + if (reply !== null) { + let value; + + try { + + // Parse the stored cache value from JSON. + value = JSON.parse(reply); + } catch (e) { + return reject(e); + } + + return resolve(value); + } + + resolve(null); + }); +}); + +/** + * This sets a value on the key with the expiry and then resolves once it is + * done. + * @param {Mixed} key Either an array of items composing a key or a string + * @param {Mixed} value Object to be serialized and set to the cache + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @return {Promise} + */ +cache.set = (key, value, expiry) => new Promise((resolve, reject) => { + + // Serialize the value as JSON. + let reply = JSON.stringify(value); + + redis.set(keyfunc(key), reply, 'EX', expiry, (err) => { + if (err) { + return reject(err); + } + + return resolve(); + }); +}); diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js index c115bcf61..fa94f74a4 100644 --- a/client/coral-admin/src/components/Comment.js +++ b/client/coral-admin/src/components/Comment.js @@ -3,7 +3,7 @@ import React from 'react'; import {Button, Icon} from 'react-mdl'; import timeago from 'timeago.js'; import styles from './CommentList.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; // Render a single comment for the list diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index b2bee0a44..aa3ba6708 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -2,7 +2,7 @@ import React from 'react'; import {Layout, Navigation, Drawer, Header} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; // App header. If we add a navbar it should be here diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index d350c74e0..5a951e588 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -1,4 +1,4 @@ -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; import React from 'react'; import Modal from 'components/Modal'; diff --git a/client/coral-admin/src/components/ui/Drawer.js b/client/coral-admin/src/components/ui/Drawer.js index 12bf7c1d2..2003ed34d 100644 --- a/client/coral-admin/src/components/ui/Drawer.js +++ b/client/coral-admin/src/components/ui/Drawer.js @@ -2,7 +2,7 @@ import React from 'react'; import {Navigation, Drawer} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; export default () => ( diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index d85a91744..2e0f77b6e 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -2,7 +2,7 @@ import React from 'react'; import {Navigation, Header} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; export default () => ( diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/Community.js index fdb542811..f8c24fd3a 100644 --- a/client/coral-admin/src/containers/Community/Community.js +++ b/client/coral-admin/src/containers/Community/Community.js @@ -1,5 +1,5 @@ import React from 'react'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import {Grid, Cell} from 'react-mdl'; diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index 97848bc9a..89737e33e 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {SelectField, Option} from 'react-mdl-selectfield'; import styles from './Community.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations'; import {setRole} from '../../actions/community'; diff --git a/client/coral-admin/src/containers/Configure.js b/client/coral-admin/src/containers/Configure.js index 7d057eacc..d5b4ce516 100644 --- a/client/coral-admin/src/containers/Configure.js +++ b/client/coral-admin/src/containers/Configure.js @@ -13,7 +13,7 @@ import { Icon } from 'react-mdl'; import styles from './Configure.css'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; class Configure extends React.Component { diff --git a/client/coral-admin/src/containers/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue.js index cfc48ae6b..7a30b8487 100644 --- a/client/coral-admin/src/containers/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue.js @@ -5,7 +5,7 @@ import CommentList from 'components/CommentList'; import {updateStatus} from 'actions/comments'; import styles from './ModerationQueue.css'; import key from 'keymaster'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; /* diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index 72e4e5919..6fc48a826 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -17,10 +17,12 @@ import Pym from 'pym.js'; import FlagButton from '../../coral-plugin-flags/FlagButton'; import LikeButton from '../../coral-plugin-likes/LikeButton'; import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton'; +import SignInContainer from '../../coral-sign-in/containers/SignInContainer'; +import UserBox from '../../coral-sign-in/components/UserBox'; const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions; const {addNotification, clearNotification} = notificationActions; -const {setLoggedInUser} = authActions; +const {logout} = authActions; const mapStateToProps = (state) => { return { @@ -31,40 +33,21 @@ const mapStateToProps = (state) => { }; }; -const mapDispatchToProps = (dispatch) => { - return { - addItem: (item, itemType) => { - return dispatch(addItem(item, itemType)); - }, - updateItem: (id, property, value, itemType) => { - return dispatch(updateItem(id, property, value, itemType)); - }, - postItem: (data, type, id) => { - return dispatch(postItem(data, type, id)); - }, - getStream: (rootId) => { - return dispatch(getStream(rootId)); - }, - addNotification: (type, text) => { - return dispatch(addNotification(type, text)); - }, - clearNotification: () => { - return dispatch(clearNotification()); - }, - setLoggedInUser: (user_id) => { - return dispatch(setLoggedInUser(user_id)); - }, - postAction: (item, action, user, itemType) => { - return dispatch(postAction(item, action, user, itemType)); - }, - deleteAction: (item, action, user, itemType) => { - return dispatch(deleteAction(item, action, user, itemType)); - }, - appendItemArray: (item, property, value, addToFront, itemType) => { - return dispatch(appendItemArray(item, property, value, addToFront, itemType)); - } - }; -}; +const mapDispatchToProps = (dispatch) => ({ + addItem: (item, itemType) => dispatch(addItem(item, itemType)), + updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)), + postItem: (data, type, id) => dispatch(postItem(data, type, id)), + getStream: (rootId) => dispatch(getStream(rootId)), + addNotification: (type, text) => dispatch(addNotification(type, text)), + clearNotification: () => dispatch(clearNotification()), + postAction: (item, action, user, itemType) => dispatch(postAction(item, action, user, itemType)), + deleteAction: (item, action, user, itemType) => { + return dispatch(deleteAction(item, action, user, itemType)); + }, + appendItemArray: (item, property, value, addToFront, itemType) => + dispatch(appendItemArray(item, property, value, addToFront, itemType)), + logout: () => dispatch(logout()) +}); class CommentStream extends Component { @@ -83,11 +66,25 @@ class CommentStream extends Component { } render () { + if (Object.keys(this.props.items).length === 0) { + // Loading mock asset + this.props.postItem({ + comments: [], + url: 'http://coralproject.net' + }, 'asset', 'assetTest'); + + // Loading mock user + //this.props.postItem({name: 'Ban Ki-Moon'}, 'user', 'user_8989') + // .then((id) => { + // this.props.setLoggedInUser(id); + // }); + } // TODO: Replace teststream id with id from params const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0]; const rootItem = this.props.items.assets && this.props.items.assets[rootItemId]; + const {loggedIn, user} = this.props.auth; return
{ rootItem @@ -99,6 +96,7 @@ class CommentStream extends Component { + {loggedIn && } + reply={false} + canPost={loggedIn} + /> + {!loggedIn && }
{ rootItem.comments && rootItem.comments.map((commentId) => { diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 86fc66026..9ba329f98 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -5,7 +5,6 @@ body { font-size: 12px; margin: 0px; padding: 0px 0px 50px 0px; - } button { diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js new file mode 100644 index 000000000..a1a44611f --- /dev/null +++ b/client/coral-framework/actions/auth.js @@ -0,0 +1,116 @@ +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from './../translations'; +const lang = new I18n(translations); +import * as actions from '../constants/auth'; +import {base, handleResp, getInit} from '../helpers/response'; + +// Dialog Actions +export const showSignInDialog = () => ({type: actions.SHOW_SIGNIN_DIALOG}); +export const hideSignInDialog = () => ({type: actions.HIDE_SIGNIN_DIALOG}); + +export const changeView = view => dispatch => + dispatch({ + type: actions.CHANGE_VIEW, + view + }); + +export const cleanState = () => ({type: actions.CLEAN_STATE}); + +// Sign In Actions + +const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST}); +const signInSuccess = (user) => ({type: actions.FETCH_SIGNIN_SUCCESS, user}); +const signInFailure = (error) => ({type: actions.FETCH_SIGNIN_FAILURE, error}); + +export const fetchSignIn = (formData) => dispatch => { + dispatch(signInRequest()); + fetch(`${base}/auth/local`, getInit('POST', formData)) + .then(handleResp) + .then(({user}) => { + dispatch(hideSignInDialog()); + dispatch(signInSuccess(user)); + }) + .catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError')))); +}; + +// Sign In - Facebook + +const signInFacebookRequest = () => ({type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST}); +const signInFacebookSuccess = user => ({type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, user}); +const signInFacebookFailure = error => ({type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, error}); + +export const fetchSignInFacebook = () => dispatch => { + dispatch(signInFacebookRequest()); + window.open( + `${base}/auth/facebook`, + 'Continue with Facebook', + 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' + ); +}; + +export const facebookCallback = (err, data) => dispatch => { + if (err) { + signInFacebookFailure(err); + return; + } + try { + dispatch(signInFacebookSuccess(JSON.parse(data))); + dispatch(hideSignInDialog()); + } catch (err) { + dispatch(signInFacebookFailure(err)); + return; + } +}; + +// Sign Up Actions + +const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST}); +const signUpSuccess = user => ({type: actions.FETCH_SIGNUP_SUCCESS, user}); +const signUpFailure = error => ({type: actions.FETCH_SIGNUP_FAILURE, error}); + +export const fetchSignUp = formData => dispatch => { + dispatch(signUpRequest()); + fetch(`${base}/user`, getInit('POST', formData)) + .then(handleResp) + .then(({user}) => { + dispatch(signUpSuccess(user)); + setTimeout(() =>{ + dispatch(changeView('SIGNIN')); + }, 3000); + }) + .catch(() => dispatch(signUpFailure(lang.t('error.emailInUse')))); // We need to inprove error handling. TODO (bc) +}; + +// Forgot Password Actions + +const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST}); +const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); +const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); + +export const fetchForgotPassword = () => dispatch => { + dispatch(forgotPassowordRequest()); + fetch(`${base}/user/request-password-reset`, getInit('POST')) + .then(handleResp) + .then(() => dispatch(forgotPassowordSuccess())) + .catch(error => dispatch(forgotPassowordFailure(error))); +}; + +// LogOut Actions + +const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); +const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); +const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); + +export const logout = () => dispatch => { + dispatch(logOutRequest()); + fetch(`${base}/auth`, getInit('DELETE')) + .then(handleResp) + .then(() => dispatch(logOutSuccess())) + .catch(error => dispatch(logOutFailure(error))); +}; + +// LogOut Actions + +export const validForm = () => ({type: actions.VALID_FORM}); +export const invalidForm = error => ({type: actions.INVALID_FORM, error}); + diff --git a/client/coral-framework/store/actions/config.js b/client/coral-framework/actions/config.js similarity index 98% rename from client/coral-framework/store/actions/config.js rename to client/coral-framework/actions/config.js index f131b8c82..d8fd886be 100644 --- a/client/coral-framework/store/actions/config.js +++ b/client/coral-framework/actions/config.js @@ -1,5 +1,3 @@ -/* @flow */ - import {fromJS} from 'immutable'; /** diff --git a/client/coral-framework/store/actions/items.js b/client/coral-framework/actions/items.js similarity index 100% rename from client/coral-framework/store/actions/items.js rename to client/coral-framework/actions/items.js diff --git a/client/coral-framework/store/actions/notification.js b/client/coral-framework/actions/notification.js similarity index 92% rename from client/coral-framework/store/actions/notification.js rename to client/coral-framework/actions/notification.js index c34adc414..4edc17601 100644 --- a/client/coral-framework/store/actions/notification.js +++ b/client/coral-framework/actions/notification.js @@ -1,5 +1,3 @@ -/* Notification Actions */ - export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'; export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION'; diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js new file mode 100644 index 000000000..a6a2c44f8 --- /dev/null +++ b/client/coral-framework/constants/auth.js @@ -0,0 +1,29 @@ +export const CHANGE_VIEW = 'CHANGE_VIEW'; +export const CLEAN_STATE = 'CLEAN_STATE'; + +export const SHOW_SIGNIN_DIALOG = 'SHOW_SIGNIN_DIALOG'; +export const HIDE_SIGNIN_DIALOG = 'HIDE_SIGNIN_DIALOG'; + +export const FETCH_SIGNUP_REQUEST = 'FETCH_SIGNUP_REQUEST'; +export const FETCH_SIGNUP_FAILURE = 'FETCH_SIGNUP_FAILURE'; +export const FETCH_SIGNUP_SUCCESS = 'FETCH_SIGNUP_SUCCESS'; + +export const FETCH_SIGNIN_REQUEST = 'FETCH_SIGNIN_REQUEST'; +export const FETCH_SIGNIN_FAILURE = 'FETCH_SIGNIN_FAILURE'; +export const FETCH_SIGNIN_SUCCESS = 'FETCH_SIGNIN_SUCCESS'; + +export const FETCH_SIGNIN_FACEBOOK_REQUEST = 'FETCH_SIGNIN_FACEBOOK_REQUEST'; +export const FETCH_SIGNIN_FACEBOOK_FAILURE = 'FETCH_SIGNIN_FACEBOOK_FAILURE'; +export const FETCH_SIGNIN_FACEBOOK_SUCCESS = 'FETCH_SIGNIN_FACEBOOK_SUCCESS'; + +export const FETCH_FORGOT_PASSWORD_REQUEST = 'FETCH_FORGOT_PASSWORD_REQUEST'; +export const FETCH_FORGOT_PASSWORD_SUCCESS = 'FETCH_FORGOT_PASSWORD_SUCCESS'; +export const FETCH_FORGOT_PASSWORD_FAILURE = 'FETCH_FORGOT_PASSWORD_FAILURE'; + +export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; +export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; +export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; + +export const INVALID_FORM = 'INVALID_FORM'; +export const VALID_FORM = 'VALID_FORM'; + diff --git a/client/coral-framework/helpers/error.js b/client/coral-framework/helpers/error.js new file mode 100644 index 000000000..34ba253ba --- /dev/null +++ b/client/coral-framework/helpers/error.js @@ -0,0 +1,10 @@ +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from './../translations'; +const lang = new I18n(translations); + +export default { + email: lang.t('error.email'), + password: lang.t('error.password'), + displayName: lang.t('error.displayName'), + confirmPassword: lang.t('error.confirmPassword') +}; diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js new file mode 100644 index 000000000..bccfc5a04 --- /dev/null +++ b/client/coral-framework/helpers/response.js @@ -0,0 +1,30 @@ +export const base = '/api/v1'; + +export const getInit = (method, body) => { + let init = { + method, + headers: new Headers({ + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }), + credentials: 'same-origin' + }; + + if (method.toLowerCase() !== 'get') { + init.body = JSON.stringify(body); + } + + return init; +}; + +export const handleResp = res => { + if (res.status === 401) { + throw new Error('Not Authorized to make this request'); + } else if (res.status > 399) { + throw new Error('Error! Status ', res.status); + } else if (res.status === 204) { + return res.text(); + } else { + return res.json(); + } +}; diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js new file mode 100644 index 000000000..8c5ebd36f --- /dev/null +++ b/client/coral-framework/helpers/validate.js @@ -0,0 +1,6 @@ +export default { + email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), + password: pass => (/^(?=.{8,}).*$/.test(pass)), + confirmPassword: () => true, + displayName: displayName => (/^(?=.{3,}).*$/.test(displayName)) +}; diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index 118935c50..4c6ae741f 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -1,10 +1,10 @@ -import Notification from './notification/Notification'; -import store from './store/store'; -import {fetchConfig} from './store/actions/config'; -import * as itemActions from './store/actions/items'; -import I18n from './i18n/i18n'; -import * as notificationActions from './store/actions/notification'; -import * as authActions from './store/actions/auth'; +import Notification from './modules/notification/Notification'; +import store from './store'; +import {fetchConfig} from './actions/config'; +import * as itemActions from './actions/items'; +import I18n from './modules/i18n/i18n'; +import * as notificationActions from './actions/notification'; +import * as authActions from './actions/auth'; export { Notification, diff --git a/client/coral-framework/i18n/i18n.js b/client/coral-framework/modules/i18n/i18n.js similarity index 96% rename from client/coral-framework/i18n/i18n.js rename to client/coral-framework/modules/i18n/i18n.js index 43144f3a6..afe8606d1 100644 --- a/client/coral-framework/i18n/i18n.js +++ b/client/coral-framework/modules/i18n/i18n.js @@ -1,5 +1,5 @@ import timeago from 'timeago.js'; -import esTA from 'timeago.js/locales/es'; +import esTA from '../../../../node_modules/timeago.js/locales/es'; /** * Default locales, this should be overriden by config file diff --git a/client/coral-framework/notification/Notification.js b/client/coral-framework/modules/notification/Notification.js similarity index 100% rename from client/coral-framework/notification/Notification.js rename to client/coral-framework/modules/notification/Notification.js diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js new file mode 100644 index 000000000..3e454892f --- /dev/null +++ b/client/coral-framework/reducers/auth.js @@ -0,0 +1,76 @@ +import {Map} from 'immutable'; +import * as actions from '../constants/auth'; + +const initialState = Map({ + isLoading: false, + loggedIn: false, + user: null, + showSignInDialog: false, + view: 'SIGNIN', + error: '', + successSignUp: false +}); + +export default function auth (state = initialState, action) { + switch (action.type) { + case actions.SHOW_SIGNIN_DIALOG : + return state + .set('showSignInDialog', true); + case actions.HIDE_SIGNIN_DIALOG : + return state.merge(Map({ + isLoading: false, + showSignInDialog: false, + view: 'SIGNIN', + error: '', + successSignUp: false + })); + case actions.CHANGE_VIEW : + return state + .set('error', '') + .set('view', action.view); + case actions.CLEAN_STATE: + return initialState; + case actions.FETCH_SIGNIN_REQUEST: + return state + .set('isLoading', true); + case actions.FETCH_SIGNIN_SUCCESS: + return state + .set('loggedIn', true) + .set('user', action.user); + case actions.FETCH_SIGNIN_FAILURE: + return state + .set('isLoading', false) + .set('error', action.error); + case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS: + return state + .set('user', action.user) + .set('loggedIn', true); + case actions.FETCH_SIGNIN_FACEBOOK_FAILURE: + return state + .set('error', action.error) + .set('user', null); + case actions.FETCH_SIGNUP_REQUEST: + return state + .set('isLoading', true); + case actions.FETCH_SIGNUP_FAILURE: + return state + .set('error', action.error) + .set('isLoading', false); + case actions.FETCH_SIGNUP_SUCCESS: + return state + .set('isLoading', false) + .set('successSignUp', true); + case actions.LOGOUT_SUCCESS: + return state + .set('loggedIn', false) + .set('user', null); + case actions.INVALID_FORM: + return state + .set('error', action.error); + case actions.VALID_FORM: + return state + .set('error', ''); + default : + return state; + } +} diff --git a/client/coral-framework/store/reducers/config.js b/client/coral-framework/reducers/config.js similarity index 100% rename from client/coral-framework/store/reducers/config.js rename to client/coral-framework/reducers/config.js diff --git a/client/coral-framework/store/reducers/index.js b/client/coral-framework/reducers/index.js similarity index 100% rename from client/coral-framework/store/reducers/index.js rename to client/coral-framework/reducers/index.js diff --git a/client/coral-framework/store/reducers/items.js b/client/coral-framework/reducers/items.js similarity index 100% rename from client/coral-framework/store/reducers/items.js rename to client/coral-framework/reducers/items.js diff --git a/client/coral-framework/store/reducers/notification.js b/client/coral-framework/reducers/notification.js similarity index 100% rename from client/coral-framework/store/reducers/notification.js rename to client/coral-framework/reducers/notification.js diff --git a/client/coral-framework/store/store.js b/client/coral-framework/store.js similarity index 99% rename from client/coral-framework/store/store.js rename to client/coral-framework/store.js index 33cc9efb5..8c3f9edc1 100644 --- a/client/coral-framework/store/store.js +++ b/client/coral-framework/store.js @@ -1,4 +1,3 @@ - import {createStore, applyMiddleware} from 'redux'; import thunk from 'redux-thunk'; import mainReducer from './reducers'; diff --git a/client/coral-framework/store/actions/auth.js b/client/coral-framework/store/actions/auth.js deleted file mode 100644 index af1e3cb34..000000000 --- a/client/coral-framework/store/actions/auth.js +++ /dev/null @@ -1,17 +0,0 @@ -/* Auth Actions */ - -export const SET_LOGGED_IN_USER = 'SET_LOGGED_IN_USER'; -export const LOG_OUT_USER = 'LOG_OUT_USER'; - -export const setLoggedInUser = (user_id) => { - return { - type: SET_LOGGED_IN_USER, - user_id - }; -}; - -export const LogOutUser = () => { - return { - type: LOG_OUT_USER - }; -}; diff --git a/client/coral-framework/store/reducers/auth.js b/client/coral-framework/store/reducers/auth.js deleted file mode 100644 index b79444cc9..000000000 --- a/client/coral-framework/store/reducers/auth.js +++ /dev/null @@ -1,17 +0,0 @@ -/* Auth */ - -import * as actions from '../actions/auth'; -import {fromJS} from 'immutable'; - -const initialState = fromJS({}); - -export default (state = initialState, action) => { - switch (action.type) { - case actions.SET_LOGGED_IN_USER: - return state.set('user', action.user_id); - case actions.LOG_OUT_USER: - return initialState; - default: - return state; - } -}; diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json new file mode 100644 index 000000000..4abd1ed03 --- /dev/null +++ b/client/coral-framework/translations.json @@ -0,0 +1,22 @@ +{ + "en": { + "error": { + "email": "Not a valid E-Mail", + "password": "Password must be at least 8 characters", + "displayName": "Display name is too short", + "confirmPassword": "Passwords don`t match. Please, check again", + "emailPasswordError": "Email and/or password combination incorrect.", + "emailInUse": "Email address already in use" + } + }, + "es": { + "error": { + "email": "No es un email válido", + "password": "La contraseña debe tener por lo menos 8 caracteres", + "displayName": "El nombre es muy corto", + "confirmPassword": "Las contraseñas no coinciden", + "emailPasswordError": "Email y/o contraseña incorrecta.", + "emailInUse": "Email address already in use" + } + } +} \ No newline at end of file diff --git a/client/coral-plugin-commentbox/CommentBox.js b/client/coral-plugin-commentbox/CommentBox.js index 9032ad328..e712d8e24 100644 --- a/client/coral-plugin-commentbox/CommentBox.js +++ b/client/coral-plugin-commentbox/CommentBox.js @@ -11,7 +11,8 @@ class CommentBox extends Component { updateItem: PropTypes.func, id: PropTypes.string, comments: PropTypes.array, - reply: PropTypes.bool + reply: PropTypes.bool, + canPost: PropTypes.bool } state = { @@ -51,18 +52,9 @@ class CommentBox extends Component { } render () { - const {styles, reply} = this.props; + const {styles, reply, canPost} = this.props; // How to handle language in plugins? Should we have a dependency on our central translation file? return
-
- this.setState({username: e.target.value})}/> -
- + { canPost && ( + + ) + }
; } diff --git a/client/coral-plugin-permalinks/PermalinkButton.js b/client/coral-plugin-permalinks/PermalinkButton.js index 978a6dd03..2372e2035 100644 --- a/client/coral-plugin-permalinks/PermalinkButton.js +++ b/client/coral-plugin-permalinks/PermalinkButton.js @@ -1,5 +1,5 @@ import React, {PropTypes} from 'react'; -import I18n from 'coral-framework/i18n/i18n'; +import I18n from 'coral-framework/modules/i18n/i18n'; import translations from './translations'; import onClickOutside from 'react-onclickoutside'; const name = 'coral-plugin-permalinks'; diff --git a/client/coral-sign-in/components/Alert.js b/client/coral-sign-in/components/Alert.js new file mode 100644 index 000000000..019c3456a --- /dev/null +++ b/client/coral-sign-in/components/Alert.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './styles.css'; + +const Alert = ({cStyle = 'error', children, className, ...props}) => ( +
+ {children} +
+); + +export default Alert; diff --git a/client/coral-sign-in/components/ForgotContent.js b/client/coral-sign-in/components/ForgotContent.js new file mode 100644 index 000000000..7214d2bb6 --- /dev/null +++ b/client/coral-sign-in/components/ForgotContent.js @@ -0,0 +1,29 @@ +import React from 'react'; +import styles from './styles.css'; +import Button from 'coral-ui/components/Button'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations'; +const lang = new I18n(translations); + +const ForgotContent = ({changeView, ...props}) => ( +
+
+

{lang.t('signIn.recoverPassword')}

+
+
{e.preventDefault(); props.fetchForgotPassword();}}> +
+ + +
+ +
+
+ {lang.t('signIn.needAnAccount')} changeView('SIGNUP')}>{lang.t('signIn.register')} + {lang.t('signIn.alreadyHaveAnAccount')} changeView('SIGNIN')}>{lang.t('signIn.signIn')} +
+
+); + +export default ForgotContent; diff --git a/client/coral-sign-in/components/FormField.js b/client/coral-sign-in/components/FormField.js new file mode 100644 index 000000000..ceb2d939e --- /dev/null +++ b/client/coral-sign-in/components/FormField.js @@ -0,0 +1,18 @@ +import React from 'react'; +import styles from './styles.css'; + +const FormField = ({className, showErrors = false, errorMsg, label, ...props}) => ( +
+ + + {showErrors && errorMsg && !{errorMsg}} +
+); + +export default FormField; diff --git a/client/coral-sign-in/components/SignDialog.js b/client/coral-sign-in/components/SignDialog.js new file mode 100644 index 000000000..242b7443a --- /dev/null +++ b/client/coral-sign-in/components/SignDialog.js @@ -0,0 +1,18 @@ +import React from 'react'; +import {Dialog} from 'coral-ui'; +import styles from './styles.css'; + +import SignInContent from './SignInContent'; +import SingUpContent from './SignUpContent'; +import ForgotContent from './ForgotContent'; + +const SignDialog = ({open, view, handleClose, ...props}) => ( + + × + {view === 'SIGNIN' && } + {view === 'SIGNUP' && } + {view === 'FORGOT' && } + +); + +export default SignDialog; diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js new file mode 100644 index 000000000..76a93f56c --- /dev/null +++ b/client/coral-sign-in/components/SignInContent.js @@ -0,0 +1,67 @@ +import React from 'react'; +import Button from 'coral-ui/components/Button'; +import FormField from './FormField'; +import Alert from './Alert'; +import Spinner from 'coral-ui/components/Spinner'; +import styles from './styles.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations'; +const lang = new I18n(translations); + +const SignInContent = ({handleChange, formData, ...props}) => ( +
+
+

+ {lang.t('signIn.signIn')} +

+
+
+ +
+
+

+ {lang.t('signIn.or')} +

+
+ { props.auth.error && {props.auth.error} } +
+ + +
+ { + !props.auth.isLoading ? + + : + + } +
+ + +
+); + +export default SignInContent; diff --git a/client/coral-sign-in/components/SignUpContent.js b/client/coral-sign-in/components/SignUpContent.js new file mode 100644 index 000000000..478971556 --- /dev/null +++ b/client/coral-sign-in/components/SignUpContent.js @@ -0,0 +1,91 @@ +import React from 'react'; +import FormField from './FormField'; +import Alert from './Alert'; +import Button from 'coral-ui/components/Button'; +import Spinner from 'coral-ui/components/Spinner'; +import Success from 'coral-ui/components/Success'; +import styles from './styles.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations'; +const lang = new I18n(translations); + +const SignUpContent = ({handleChange, formData, ...props}) => ( +
+
+

+ {lang.t('signIn.signUp')} +

+
+
+ +
+
+

+ {lang.t('signIn.or')} +

+
+ { props.auth.error && {props.auth.error} } +
+ + + + { !props.errors.password && Password must be at least 8 characters. } + +
+ { !props.auth.isLoading && !props.auth.successSignUp && ( + + )} + { props.auth.isLoading && } + { !props.auth.isLoading && props.auth.successSignUp && } +
+ +
+ + {lang.t('signIn.alreadyHaveAnAccount')} + props.changeView('SIGNIN')}> + {lang.t('signIn.signIn')} + + +
+
+); + +export default SignUpContent; diff --git a/client/coral-sign-in/components/UserBox.js b/client/coral-sign-in/components/UserBox.js new file mode 100644 index 000000000..17c8453fb --- /dev/null +++ b/client/coral-sign-in/components/UserBox.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './styles.css'; + +const UserBox = ({className, user, logout, ...props}) => ( +
+ Signed in as {user.displayName}. Not you? Sign out +
+); + +export default UserBox; diff --git a/client/coral-sign-in/components/styles.css b/client/coral-sign-in/components/styles.css new file mode 100644 index 000000000..81864eb2f --- /dev/null +++ b/client/coral-sign-in/components/styles.css @@ -0,0 +1,131 @@ +.dialog { + border: none; + box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2); + width: 280px; + top: 10px; +} + +.header { + margin-bottom: 20px; +} + +.header h1, .separator h1{ + text-align: center; + font-size: 1.2em; +} + +.formField { + margin-top: 15px; +} + +.formField label { + font-size: 1.08em; + font-weight: bold; + margin-bottom: 5px; +} + +.formField input { + width: 100%; + display: block; + border: none; + outline: none; + border: 1px solid rgba(0,0,0,.12); + padding: 10px 6px; + box-sizing: border-box; + border-radius: 2px; + margin: 5px auto; +} + +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + +.socialConnections { + margin-bottom: 20px; +} + +.signInButton { + margin-top: 10px; +} + +.close { + font-size: 20px; + line-height: 14px; + top: 10px; + right: 10px; + position: absolute; + display: block; + font-weight: bold; + color: #363636; + cursor: pointer; +} + +.close:hover { + color: #6b6b6b; +} + +input.error{ + border: solid 2px #f44336; +} + +.errorMsg, .hint { + color: grey; + font-weight: 600; + padding: 3px 0 16px; +} + +.alert { + padding: 10px; + margin-bottom: 20px; + border-radius: 2px; +} + +.alert--success { + border: solid 1px #1ec00e; + background: #cbf1b8; + color: #006900; +} + +.alert--error { + background: #FFEBEE; + color: #B71C1C; +} + +.userBox a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + +.attention { + display: inline-block; + width: 15px; + height: 15px; + background: #B71C1C; + color: #FFEBEE; + font-weight: bolder; + padding: 4px; + vertical-align: middle; + border-radius: 20px; + box-sizing: border-box; + font-size: 9px; + line-height: 7px; + text-align: center; + margin-right: 5px; +} + +.action { + margin-top: 15px; +} \ No newline at end of file diff --git a/client/coral-sign-in/containers/SignInContainer.js b/client/coral-sign-in/containers/SignInContainer.js new file mode 100644 index 000000000..fc85fd3c4 --- /dev/null +++ b/client/coral-sign-in/containers/SignInContainer.js @@ -0,0 +1,165 @@ +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import SignDialog from '../components/SignDialog'; +import Button from 'coral-ui/components/Button'; +import validate from 'coral-framework/helpers/validate'; +import errorMsj from 'coral-framework/helpers/error'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations'; +const lang = new I18n(translations); + +import { + changeView, + fetchSignUp, + fetchSignIn, + showSignInDialog, + hideSignInDialog, + fetchSignInFacebook, + fetchForgotPassword, + facebookCallback, + invalidForm, + validForm +} from '../../coral-framework/actions/auth'; + +class SignInContainer extends Component { + initialState = { + formData: { + email: '', + displayName: '', + password: '', + confirmPassword: '' + }, + errors: {}, + showErrors: false + }; + + constructor(props) { + super(props); + this.state = this.initialState; + this.handleChange = this.handleChange.bind(this); + this.handleSignUp = this.handleSignUp.bind(this); + this.handleSignIn = this.handleSignIn.bind(this); + this.handleClose = this.handleClose.bind(this); + this.addError = this.addError.bind(this); + } + + componentDidMount() { + window.authCallback = this.props.facebookCallback; + const {formData} = this.state; + const errors = Object.keys(formData).reduce((map, prop) => { + map[prop] = lang.t('signIn.requiredField'); + return map; + }, {}); + this.setState({errors}); + } + + handleChange(e) { + const {name, value} = e.target; + this.setState(state => ({ + ...state, + formData: { + ...state.formData, + [name]: value + } + }), () => { + this.validation(name, value); + }); + } + + addError(name, error) { + return this.setState(state => ({ + errors: { + ...state.errors, + [name]: error + } + })); + } + + validation(name, value) { + const {addError} = this; + const {formData} = this.state; + + if (!value.length) { + addError(name, lang.t('signIn.requiredField')); + } else if (name === 'confirmPassword' && formData.confirmPassword !== formData.password) { + addError('confirmPassword', lang.t('signIn.passwordsDontMatch')); + } else if (!validate[name](value)) { + addError(name, errorMsj[name]); + } else { + const { [name]: prop, ...errors } = this.state.errors; // eslint-disable-line + // Removes Error + this.setState(state => ({...state, errors})); + } + } + + isCompleted() { + const {formData} = this.state; + return !Object.keys(formData).filter(prop => !formData[prop].length).length; + } + + displayErrors(show = true) { + this.setState({showErrors: show}); + } + + handleSignUp(e) { + e.preventDefault(); + const {errors} = this.state; + const {fetchSignUp, validForm, invalidForm} = this.props; + this.displayErrors(); + if (this.isCompleted() && !Object.keys(errors).length) { + fetchSignUp(this.state.formData); + validForm(); + } else { + invalidForm(lang.t('signIn.checkTheForm')); + } + } + + handleSignIn(e) { + e.preventDefault(); + this.props.fetchSignIn(this.state.formData); + } + + handleClose() { + this.props.hideSignInDialog(); + } + + render() { + const {auth, showSignInDialog} = this.props; + return ( +
+ + +
+ ); + } +} + +const mapStateToProps = state => ({ + auth: state.auth.toJS() +}); + +const mapDispatchToProps = dispatch => ({ + facebookCallback: (err, data) => dispatch(facebookCallback(err, data)), + fetchSignUp: formData => dispatch(fetchSignUp(formData)), + fetchSignIn: formData => dispatch(fetchSignIn(formData)), + fetchSignInFacebook: () => dispatch(fetchSignInFacebook()), + fetchForgotPassword: formData => dispatch(fetchForgotPassword(formData)), + showSignInDialog: () => dispatch(showSignInDialog()), + changeView: view => dispatch(changeView(view)), + handleClose: () => dispatch(hideSignInDialog()), + invalidForm: error => dispatch(invalidForm(error)), + validForm: () => dispatch(validForm()) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(SignInContainer); diff --git a/client/coral-sign-in/translations.js b/client/coral-sign-in/translations.js new file mode 100644 index 000000000..4e1d35805 --- /dev/null +++ b/client/coral-sign-in/translations.js @@ -0,0 +1,48 @@ +export default { + en: { + 'signIn': { + facebookSignIn: 'Sign in with Facebook', + facebookSignUp: 'Sign up with Facebook', + logout: 'Logout', + signIn: 'Sign In', + or: 'Or', + email: 'E-mail Address', + password: 'Password', + forgotYourPass: 'Forgot your password?', + needAnAccount: 'Need an account?', + register: 'Register', + signUp: 'Sign Up', + confirmPassword: 'Confirm Password', + displayName: 'Display Name', + alreadyHaveAnAccount: 'Already have an account?', + recoverPassword: 'Recover password', + emailInUse: 'Email address already in use', + requiredField: 'This field is required', + passwordsDontMatch: 'Passwords don\'t match.', + checkTheForm: 'Invalid Form. Please, check the fields' + } + }, + es: { + 'signIn': { + facebookSignIn: 'Entrar con Facebook', + facebookSignUp: 'Regístrate con Facebook', + logout: 'Salir', + signIn: 'Entrar', + or: 'o', + email: 'E-mail', + password: 'Contraseña', + forgotYourPass: 'Has olvidado tu contraseña?', + needAnAccount: 'Necesitas una cuenta?', + register: 'Regístrate', + signUp: 'Registro', + confirmPassword: 'Confirmar Contraseña', + displayName: 'Nombre', + alreadyHaveAnAccount: 'Ya tienes una cuenta?', + recoverPassword: 'Recuperar contraseña', + emailInUse: 'Este email se encuentra en uso', + requiredField: 'Este campo es requerido', + passwordsDontMatch: 'Las contraseñas no coinciden', + checkTheForm: 'Formulario Inválido. Por favor, completa los campos' + } + } +}; diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css new file mode 100644 index 000000000..150f1df7b --- /dev/null +++ b/client/coral-ui/components/Button.css @@ -0,0 +1,49 @@ +.button { + background: 0 0; + border: none; + border-radius: 2px; + color: #000; + display: block; + position: relative; + height: 36px; + min-width: 64px; + padding: 0 8px; + display: inline-block; + font-family: 'Roboto','Helvetica','Arial',sans-serif; + font-size: 14px; + font-weight: 500; + letter-spacing: 0; + overflow: hidden; + will-change: box-shadow,transform; + -webkit-transition: box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1); + transition: box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1); + outline: none; + cursor: pointer; + text-decoration: none; + text-align: center; + line-height: 36px; + vertical-align: middle; + width: 100%; + margin: 0; +} + +.type--black { + color: #E0E0E0; + background: #212121; +} + +.type--local { + background: #E0E0E0; + color: #212121; +} + +.type--facebook { + background-color: #4267b2; + border-color: #4267b2; + color: rgb(255, 255, 255); +} + +.type--facebook:hover { + background-color: #365899; + border-color: #365899; +} \ No newline at end of file diff --git a/client/coral-ui/components/Button.js b/client/coral-ui/components/Button.js new file mode 100644 index 000000000..13ea46349 --- /dev/null +++ b/client/coral-ui/components/Button.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './Button.css'; + +const Button = ({cStyle = 'local', children, className, ...props}) => ( + +); + +export default Button; diff --git a/client/coral-ui/components/Dialog.js b/client/coral-ui/components/Dialog.js new file mode 100644 index 000000000..3f6585297 --- /dev/null +++ b/client/coral-ui/components/Dialog.js @@ -0,0 +1,62 @@ +import React, {Component, PropTypes} from 'react'; +import ReactDOM from 'react-dom'; +import dialogPolyfill from 'dialog-polyfill'; +import 'dialog-polyfill/dialog-polyfill.css'; + +export default class Dialog extends Component { + static propTypes = { + className: PropTypes.string, + title: PropTypes.string, + onCancel: PropTypes.func, + onClose: PropTypes.func, + open: PropTypes.bool, + style: PropTypes.object + }; + + static defaultProps = { + onCancel: e => e.preventDefault(), + onClose: e => e.preventDefault() + }; + + componentDidMount(){ + const dialog = ReactDOM.findDOMNode(this.refs.dialog); + dialogPolyfill.registerDialog(dialog); + + if (this.props.open) { + dialog.showModal(); + } + + dialog.addEventListener('close', this.props.onClose); + dialog.addEventListener('cancel', this.props.onCancel); + } + + componentDidUpdate(prevProps) { + const dialog = ReactDOM.findDOMNode(this.refs.dialog); + if (this.props.open !== prevProps.open) { + if (this.props.open) { + dialog.showModal(); + } else { + dialog.close(); + } + } + } + + componentWillUnmount() { + const dialog = ReactDOM.findDOMNode(this.refs.dialog); + dialog.removeEventListener('cancel', this.props.onCancel); + } + + render() { + const {children, className = '', onClose, onCancel, open, ...rest} = this.props; // eslint-disable-line + + return ( + + {children} + + ); + } +} diff --git a/client/coral-ui/components/Spinner.css b/client/coral-ui/components/Spinner.css new file mode 100644 index 000000000..00b7f93a5 --- /dev/null +++ b/client/coral-ui/components/Spinner.css @@ -0,0 +1,87 @@ +.container { + display: block; + text-align: center; +} + +.spinner { + -webkit-animation: rotator 1.4s linear infinite; + animation: rotator 1.4s linear infinite; +} + +@-webkit-keyframes rotator { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(270deg); + transform: rotate(270deg); + } +} + +@keyframes rotator { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(270deg); + transform: rotate(270deg); + } +} +.path { + stroke-dasharray: 187; + stroke-dashoffset: 0; + -webkit-transform-origin: center; + transform-origin: center; + -webkit-animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; + animation: dash 1.4s ease-in-out infinite, colors 5.6s ease-in-out infinite; +} + +@-webkit-keyframes colors { + 0% { + stroke: #f67150; + } + 100% { + stroke: #f6a47e; + } +} + +@keyframes colors { + 0% { + stroke: #f67150; + } + 100% { + stroke: #f6a47e; + } +} +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + -webkit-transform: rotate(135deg); + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + -webkit-transform: rotate(450deg); + transform: rotate(450deg); + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 187; + } + 50% { + stroke-dashoffset: 46.75; + -webkit-transform: rotate(135deg); + transform: rotate(135deg); + } + 100% { + stroke-dashoffset: 187; + -webkit-transform: rotate(450deg); + transform: rotate(450deg); + } +} diff --git a/client/coral-ui/components/Spinner.js b/client/coral-ui/components/Spinner.js new file mode 100644 index 000000000..058f1af7c --- /dev/null +++ b/client/coral-ui/components/Spinner.js @@ -0,0 +1,12 @@ +import React from 'react'; +import styles from './Spinner.css'; + +const Spinner = () => ( +
+ + + +
+); + +export default Spinner; diff --git a/client/coral-ui/components/Success.css b/client/coral-ui/components/Success.css new file mode 100644 index 000000000..00b5ba260 --- /dev/null +++ b/client/coral-ui/components/Success.css @@ -0,0 +1,55 @@ +.container { + display: block; + text-align: center; +} + +.circle { + stroke-dasharray: 166; + stroke-dashoffset: 166; + stroke-width: 2; + stroke-miterlimit: 10; + stroke: #00897B; + fill: none; + animation: stroke .6s cubic-bezier(0.650, 0.000, 0.450, 1.000) forwards; +} + +.checkmark { + width: 56px; + height: 56px; + border-radius: 50%; + display: block; + stroke-width: 2; + stroke: #fff; + stroke-miterlimit: 10; + margin: 10% auto; + box-shadow: inset 0px 0px 0px #00897B; + animation: fill .4s ease-in-out .4s forwards, scale .3s ease-in-out .9s both; +} + +.check { + transform-origin: 50% 50%; + stroke-dasharray: 48; + stroke-dashoffset: 48; + animation: stroke .3s cubic-bezier(0.650, 0.000, 0.450, 1.000) .8s forwards; +} + +@keyframes stroke { + 100% { + stroke-dashoffset: 0; + } +} + +@keyframes scale { + 0%, 100% { + transform: none; + } + 50% { + transform: scale3d(1.1, 1.1, 1); + } +} + +@keyframes fill { + 100% { + box-shadow: inset 0px 0px 0px 30px #00897B; + } +} \ No newline at end of file diff --git a/client/coral-ui/components/Success.js b/client/coral-ui/components/Success.js new file mode 100644 index 000000000..4db697794 --- /dev/null +++ b/client/coral-ui/components/Success.js @@ -0,0 +1,13 @@ +import React from 'react'; +import styles from './Success.css'; + +const Success = () => ( +
+ + + + +
+); + +export default Success; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js new file mode 100644 index 000000000..5116a16dd --- /dev/null +++ b/client/coral-ui/index.js @@ -0,0 +1 @@ +export {default as Dialog} from './components/Dialog'; diff --git a/docker-compose.yml b/docker-compose.yml index ecfac00bc..d7a180277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,10 @@ services: environment: - "TALK_PORT=5000" - "TALK_MONGO_URL=mongodb://mongo" + - "TALK_REDIS_URL=redis://redis" depends_on: - mongo + - redis mongo: image: mongo:3.2 @@ -19,6 +21,14 @@ services: volumes: - mongo:/data/db + redis: + image: redis:3.2-alpine + restart: always + volumes: + - redis:/data + volumes: mongo: external: false + redis: + external: false diff --git a/middleware/authorization.js b/middleware/authorization.js new file mode 100644 index 000000000..238219a06 --- /dev/null +++ b/middleware/authorization.js @@ -0,0 +1,53 @@ +/** + * authorization contains the references to the authorization middleware. + * @type {Object} + */ +const authorization = module.exports = {}; + +const debug = require('debug')('talk:middleware:authorization'); + +/** + * ErrNotAuthorized is an error that is returned in the event an operation is + * deemed not authorized. + * @type {Error} + */ +const ErrNotAuthorized = new Error('not authorized'); +ErrNotAuthorized.status = 401; + +// Add the ErrNotAuthorized error to the authorization object to be exported. +authorization.ErrNotAuthorized = ErrNotAuthorized; + +/** + * has returns true if the user has all the roles specified, otherwise it will + * return false. + * @param {Object} user the user to check for roles + * @param {Array} roles all the roles that a user must have + * @return {Boolean} true if the user has all the roles required, false + * otherwise + */ +authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf(role) >= 0); + +/** + * needed is a connect middleware layer that ensures that all requests coming + * here are both authenticated and match a set of roles required to continue. + * @param {Array} roles all the roles that a user must have + * @return {Callback} connect middleware + */ +authorization.needed = (...roles) => (req, res, next) => { + // All routes that are wrapepd with this middleware actually require a role. + if (!req.user) { + debug(`No user on request, returning with ${ErrNotAuthorized}`); + return next(ErrNotAuthorized); + } + + // Check to see if the current user has all the roles requested for the given + // array of roles requested, if one is not on the user, then this will + // evaluate to true. + if (!authorization.has(req.user, ...roles)) { + debug('User does not have all the required roles to access this page'); + return next(ErrNotAuthorized); + } + + // Looks like they're allowed! + return next(); +}; diff --git a/models/setting.js b/models/setting.js index a35a1aba4..fb35e1eef 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,6 +1,13 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +/** + * this Schema manages application settings that get used on front- and backend + * NOTE: when you set a setting here, it will not automatically be exposed to + * the front end. You must add it to the whitelist in the settings route + * in /routes/api/settings/index.js + * @type {Schema} + */ const SettingSchema = new Schema({ id: {type: String, default: '1'}, moderation: {type: String, enum: ['pre', 'post'], default: 'pre'}, diff --git a/models/user.js b/models/user.js index d841480d8..83677e12c 100644 --- a/models/user.js +++ b/models/user.js @@ -1,6 +1,7 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. @@ -12,6 +13,14 @@ const USER_ROLES = [ 'moderator' ]; +// In the event that the TALK_SESSION_SECRET is missing but we are testing, then +// set the process.env.TALK_SESSION_SECRET. +if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { + process.env.TALK_SESSION_SECRET = 'keyboard cat'; +} else if (!process.env.TALK_SESSION_SECRET) { + throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); +} + // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. const UserSchema = new mongoose.Schema({ @@ -130,10 +139,14 @@ const UserService = module.exports = {}; * @param {Function} done [description] */ UserService.findLocalUser = (email, password) => { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required for findLocalUser'); + } + return UserModel.findOne({ profiles: { $elemMatch: { - id: email, + id: email.toLowerCase(), provider: 'local' } } @@ -237,6 +250,7 @@ UserService.changePassword = (id, password) => { }) .then((hashedPassword) => { return UserModel.update({id}, { + $inc: {__v: 1}, $set: { password: hashedPassword } @@ -268,6 +282,8 @@ UserService.createLocalUser = (email, password, displayName) => { return Promise.reject('email is required'); } + email = email.toLowerCase(); + if (!password) { return Promise.reject('password is required'); } @@ -296,9 +312,11 @@ UserService.createLocalUser = (email, password, displayName) => { user.save((err) => { if (err) { + if (err.code === 11000) { + return reject('Email address already in use'); + } return reject(err); } - return resolve(user); }); }); @@ -393,6 +411,57 @@ UserService.findByIdArray = (ids) => { }); }; +/** + * Creates a JWT from a user email. Only works for local accounts. + * @param {String} email of the local user + */ +UserService.createPasswordResetToken = function (email) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) + .then(user => { + + if (user === null) { + // since we don't want to reveal that the email does/doesn't exist + // just go ahead and resolve the Promise with null and check in the endpoint + return Promise.resolve(null); + } + + const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v}; + const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'}); + + return token; + }); +}; + +/** + * verifies a jwt and returns the associated user + * @param {String} token the JSON Web Token to verify + */ +UserService.verifyPasswordResetToken = token => { + return new Promise((resolve, reject) => { + jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => { + if (error) { + return reject(error); + } + + resolve(decoded); + }); + }) + .then(decoded => { + /** + * TODO: check the jti from this decoded token in redis + * and make an entry if it does not exist. + * reject if entry already exists. + */ + return UserService.findById(decoded.userId); + }); +}; + /** * Finds a user using a value which gets compared using a prefix match against * the user's email address and/or their display name. @@ -426,3 +495,19 @@ UserService.search = (value) => { ] }); }; + +/** + * Returns a count of the current users. + * @return {Promise} + */ +UserService.count = () => { + return UserModel.count(); +}; + +/** + * Returns all the users. + * @return {Promise} + */ +UserService.all = () => { + return UserModel.find(); +}; diff --git a/mongoose.js b/mongoose.js index 9062816e6..712b2fcb0 100644 --- a/mongoose.js +++ b/mongoose.js @@ -10,16 +10,12 @@ if (enabled('talk:db')) { mongoose.set('debug', true); } -try { - mongoose.connect(url, (err) => { - if (err) { - throw err; - } +mongoose.connect(url, (err) => { + if (err) { + throw err; + } - debug('Connected to MongoDB!'); - }); -} catch (err) { - console.error('Cannot stablish a connection with MongoDB', err); -} + debug('connection established'); +}); module.exports = mongoose; diff --git a/package.json b/package.json index 354340ae1..dd9f8db8a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint . --fix", - "pretest": "npm install", "test": "mocha --compilers js:babel-core/register --recursive tests", "test-watch": "mocha --compilers js:babel-core/register --recursive -w tests", "embed-start": "NODE_ENV=development npm run build && ./bin/www" @@ -37,13 +36,27 @@ "dependencies": { "bcrypt": "^0.8.7", "body-parser": "^1.15.2", + "cli-table": "^0.3.1", "commander": "^2.9.0", + "connect-redis": "^3.1.0", "debug": "^2.2.0", "ejs": "^2.5.2", "express": "^4.14.0", + "express-session": "^1.14.2", + "helmet": "^3.1.0", + "lodash.debounce": "^4.0.8", "mongoose": "^4.6.5", "morgan": "^1.7.0", + "passport": "^0.3.2", + "passport-facebook": "^2.1.1", + "passport-local": "^1.0.0", + "jsonwebtoken": "^7.1.9", + "lodash": "^4.16.6", + "mongoose": "^4.6.5", + "morgan": "^1.7.0", + "nodemailer": "^2.6.4", "prompt": "^1.0.0", + "redis": "^2.6.3", "uuid": "^2.0.3" }, "devDependencies": { @@ -66,6 +79,7 @@ "chai-http": "^3.0.0", "copy-webpack-plugin": "^4.0.0", "css-loader": "^0.25.0", + "dialog-polyfill": "^0.4.4", "eslint": "^3.9.1", "eslint-config-postcss": "^2.0.2", "eslint-config-standard": "^6.2.1", diff --git a/passport.js b/passport.js new file mode 100644 index 000000000..2e084eafd --- /dev/null +++ b/passport.js @@ -0,0 +1,84 @@ +const passport = require('passport'); +const User = require('./models/user'); +const LocalStrategy = require('passport-local').Strategy; +const FacebookStrategy = require('passport-facebook').Strategy; + +//============================================================================== +// SESSION SERIALIZATION +//============================================================================== + +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +passport.deserializeUser((id, done) => { + User + .findById(id) + .then((user) => { + done(null, user); + }) + .catch((err) => { + done(err); + }); +}); + +/** + * Validates that a user is allowed to login. + * @param {User} user the user to be validated + * @param {Function} done the callback for the validation + */ +function ValidateUserLogin(user, done) { + if (!user) { + return done(new Error('user not found')); + } + + if (user.disabled) { + return done(null, false, {message: 'Account disabled'}); + } + + return done(null, user); +} + +//============================================================================== +// STRATEGIES +//============================================================================== + +passport.use(new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' +}, (email, password, done) => { + User + .findLocalUser(email, password) + .then((user) => { + if (!user) { + return done(null, false, {message: 'Incorrect email/password combination'}); + } + + return ValidateUserLogin(user, done); + }) + .catch((err) => { + done(err); + }); +})); + +if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && process.env.TALK_ROOT_URL) { + passport.use(new FacebookStrategy({ + clientID: process.env.TALK_FACEBOOK_APP_ID, + clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, + callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, + profileFields: ['id', 'displayName', 'picture.type(large)'] + }, (accessToken, refreshToken, profile, done) => { + User + .findOrCreateExternalUser(profile) + .then((user) => + ValidateUserLogin(user, done) + ) + .catch((err) => { + done(err); + }); + })); +} else { + console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL'); +} + +module.exports = passport; diff --git a/redis.js b/redis.js new file mode 100644 index 000000000..c37fcc64e --- /dev/null +++ b/redis.js @@ -0,0 +1,39 @@ +const redis = require('redis'); +const debug = require('debug')('talk:redis'); +const url = process.env.TALK_REDIS_URL || 'redis://localhost'; + +const client = redis.createClient(url, { + retry_strategy: function(options) { + if (options.error && options.error.code === 'ECONNREFUSED') { + + // End reconnecting on a specific error and flush all commands with a individual error + return new Error('The server refused the connection'); + } + if (options.total_retry_time > 1000 * 60 * 60) { + + // End reconnecting after a specific timeout and flush all commands with a individual error + return new Error('Retry time exhausted'); + } + + if (options.times_connected > 10) { + + // End reconnecting with built in error + return undefined; + } + + // reconnect after + return Math.max(options.attempt * 100, 3000); + } +}); + +client.ping((err) => { + if (err) { + console.error('Can\'t ping the redis server!'); + + throw err; + } + + debug('connection established'); +}); + +module.exports = client; diff --git a/routes/admin/index.js b/routes/admin/index.js index 52957f54a..2b967708a 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,11 +1,15 @@ const express = require('express'); - const router = express.Router(); router.get('/embed/stream/preview', (req, res) => { res.render('embed-stream', {basePath: '/client/embed/stream'}); }); +router.get('/password-reset/:token', (req, res, next) => { + // render a page or something? + res.send('ok'); +}); + router.get('*', (req, res) => { res.render('admin', {basePath: '/client/coral-admin'}); }); diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js new file mode 100644 index 000000000..87ffb62c8 --- /dev/null +++ b/routes/api/auth/index.js @@ -0,0 +1,94 @@ +const express = require('express'); +const passport = require('../../../passport'); +const authorization = require('../../../middleware/authorization'); + +const router = express.Router(); + +/** + * This returns the user if they are logged in. + */ +router.get('/', authorization.needed(), (req, res) => { + res.json(req.user); +}); + +/** + * This destroys the session of a user, if they have one. + */ +router.delete('/', (req, res) => { + req.session.destroy(() => { + res.status(204).end(); + }); +}); + +/** + * This sends back the user data as JSON. + */ +const HandleAuthCallback = (req, res, next) => (err, user) => { + if (err) { + return next(err); + } + + if (!user) { + return next(authorization.ErrNotAuthorized); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return next(err); + } + + // We logged in the user! Let's send back the user data. + res.json({user}); + }); +}; + +/** + * Returns the response to the login attempt via a popup callback with some JS. + */ +const HandleAuthPopupCallback = (req, res, next) => (err, user) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + if (!user) { + return res.render('auth-callback', {err: JSON.stringify(authorization.ErrNotAuthorized), data: null}); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + // We logged in the user! Let's send back the user data. + res.render('auth-callback', {err: null, data: JSON.stringify(user)}); + }); +}; + +/** + * Local auth endpoint, will recieve a email and password + */ +router.post('/local', (req, res, next) => { + + // Perform the local authentication. + passport.authenticate('local', HandleAuthCallback(req, res, next))(req, res, next); +}); + +/** + * Facebook auth endpoint, this will redirect the user immediatly to facebook + * for authorization. + */ +router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']})); + +/** + * Facebook callback endpoint, this will send the user a html page designed to + * send back the user credentials upon sucesfull login. + */ +router.get('/facebook/callback', (req, res, next) => { + + // Perform the facebook login flow and pass the data back through the opener. + passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next); +}); + +module.exports = router; diff --git a/routes/api/index.js b/routes/api/index.js index 96e1f59d1..c49e52c9d 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); router.use('/asset', require('./asset')); +router.use('/auth', require('./auth')); router.use('/comments', require('./comments')); router.use('/queue', require('./queue')); router.use('/settings', require('./settings')); diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 585bf083a..2665cacc8 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const express = require('express'); const router = express.Router(); const Setting = require('../../../models/setting'); @@ -5,7 +6,10 @@ const Setting = require('../../../models/setting'); router.get('/', (req, res, next) => { Setting .getSettings() - .then(settings => res.json(settings)) + .then(settings => { + const whitelist = ['moderation']; + res.json(_.pick(settings, whitelist)); + }) .catch(next); }); diff --git a/routes/api/user/index.js b/routes/api/user/index.js index 71407300a..39bb8a5c9 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -1,6 +1,12 @@ const express = require('express'); const router = express.Router(); const User = require('../../../models/user'); +const mailer = require('../../../services/mailer'); +const ejs = require('ejs'); +const fs = require('fs'); +const path = require('path'); +const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs')); +const resetEmailTemplate = ejs.compile(resetEmailFile.toString()); router.get('/', (req, res, next) => { const { @@ -52,4 +58,80 @@ router.post('/:user_id/role', (req, res, next) => { .catch(next); }); +router.post('/', (req, res, next) => { + const {email, password, displayName} = req.body; + + User + .createLocalUser(email, password, displayName) + .then(user => { + res.status(201).send(user); + }) + .catch(err => { + next(err); + }); +}); + +/** + * expects 2 fields in the body of the request + * 1) the token that was in the url of the email link {String} + * 2) the new password {String} + */ +router.post('/update-password', (req, res, next) => { + const {token, password} = req.body; + + User.verifyPasswordResetToken(token) + .then(user => { + return User.changePassword(user.id, password); + }) + .then(() => { + res.status(204).end(); + }) + .catch(error => { + console.error(error); + res.status(401).send('Not Authorized'); + }); +}); + +/** + * this endpoint takes an email (username) and checks if it belongs to a User account + * if it does, create a JWT and send an email + */ +router.post('/request-password-reset', (req, res, next) => { + const {email} = req.body; + + if (!email) { + return next(); + } + + User + .createPasswordResetToken(email) + .then(token => { + if (token === null) { + return Promise.resolve('the email was not found in the db.'); + } + + const options = { + subject: 'Password Reset Requested - Talk', + from: 'noreply@coralproject.net', + to: email, + html: resetEmailTemplate({ + token, + // probably more clear to explicitly pass this + rootURL: process.env.TALK_ROOT_URL + }) + }; + return mailer.sendSimple(options); + }) + .then(() => { + // we want to send a 204 regardless of the user being found in the db + // if we fail on missing emails, it would reveal if people are registered or not. + res.status(204).send('OK'); + }) + .catch(error => { + const errorMsg = typeof error === 'string' ? error : error.message; + + res.status(500).json({error: errorMsg}); + }); +}); + module.exports = router; diff --git a/services/mailer.js b/services/mailer.js new file mode 100644 index 000000000..4da8024ca --- /dev/null +++ b/services/mailer.js @@ -0,0 +1,34 @@ +const nodemailer = require('nodemailer'); + +if (!process.env.TALK_SMTP_CONNECTION_URL) { + console.error('TALK_SMTP_CONNECTION_URL should be defined if you would like to send password reset emails from Talk'); +} + +const defaultTransporter = nodemailer.createTransport(process.env.TALK_SMTP_CONNECTION_URL); + +const mailer = { + + /** + * sendSimple + * + * @param {Object} {from, to, subject, text = '', html = ''} + * @returns + */ + sendSimple({from, to, subject, text = '', html = '', transporter = defaultTransporter}) { + return new Promise((resolve, reject) => { + if (!from) { + reject('sendSimple requires a from address'); + } + if (!to) { + reject('sendSimple requires a comma-separated list of "to" addresses'); + } + if (!subject) { + reject('sendSimple requires a subject for the email'); + } + + return resolve(transporter.sendMail({from, to, subject, text, html})); + }); + } +}; + +module.exports = mailer; diff --git a/swagger.yaml b/swagger.yaml index ba945e2e1..16ae9a960 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -162,6 +162,36 @@ paths: responses: 204: description: OK + + /user/request-password-reset: + post: + tags: + - Users + description: trigger a reset password email. sends a success code whether email was found or no. + responses: + 204: + description: OK + + /user/update-password: + post: + tags: + - Users + description: Update existing user password + parameters: + - name: token + type: string + in: body + description: JSON Web token taken taken from emailed link + required: true + - name: password + type: string + in: body + description: new password to be settings + required: true + responses: + 204: + description: OK + /asset: get: tags: @@ -276,7 +306,7 @@ definitions: description: A summary of the asset, inferred on initial load. section: type: string - description: The section the asset is in. + description: The section the asset is in. subsection: type: string description: The subsection that the asset is in. diff --git a/tests/client/coral-framework/store/authReducer.js b/tests/client/coral-framework/store/authReducer.js deleted file mode 100644 index aaebe1897..000000000 --- a/tests/client/coral-framework/store/authReducer.js +++ /dev/null @@ -1,31 +0,0 @@ -import {Map} from 'immutable'; -import {expect} from 'chai'; -import authReducer from '../../../../client/coral-framework/store/reducers/auth'; -import * as actions from '../../../../client/coral-framework/store/actions/auth'; - -describe ('authReducer', () => { - describe('SET_LOGGED_IN_USER', () => { - it('should set a logged in user', () => { - const action = { - type: actions.SET_LOGGED_IN_USER, - user_id: '123' - }; - const store = new Map({}); - const result = authReducer(store, action); - expect(result.get('user')).to.equal(action.user_id); - }); - }); - - describe('LOG_OUT_USER', () => { - it('should clear the user store', () => { - const action = { - type: actions.LOG_OUT_USER - }; - const store = new Map({ - user: '123' - }); - const result = authReducer(store, action); - expect(result.get('user')).to.equal(undefined); - }); - }); -}); diff --git a/tests/client/coral-framework/store/itemActions.spec.js b/tests/client/coral-framework/store/itemActions.spec.js index 14da38b41..46650c592 100644 --- a/tests/client/coral-framework/store/itemActions.spec.js +++ b/tests/client/coral-framework/store/itemActions.spec.js @@ -2,7 +2,7 @@ import 'react'; import 'redux'; import {expect} from 'chai'; import fetchMock from 'fetch-mock'; -import * as actions from '../../../../client/coral-framework/store/actions/items'; +import * as actions from '../../../../client/coral-framework/actions/items'; import {Map} from 'immutable'; import configureStore from 'redux-mock-store'; diff --git a/tests/client/coral-framework/store/itemReducer.spec.js b/tests/client/coral-framework/store/itemReducer.spec.js index 9aea845f3..16377327b 100644 --- a/tests/client/coral-framework/store/itemReducer.spec.js +++ b/tests/client/coral-framework/store/itemReducer.spec.js @@ -1,6 +1,6 @@ import {Map, fromJS} from 'immutable'; import {expect} from 'chai'; -import itemsReducer from '../../../../client/coral-framework/store/reducers/items'; +import itemsReducer from '../../../../client/coral-framework/reducers/items'; describe ('itemsReducer', () => { describe('ADD_ITEM', () => { diff --git a/tests/client/coral-framework/store/notificationReducer.spec.js b/tests/client/coral-framework/store/notificationReducer.spec.js index 60b944a33..960034119 100644 --- a/tests/client/coral-framework/store/notificationReducer.spec.js +++ b/tests/client/coral-framework/store/notificationReducer.spec.js @@ -1,7 +1,7 @@ import {Map} from 'immutable'; import {expect} from 'chai'; -import notificationReducer from '../../../../client/coral-framework/store/reducers/notification'; -import * as actions from '../../../../client/coral-framework/store/actions/notification'; +import notificationReducer from '../../../../client/coral-framework/reducers/notification'; +import * as actions from '../../../../client/coral-framework/actions/notification'; describe ('notificationsReducer', () => { describe('ADD_NOTIFICATION', () => { diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js new file mode 100644 index 000000000..dd49a1472 --- /dev/null +++ b/tests/routes/api/auth/index.js @@ -0,0 +1,39 @@ +require('../../../utils/mongoose'); + +const app = require('../../../../app'); +const chai = require('chai'); +const expect = chai.expect; + +chai.use(require('chai-http')); + +const User = require('../../../../models/user'); + +describe('POST /auth/local', () => { + + beforeEach(() => { + return User.createLocalUser('maria@gmail.com', 'password!', 'Maria'); + }); + + it('should send back the user on a successful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .catch((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); + + it('should not send back the user on a unsuccessful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!3'}) + .catch((err) => { + expect(err).to.not.be.null; + expect(err.response).to.have.status(401); + expect(err.response.body).to.have.property('message', 'not authorized'); + }); + }); +}); diff --git a/views/admin.ejs~HEAD b/views/admin.ejs~HEAD deleted file mode 100644 index 038fb58d7..000000000 --- a/views/admin.ejs~HEAD +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - Talk - Coral Admin - - - - - -
- - - diff --git a/views/article.ejs b/views/article.ejs index 95c0d7904..7d168f960 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -3,23 +3,40 @@ - - + -
+

<%= title %>

-

Lorem ipsum dolor sponge amet, consectetur adipiscing clam. Ut lobortis sollicitudin pillar a ornare. Curabitur dignissim vestibulum cay non rhoncus. Cras laoreet ante vel nunc hendrerit, shelf imperdiet neque egestas. Suspendisse aliquet iaculis fermentum. Talk volutpat, tellus posuere laoreet consequat, mi lacus laoreet massa, sed vehicula mauris velit non lectus. Integer non trust nec neque congue faucibus porttitor sit amet elkhorn.

+

+ Lorem ipsum dolor sponge amet, consectetur adipiscing clam. + Ut lobortis sollicitudin pillar a ornare. Curabitur dignissim + vestibulum cay non rhoncus. Cras laoreet ante vel nunc hendrerit, + shelf imperdiet neque egestas. Suspendisse aliquet iaculis fermentum. + Talk volutpat, tellus posuere laoreet consequat, mi lacus laoreet massa, + sed vehicula mauris velit non lectus. Integer non trust nec neque congue + faucibus porttitor sit amet elkhorn. +

Visit the moderation console

+
+
-
- - -
+ + + diff --git a/views/auth-callback.ejs b/views/auth-callback.ejs new file mode 100644 index 000000000..d38d759ee --- /dev/null +++ b/views/auth-callback.ejs @@ -0,0 +1,9 @@ + + + + + + diff --git a/views/embed-stream.ejs b/views/embed-stream.ejs index 36f7ce43b..e1ddb17e7 100644 --- a/views/embed-stream.ejs +++ b/views/embed-stream.ejs @@ -11,18 +11,22 @@ body, #root { width: 100%; height: 100%; - margin: 0; - background: #fff; + min-height: 600px; + margin: 0; + background: #fff; }
+ + - - + var pymParent = new pym.Parent('coralStreamEmbed', '/embed/stream', {title: 'Talk Comments'}); + pymParent.onMessage('height', function(height) { + document.querySelector('#coralStreamEmbed iframe').height = height + 'px'; + }); + diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index dd7142c3b..5cdef0811 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -7,7 +7,7 @@ rel="stylesheet"> -
+
diff --git a/views/password-reset-email.ejs b/views/password-reset-email.ejs new file mode 100644 index 000000000..0637b6bb2 --- /dev/null +++ b/views/password-reset-email.ejs @@ -0,0 +1,6 @@ + +

We received a request to reset your password. If you did not request this change, you can ignore this email.
+If you did, please click here to reset password.

+<% if (process.env.NODE_ENV !== 'production') { %> +

<%= token %>

+<% } %>