diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index af748ab7b..2bd59b79d 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,7 +1,7 @@ import React from 'react'; import {Router, Route, IndexRoute, IndexRedirect, browserHistory} from 'react-router'; -import Streams from 'containers/Streams/Streams'; +import Stories from 'containers/Stories/Stories'; import Configure from 'containers/Configure/Configure'; import LayoutContainer from 'containers/LayoutContainer'; import InstallContainer from 'containers/Install/InstallContainer'; @@ -21,7 +21,7 @@ const routes = ( - + {/* Community Routes */} diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index 389e8b4d2..4e949f7b7 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -2,15 +2,24 @@ import * as actions from '../constants/auth'; import coralApi from 'coral-framework/helpers/response'; // Log In. -export const handleLogin = (email, password) => dispatch => { +export const handleLogin = (email, password, recaptchaResponse) => dispatch => { dispatch({type: actions.LOGIN_REQUEST}); - return coralApi('/auth/local', {method: 'POST', body: {email, password}}) + const params = {method: 'POST', body: {email, password}}; + if (recaptchaResponse) { + params.headers = {'X-Recaptcha-Response': recaptchaResponse}; + } + return coralApi('/auth/local', params) .then(result => { const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) .catch(error => { - dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); + + if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { + dispatch({type: actions.LOGIN_MAXIMUM_EXCEEDED, message: error.translation_key}); + } else { + dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); + } }); }; @@ -34,9 +43,13 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); return coralApi('/auth') - .then(result => { - const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; - dispatch(checkLoginSuccess(result.user, isAdmin)); + .then(({user}) => { + if (!user) { + return dispatch(checkLoginFailure('not logged in')); + } + + const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; + dispatch(checkLoginSuccess(user, isAdmin)); }) .catch(error => { console.error(error); diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index 7c7d4b00f..11e840eb7 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -4,6 +4,7 @@ import styles from './NotFound.css'; import {Button, TextField, Alert, Success} from 'coral-ui'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations'; +import Recaptcha from 'react-recaptcha'; const lang = new I18n(translations); class AdminLogin extends React.Component { @@ -18,13 +19,22 @@ class AdminLogin extends React.Component { this.props.handleLogin(this.state.email, this.state.password); } + onRecaptchaLoad = () => { + + // do something? + } + + onRecaptchaVerify = (recaptchaResponse) => { + this.props.handleLogin(this.state.email, this.state.password, recaptchaResponse); + } + handleRequestPassword = e => { e.preventDefault(); this.props.requestPasswordReset(this.state.email); } render () { - const {errorMessage} = this.props; + const {errorMessage, loginMaxExceeded} = this.props; const signInForm = (
{errorMessage && {lang.t(`errors.${errorMessage}`)}} @@ -49,6 +59,15 @@ class AdminLogin extends React.Component { this.setState({requestPassword: true}); }}>Request a new one.

