mirror of
https://github.com/wassname/talk.git
synced 2026-07-09 18:04:44 +08:00
Merge branch 'master' into plugins
This commit is contained in:
@@ -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 = (
|
||||
<IndexRoute component={Dashboard} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
<Route path='streams' component={Streams} />
|
||||
<Route path='stories' component={Stories} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = (
|
||||
<form onSubmit={this.handleSignIn}>
|
||||
{errorMessage && <Alert>{lang.t(`errors.${errorMessage}`)}</Alert>}
|
||||
@@ -49,6 +59,15 @@ class AdminLogin extends React.Component {
|
||||
this.setState({requestPassword: true});
|
||||
}}>Request a new one.</a>
|
||||
</p>
|
||||
{
|
||||
loginMaxExceeded &&
|
||||
<Recaptcha
|
||||
sitekey={process.env.TALK_RECAPTCHA_PUBLIC}
|
||||
render='explicit'
|
||||
theme='dark'
|
||||
onloadCallback={this.onRecaptchaLoad}
|
||||
verifyCallback={this.onRecaptchaVerify} />
|
||||
}
|
||||
</form>
|
||||
);
|
||||
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
|
||||
|
||||
@@ -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 (
|
||||
<p
|
||||
style={{display: hideReloadNote ? 'none' : 'block'}}
|
||||
className={styles.autoUpdate}
|
||||
onClick={this.dismissNote}>
|
||||
<b>×</b>
|
||||
<Icon name='timer' /> <strong>{lang.t('dashboard.next-update', this.formatTime())}</strong> {lang.t('dashboard.auto-update')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CountdownTimer;
|
||||
@@ -186,4 +186,5 @@
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ const CoralDrawer = ({handleLogout, restricted = false}) => (
|
||||
{lang.t('configure.moderate')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/streams"
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.streams')}
|
||||
{lang.t('configure.stories')}
|
||||
</Link>
|
||||
<Link className={styles.navLink}
|
||||
to="/admin/community"
|
||||
|
||||
@@ -30,9 +30,9 @@ const CoralHeader = ({handleLogout, restricted = false}) => (
|
||||
<Link
|
||||
id='streamsNav'
|
||||
className={styles.navLink}
|
||||
to="/admin/streams"
|
||||
to="/admin/stories"
|
||||
activeClassName={styles.active}>
|
||||
{lang.t('configure.streams')}
|
||||
{lang.t('configure.stories')}
|
||||
</Link>
|
||||
<Link
|
||||
id='communityNav'
|
||||
|
||||
@@ -11,6 +11,7 @@ export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
|
||||
export const LOGIN_REQUEST = 'LOGIN_REQUEST';
|
||||
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
|
||||
export const LOGIN_FAILURE = 'LOGIN_FAILURE';
|
||||
export const LOGIN_MAXIMUM_EXCEEDED = 'LOGIN_MAXIMUM_EXCEEDED';
|
||||
|
||||
export const FETCH_FORGOT_PASSWORD_REQUEST = 'FETCH_FORGOT_PASSWORD_REQUEST';
|
||||
export const FETCH_FORGOT_PASSWORD_SUCCESS = 'FETCH_FORGOT_PASSWORD_SUCCESS';
|
||||
|
||||
@@ -16,6 +16,11 @@ const updateEmailConfirmation = (updateSettings, verify) => () => {
|
||||
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}) => {
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.premodLinksEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updatePremodLinksEnable(updateSettings, settings.premodLinksEnable)}
|
||||
checked={settings.premodLinksEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-premod-links')}</div>
|
||||
<p>
|
||||
{lang.t('configure.enable-premod-links-text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Wordlist
|
||||
bannedWords={settings.wordlist.banned}
|
||||
suspectWords={settings.wordlist.suspect}
|
||||
|
||||
@@ -32,11 +32,6 @@ const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
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}) => {
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.premodLinksEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updatePremodLinksEnable(updateSettings, settings.premodLinksEnable)}
|
||||
checked={settings.premodLinksEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-premod-links')}</div>
|
||||
<p>
|
||||
{lang.t('configure.enable-premod-links-text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
import styles from './Widget.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const ActivityWidget = ({assets}) => {
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most conversations</h2>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{lang.t('streams.article')}</p>
|
||||
<p>{lang.t('dashboard.comment_count')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map(asset => {
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{asset.commentCount}</p>
|
||||
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</Link>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{lang.t('dashboard.no_activity')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 <Spinner />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<p
|
||||
style={{display: this.state.noteHidden ? 'none' : 'block'}}
|
||||
style={{display: hideReloadNote ? 'none' : 'block'}}
|
||||
className={styles.autoUpdate}
|
||||
onClick={() => this.setState({noteHidden: true})}>
|
||||
onClick={this.dismissNote}>
|
||||
<b>×</b>
|
||||
<Icon name='timer' /> <strong>{lang.t('dashboard.next-update', this.formatTime())}</strong> {lang.t('dashboard.auto-update')}
|
||||
</p>
|
||||
<div className={styles.Dashboard}>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
|
||||
<LikeWidget assets={assetsByLike} />
|
||||
<ActivityWidget assets={assetsByActivity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.widget}>
|
||||
@@ -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 (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.widget}>
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
padding-left: 10px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
|
||||
@@ -16,12 +16,14 @@ class LayoutContainer extends Component {
|
||||
loggedIn,
|
||||
loadingUser,
|
||||
loginError,
|
||||
loginMaxExceeded,
|
||||
passwordRequestSuccess
|
||||
} = this.props.auth;
|
||||
const {handleLogout} = this.props;
|
||||
if (loadingUser) { return <FullLoading />; }
|
||||
if (!isAdmin) {
|
||||
return <AdminLogin
|
||||
loginMaxExceeded={loginMaxExceeded}
|
||||
handleLogin={this.props.handleLogin}
|
||||
requestPasswordReset={this.props.requestPasswordReset}
|
||||
passwordRequestSuccess={passwordRequestSuccess}
|
||||
@@ -38,7 +40,7 @@ const mapStateToProps = state => ({
|
||||
|
||||
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())
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+3
-3
@@ -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);
|
||||
@@ -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}) => <Link to={`/admin/moderate/${id}`}>{title}</Link>
|
||||
|
||||
renderStatus = (closedAt, {id}) => {
|
||||
const closed = closedAt && new Date(closedAt).getTime() < Date.now();
|
||||
const statusMenuOpen = this.state.statusMenus[id];
|
||||
return <div className={styles.statusMenu}>
|
||||
<div
|
||||
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
|
||||
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
|
||||
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
|
||||
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
</div>
|
||||
{
|
||||
statusMenuOpen &&
|
||||
<div
|
||||
className={!closed ? styles.statusMenuClosed : styles.statusMenuOpen}
|
||||
onClick={this.onStatusClick(!closed, id, statusMenuOpen)}>
|
||||
{!closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
<RadioGroup
|
||||
name='status filter'
|
||||
value={filter}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('filter')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='all'>{lang.t('streams.all')}</Radio>
|
||||
<Radio value='open'>{lang.t('streams.open')}</Radio>
|
||||
<Radio value='closed'>{lang.t('streams.closed')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.sort-by')}</div>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{
|
||||
assetsIds.length
|
||||
? <div className={styles.mainContent}>
|
||||
<DataTable className={styles.streamsTable} rows={assetsIds} onClick={this.goToModeration}>
|
||||
<TableHeader name="title" cellFormatter={this.renderTitle}>{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick} />
|
||||
</div>
|
||||
: <EmptyCard>{lang.t('streams.empty_result')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -4,6 +4,7 @@ fragment metrics on Asset {
|
||||
url
|
||||
author
|
||||
created_at
|
||||
commentCount
|
||||
action_summaries {
|
||||
type: __typename
|
||||
actionCount
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -157,31 +157,31 @@ class Comment extends React.Component {
|
||||
<PubDate created_at={comment.created_at} />
|
||||
<Content body={comment.body} />
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<ActionButton>
|
||||
<LikeButton
|
||||
like={like}
|
||||
id={comment.id}
|
||||
postLike={postLike}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag} />
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
</div>
|
||||
<ActionButton>
|
||||
<LikeButton
|
||||
like={like}
|
||||
id={comment.id}
|
||||
postLike={postLike}
|
||||
deleteAction={deleteAction}
|
||||
showSignInDialog={showSignInDialog}
|
||||
currentUser={currentUser} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<ReplyButton
|
||||
onClick={() => setActiveReplyBox(comment.id)}
|
||||
parentCommentId={parentId || comment.id}
|
||||
currentUserId={currentUser && currentUser.id}
|
||||
banned={false} />
|
||||
</ActionButton>
|
||||
<ActionButton>
|
||||
<IfUserCanModifyBest user={currentUser}>
|
||||
<BestButton
|
||||
isBest={commentIsBest(comment)}
|
||||
addBest={addBestTag}
|
||||
removeBest={removeBestTag} />
|
||||
</IfUserCanModifyBest>
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="commentActionsRight comment__action-container">
|
||||
<ActionButton>
|
||||
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
|
||||
@@ -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}/>
|
||||
</div>
|
||||
|
||||
@@ -221,6 +221,7 @@ class Embed extends Component {
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<LoadMore
|
||||
topLevel={true}
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
moreComments={asset.commentCount > asset.comments.length}
|
||||
|
||||
@@ -24,22 +24,45 @@ const loadMoreComments = (assetId, comments, loadMore, parentId) => {
|
||||
});
|
||||
};
|
||||
|
||||
const LoadMore = ({assetId, comments, loadMore, moreComments, parentId}) => (
|
||||
moreComments
|
||||
? <Button
|
||||
className='coral-load-more'
|
||||
onClick={() => loadMoreComments(assetId, comments, loadMore, parentId)}>
|
||||
{
|
||||
lang.t('loadMore')
|
||||
}
|
||||
</Button>
|
||||
: 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
|
||||
? <Button
|
||||
className='coral-load-more'
|
||||
onClick={() => {
|
||||
this.initialState = false;
|
||||
loadMoreComments(assetId, comments, loadMore, parentId);
|
||||
}}>
|
||||
{topLevel ? lang.t('viewMoreComments') : this.replyCountFormat(replyCount)}
|
||||
</Button>
|
||||
: 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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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}),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -63,6 +63,10 @@ const RootQuery = {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sort === 'ACTIVITY') {
|
||||
return Assets.getActivity({from, to, limit});
|
||||
}
|
||||
|
||||
return Assets.get({from, to, sort, limit});
|
||||
},
|
||||
|
||||
|
||||
+17
-1
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+236
-26
@@ -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');
|
||||
|
||||
+86
-18
@@ -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
|
||||
|
||||
+2
-10
@@ -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!-<nope>')
|
||||
.then((user) => {
|
||||
expect(user).to.equal(false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('#createLocalUser', () => {
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src='https://www.google.com/recaptcha/api.js?render=explicit' async defer></script>
|
||||
<script src="<%= basePath %>/bundle.js" charset="utf-8"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user