Merge branch 'master' into story-138187767-mod-flag-names

This commit is contained in:
gaba
2017-03-20 07:21:29 -07:00
79 changed files with 2697 additions and 713 deletions
+4
View File
@@ -14,3 +14,7 @@ dump.rdb
coverage/
.tags
.tags1
# remove plugin folders
plugins
plugins.json
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:7.6
FROM node:7
# Create app directory
RUN mkdir -p /usr/src/app
@@ -17,4 +17,4 @@ RUN yarn install --production
# Bundle app source
COPY . /usr/src/app
CMD [ "yarn", "start" ]
CMD ["yarn", "start"]
+265
View File
@@ -0,0 +1,265 @@
# Talk Plugins
Plugins for Talk can take various forms, currently we are only supporting server
side plugins.
## Plugin Registration: `plugins.json`
All plugins must be registered in the root file `plugins.json`.
The format for this file is thus:
```js
{
"server": [
"people"
]
}
```
Where we have a `server` key with an array of plugins that match the folder
name in the `plugins/` folder. For example, the above `plugins.json` would
require a plugin from `plugins/people`, which must provide a `index.js` file
that returns an object that matches the Plugin Specification.
## Server Plugins
### Specification
Each plugin should export a single object with all hooks available on it.
_**Note: You will have access to the whole core and other plugin's typeDefs,
context, loaders, mutators, resolvers, hooks. This is intentional, as it
encourages composing plugins to merge functionality, like a Slack plugin which
provides a Slack notify context function as well as having the loader for
comments.**_
The following are the hooks available:
#### Field: `typeDefs`
```graphql
enum COLOUR {
RED
BLUE
}
type Person {
name: String!
colour: COLOUR!
}
type RootMutation {
createPerson(name: String!): Person
}
type RootQuery {
people: [Person!]
}
```
Thanks to [gql-merge](https://www.npmjs.com/package/gql-merge) the contents of
`typeDefs` should be a string that will be _merged_ with the existing type
definitions. `enum`'s will be appended to, types will be appended, and new types
will be added.
#### Field: `context`
```js
{
Slack: (context) => ({
notify: (message) => {
// return a promise after we're done sending notifications.
}
})
}
```
Any property provided here will be added to the context parameter available
inside all resolvers, loaders, mutators, and of course, other context based
plugins.
The top level item must accept a context for the request which it should use to
configure the context plugin before it would be mounted at `context.plugins`.
This plugin above would mount at: `context.plugins.Slack`, or, if you're using
[object destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), `{plugins: {Slack}}`.
#### Field: `loaders`
```js
(context) => ({
People: {
load: () => db.people.find({user: context.user})
}
})
```
Loaders should be provided as a function which returns a map which is used in
the resolvers function. These must return a promise or a value.
#### Field: `mutators`
```js
(context) => ({
People: {
create: (name) => {
return db.people.insert({user: context.user, name});
}
}
})
```
Mutators should be provided as a function which returns a map which is used in
the resolvers function. These must return a promise or a value.
#### Field: `resolvers`
```js
{
Person: {
name(obj, args, context) {
return obj.name;
},
colour(obj, args, context) {
// Bill likes the colour red, everyone else likes blue.
return obj.name === 'bill' ? 'RED' : 'BLUE';
}
},
RootQuery: {
people(obj, args, {loaders: {People}}) {
return People.load();
}
},
RootMutation: {
createPerson(obj, {name}, {mutators: {People}}) {
return People.create(name);
}
}
}
```
Should return a resolver map as described in the
[Apollo Docs](http://dev.apollodata.com/tools/graphql-tools/resolvers.html#Resolver-map).
This will merge with the existing resolvers in core and from previous plugins.
#### Field: `hooks`
```js
{
RootMutation: {
createPerson: {
post: async (obj, args, {plugins: {Slack}}, person) {
if (!person) {
return person;
}
await Slack.notify(`A new person just was created with name ${person.name}`);
return person;
}
}
}
}
```
Hooks here are pretty special, for each resolver field, you can specify a
pre/post hook that will execute pre and post field resolution.
If your post function accepts four parameters, then it can modify the field
result. It is *required* that the function resolves a promise (or returns) with
the modified value or simply the original if you didn't modify it.
### Full Example
Contents of `plugins.json`:
```json
{
"server": [
"people"
]
}
```
Located in `plugins/people/index.js`:
```js
module.exports = {
typeDefs: `
enum COLOUR {
RED
BLUE
}
type Person {
name: String!
colour: COLOUR!
}
type RootMutation {
createPerson(name: String!): Person
}
type RootQuery {
people: [Person!]
}
`,
context: {
Slack: () => ({
notify: (message) => {
// return a promise after we're done sending notifications.
}
})
},
loaders: ({user}) => ({
People: {
load: () => db.people.find({user})
}
}),
mutators: ({user}) => ({
People: {
create: (name) => {
return db.people.insert({user, name});
}
}
}),
resolvers: {
Person: {
name(obj, args, context) {
return obj.name;
},
colour(obj, args, context) {
// Bill likes the colour red, everyone else likes blue.
return obj.name === 'bill' ? 'RED' : 'BLUE';
}
},
RootQuery: {
people(obj, args, {loaders: {People}}) {
return People.load();
}
},
RootMutation: {
createPerson(obj, {name}, {mutators: {People}}) {
return People.create(name);
}
}
},
hooks: {
RootMutation: {
createPerson: {
post: async (obj, args, {plugins: {Slack}}, person) => {
if (!person) {
return person;
}
await Slack.notify(`A new person just was created with name ${person.name}`);
return person;
}
}
}
}
};
```
+2 -2
View File
@@ -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 */}
+19 -6
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
export const CONFIG_UPDATED = 'CONFIG_UPDATED';
export const fetchConfig = () => dispatch => {
let json = document.getElementById('data');
let data = JSON.parse(json.textContent);
dispatch({type: CONFIG_UPDATED, data});
};
+1 -1
View File
@@ -4,5 +4,5 @@ export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open});
export const singleView = () => ({type: actions.SINGLE_VIEW});
// Ban User Dialog
export const showBanUserDialog = (user, commentId) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId});
export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote});
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
@@ -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, recaptchaPublic} = 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={recaptchaPublic}
render='explicit'
theme='dark'
onloadCallback={this.onRecaptchaLoad}
verifyCallback={this.onRecaptchaVerify} />
}
</form>
);
const requestPasswordForm = (
@@ -84,9 +103,11 @@ class AdminLogin extends React.Component {
}
AdminLogin.propTypes = {
loginMaxExceeded: PropTypes.bool.isRequired,
handleLogin: PropTypes.func.isRequired,
passwordRequestSuccess: PropTypes.string,
loginError: PropTypes.string
loginError: PropTypes.string,
recaptchaPublic: PropTypes.string
};
export default AdminLogin;
@@ -8,7 +8,14 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
const onBanClick = (userId, commentId, handleBanUser, rejectComment, handleClose) => (e) => {
e.preventDefault();
handleBanUser({userId})
.then(handleClose)
.then(() => rejectComment({commentId}));
};
const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId, showRejectedNote}) => (
<Dialog
className={styles.dialog}
id="banuserDialog"
@@ -23,13 +30,13 @@ const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
</div>
<div className={styles.separator}>
<h3>{lang.t('bandialog.are_you_sure', user.name)}</h3>
<i>{lang.t('bandialog.note')}</i>
<i>{showRejectedNote && lang.t('bandialog.note')}</i>
</div>
<div className={styles.buttons}>
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
{lang.t('bandialog.cancel')}
</Button>
<Button cStyle="black" className={styles.ban} onClick={() => handleBanUser({userId: user.id})} raised>
<Button cStyle="black" className={styles.ban} onClick={onBanClick(user.id, commentId, handleBanUser, rejectComment, handleClose)} raised>
{lang.t('bandialog.yes_ban_user')}
</Button>
</div>
@@ -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'
+1
View File
@@ -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';
@@ -90,6 +90,11 @@
.configTimeoutSelect {
display: inline-block;
margin-left: 20px;
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
padding: 20px 0;
vertical-align: top;
}
}
.charCountTexfield {
@@ -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
@@ -171,6 +153,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
</div>
</div>
</Card>
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
</div>
);
};
@@ -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;
@@ -7,3 +7,28 @@
font-size: 1.5rem;
font-weight: bold;
}
.autoUpdate {
background-color: #d5d5d5;
padding: 3px 10px 10px 10px;
margin-bottom: 0;
i {
position: relative;
top: 7px;
}
b {
float: right;
border-radius: 20px;
cursor: pointer;
background-color: #c0c0c0;
width: 30px;
height: 30px;
text-align: center;
top: 4px;
position: relative;
line-height: 1.7em;
font-size: 1.3em;
}
}
@@ -5,24 +5,91 @@ 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';
import {Spinner, Icon} from 'coral-ui';
import {Spinner} from 'coral-ui';
const lang = new I18n(translations);
const refreshIntervalSeconds = 60 * 5;
class Dashboard extends React.Component {
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 () {
setInterval(() => { // the countdown timer
let nextCount = this.state.secondsUntilRefresh - 1;
if (nextCount < 0) {
nextCount = refreshIntervalSeconds;
this.props.data.refetch();
}
this.setState({secondsUntilRefresh: nextCount});
}, 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();
if (seconds.length < 2) {
seconds = `0${seconds}`;
}
return `${minutes}:${seconds}`;
}
render () {
if (this.props.data && this.props.data.loading) {
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 className={styles.Dashboard}>
<FlagWidget assets={assetsByFlag} />
<LikeWidget assets={assetsByLike} />
<div>
<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>
<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,51 +6,50 @@ 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}>
<h2 className={styles.heading}>Articles with the most flags</h2>
<table className={styles.widgetTable}>
<thead className={styles.widgetHead}>
<tr>
<th>{lang.t('streams.article')}</th>
<th colSpan='2'>{lang.t('dashboard.flags')}</th>
</tr>
</thead>
<tbody>
{
assets.length
? assets.map(asset => {
const flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary');
return (
<tr className={styles.rowLinkify} key={asset.id}>
<td>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</Link>
</td>
<td>
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
</td>
<td>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
</td>
</tr>
);
})
: <tr className={styles.rowLinkify}><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
}
{ /* rows in a table with a fixed height will expand and ignore height.
this extra row will expand to fill the extra space. */
assets.length < 10 ? <tr></tr> : null
}
</tbody>
</table>
<div className={styles.widgetHead}>
<p>{lang.t('streams.article')}</p>
<p>{lang.t('dashboard.flags')}</p>
</div>
<div className={styles.widgetTable}>
{
assets.length
? assets.map(asset => {
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>
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</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_flags')}</div>
}
</div>
</div>
);
};
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,52 +6,46 @@ 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}>
<h2 className={styles.heading}>Articles with the most likes</h2>
<table className={styles.widgetTable}>
<thead className={styles.widgetHead}>
<tr>
<th>{lang.t('streams.article')}</th>
<th colSpan='2'>{lang.t('modqueue.likes')}</th>
</tr>
</thead>
<tbody>
{
assets.length
? assets.map(asset => {
const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary');
return (
<tr className={styles.rowLinkify} key={asset.id}>
<td>
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
<p className={styles.assetTitle}>{asset.title}</p>
<p className={styles.lede}>{asset.author} Published: {new Date(asset.created_at).toLocaleDateString()}</p>
</Link>
</td>
<td>
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
</td>
<td>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
</td>
</tr>
);
})
: <tr className={styles.rowLinkify}><td colSpan="3">{lang.t('dashboard.no_likes')}</td></tr>
}
{ /* rows in a table with a fixed height will expand and ignore height.
this extra row will expand to fill the extra space. */
assets.length < 10 ? <tr></tr> : null
}
</tbody>
</table>
<div className={styles.widgetHead}>
<p>{lang.t('streams.article')}</p>
<p>{lang.t('modqueue.likes')}</p>
</div>
<div className={styles.widgetTable}>
{
assets.length
? assets.map(asset => {
const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary');
return (
<div className={styles.rowLinkify} key={asset.id}>
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</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_likes')}</div>
}
</div>
</div>
);
};
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;
@@ -1,14 +1,21 @@
:root {
--row-height: 80px;
--row-height: 60px;
}
.widget {
box-sizing: border-box;
margin: 10px 5px 5px 5px;
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
padding: 15px;
flex: 1;
background-color: white;
box-sizing: border-box;
}
.widget * {
box-sizing: border-box;
}
.widget * {
box-sizing: border-box;
}
.heading {
@@ -19,21 +26,31 @@
}
.widgetTable {
width: 100%;
border-collapse: collapse;
user-select: none;
margin: 5px 15px 15px 15px;
height: calc(var(--row-height) * 10);
}
.widgetTable thead th {
.widgetTable + div:after {
content: '';
clear: both;
display: block;
}
.widgetHead p {
color: rgb(35, 102, 223);
padding: 10px;
text-align: left;
text-transform: capitalize;
display: inline-block;
box-sizing: border-box;
margin-bottom: 0;
}
.widgetTable thead th:last-child {
width: 10%;
.widgetHead p:last-child {
float: right;
margin-right: 100px;
}
.rowLinkify {
@@ -41,6 +58,7 @@
border-bottom: 1px solid lightgrey;
color: #555;
height: var(--row-height);
padding: 10px;
}
.rowLinkify:last-child {
@@ -51,16 +69,8 @@
background-color: #f8f8f8;
}
.widgetTable tbody td {
padding: 10px;
}
.widgetTable tbody td:last-child {
padding-top: 0;
}
.linkToAsset {
display: block;
display: inline-block;
text-decoration: none;
}
@@ -69,6 +79,8 @@
padding: 10px 14px;
text-decoration: none;
color: black;
float: right;
margin-left: 15px;
}
.linkToModerate:hover {
@@ -96,4 +108,6 @@
color: #555;
font-size: 1.3em;
font-weight: 400;
float: right;
margin-top: 7px;
}
@@ -2,13 +2,16 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import Layout from '../components/ui/Layout';
import {checkLogin, handleLogin, logout, requestPasswordReset} from '../actions/auth';
import {fetchConfig} from '../actions/config';
import {FullLoading} from '../components/FullLoading';
import AdminLogin from '../components/AdminLogin';
class LayoutContainer extends Component {
componentWillMount () {
const {checkLogin} = this.props;
const {checkLogin, fetchConfig} = this.props;
checkLogin();
fetchConfig();
}
render () {
const {
@@ -16,15 +19,19 @@ class LayoutContainer extends Component {
loggedIn,
loadingUser,
loginError,
loginMaxExceeded,
passwordRequestSuccess
} = this.props.auth;
const {handleLogout} = this.props;
const {handleLogout, TALK_RECAPTCHA_PUBLIC} = this.props;
if (loadingUser) { return <FullLoading />; }
if (!isAdmin) {
return <AdminLogin
loginMaxExceeded={loginMaxExceeded}
handleLogin={this.props.handleLogin}
requestPasswordReset={this.props.requestPasswordReset}
passwordRequestSuccess={passwordRequestSuccess}
recaptchaPublic={TALK_RECAPTCHA_PUBLIC}
errorMessage={loginError} />;
}
if (isAdmin && loggedIn) { return <Layout handleLogout={handleLogout} {...this.props} />; }
@@ -33,12 +40,14 @@ class LayoutContainer extends Component {
}
const mapStateToProps = state => ({
auth: state.auth.toJS()
auth: state.auth.toJS(),
TALK_RECAPTCHA_PUBLIC: state.config.get('data').get('TALK_RECAPTCHA_PUBLIC', null)
});
const mapDispatchToProps = dispatch => ({
checkLogin: () => dispatch(checkLogin()),
handleLogin: (username, password) => dispatch(handleLogin(username, password)),
fetchConfig: () => dispatch(fetchConfig()),
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';
@@ -21,12 +22,13 @@ import ModerationKeysModal from '../../components/ModerationKeysModal';
class ModerationContainer extends Component {
state = {
selectedIndex: 0
selectedIndex: 0,
sort: 'REVERSE_CHRONOLOGICAL'
}
componentWillMount() {
const {toggleModal, singleView} = this.props;
this.props.fetchSettings();
key('s', () => singleView());
key('shift+/', () => toggleModal(true));
@@ -74,6 +76,11 @@ class ModerationContainer extends Component {
}
}
selectSort = (sort) => {
this.setState({sort});
this.props.modQueueResort(sort);
}
componentWillUnmount() {
key.unbind('s');
key.unbind('shift+/');
@@ -84,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)) {
@@ -92,7 +110,7 @@ class ModerationContainer extends Component {
}
render () {
const {data, moderation, settings, assets, modQueueResort, onClose, ...props} = this.props;
const {data, moderation, settings, assets, onClose, ...props} = this.props;
const providedAssetId = this.props.params.id;
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
@@ -115,6 +133,18 @@ class ModerationContainer extends Component {
}
const comments = data[activeTab];
let activeTabCount;
switch(activeTab) {
case 'premod':
activeTabCount = data.premodCount;
break;
case 'flagged':
activeTabCount = data.flaggedCount;
break;
case 'rejected':
activeTabCount = data.rejectedCount;
break;
}
return (
<div>
@@ -124,7 +154,8 @@ class ModerationContainer extends Component {
premodCount={data.premodCount}
rejectedCount={data.rejectedCount}
flaggedCount={data.flaggedCount}
modQueueResort={modQueueResort}
selectSort={this.selectSort}
sort={this.state.sort}
/>
<ModerationQueue
currentAsset={asset}
@@ -136,12 +167,19 @@ class ModerationContainer extends Component {
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={props.loadMore}
assetId={providedAssetId}
sort={this.state.sort}
commentCount={activeTabCount}
/>
<BanUserDialog
open={moderation.banDialog}
user={moderation.user}
commentId={moderation.commentId}
handleClose={props.hideBanUserDialog}
handleBanUser={props.banUser}
showRejectedNote={moderation.showRejectedNote}
rejectComment={props.rejectComment}
/>
<ModerationKeysModal
open={moderation.modalOpen}
@@ -163,7 +201,7 @@ const mapDispatchToProps = dispatch => ({
singleView: () => dispatch(singleView()),
updateAssets: assets => dispatch(updateAssets(assets)),
fetchSettings: () => dispatch(fetchSettings()),
showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)),
showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
});
@@ -6,9 +6,10 @@ import EmptyCard from '../../components/EmptyCard';
import {actionsMap} from './helpers/moderationQueueActionsMap';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from 'coral-admin/src/translations';
import LoadMore from './components/LoadMore';
const lang = new I18n(translations);
const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props}) => {
return (
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
<ul style={{paddingLeft: 0}}>
@@ -20,7 +21,7 @@ const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
key={i}
index={i}
comment={comment}
commentType={props.activeTab}
commentType={activeTab}
selected={i === selectedIndex}
suspectWords={props.suspectWords}
actions={actionsMap[status]}
@@ -33,6 +34,14 @@ const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
: <EmptyCard>{lang.t('modqueue.emptyqueue')}</EmptyCard>
}
</ul>
<LoadMore
comments={comments}
loadMore={loadMore}
sort={sort}
tab={activeTab}
showLoadMore={comments.length < commentCount}
assetId={props.assetId}
/>
</div>
);
};
@@ -31,7 +31,7 @@ const Comment = ({actions = [], ...props}) => {
<span className={styles.created}>
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
</span>
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id)} />
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
<CommentType type={props.commentType} />
</div>
{props.comment.user.status === 'banned' ?
@@ -0,0 +1,31 @@
import React, {PropTypes} from 'react';
import {Button} from 'coral-ui';
import styles from './styles.css';
const LoadMore = ({comments, loadMore, sort, tab, assetId, showLoadMore}) =>
<div className={styles.loadMoreContainer}>
{
showLoadMore && <Button
className={styles.loadMore}
onClick={() =>
loadMore({
cursor: comments[comments.length - 1].created_at,
sort,
tab,
asset_id: assetId
})}>
Load More
</Button>
}
</div>;
LoadMore.propTypes = {
comments: PropTypes.array.isRequired,
loadMore: PropTypes.func.isRequired,
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged']).isRequired,
assetId: PropTypes.string,
showLoadMore: PropTypes.bool.isRequired
};
export default LoadMore;
@@ -1,4 +1,4 @@
import React, {PropTypes, Component} from 'react';
import React, {PropTypes} from 'react';
import CommentCount from './CommentCount';
import styles from './styles.css';
import {SelectField, Option} from 'react-mdl-selectfield';
@@ -8,57 +8,45 @@ import {Link} from 'react-router';
const lang = new I18n(translations);
class ModerationMenu extends Component {
state = {
sort: 'REVERSE_CHRONOLOGICAL',
}
static propTypes = {
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
flaggedCount: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
}
selectSort = (sort) => {
this.setState({sort});
this.props.modQueueResort(sort);
}
render() {
const {asset, premodCount, rejectedCount, flaggedCount} = this.props;
const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
return (
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div className={styles.tabBarPadding}/>
<div>
<Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
<Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
<Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
</Link>
</div>
<SelectField
className={styles.selectField}
label='Sort'
value={this.state.sort}
onChange={sort => this.selectSort(sort)}>
<Option value={'REVERSE_CHRONOLOGICAL'}>Newest First</Option>
<Option value={'CHRONOLOGICAL'}>Oldest First</Option>
</SelectField>
const ModerationMenu = ({asset, premodCount, rejectedCount, flaggedCount, selectSort, sort}) => {
const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
return (
<div className='mdl-tabs'>
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
<div className={styles.tabBarPadding}/>
<div>
<Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
</Link>
<Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
</Link>
<Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
</Link>
</div>
<SelectField
className={styles.selectField}
label='Sort'
value={sort}
onChange={sort => selectSort(sort)}>
<Option value={'REVERSE_CHRONOLOGICAL'}>Newest First</Option>
<Option value={'CHRONOLOGICAL'}>Oldest First</Option>
</SelectField>
</div>
);
}
}
</div>
);
};
ModerationMenu.propTypes = {
premodCount: PropTypes.number.isRequired,
rejectedCount: PropTypes.number.isRequired,
flaggedCount: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string
})
};
export default ModerationMenu;
@@ -394,3 +394,23 @@ span {
cursor: pointer;
}
}
.loadMoreContainer {
display: flex;
justify-content: center;
width: 100%;
};
.loadMore {
width: 100%;
text-align: center;
color: #FFF;
max-width: 660px;
margin-bottom: 30px;
background-color: #2376D8;
cursor: pointer;
}
.loadMore:hover {
background-color: #4399FF;
}
@@ -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: '',
@@ -80,8 +80,8 @@ class Streams extends Component {
<div
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
</div>
{
statusMenuOpen &&
@@ -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
@@ -51,7 +51,26 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
commentId,
status: 'ACCEPTED'
},
refetchQueries: ['ModQueue']
updateQueries: {
ModQueue: (oldData) => {
const premod = oldData.premod.filter(c => c.id !== commentId);
const flagged = oldData.flagged.filter(c => c.id !== commentId);
const rejected = oldData.rejected.filter(c => c.id !== commentId);
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
const rejectedCount = rejected.length < oldData.rejected.length ? oldData.rejectedCount - 1 : oldData.rejectedCount;
return {
...oldData,
premodCount,
flaggedCount,
rejectedCount,
premod,
flagged,
rejected,
};
}
}
});
},
rejectComment: ({commentId}) => {
@@ -60,7 +79,27 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
commentId,
status: 'REJECTED'
},
refetchQueries: ['ModQueue']
updateQueries: {
ModQueue: (oldData) => {
const comment = oldData.premod.concat(oldData.flagged).filter(c => c.id === commentId)[0];
const rejected = [comment].concat(oldData.rejected);
const premod = oldData.premod.filter(c => c.id !== commentId);
const flagged = oldData.flagged.filter(c => c.id !== commentId);
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
const rejectedCount = oldData.rejectedCount + 1;
return {
...oldData,
premodCount,
flaggedCount,
rejectedCount,
premod,
flagged,
rejected
};
}
}
});
}
})
@@ -1,6 +1,7 @@
import {graphql} from 'react-apollo';
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
@@ -15,7 +16,8 @@ export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
},
props: ({ownProps: {params: {id = null}}, data}) => ({
data,
modQueueResort: modQueueResort(id, data.fetchMore)
modQueueResort: modQueueResort(id, data.fetchMore),
loadMore: loadMore(data.fetchMore)
})
});
@@ -31,6 +33,40 @@ export const getMetrics = graphql(METRICS, {
}
});
export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => {
let statuses;
switch(tab) {
case 'premod':
statuses = ['PREMOD'];
break;
case 'flagged':
statuses = ['NONE', 'PREMOD'];
break;
case 'rejected':
statuses = ['REJECTED'];
break;
}
return fetchMore({
query: MOD_QUEUE_LOAD_MORE,
variables: {
limit,
cursor,
sort,
statuses,
asset_id
},
updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => {
return {
...oldData,
[tab]: [
...oldData[tab],
...comments
]
};
}
});
};
export const modUserFlaggedQuery = graphql(MOD_USER_FLAGGED_QUERY);
export const modQueueResort = (id, fetchMore) => (sort) => {
@@ -0,0 +1,13 @@
#import "../fragments/commentView.graphql"
query LoadMoreModQueue($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort}) {
...commentView
action_summaries {
count
... on FlagActionSummary {
reason
}
}
}
}
@@ -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
}
}
+7
View File
@@ -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;
}
+16
View File
@@ -0,0 +1,16 @@
import {Map} from 'immutable';
import * as actions from '../actions/config';
const initialState = Map({
data: Map({})
});
export default function config (state = initialState, action) {
switch (action.type) {
case actions.CONFIG_UPDATED:
return state.set('data', Map(action.data));
default:
return state;
}
}
+3 -1
View File
@@ -4,6 +4,7 @@ import settings from './settings';
import community from './community';
import moderation from './moderation';
import install from './install';
import config from './config';
export default {
auth,
@@ -11,5 +12,6 @@ export default {
settings,
community,
moderation,
install
install,
config
};
@@ -19,6 +19,7 @@ export default function moderation (state = initialState, action) {
.merge({
user: Map(action.user),
commentId: action.commentId,
showRejectedNote: action.showRejectedNote,
banDialog: true
});
case actions.SET_ACTIVE_TAB:
+15 -7
View File
@@ -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",
@@ -131,9 +132,12 @@
"write_message": "Write a message"
},
"dashboard": {
"next-update": "{0} minutes until next update.",
"auto-update": "Data automatically updates every five minutes or when you Reload.",
"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": {
@@ -149,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",
@@ -250,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",
@@ -276,9 +281,12 @@
"yes_ban_user": "Si, Suspendan el usuario"
},
"dashbord": {
"next-update": "{0} minutos hasta la siguiente actualización.",
"auto-update": "Los datos se actualizan automaticamente cada 5 minutos o cuando Recargas.",
"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": {
@@ -294,7 +302,7 @@
"sort-by": "",
"open": "",
"closed": "",
"article": "",
"article": "artículo",
"pubdate": "",
"status": ""
}
+28 -26
View File
@@ -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>
+2 -1
View File
@@ -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}
@@ -256,7 +257,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
requestConfirmEmail: () => dispatch(requestConfirmEmail()),
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
addNotification: (type, text) => dispatch(addNotification(type, text)),
addNotification: (type, text) => addNotification(type, text),
clearNotification: () => dispatch(clearNotification()),
editName: (username) => dispatch(editName(username)),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
+34 -11
View File
@@ -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 {
+1 -1
View File
@@ -13,7 +13,7 @@ const snackbarStyles = {
color: '#fff',
borderRadius: '3px 3px 0 0',
textAlign: 'center',
maxWidth: '300px',
maxWidth: '400px',
left: '50%',
opacity: 0,
transform: 'translate(-50%, 20px)',
+1 -1
View File
@@ -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());
})
+11 -5
View File
@@ -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",
+15 -3
View File
@@ -32,18 +32,30 @@ class FlagButton extends Component {
if (flagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
deleteAction(localPost || flag.current_user.id);
} else if (this.state.showMenu){
this.closeMenu();
} else {
this.setState({showMenu: !this.state.showMenu});
this.setState({showMenu: true});
}
}
closeMenu = () => {
this.setState({
showMenu: false,
itemType: '',
reason: '',
message: '',
step: 0
});
}
onPopupContinue = () => {
const {postFlag, postDontAgree, id, author_id} = this.props;
const {itemType, reason, step, posted, message} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
this.setState({showMenu: false});
this.closeMenu();
} else {
this.setState({step: step + 1});
}
@@ -114,7 +126,7 @@ class FlagButton extends Component {
}
handleClickOutside () {
this.setState({showMenu: false});
this.closeMenu();
}
render () {
+4 -4
View File
@@ -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": {
+7 -1
View File
@@ -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
};
+29
View File
@@ -1,6 +1,32 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const plugins = require('../plugins');
const debug = require('debug')('talk:graph:context');
/**
* Contains the array of plugins that provide context to the server, these top
* level functions all need the context reference.
* @type {Array}
*/
const contextPlugins = plugins.get('server', 'context').map(({plugin, context}) => {
debug(`added plugin '${plugin.name}'`);
return {context};
});
/**
* This should itterate over the passed in plugins and load them all with the
* current graph context.
* @return {Object} the saturated plugins object
*/
const decorateContextPlugins = (context, contextPlugins) => contextPlugins.reduce((acc, plugin) => {
Object.keys(plugin.context).forEach((service) => {
acc[service] = plugin.context[service](context);
});
return acc;
}, {});
/**
* Stores the request context.
*/
@@ -17,6 +43,9 @@ class Context {
// Create the mutators.
this.mutators = mutators(this);
// Decorate the plugin context.
this.plugins = decorateContextPlugins(this, contextPlugins);
}
}
+132
View File
@@ -0,0 +1,132 @@
const {forEachField} = require('graphql-tools');
const debug = require('debug')('talk:graph:schema');
/**
* XXX taken from graphql-js: src/execution/execute.js, because that function
* is not exported
*
* If a resolve function is not given, then a default resolve behavior is used
* which takes the property of the source object of the same name as the field
* and returns it as the result, or if it's a function, returns the result
* of calling that function.
*/
const defaultResolveFn = (source, args, context, {fieldName}) => {
// ensure source is a value for which property access is acceptable.
if (typeof source === 'object' || typeof source === 'function') {
const property = source[fieldName];
if (typeof property === 'function') {
return source[fieldName](args, context);
}
return property;
}
};
/**
* Decorates the schema with pre and post hooks as provided by the Plugin
* Manager.
* @param {GraphQLSchema} schema the schema to decorate
* @param {Array} hooks hooks to apply to the schema
* @return {void}
*/
const decorateWithHooks = (schema, hooks) => forEachField(schema, (field, typeName, fieldName) => {
// Pull out the pre/post hooks from the available hooks.
const {
pre,
post
} = hooks
// Only grab hooks that are associated with thie field and typeName.
.filter(({hooks}) => (typeName in hooks) && (fieldName in hooks[typeName]))
// Grab the hooks we need.
.map(({plugin, hooks}) => ({plugin, hooks: hooks[typeName][fieldName]}))
// Combine the pre/post hooks from each plugin into an array we can
// execute.
.reduce((acc, {plugin, hooks}) => {
// Itterate over the hooks on the fields and look at it with a switch
// block to check for misconfigured plugins.
Object.keys(hooks).forEach((hook) => {
switch (hook) {
case 'pre':
debug(`adding pre hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
if (typeof hooks.pre !== 'function') {
throw new Error(`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${plugin.name}' to be a function, it was a '${typeof hooks[hook]}'`);
}
acc.pre.push(hooks.pre);
break;
case 'post':
debug(`adding post hook to resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
if (typeof hooks.post !== 'function') {
throw new Error(`expected ${hook} hook on resolver ${typeName}.${fieldName} from plugin '${plugin.name}' to be a function, it was a '${typeof hooks[hook]}'`);
}
acc.post.unshift(hooks.post);
break;
default:
throw new Error(`invalid hook '${hook}' on resolver ${typeName}.${fieldName} from plugin '${plugin.name}'`);
}
});
return acc;
}, {
pre: [],
post: []
});
// If we have no hooks to add here, don't try to modify anything.
if (pre.length === 0 && post.length === 0) {
return;
}
// Cache the original resolve function, this emulates the beheviour found in
// graphql-tools: https://github.com/apollographql/graphql-tools/blob/6e9cc124b10d673448386041e6c3d058bc205a02/src/schemaGenerator.ts#L423-L425
let resolve = field.resolve;
if (typeof resolve === 'undefined') {
resolve = defaultResolveFn;
}
// Apply our async resolve function which will fire all pre functions (and
// wait until they resolve) followed by waiting for the response and then
// firing their post hooks. Lastly, we respond with the result of the
// original resolver.
field.resolve = async (obj, args, context, info) => {
// Issue all pre hooks before we resolve the field.
await Promise.all(pre.map((pre) => pre(obj, args, context, info)));
// Resolve the field.
let result = resolve(obj, args, context, info);
// Insure all post hooks after we've resolved the field with the result
// passed in as the fifth argument.
return await post.reduce(async (result, post) => {
// Wait for the accumulator to resolve before we continue.
result = await result;
// Check to see if this post function accepts a result, if it does, we
// expect that it modifies the result, otherwise, just fire the post hook,
// wait till it's done, then move onto the next hook.
if (post.length === 5) {
return await post(obj, args, context, info, result);
}
// Wait for the post hook to finish.
await post(obj, args, context, info);
// Return the result, which we already awaited for before.
return result;
}, result);
};
});
module.exports = {
decorateWithHooks
};
+45 -8
View File
@@ -1,4 +1,8 @@
const util = require('./util');
const {
SharedCounterDataLoader,
singleJoinBy,
arrayJoinBy
} = require('./util');
const DataLoader = require('dataloader');
const CommentModel = require('../../models/comment');
@@ -11,6 +15,38 @@ const CommentModel = require('../../models/comment');
* comments that we want to get
*/
const getCountsByAssetID = (context, asset_ids) => {
return CommentModel.aggregate([
{
$match: {
asset_id: {
$in: asset_ids
},
status: {
$in: ['NONE', 'ACCEPTED']
}
}
},
{
$group: {
_id: '$asset_id',
count: {
$sum: 1
}
}
}
])
.then(singleJoinBy(asset_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
/**
* Returns the comment count for all comments that are public based on their
* asset ids.
* @param {Object} context graph context
* @param {Array<String>} asset_ids the ids of assets for which there are
* comments that we want to get
*/
const getParentCountsByAssetID = (context, asset_ids) => {
return CommentModel.aggregate([
{
$match: {
@@ -32,7 +68,7 @@ const getCountsByAssetID = (context, asset_ids) => {
}
}
])
.then(util.singleJoinBy(asset_ids, '_id'))
.then(singleJoinBy(asset_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
@@ -64,7 +100,7 @@ const getCountsByParentID = (context, parent_ids) => {
}
}
])
.then(util.singleJoinBy(parent_ids, '_id'))
.then(singleJoinBy(parent_ids, '_id'))
.then((results) => results.map((result) => result ? result.count : 0));
};
@@ -216,7 +252,7 @@ const genRecentReplies = (context, ids) => {
])
.then((replies) => replies.map((reply) => reply.replies))
.then(util.arrayJoinBy(ids, 'parent_id'));
.then(arrayJoinBy(ids, 'parent_id'));
};
/**
@@ -267,7 +303,7 @@ const genRecentComments = (_, ids) => {
])
.then((replies) => replies.map((reply) => reply.comments))
.then(util.arrayJoinBy(ids, 'asset_id'));
.then(arrayJoinBy(ids, 'asset_id'));
};
/**
@@ -294,7 +330,7 @@ const genComments = ({user}, ids) => {
}
});
}
return comments.then(util.singleJoinBy(ids, 'id'));
return comments.then(singleJoinBy(ids, 'id'));
};
/**
@@ -307,8 +343,9 @@ module.exports = (context) => ({
get: new DataLoader((ids) => genComments(context, ids)),
getByQuery: (query) => getCommentsByQuery(context, query),
getCountByQuery: (query) => getCommentCountByQuery(context, query),
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', 3600, (ids) => getCountsByAssetID(context, ids)),
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', 3600, (ids) => getParentCountsByAssetID(context, ids)),
countByParentID: new SharedCounterDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
}
+23 -8
View File
@@ -1,4 +1,5 @@
const _ = require('lodash');
const debug = require('debug')('talk:graph:loaders');
const Actions = require('./actions');
const Assets = require('./assets');
@@ -7,6 +8,27 @@ const Metrics = require('./metrics');
const Settings = require('./settings');
const Users = require('./users');
const plugins = require('../../plugins');
let loaders = [
// Load the core loaders.
Actions,
Assets,
Comments,
Metrics,
Settings,
Users,
// Load the plugin loaders from the manager.
...plugins
.get('server', 'loaders').map(({plugin, loaders}) => {
debug(`added plugin '${plugin.name}'`);
return loaders;
})
];
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
@@ -15,14 +37,7 @@ const Users = require('./users');
module.exports = (context) => {
// We need to return an object to be accessed.
return _.merge(...[
Actions,
Assets,
Comments,
Metrics,
Settings,
Users
].map((loaders) => {
return _.merge(...loaders.map((loaders) => {
// Each loader is a function which takes the context.
return loaders(context);
+50 -2
View File
@@ -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}),
}
}
});
+25 -1
View File
@@ -121,6 +121,29 @@ class SharedCacheDataLoader extends DataLoader {
}
}
/**
* SharedCounterDataLoader is identical to SharedCacheDataLoader with the
* exception in that it is designed to work with numerical cached data.
*/
class SharedCounterDataLoader extends SharedCacheDataLoader {
/**
* Increments the key in the cache if it already exists in the cache, if not
* it does nothing.
*/
incr(key) {
return cache.incr(key, this._expiry, this._keyFunc);
}
/**
* Decrements the key in the cache if it already exists in the cache, if not
* it does nothing.
*/
decr(key) {
return cache.decr(key, this._expiry, this._keyFunc);
}
}
/**
* Maps an object's paths to a string that can be used as a cache key.
* @param {Array} paths paths on the object to be used to generate the cache
@@ -145,5 +168,6 @@ module.exports = {
objectCacheKeyFn,
arrayCacheKeyFn,
SingletonResolver,
SharedCacheDataLoader
SharedCacheDataLoader,
SharedCounterDataLoader
};
+17 -13
View File
@@ -33,16 +33,17 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
})
.then((comment) => {
// TODO: explore using an `INCR` operation to update the counts here
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated.
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
// is approved or without a comment.
if (status === 'NONE' || status === 'APPROVED') {
if (parent_id != null) {
Comments.countByParentID.clear(parent_id);
Comments.countByParentID.incr(parent_id);
} else {
Comments.countByAssetID.clear(asset_id);
Comments.parentCountByAssetID.incr(asset_id);
}
Comments.countByAssetID.incr(asset_id);
}
return comment;
@@ -182,15 +183,18 @@ const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
.then((comment) => {
// If the loaders are present, clear the caches for these values because we
// just added a new comment, hence the counts should be updated.
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
if (comment.parent_id != null) {
Comments.countByParentID.clear(comment.parent_id);
} else {
Comments.countByAssetID.clear(comment.asset_id);
}
// just added a new comment, hence the counts should be updated. It would
// be nice if we could decrement the counters here, but that would result
// in us having to know the initial state of the comment, which would
// require another database query.
if (comment.parent_id != null) {
Comments.countByParentID.clear(comment.parent_id);
} else {
Comments.parentCountByAssetID.clear(comment.asset_id);
}
Comments.countByAssetID.clear(comment.asset_id);
return comment;
});
};
+25 -5
View File
@@ -1,17 +1,37 @@
const _ = require('lodash');
const debug = require('debug')('talk:graph:mutators');
const Comment = require('./comment');
const Action = require('./action');
const User = require('./user');
const plugins = require('../../plugins');
let mutators = [
// Load in the core mutators.
Comment,
Action,
User,
// Load the plugin mutators from the manager.
...plugins
.get('server', 'mutators').map(({plugin, mutators}) => {
debug(`added plugin '${plugin.name}'`);
return mutators;
})
];
/**
* Creates a set of mutators based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of mutators
*/
module.exports = (context) => {
// We need to return an object to be accessed.
return _.merge(...[
Comment,
Action,
User,
].map((mutators) => {
return _.merge(...mutators.map((mutators) => {
// Each set of mutators is a function which takes the context.
return mutators(context);
+12 -1
View File
@@ -10,7 +10,18 @@ const Asset = {
parent_id: null
});
},
commentCount({id}, _, {loaders: {Comments}}) {
commentCount({id, commentCount}, _, {loaders: {Comments}}) {
if (commentCount != null) {
return commentCount;
}
return Comments.parentCountByAssetID.load(id);
},
totalCommentCount({id, totalCommentCount}, _, {loaders: {Comments}}) {
if (totalCommentCount != null) {
return totalCommentCount;
}
return Comments.countByAssetID.load(id);
},
settings({settings = null}, _, {loaders: {Settings}}) {
+20 -1
View File
@@ -1,3 +1,6 @@
const _ = require('lodash');
const debug = require('debug')('talk:graph:resolvers');
const ActionSummary = require('./action_summary');
const Action = require('./action');
const AssetActionSummary = require('./asset_action_summary');
@@ -17,7 +20,10 @@ const UserError = require('./user_error');
const User = require('./user');
const ValidationUserError = require('./validation_user_error');
module.exports = {
const plugins = require('../../plugins');
// Provide the core resolvers.
let resolvers = {
ActionSummary,
Action,
AssetActionSummary,
@@ -37,3 +43,16 @@ module.exports = {
User,
ValidationUserError,
};
/**
* Plugin support requires that we merge in existing resolvers with our new
* plugin based ones. This allows plugins to extend existing resolvers as well
* as provide new ones.
*/
resolvers = plugins.get('server', 'resolvers').reduce((resolvers, {plugin}) => {
debug(`added plugin '${plugin.name}'`);
return _.merge(resolvers, plugin.resolvers);
}, resolvers);
module.exports = resolvers;
+4
View File
@@ -63,6 +63,10 @@ const RootQuery = {
return null;
}
if (sort === 'ACTIVITY') {
return Assets.getActivity({from, to, limit});
}
return Assets.get({from, to, sort, limit});
},
+9 -3
View File
@@ -1,11 +1,17 @@
const tools = require('graphql-tools');
const maskErrors = require('graphql-errors').maskErrors;
const {makeExecutableSchema} = require('graphql-tools');
const {maskErrors} = require('graphql-errors');
const {decorateWithHooks} = require('./hooks');
const plugins = require('../plugins');
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = tools.makeExecutableSchema({typeDefs, resolvers});
const schema = makeExecutableSchema({typeDefs, resolvers});
// Plugin to the schema level resolvers to provide an before/after hook.
decorateWithHooks(schema, plugins.get('server', 'hooks'));
// If we are in production mode, don't show server errors to the front end.
if (process.env.NODE_ENV === 'production') {
// Mask errors that are thrown if we are in a production environment.
+20 -1
View File
@@ -422,6 +422,9 @@ type Asset {
# The count of top level comments on the asset.
commentCount: Int
# The total count of all comments made on the asset.
totalCommentCount: Int
# The settings (rectified with the global settings) that should be applied to
# this asset.
settings: Settings!
@@ -493,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.
@@ -523,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.
+20 -2
View File
@@ -4,8 +4,26 @@
const fs = require('fs');
const path = require('path');
const {mergeStrings} = require('gql-merge');
const debug = require('debug')('talk:graph:typeDefs');
const plugins = require('../plugins');
// Load the typeDefs from the graphql file.
const typeDefs = fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8');
/**
* Plugin support requires us to merge the type definitions from the loaded
* graphql tags, this gives us the ability to extend any portion of the
* available graph.
*/
const typeDefs = mergeStrings([
// Load the core graph definitions from the filesystem.
fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8'),
// Load the plugin definitions from the manager.
...plugins.get('server', 'typeDefs').map(({plugin, typeDefs}) => {
debug(`added plugin '${plugin.name}'`);
return typeDefs;
})
]);
module.exports = typeDefs;
+20
View File
@@ -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}
+6 -3
View File
@@ -62,6 +62,8 @@
"env-rewrite": "^1.0.2",
"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",
@@ -77,13 +79,15 @@
"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",
"redis": "^2.6.3",
"react-recaptcha": "^2.2.6",
"redis": "^2.6.5",
"uuid": "^2.0.3"
},
"devDependencies": {
@@ -137,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",
@@ -167,6 +170,6 @@
"webpack": "^2.2.1"
},
"engines": {
"node": "~7.6.0"
"node": "^7.7.0"
}
}
+81
View File
@@ -0,0 +1,81 @@
const fs = require('fs');
const path = require('path');
let plugins = {};
// Try to parse the plugins.json file, logging out an error if the plugins.json
// file isn't loaded, but continuing. Else, like a parsing error, throw it and
// crash the program.
try {
plugins = JSON.parse(fs.readFileSync(path.join(__dirname, 'plugins.json'), 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') {
console.error('plugins.json not found, plugins will not be active');
} else {
throw err;
}
}
/**
* Stores a reference to a section for a section of Plugins.
*/
class PluginSection {
constructor(plugin_names) {
this.plugins = plugin_names.map((plugin_name) => {
let plugin = require(`./plugins/${plugin_name}`);
// Ensure we have a default plugin name, but allow the name to be
// overrided by the plugin.
plugin.name = plugin.name || plugin_name;
return plugin;
});
}
/**
* This itterates over the section to provide all plugin hooks that are
* available.
*/
hook(hook) {
return this.plugins
.filter((plugin) => hook in plugin)
.map((plugin) => ({plugin, [hook]: plugin[hook]}));
}
}
const NullPluginSection = new PluginSection([]);
/**
* Stores references to all the plugins available on the application.
*/
class PluginManager {
constructor(plugins) {
this.sections = {};
for (let section in plugins) {
this.sections[section] = new PluginSection(plugins[section]);
}
}
/**
* Utility function which combines the Plugins.section and PluginSection.hook
* calls.
*/
get(section, hook) {
return this.section(section).hook(hook);
}
/**
* Returns the named section if it exists, otherwise it returns an empty
* plugin section.
*/
section(section) {
if (section in this.sections) {
return this.sections[section];
}
return NullPluginSection;
}
}
module.exports = new PluginManager(plugins);
+5 -1
View File
@@ -16,7 +16,11 @@ router.get('/password-reset', (req, res) => {
});
router.get('*', (req, res) => {
res.render('admin', {basePath: '/client/coral-admin'});
const data = {
TALK_RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC
};
res.render('admin', {basePath: '/client/coral-admin', data});
});
module.exports = router;
+5 -1
View File
@@ -7,7 +7,11 @@ router.use('/:embed', (req, res, next) => {
case 'stream':
return SettingsService.retrieve()
.then(({customCssUrl}) => {
return res.render('embed/stream', {customCssUrl});
const data = {
TALK_RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC
};
return res.render('embed/stream', {customCssUrl, data});
});
default:
+152
View File
@@ -1,5 +1,6 @@
const redis = require('./redis');
const debug = require('debug')('talk:cache');
const crypto = require('crypto');
const cache = module.exports = {
client: redis.createClient()
@@ -60,6 +61,157 @@ cache.wrap = (key, expiry, work, kf = keyfunc) => {
});
};
// This is designed to increment a key and add an expiry iff the key already
// exists.
const INCR_SCRIPT = `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`;
// Stores the SHA1 hash of INCR_SCRIPT, used for executing via EVALSHA.
let INCR_SCRIPT_HASH;
// This is designed to decrement a key and add an expiry iff the key already
// exists.
const DECR_SCRIPT = `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('DECR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`;
// Stores the SHA1 hash of DECR_SCRIPT, used for executing via EVALSHA.
let DECR_SCRIPT_HASH;
// Load the script into redis and track the script hash that we will use to exec
// increments on.
const loadScript = (name, script) => new Promise((resolve, reject) => {
let shasum = crypto.createHash('sha1');
shasum.update(script);
let hash = shasum.digest('hex');
cache.client
.script('EXISTS', hash, (err, [exists]) => {
if (err) {
return reject(err);
}
if (exists) {
debug(`already loaded ${name} as SHA[${hash}], not loading again`);
return resolve(hash);
}
debug(`${name} not loaded as SHA[${hash}], loading`);
cache.client
.script('load', script, (err, hash) => {
if (err) {
return reject(err);
}
debug(`loaded ${name} as SHA[${hash}]`);
resolve(hash);
});
});
});
// Load the INCR_SCRIPT and DECR_SCRIPT into Redis.
Promise.all([
loadScript('INCR_SCRIPT', INCR_SCRIPT),
loadScript('DECR_SCRIPT', DECR_SCRIPT)
])
.then(([incrScriptHash, decrScriptHash]) => {
INCR_SCRIPT_HASH = incrScriptHash;
DECR_SCRIPT_HASH = decrScriptHash;
})
.catch((err) => {
throw err;
});
/**
* This will increment a key in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.incr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client
.evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
/**
* This will decrement a key in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.decr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
cache.client
.evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry, (err) => {
if (err) {
return reject(err);
}
return resolve();
});
});
/**
* This will increment many keys in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.incrMany = (keys, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
keys.forEach((key) => {
// Queue up the evalsha command.
multi.evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry);
});
return new Promise((resolve, reject) => {
multi.exec((err) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
/**
* This will decrement many keys in redis and update the expiry iff it already
* exists, otherwise it will do nothing.
*/
cache.decrMany = (keys, expiry, kf = keyfunc) => {
let multi = cache.client.multi();
keys.forEach((key) => {
// Queue up the evalsha command.
multi.evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry);
});
return new Promise((resolve, reject) => {
multi.exec((err) => {
if (err) {
return reject(err);
}
resolve();
});
});
};
/**
* [wrapMany description]
* @param {Array<String>} keys Either an array of objects represening
+236 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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', () => {
+4
View File
@@ -82,9 +82,13 @@
}
</style>
<% if (data != null) { %>
<script id="data" type="application/json"><%- JSON.stringify(data) %></script>
<% } %>
</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>
+4
View File
@@ -8,8 +8,12 @@
<% if (locals.customCssUrl) { %>
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
<% } %>
<% if (data != null) { %>
<script id="data" type="application/json"><%- JSON.stringify(data) %></script>
<% } %>
</head>
<body>
<div id="coralStream"></div>
<script src="/client/embed/stream/bundle.js"></script>
</body>
+378 -338
View File
File diff suppressed because it is too large Load Diff