+ { + loginMaxExceeded && + + } ); const requestPasswordForm = ( @@ -84,6 +103,7 @@ class AdminLogin extends React.Component { } AdminLogin.propTypes = { + loginMaxExceeded: PropTypes.bool.isRequired, handleLogin: PropTypes.func.isRequired, passwordRequestSuccess: PropTypes.string, loginError: PropTypes.string diff --git a/client/coral-admin/src/components/CountdownTimer.js b/client/coral-admin/src/components/CountdownTimer.js new file mode 100644 index 000000000..aba4d0b5a --- /dev/null +++ b/client/coral-admin/src/components/CountdownTimer.js @@ -0,0 +1,83 @@ +import React, {PropTypes} from 'react'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from 'coral-admin/src/translations'; +import styles from 'coral-admin/src/containers/Dashboard/Dashboard.css'; +import {Icon} from 'coral-ui'; + +const lang = new I18n(translations); +const refreshIntervalSeconds = 60 * 5; + +class CountdownTimer extends React.Component { + + static propTypes = { + handleTimeout: PropTypes.func.isRequired + } + + constructor (props) { + super(props); + try { + if (window.localStorage.getItem('coral:dashboardNote') === null) { + window.localStorage.setItem('coral:dashboardNote', 'show'); + } + } catch (e) { + + // above will fail in Private Mode in some browsers. + } + + this.state = { + secondsUntilRefresh: refreshIntervalSeconds, + dashboardNote: window.localStorage.getItem('coral:dashboardNote') || 'show' + }; + } + + componentWillMount () { + this.interval = setInterval(() => { // the countdown timer + let nextCount = this.state.secondsUntilRefresh - 1; + if (nextCount < 0) { + nextCount = refreshIntervalSeconds; + return this.props.handleTimeout(); + } + this.setState({secondsUntilRefresh: nextCount}); + }, 1000); + } + + componentWillUnmount () { + window.clearInterval(this.interval); + } + + formatTime = () => { + const minutes = Math.floor(this.state.secondsUntilRefresh / 60); + let seconds = (this.state.secondsUntilRefresh % 60).toString(); + if (seconds.length < 2) { + seconds = `0${seconds}`; + } + + return `${minutes}:${seconds}`; + } + + dismissNote = () => { + try { + window.localStorage.setItem('coral:dashboardNote', 'hide'); + } catch (e) { + + // when setItem fails in Safari Private mode + this.setState({dashboardNote: 'hide'}); + } + } + + render () { + const hideReloadNote = window.localStorage.getItem('coral:dashboardNote') === 'hide' || + this.state.dashboardNote === 'hide'; // for Safari Incognito + return ( +

+ × + {lang.t('dashboard.next-update', this.formatTime())} {lang.t('dashboard.auto-update')} +

+ ); + } +} + +export default CountdownTimer; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index 7036998d1..fc2ba9931 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -186,4 +186,5 @@ .actionButton { transform: scale(.8); margin: 0; + width: 140px; } diff --git a/client/coral-admin/src/components/ui/Drawer.js b/client/coral-admin/src/components/ui/Drawer.js index 9af651f3b..5ac55045c 100644 --- a/client/coral-admin/src/components/ui/Drawer.js +++ b/client/coral-admin/src/components/ui/Drawer.js @@ -23,9 +23,9 @@ const CoralDrawer = ({handleLogout, restricted = false}) => ( {lang.t('configure.moderate')} - {lang.t('configure.streams')} + {lang.t('configure.stories')} ( - {lang.t('configure.streams')} + {lang.t('configure.stories')} () => { updateSettings({requireEmailConfirmation: !verify}); }; +const updatePremodLinksEnable = (updateSettings, premodLinks) => () => { + const premodLinksEnable = !premodLinks; + updateSettings({premodLinksEnable}); +}; + const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => { // just putting this here for shorthand below @@ -50,6 +55,19 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {

+ +
+ +
+
+
{lang.t('configure.enable-premod-links')}
+

+ {lang.t('configure.enable-premod-links-text')} +

+
+
() => { updateSettings({infoBoxEnable}); }; -const updatePremodLinksEnable = (updateSettings, premodLinks) => () => { - const premodLinksEnable = !premodLinks; - updateSettings({premodLinksEnable}); -}; - const updateInfoBoxContent = (updateSettings) => (event) => { const infoBoxContent = event.target.value; updateSettings({infoBoxContent}); @@ -99,19 +94,6 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {

- -
- -
-
-
{lang.t('configure.enable-premod-links')}
-

- {lang.t('configure.enable-premod-links-text')} -

-
-
{ + return ( +
+

Articles with the most conversations

+
+

{lang.t('streams.article')}

+

{lang.t('dashboard.comment_count')}

+
+
+ { + assets.length + ? assets.map(asset => { + return ( +
+ Moderate +

{asset.commentCount}

+ +

{asset.title}

+ +

{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}

+
+ ); + }) + :
{lang.t('dashboard.no_activity')}
+ } +
+
+ ); +}; + +ActivityWidget.propTypes = { + assets: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + url: PropTypes.string, + commentCount: PropTypes.number, + author: PropTypes.string, + created_at: PropTypes.string + })).isRequired +}; + +export default ActivityWidget; diff --git a/client/coral-admin/src/containers/Dashboard/Dashboard.js b/client/coral-admin/src/containers/Dashboard/Dashboard.js index 5b4a20282..e43db9628 100644 --- a/client/coral-admin/src/containers/Dashboard/Dashboard.js +++ b/client/coral-admin/src/containers/Dashboard/Dashboard.js @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import {getMetrics} from 'coral-admin/src/graphql/queries'; import FlagWidget from './FlagWidget'; import LikeWidget from './LikeWidget'; +import ActivityWidget from './ActivityWidget'; import {showBanUserDialog, hideBanUserDialog} from 'coral-admin/src/actions/moderation'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from 'coral-admin/src/translations'; @@ -15,9 +16,22 @@ const refreshIntervalSeconds = 60 * 5; class Dashboard extends React.Component { - state = { - noteHidden: false, - secondsUntilRefresh: refreshIntervalSeconds + constructor (props) { + super(props); + + try { + if (window.localStorage.getItem('coral:dashboardNote') === null) { + window.localStorage.setItem('coral:dashboardNote', 'show'); + } + } catch (e) { + + // above will fail in Private Mode in some browsers. + } + + this.state = { + secondsUntilRefresh: refreshIntervalSeconds, + dashboardNote: window.localStorage.getItem('coral:dashboardNote') || 'show' + }; } componentWillMount () { @@ -31,6 +45,16 @@ class Dashboard extends React.Component { }, 1000); } + dismissNote = () => { + try { + window.localStorage.setItem('coral:dashboardNote', 'hide'); + } catch (e) { + + // when setItem fails in Safari Private mode + this.setState({dashboardNote: 'hide'}); + } + } + formatTime = () => { const minutes = Math.floor(this.state.secondsUntilRefresh / 60); let seconds = (this.state.secondsUntilRefresh % 60).toString(); @@ -47,20 +71,24 @@ class Dashboard extends React.Component { return ; } - const {data: {assetsByLike, assetsByFlag}} = this.props; + const {data: {assetsByLike, assetsByFlag, assetsByActivity}} = this.props; + const hideReloadNote = window.localStorage.getItem('coral:dashboardNote') === 'hide' || + this.state.dashboardNote === 'hide'; // for Safari Incognito return (

this.setState({noteHidden: true})}> + onClick={this.dismissNote}> × {lang.t('dashboard.next-update', this.formatTime())} {lang.t('dashboard.auto-update')}

+ +
); diff --git a/client/coral-admin/src/containers/Dashboard/FlagWidget.js b/client/coral-admin/src/containers/Dashboard/FlagWidget.js index 7d2ffe17a..17be2acbd 100644 --- a/client/coral-admin/src/containers/Dashboard/FlagWidget.js +++ b/client/coral-admin/src/containers/Dashboard/FlagWidget.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {PropTypes} from 'react'; import {Link} from 'react-router'; import styles from './Widget.css'; import I18n from 'coral-framework/modules/i18n/i18n'; @@ -6,8 +6,7 @@ import translations from 'coral-admin/src/translations'; const lang = new I18n(translations); -const FlagWidget = (props) => { - const {assets} = props; +const FlagWidget = ({assets}) => { return (
@@ -20,7 +19,11 @@ const FlagWidget = (props) => { { assets.length ? assets.map(asset => { - const flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary'); + let flagSummary = null; + if (asset.action_summaries) { + flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary'); + } + return (
Moderate @@ -39,4 +42,14 @@ const FlagWidget = (props) => { ); }; +FlagWidget.propTypes = { + assets: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + url: PropTypes.string, + action_summaries: PropTypes.array, + author: PropTypes.string, + created_at: PropTypes.string + })).isRequired +}; + export default FlagWidget; diff --git a/client/coral-admin/src/containers/Dashboard/LikeWidget.js b/client/coral-admin/src/containers/Dashboard/LikeWidget.js index 436fbcaf3..b878c281b 100644 --- a/client/coral-admin/src/containers/Dashboard/LikeWidget.js +++ b/client/coral-admin/src/containers/Dashboard/LikeWidget.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {PropTypes} from 'react'; import {Link} from 'react-router'; import styles from './Widget.css'; import I18n from 'coral-framework/modules/i18n/i18n'; @@ -6,9 +6,7 @@ import translations from 'coral-admin/src/translations'; const lang = new I18n(translations); -const LikeWidget = (props) => { - - const {assets} = props; +const LikeWidget = ({assets}) => { return (
@@ -40,4 +38,14 @@ const LikeWidget = (props) => { ); }; +LikeWidget.propTypes = { + assets: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + url: PropTypes.string, + action_summaries: PropTypes.array, + author: PropTypes.string, + created_at: PropTypes.string + })).isRequired +}; + export default LikeWidget; diff --git a/client/coral-admin/src/containers/Dashboard/Widget.css b/client/coral-admin/src/containers/Dashboard/Widget.css index 9912ac4e4..b4515786f 100644 --- a/client/coral-admin/src/containers/Dashboard/Widget.css +++ b/client/coral-admin/src/containers/Dashboard/Widget.css @@ -23,7 +23,6 @@ padding-left: 10px; font-size: 1.5rem; font-weight: bold; - background-color: #e0e0e0; } .widgetTable { diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index d122cd23a..d2493a548 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -16,12 +16,14 @@ class LayoutContainer extends Component { loggedIn, loadingUser, loginError, + loginMaxExceeded, passwordRequestSuccess } = this.props.auth; const {handleLogout} = this.props; if (loadingUser) { return ; } if (!isAdmin) { return ({ const mapDispatchToProps = dispatch => ({ checkLogin: () => dispatch(checkLogin()), - handleLogin: (username, password) => dispatch(handleLogin(username, password)), + handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)), requestPasswordReset: email => dispatch(requestPasswordReset(email)), handleLogout: () => dispatch(logout()) }); diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index d936d840f..a6d3527c7 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -3,6 +3,7 @@ import {connect} from 'react-redux'; import {compose} from 'react-apollo'; import key from 'keymaster'; import isEqual from 'lodash/isEqual'; +import styles from './components/styles.css'; import {modQueueQuery} from '../../graphql/queries'; import {banUser, setCommentStatus} from '../../graphql/mutations'; @@ -90,6 +91,17 @@ class ModerationContainer extends Component { key.unbind('t'); } + componentDidUpdate(_, prevState) { + + // If paging through using keybaord shortcuts, scroll the page to keep the selected + // comment in view. + if (prevState.selectedIndex !== this.state.selectedIndex) { + + // the 'smooth' flag only works in FF as of March 2017 + document.querySelector(`.${styles.selected}`).scrollIntoView({behavior: 'smooth'}); + } + } + componentWillReceiveProps(nextProps) { const {updateAssets} = this.props; if(!isEqual(nextProps.data.assets, this.props.data.assets)) { diff --git a/client/coral-admin/src/containers/Streams/Streams.css b/client/coral-admin/src/containers/Stories/Stories.css similarity index 100% rename from client/coral-admin/src/containers/Streams/Streams.css rename to client/coral-admin/src/containers/Stories/Stories.css diff --git a/client/coral-admin/src/containers/Streams/Streams.js b/client/coral-admin/src/containers/Stories/Stories.js similarity index 97% rename from client/coral-admin/src/containers/Streams/Streams.js rename to client/coral-admin/src/containers/Stories/Stories.js index bd5ce6e85..4d2ad086a 100644 --- a/client/coral-admin/src/containers/Streams/Streams.js +++ b/client/coral-admin/src/containers/Stories/Stories.js @@ -1,5 +1,5 @@ import React, {Component} from 'react'; -import styles from './Streams.css'; +import styles from './Stories.css'; import {connect} from 'react-redux'; import I18n from 'coral-framework/modules/i18n/i18n'; import {fetchAssets, updateAssetState} from '../../actions/assets'; @@ -12,7 +12,7 @@ import EmptyCard from 'coral-admin/src/components/EmptyCard'; const limit = 25; -class Streams extends Component { +class Stories extends Component { state = { search: '', @@ -182,6 +182,6 @@ const mapDispatchToProps = (dispatch) => { }; }; -export default connect(mapStateToProps, mapDispatchToProps)(Streams); +export default connect(mapStateToProps, mapDispatchToProps)(Stories); const lang = new I18n(translations); diff --git a/client/coral-admin/src/containers/Streams/Stories.js b/client/coral-admin/src/containers/Streams/Stories.js new file mode 100644 index 000000000..4d2ad086a --- /dev/null +++ b/client/coral-admin/src/containers/Streams/Stories.js @@ -0,0 +1,187 @@ +import React, {Component} from 'react'; +import styles from './Stories.css'; +import {connect} from 'react-redux'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import {fetchAssets, updateAssetState} from '../../actions/assets'; +import translations from '../../translations.json'; +import {Link} from 'react-router'; + +import {Pager, Icon} from 'coral-ui'; +import {DataTable, TableHeader, RadioGroup, Radio} from 'react-mdl'; +import EmptyCard from 'coral-admin/src/components/EmptyCard'; + +const limit = 25; + +class Stories extends Component { + + state = { + search: '', + sort: 'desc', + filter: 'all', + statusMenus: {}, + timer: null, + page: 0 + } + + componentDidMount () { + this.props.fetchAssets(0, limit, '', this.state.sortBy); + } + + onSettingChange = (setting) => (e) => { + let options = this.state; + this.setState({[setting]: e.target.value}); + options[setting] = e.target.value; + this.props.fetchAssets(0, limit, options.search, options.sort, options.filter); + } + + onSearchChange = (e) => { + const search = e.target.value; + this.setState((prevState) => { + prevState.search = search; + clearTimeout(prevState.timer); + const fetchAssets = this.props.fetchAssets; + prevState.timer = setTimeout(() => { + fetchAssets(0, limit, search, this.state.sort, this.state.filter); + }, 350); + return prevState; + }); + } + + renderDate = (date) => { + const d = new Date(date); + return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`; + } + + onStatusClick = (closeStream, id, statusMenuOpen) => () => { + if (statusMenuOpen) { + this.setState(prev => { + prev.statusMenus[id] = false; + return prev; + }); + this.props.updateAssetState(id, closeStream ? Date.now() : null) + .then(() => { + const {search, sort, filter, page} = this.state; + this.props.fetchAssets(page, limit, search, sort, filter); + }); + } else { + this.setState(prev => { + prev.statusMenus[id] = true; + return prev; + }); + } + } + + renderTitle = (title, {id}) => {title} + + renderStatus = (closedAt, {id}) => { + const closed = closedAt && new Date(closedAt).getTime() < Date.now(); + const statusMenuOpen = this.state.statusMenus[id]; + return
+
+ {!statusMenuOpen && } + {closed ? lang.t('streams.closed') : lang.t('streams.open')} +
+ { + statusMenuOpen && +
+ {!closed ? lang.t('streams.closed') : lang.t('streams.open')} +
+ } +
; + } + + onPageClick = (page) => { + this.setState({page}); + const {search, sort, filter} = this.state; + this.props.fetchAssets((page - 1) * limit, limit, search, sort, filter); + } + + render () { + const {search, sort, filter} = this.state; + const {assets} = this.props; + + const assetsIds = assets.ids.map((id) => assets.byId[id]); + + return ( +
+
+
+ + +
+
{lang.t('streams.filter-streams')}
+
{lang.t('streams.stream-status')}
+ + {lang.t('streams.all')} + {lang.t('streams.open')} + {lang.t('streams.closed')} + +
{lang.t('streams.sort-by')}
+ + {lang.t('streams.newest')} + {lang.t('streams.oldest')} + +
+ { + assetsIds.length + ?
+ + {lang.t('streams.article')} + + {lang.t('streams.pubdate')} + + + {lang.t('streams.status')} + + + +
+ : {lang.t('streams.empty_result')} + } +
+ ); + } +} + +const mapStateToProps = ({assets}) => { + return { + assets: assets.toJS() + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + fetchAssets: (...args) => { + dispatch(fetchAssets.apply(this, args)); + }, + updateAssetState: (...args) => dispatch(updateAssetState.apply(this, args)) + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(Stories); + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/graphql/fragments/assetMetricsView.graphql b/client/coral-admin/src/graphql/fragments/assetMetricsView.graphql index 37335aeaa..c77fbc32b 100644 --- a/client/coral-admin/src/graphql/fragments/assetMetricsView.graphql +++ b/client/coral-admin/src/graphql/fragments/assetMetricsView.graphql @@ -4,6 +4,7 @@ fragment metrics on Asset { url author created_at + commentCount action_summaries { type: __typename actionCount diff --git a/client/coral-admin/src/graphql/queries/metricsQuery.graphql b/client/coral-admin/src/graphql/queries/metricsQuery.graphql index 42a9fb70e..f0dff8965 100644 --- a/client/coral-admin/src/graphql/queries/metricsQuery.graphql +++ b/client/coral-admin/src/graphql/queries/metricsQuery.graphql @@ -7,4 +7,7 @@ query Metrics ($from: Date!, $to: Date!) { assetsByLike: assetMetrics(from: $from, to: $to, sort: LIKE) { ...metrics } + assetsByActivity: assetMetrics(from: $from, to: $to, sort: ACTIVITY) { + ...metrics + } } diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index b52efdb85..a7054ddfa 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -6,6 +6,7 @@ const initialState = Map({ user: null, isAdmin: false, loginError: null, + loginMaxExceeded: false, passwordRequestSuccess: null }); @@ -29,12 +30,18 @@ export default function auth (state = initialState, action) { return initialState; case actions.LOGIN_REQUEST: return state.set('loginError', null); + case actions.LOGIN_SUCCESS: + return state.set('loginMaxExceeded', false).set('loginError', null); case actions.LOGIN_FAILURE: return state.set('loginError', action.message); case actions.FETCH_FORGOT_PASSWORD_REQUEST: return state.set('passwordRequestSuccess', null); case actions.FETCH_FORGOT_PASSWORD_SUCCESS: return state.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email.'); + case actions.LOGIN_MAXIMUM_EXCEEDED: + return state + .set('loginMaxExceeded', true) + .set('loginError', action.message); default : return state; } diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 7e0f13989..a5b50b21c 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -1,7 +1,8 @@ { "en": { "errors": { - "NOT_AUTHORIZED": "Your username or password is not recognized by our system." + "NOT_AUTHORIZED": "Your username or password is not recognized by our system.", + "LOGIN_MAXIMUM_EXCEEDED": "You have made too many unsuccessful password attempts. Please wait." }, "community": { "username_and_email": "Username and Email", @@ -94,7 +95,7 @@ "moderate": "Moderate", "configure": "Configure", "community": "Community", - "streams": "Streams", + "stories": "Stories", "closed-comments-desc": "Write a message to be displayed when when your comment stream is closed and no longer accepting comments.", "closed-comments-label": "Write a message...", "hours": "Hours", @@ -136,6 +137,7 @@ "no_flags": "There have been no flags in the last 5 minutes! Hooray!", "no_likes": "There have been no likes in the last 5 minutes. All quiet.", "flags": "Flags", + "no_activity": "There haven't been any comments anywhere in the last five minutes.", "comment_count": "comments" }, "streams": { @@ -151,14 +153,15 @@ "sort-by": "Sort By", "open": "Open", "closed": "Closed", - "article": "Article", + "article": "Story", "pubdate": "Publication Date", - "status": "Status" + "status": "Stream Status" } }, "es": { "errors": { - "NOT_AUTHORIZED": "Acción no autorizada." + "NOT_AUTHORIZED": "Acción no autorizada.", + "LOGIN_MAXIMUM_EXCEEDED": "Ha realizado demasiados intentos fallidos de contraseña. Por favor espera." }, "community": { "username_and_email": "Usuario y E-mail", @@ -252,7 +255,7 @@ "moderate": "Moderar", "configure": "Configurar", "community": "Comunidad", - "streams": "Streams", + "stories": "Artículos", "closed-comments-desc": "Escribe un mensaje que será mostrado cuando los comentarios estén cerrados y no se acepten más comentarios.", "closed-comments-label": "Escribe un mensaje...", "never": "Nunca", @@ -283,6 +286,7 @@ "no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!", "no_likes": "A nadie le ha gustado algún comentario en los últimos 5 minutos. Todo tranquilo.", "flags": "Marcados", + "no_activity": "No hubo comentarios en los ultimos 5 minutos", "comment_count": "comentarios" }, "streams": { @@ -298,7 +302,7 @@ "sort-by": "", "open": "", "closed": "", - "article": "", + "article": "artículo", "pubdate": "", "status": "" } diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js index 0a3cdb78b..06352ec2f 100644 --- a/client/coral-embed-stream/src/Comment.js +++ b/client/coral-embed-stream/src/Comment.js @@ -157,31 +157,31 @@ class Comment extends React.Component {
- - - - - setActiveReplyBox(comment.id)} - parentCommentId={parentId || comment.id} - currentUserId={currentUser && currentUser.id} - banned={false} /> - - - - - - -
+ + + + + setActiveReplyBox(comment.id)} + parentCommentId={parentId || comment.id} + currentUserId={currentUser && currentUser.id} + banned={false} /> + + + + + + +
@@ -232,7 +232,7 @@ class Comment extends React.Component { removeCommentTag={removeCommentTag} showSignInDialog={showSignInDialog} reactKey={reply.id} - key={reply.id} + key={`${reply.id}:${depth}`} comment={reply} />; }) } @@ -243,6 +243,8 @@ class Comment extends React.Component { assetId={asset.id} comments={comment.replies} parentId={comment.id} + topLevel={false} + replyCount={comment.replyCount} moreComments={comment.replyCount > comment.replies.length} loadMore={loadMore}/>
diff --git a/client/coral-embed-stream/src/Embed.js b/client/coral-embed-stream/src/Embed.js index 0b9bc4fc5..ce6dc638a 100644 --- a/client/coral-embed-stream/src/Embed.js +++ b/client/coral-embed-stream/src/Embed.js @@ -221,6 +221,7 @@ class Embed extends Component { comments={asset.comments} />
asset.comments.length} diff --git a/client/coral-embed-stream/src/LoadMore.js b/client/coral-embed-stream/src/LoadMore.js index e33828bc1..bf89793d0 100644 --- a/client/coral-embed-stream/src/LoadMore.js +++ b/client/coral-embed-stream/src/LoadMore.js @@ -24,22 +24,45 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => { }); }; -const LoadMore = ({assetId, comments, loadMore, moreComments, parentId}) => ( - moreComments - ? - : null -); +class LoadMore extends React.Component { + + componentDidMount () { + this.initialState = true; + } + + replyCountFormat = (count) => { + if (count === 1) { + return lang.t('viewReply'); + } + + if (this.initialState) { + return lang.t('viewAllRepliesInitial', count); + } else { + return lang.t('viewAllReplies', count); + } + } + + render () { + const {assetId, comments, loadMore, moreComments, parentId, replyCount, topLevel} = this.props; + return moreComments + ? + : null; + } +} LoadMore.propTypes = { assetId: PropTypes.string.isRequired, comments: PropTypes.array.isRequired, moreComments: PropTypes.bool.isRequired, + topLevel: PropTypes.bool.isRequired, + replyCount: PropTypes.number, loadMore: PropTypes.func.isRequired }; diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 68bac3110..629efd29f 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -425,6 +425,8 @@ button.coral-load-more { cursor: pointer; padding: 10px; border-radius: 2px; + line-height: 1em; + text-transform: capitalize; } button.coral-load-more:hover { diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index ba1952e29..9dedaa2f2 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -48,7 +48,7 @@ export const fetchSignIn = (formData) => (dispatch) => { dispatch(signInRequest()); return coralApi('/auth/local', {method: 'POST', body: formData}) .then(({user}) => { - const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; + const isAdmin = !!user && !!user.roles.filter(i => i === 'ADMIN').length; dispatch(signInSuccess(user, isAdmin)); dispatch(hideSignInDialog()); }) diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index d115aedca..b327a7097 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -4,14 +4,17 @@ "successUpdateSettings": "The changes you have made have been applied to the comment stream on this article", "successNameUpdate": "Your username has been updated", "contentNotAvailable": "This content is not available", - "loadMore": "View more", - "bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Flag, or write comments. Please contact moderator@fakeurl.com for more information", + "bannedAccountMsg": "Your account is currently suspended. This means that you cannot Like, Report, or write comments. Please contact us if you have any questions.", "editName": { - "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. You may contact moderator@fakeurl.com for more information.", + "msg": "Your account is currently suspended because your username has been deemed inappropriate. To restore your account, please enter a new username. Please contact us if you have any questions.", "label": "New Username", "button": "Submit", "error": "Usernames can contain letters, numbers and _ only" }, + "viewMoreComments": "view more comments", + "viewReply": "view reply", + "viewAllRepliesInitial": "view all {0} replies", + "viewAllReplies": "view {0} replies", "newCount": "View {0} more {1}", "comment": "comment", "comments": "comments", @@ -41,9 +44,12 @@ "successUpdateSettings": "La configuración de este articulo fue actualizada", "successBioUpdate": "Tu bio fue actualizada", "contentNotAvailable": "El contenido no se encuentra disponible", - "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios. Por favor, contacta moderator@fakeurl for more information", + "bannedAccountMsg": "Tu cuenta se encuentra suspendida. Esto significa que no puedes dar Like, Marcar o escribir commentarios.", "editNameMsg": "", - "loadMore": "Ver más", + "viewMoreComments": "Var commentarios más", + "viewReply": "ver respuesta", + "viewAllRepliesInitial": "ver todas las {0} respuestas", + "viewAllReplies": "ver {0} respuestas", "newCount": "Ver {0} {1} más", "comment": "commentario", "comments": "commentarios", diff --git a/client/coral-plugin-flags/translations.json b/client/coral-plugin-flags/translations.json index caac6cbf1..255e13f3c 100644 --- a/client/coral-plugin-flags/translations.json +++ b/client/coral-plugin-flags/translations.json @@ -7,8 +7,8 @@ "step-1-header": "Report an issue", "step-2-header": "Help us understand", "step-3-header": "Thank you for your input", - "flag-username": "Flag username", - "flag-comment": "Flag comment", + "flag-username": "Report username", + "flag-comment": "Report comment", "continue": "Continue", "done": "Done", "no-agree-comment": "I don't agree with this comment", @@ -20,8 +20,8 @@ "no-like-bio": "I don't like this bio", "marketing": "This looks like an ad/marketing", "user-impersonating": "This user is impersonating", - "thank-you": "We value your safety and feedback. A moderator will review your flag.", - "flag-reason": "Reason for flag (Optional)", + "thank-you": "We value your safety and feedback. A moderator will review your report.", + "flag-reason": "Reason for reporting (Optional)", "other": "Other" }, "es": { diff --git a/errors.js b/errors.js index 6d9abbcfd..6cd8d8da0 100644 --- a/errors.js +++ b/errors.js @@ -148,6 +148,11 @@ const ErrPermissionUpdateUsername = new APIError('You do not have permission to status: 500 }); +const ErrLoginAttemptMaximumExceeded = new APIError('You have made too many incorrect password attempts.', { + translation_key: 'LOGIN_MAXIMUM_EXCEEDED', + status: 429 +}); + module.exports = { ExtendableError, APIError, @@ -168,5 +173,6 @@ module.exports = { ErrNotAuthorized, ErrPermissionUpdateUsername, ErrSettingsInit, - ErrInstallLock + ErrInstallLock, + ErrLoginAttemptMaximumExceeded }; diff --git a/graph/loaders/metrics.js b/graph/loaders/metrics.js index a6fe5afe4..a0408ed4d 100644 --- a/graph/loaders/metrics.js +++ b/graph/loaders/metrics.js @@ -3,6 +3,53 @@ const DataLoader = require('dataloader'); const {objectCacheKeyFn} = require('./util'); const ActionModel = require('../../models/action'); +const CommentModel = require('../../models/comment'); + +/** + * Returns the assets which have had comments made within the last time period. + */ +const getAssetActivityMetrics = ({loaders: {Assets}}, {from, to, limit}) => { + let assetMetrics = []; + + return CommentModel.aggregate([ + {$match: { + parent_id: null, + created_at: { + $gt: from, + $lt: to + } + }}, + {$group: { + _id: '$asset_id', + commentCount: { + $sum: 1 + } + }}, + {$project: { + _id: false, + asset_id: '$_id', + commentCount: '$commentCount' + }}, + {$sort: { + commentCount: -1 + }}, + {$limit: limit} + ]) + .then((results) => { + assetMetrics = results; + + return Assets.getByID.loadMany(results.map((result) => result.asset_id)); + }) + .then((assets) => assets.map((asset, i) => { + + // We're leveraging the fact that the comments returned by the aggregation + // query are in the request order that we just made, it's what the + // Assets.getByID loader does. + asset.commentCount = assetMetrics[i].commentCount; + + return asset; + })); +}; /** * Returns a list of assets with action metadata included on the models. @@ -208,10 +255,11 @@ module.exports = (context) => ({ cacheKeyFn: objectCacheKeyFn('from', 'to') }), Assets: { - get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit}) + get: ({from, to, sort, limit}) => getAssetMetrics(context, {from, to, sort, limit}), + getActivity: ({from, to, limit}) => getAssetActivityMetrics(context, {from, to, limit}), }, Comments: { - get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit}) + get: ({from, to, sort, limit}) => getCommentMetrics(context, {from, to, sort, limit}), } } }); diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index d577ddddb..ce11fcae9 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -63,6 +63,10 @@ const RootQuery = { return null; } + if (sort === 'ACTIVITY') { + return Assets.getActivity({from, to, limit}); + } + return Assets.get({from, to, sort, limit}); }, diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 9655354fb..306a6f22a 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -496,6 +496,22 @@ enum USER_STATUS { APPROVED } +# Metrics for the assets. +enum ASSET_METRICS_SORT { + + # Represents a LikeAction. + LIKE + + # Represents a FlagAction. + FLAG + + # Represents a don't agree action. + DONTAGREE + + # Represents activity. + ACTIVITY +} + type RootQuery { # Site wide settings and defaults. @@ -526,7 +542,7 @@ type RootQuery { # Asset metrics related to user actions are saturated into the assets # returned. - assetMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset!] + assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!] # Comment metrics related to user actions are saturated into the comments # returned. diff --git a/models/user.js b/models/user.js index ea4195b8a..e1cbb466b 100644 --- a/models/user.js +++ b/models/user.js @@ -1,4 +1,5 @@ const mongoose = require('../services/mongoose'); +const bcrypt = require('bcrypt'); const uuid = require('uuid'); // USER_ROLES is the array of roles that is permissible as a user role. @@ -146,6 +147,25 @@ UserSchema.method('hasRoles', function(...roles) { }); }); +/** + * This verifies that a password is valid. + */ +UserSchema.method('verifyPassword', function(password) { + return new Promise((resolve, reject) => { + bcrypt.compare(password, this.password, (err, res) => { + if (err) { + return reject(err); + } + + if (!res) { + return resolve(false); + } + + return resolve(true); + }); + }); +}); + /** * All the graph operations that are available for a user. * @type {Array} diff --git a/package.json b/package.json index ec94e1db3..909452231 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "express": "^4.14.0", "express-session": "^1.14.2", "gql-merge": "^0.0.4", + "form-data": "^2.1.2", "graphql": "^0.8.2", "graphql-errors": "^2.1.0", "graphql-server-express": "^0.5.0", @@ -78,12 +79,14 @@ "mongoose": "^4.6.5", "morgan": "^1.7.0", "natural": "^0.4.0", + "node-fetch": "^1.6.3", "nodemailer": "^2.6.4", "parse-duration": "^0.1.1", "passport": "^0.3.2", "passport-facebook": "^2.1.1", "passport-local": "^1.0.0", "react-apollo": "^0.10.0", + "react-recaptcha": "^2.2.6", "redis": "^2.6.5", "uuid": "^2.0.3" }, @@ -138,7 +141,6 @@ "mocha": "^3.1.2", "mocha-junit-reporter": "^1.12.1", "nightwatch": "^0.9.11", - "node-fetch": "^1.6.3", "nodemon": "^1.11.0", "postcss-loader": "^1.1.0", "postcss-modules": "^0.5.2", diff --git a/services/passport.js b/services/passport.js index 229541521..4ec80bb60 100644 --- a/services/passport.js +++ b/services/passport.js @@ -1,9 +1,12 @@ const passport = require('passport'); const UsersService = require('./users'); const SettingsService = require('./settings'); +const fetch = require('node-fetch'); +const FormData = require('form-data'); const LocalStrategy = require('passport-local').Strategy; const FacebookStrategy = require('passport-facebook').Strategy; const errors = require('../errors'); +const debug = require('debug')('talk:passport'); //============================================================================== // SESSION SERIALIZATION @@ -74,27 +77,233 @@ function ValidateUserLogin(loginProfile, user, done) { // STRATEGIES //============================================================================== +/** + * This looks at the request headers to see if there is a recaptcha response on + * the input request. + */ +const CheckIfRecaptcha = (req) => { + let response = req.get('X-Recaptcha-Response'); + + if (response && response.length > 0) { + return true; + } + + return false; +}; + +/** + * This checks the user to see if the current email profile needs to get checked + * for recaptcha compliance before being allowed to login. + */ +const CheckIfNeedsRecaptcha = (user, email) => { + + // Get the profile representing the local account. + let profile = user.profiles.find((profile) => profile.id === email); + + // This should never get to this point, if it does, don't let this past. + if (!profile) { + throw new Error('ID indicated by loginProfile is not on user object'); + } + + if (profile.metadata && profile.metadata.recaptcha_required) { + return true; + } + + return false; +}; + +/** + * This stores the Recaptcha secret. + */ +const RECAPTCHA_SECRET = process.env.TALK_RECAPTCHA_SECRET; + +/** + * This is true when the recaptcha secret is provided and the Recaptcha feature + * is to be enabled. + */ +const RECAPTCHA_ENABLED = RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0; +if (!RECAPTCHA_ENABLED) { + console.log('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET to enable Recaptcha.'); +} + +/** + * This sends the request details down Google to check to see if the response is + * genuine or not. + * @return {Promise} resolves with the success status of the recaptcha + */ +const CheckRecaptcha = async (req) => { + + // Ask Google to verify the recaptcha response: https://developers.google.com/recaptcha/docs/verify + const form = new FormData(); + + form.append('secret', RECAPTCHA_SECRET); + form.append('response', req.get('X-Recaptcha-Response')); + form.append('remoteip', req.ip); + + // Perform the request. + let res = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + body: form, + headers: form.getHeaders() + }); + + // Parse the JSON response. + let json = await res.json(); + + return json.success; +}; + +/** + * This records a login attempt failure as well as optionally flags an account + * for requiring a recaptcha in the future outside the temporary window. + * @return {Promise} resolves with nothing if rate limit not exeeded, errors if + * there is a rate limit error + */ +const HandleFailedAttempt = async (email, userNeedsRecaptcha) => { + try { + await UsersService.recordLoginAttempt(email); + } catch (err) { + if (err === errors.ErrLoginAttemptMaximumExceeded && !userNeedsRecaptcha && RECAPTCHA_ENABLED) { + + debug(`flagging user email=${email}`); + await UsersService.flagForRecaptchaRequirement(email, true); + } + + throw err; + } +}; + passport.use(new LocalStrategy({ usernameField: 'email', - passwordField: 'password' -}, (email, password, done) => { - UsersService - .findLocalUser(email, password) - .then((user) => { - if (!user) { - return done(null, false, {message: 'Incorrect email/password combination'}); + passwordField: 'password', + passReqToCallback: true +}, async (req, email, password, done) => { + + // We need to check if this request has a recaptcha on it at all, if it does, + // we must verify it first. If verification fails, we fail the request early. + // We can only do this obviously when recaptcha is enabled. + let hasRecaptcha = CheckIfRecaptcha(req); + let recaptchaPassed = false; + if (RECAPTCHA_ENABLED && hasRecaptcha) { + + try { + + // Check to see if this recaptcha passed. + recaptchaPassed = await CheckRecaptcha(req); + } catch (err) { + return done(err); + } + + if (!recaptchaPassed) { + try { + await HandleFailedAttempt(email); + } catch (err) { + return done(err); } - // Define the loginProfile being used to perform an additional - // verificaiton. - let loginProfile = {id: email, provider: 'local'}; + return done(null, false, {message: 'Incorrect recaptcha'}); + } + } - // Validate the user login. - return ValidateUserLogin(loginProfile, user, done); - }) - .catch((err) => { - done(err); - }); + debug(`hasRecaptcha=${hasRecaptcha}, recaptchaPassed=${recaptchaPassed}`); + + // If the request didn't have a recaptcha, check to see if we did need one by + // checking the rate limit against failed attempts on this email + // address/login. + if (!hasRecaptcha) { + try { + await UsersService.checkLoginAttempts(email); + } catch (err) { + if (err === errors.ErrLoginAttemptMaximumExceeded) { + + // This says, we didn't have a recaptcha, yet we needed one.. Reject + // here. + + try { + await HandleFailedAttempt(email); + } catch (err) { + return done(err); + } + + return done(null, false, {message: 'Incorrect recaptcha'}); + } + + // Some other unexpected error occured. + return done(err); + } + } + + // Let's find the user for which this login is connected to. + let user; + try { + user = await UsersService.findLocalUser(email); + } catch (err) { + return done(err); + } + + debug(`user=${user != null}`); + + // If the user doesn't exist, then mark this as a failed attempt at logging in + // this non-existant user and continue. + if (!user) { + try { + await HandleFailedAttempt(email); + } catch (err) { + return done(err); + } + + return done(null, false, {message: 'Incorrect email/password combination'}); + } + + // Let's check if the user indeed needed recaptcha in order to authenticate. + // We can only do this obviously when recaptcha is enabled. + let userNeedsRecaptcha = false; + if (RECAPTCHA_ENABLED && user) { + userNeedsRecaptcha = CheckIfNeedsRecaptcha(user, email); + } + + debug(`userNeedsRecaptcha=${userNeedsRecaptcha}`); + + // Let's check now if their password is correct. + let userPasswordCorrect; + try { + userPasswordCorrect = await user.verifyPassword(password); + } catch (err) { + return done(err); + } + + debug(`userPasswordCorrect=${userPasswordCorrect}`); + + // If their password wasn't correct, mark their attempt as failed and + // continue. + if (!userPasswordCorrect) { + try { + await HandleFailedAttempt(email, userNeedsRecaptcha); + } catch (err) { + return done(err); + } + + return done(null, false, {message: 'Incorrect email/password combination'}); + } + + // If the user needed a recaptcha, yet we have gotten this far, this indicates + // that the password was correct, so let's unflag their account for logins. We + // can only do this obviously when recaptcha is enabled. The account wouldn't + // have been flagged otherwise. + if (RECAPTCHA_ENABLED && userNeedsRecaptcha) { + try { + await UsersService.flagForRecaptchaRequirement(email, false); + } catch (err) { + return done(err); + } + } + + // Define the loginProfile being used to perform an additional + // verificaiton. + let loginProfile = {id: email, provider: 'local'}; + + // Perform final steps to login the user. + return ValidateUserLogin(loginProfile, user, done); })); if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && process.env.TALK_ROOT_URL) { @@ -102,17 +311,18 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && 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`, - + passReqToCallback: true, profileFields: ['id', 'displayName', 'picture.type(large)'] - }, (accessToken, refreshToken, profile, done) => { - UsersService - .findOrCreateExternalUser(profile) - .then((user) => { - return ValidateUserLogin(profile, user, done); - }) - .catch((err) => { - done(err); - }); + }, async (req, accessToken, refreshToken, profile, done) => { + + let user; + try { + user = await UsersService.findOrCreateExternalUser(profile); + } catch (err) { + return done(err); + } + + return ValidateUserLogin(profile, user, done); })); } else { console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL'); diff --git a/services/users.js b/services/users.js index 90ca2fae2..cd10c418c 100644 --- a/services/users.js +++ b/services/users.js @@ -3,11 +3,16 @@ const jwt = require('jsonwebtoken'); const Wordlist = require('./wordlist'); const errors = require('../errors'); const uuid = require('uuid'); +const redis = require('./redis'); +const redisClient = redis.createClient(); const UserModel = require('../models/user'); const USER_STATUS = require('../models/user').USER_STATUS; const USER_ROLES = require('../models/user').USER_ROLES; +const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes. +const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required. + const ActionsService = require('./actions'); // In the event that the TALK_SESSION_SECRET is missing but we are testing, then @@ -30,13 +35,9 @@ const SALT_ROUNDS = 10; module.exports = class UsersService { /** - * Finds a user given their email address that we have for them in the system - * and ensures that the retuned user matches the password passed in as well. - * @param {string} email - email to look up the user by - * @param {string} password - password to match against the found user - * @param {Function} done [description] + * Returns a user (if found) for the given email address. */ - static findLocalUser(email, password) { + static findLocalUser(email) { if (!email || typeof email !== 'string') { return Promise.reject('email is required for findLocalUser'); } @@ -48,32 +49,99 @@ module.exports = class UsersService { provider: 'local' } } - }) - .then((user) => { - if (!user) { - return false; - } + }); + } - return new Promise((resolve, reject) => { - bcrypt.compare(password, user.password, (err, res) => { + /** + * This records an unsucesfull login attempt for the given email address. If + * the maximum has been reached, the promise will be rejected with: + * + * errors.ErrLoginAttemptMaximumExceeded + * + * Indicating that the account should be flagged as "login recaptcha required" + * where future login attempts must be made with the recaptcha flag. + */ + static recordLoginAttempt(email) { + const rdskey = `la[${email.toLowerCase().trim()}]`; + + return new Promise((resolve, reject) => { + redisClient + .multi() + .incr(rdskey) + .expire(rdskey, RECAPTCHA_WINDOW_SECONDS) + .exec((err, replies) => { if (err) { return reject(err); } - if (!res) { - return resolve(false); + // if this is new or has no expiry + if (replies[0] === 1 || replies[1] === -1) { + + // then expire it after the timeout + redisClient.expire(rdskey, RECAPTCHA_WINDOW_SECONDS); } - return resolve(user); + if (replies[0] >= RECAPTCHA_INCORRECT_TRIGGER) { + return reject(errors.ErrLoginAttemptMaximumExceeded); + } + + resolve(); }); - }); + }); + } + + /** + * This checks to see if the current login attempts against a user exeeds the + * maximum value allowed, if so, it rejects with: + * + * errors.ErrLoginAttemptMaximumExceeded + */ + static checkLoginAttempts(email) { + const rdskey = `la[${email.toLowerCase().trim()}]`; + + return new Promise((resolve, reject) => { + redisClient + .get(rdskey, (err, reply) => { + if (err) { + return reject(err); + } + + if (!reply) { + return resolve(); + } + + if (reply >= RECAPTCHA_INCORRECT_TRIGGER) { + return reject(errors.ErrLoginAttemptMaximumExceeded); + } + + resolve(); + }); + }); + } + + /** + * Sets or unsets the recaptcha_required flag on a user's local profile. + */ + static flagForRecaptchaRequirement(email, required) { + return UserModel.update({ + profiles: { + $elemMatch: { + id: email.toLowerCase(), + provider: 'local' + } + } + }, { + $set: { + 'profiles.$.metadata.recaptcha_required': required + } }); } /** * Merges two users together by taking all the profiles on a given user and * pushing them into the source user followed by deleting the destination user's - * user account. This will not merge the roles associated with the source user. + * user account. This will + * not merge the roles associated with the source user. * @param {String} dstUserID id of the user to which is the target of the merge * @param {String} srcUserID id of the user to which is the source of the merge * @return {Promise} resolves when the users are merged diff --git a/test/services/users.js b/test/services/users.js index 51ba01403..94aa92432 100644 --- a/test/services/users.js +++ b/test/services/users.js @@ -64,22 +64,14 @@ describe('services.UsersService', () => { describe('#findLocalUser', () => { - it('should find a user when we give the right credentials', () => { + it('should find a user', () => { return UsersService - .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') + .findLocalUser(mockUsers[0].profiles[0].id) .then((user) => { expect(user).to.have.property('username', mockUsers[0].username); }); }); - it('should not find the user when we give the wrong credentials', () => { - return UsersService - .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') - .then((user) => { - expect(user).to.equal(false); - }); - }); - }); describe('#createLocalUser', () => { diff --git a/views/admin.ejs b/views/admin.ejs index dafd5e3cc..d4d635dcc 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -85,6 +85,7 @@
+ diff --git a/webpack.config.js b/webpack.config.js index 432bb6155..e7652a67d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -118,6 +118,9 @@ module.exports = { 'process.env': { 'VERSION': `"${require('./package.json').version}"` } + }), + new webpack.EnvironmentPlugin({ + TALK_RECAPTCHA_PUBLIC: JSON.stringify(process.env.TALK_RECAPTCHA_PUBLIC) }) ], resolve: { diff --git a/yarn.lock b/yarn.lock index fdf36c0e3..25171f74d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2979,7 +2979,7 @@ form-data@^0.2.0: combined-stream "~0.0.4" mime-types "~2.0.3" -form-data@~2.1.1: +form-data@^2.1.2, form-data@~2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.1.2.tgz#89c3534008b97eada4cbb157d58f6f5df025eae4" dependencies: @@ -6481,6 +6481,10 @@ react-onclickoutside@^5.7.1: version "5.8.4" resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.8.4.tgz#a2c673a8d1b104a550e565574b95419feb12cc3f" +react-recaptcha@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.2.6.tgz#bb44c1948a39b37d5a41920c73db833e5d8524f9" + react-redux@^4.4.5: version "4.4.6" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-4.4.6.tgz#4b9d32985307a11096a2dd61561980044fcc6209"