diff --git a/.eslintrc.json b/.eslintrc.json index 2186efd8b..12355ac34 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -53,7 +53,8 @@ "no-lonely-if": [2], "curly": [2], "no-unused-vars": ["error", { - "argsIgnorePattern": "next" + "argsIgnorePattern": "^_|next", + "varsIgnorePattern": "^_" }], "no-multiple-empty-lines": ["error", { "max": 1 diff --git a/bin/cli b/bin/cli index 1f3d50ad2..33560c53b 100755 --- a/bin/cli +++ b/bin/cli @@ -12,6 +12,7 @@ program .command('assets', 'interact with assets') .command('setup', 'setup the application') .command('jobs', 'work with the job queues') + .command('token', 'work with the access tokens') .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') .command('plugins', 'provides utilities for interacting with the plugin system') diff --git a/bin/cli-token b/bin/cli-token new file mode 100755 index 000000000..fa5d2e350 --- /dev/null +++ b/bin/cli-token @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +const program = require('./commander'); +const mongoose = require('../services/mongoose'); +const TokensService = require('../services/tokens'); +const util = require('./util'); +const Table = require('cli-table'); + +// Register the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); + +async function listTokens(userID) { + try { + let tokens = await TokensService.list(userID); + + let table = new Table({ + head: [ + 'ID', + 'Name', + 'Status' + ] + }); + + tokens.forEach((token) => { + table.push([ + token.id, + token.name, + token.active ? 'Active' : 'Revoked' + ]); + }); + + console.log(table.toString()); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function revokeToken(tokenID) { + try { + + await TokensService.revoke(null, tokenID); + + console.log(`Revoked Token[${tokenID}]`); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function createToken(userID, tokenName) { + try { + + let {pat: {id}, jwt} = await TokensService.create(userID, tokenName); + + console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('list ') + .description('list tokens for a user') + .action(listTokens); + +program + .command('revoke ') + .description('revokes a token with a given id') + .action(revokeToken); + +program + .command('create ') + .description('create a token for a user with a given name') + .action(createToken); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); + util.shutdown(); +} diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 3bde5f931..385076b21 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -50,7 +50,7 @@ const routes = ( - + diff --git a/client/coral-admin/src/actions/assets.js b/client/coral-admin/src/actions/assets.js index 8a18a8e3f..75bd2477d 100644 --- a/client/coral-admin/src/actions/assets.js +++ b/client/coral-admin/src/actions/assets.js @@ -9,6 +9,7 @@ import { } from '../constants/assets'; import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; /** * Action disptacher related to assets @@ -19,12 +20,16 @@ import coralApi from '../../../coral-framework/helpers/request'; export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filter = '') => (dispatch) => { dispatch({type: FETCH_ASSETS_REQUEST}); return coralApi(`/assets?skip=${skip}&limit=${limit}&sort=${sort}&search=${search}&filter=${filter}`) - .then(({result, count}) => - dispatch({type: FETCH_ASSETS_SUCCESS, - assets: result, - count - })) - .catch((error) => dispatch({type: FETCH_ASSETS_FAILURE, error})); + .then(({result, count}) => + dispatch({type: FETCH_ASSETS_SUCCESS, + assets: result, + count + })) + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: FETCH_ASSETS_FAILURE, error: errorMessage}); + }); }; // Update an asset state @@ -32,9 +37,12 @@ export const fetchAssets = (skip = '', limit = '', search = '', sort = '', filte export const updateAssetState = (id, closedAt) => (dispatch) => { dispatch({type: UPDATE_ASSET_STATE_REQUEST}); return coralApi(`/assets/${id}/status`, {method: 'PUT', body: {closedAt}}) - .then(() => - dispatch({type: UPDATE_ASSET_STATE_SUCCESS})) - .catch((error) => dispatch({type: UPDATE_ASSET_STATE_FAILURE, error})); + .then(() => dispatch({type: UPDATE_ASSET_STATE_SUCCESS})) + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: UPDATE_ASSET_STATE_FAILURE, error: errorMessage}); + }); }; export const updateAssets = (assets) => (dispatch) => { diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index 3a9e282ff..0c1264e1f 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -4,6 +4,7 @@ import coralApi from 'coral-framework/helpers/request'; import * as Storage from 'coral-framework/helpers/storage'; import {handleAuthToken} from 'coral-framework/actions/auth'; import {resetWebsocket} from 'coral-framework/services/client'; +import t from 'coral-framework/services/i18n'; //============================================================================== // SIGN IN @@ -41,13 +42,27 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => dispatch(checkLoginSuccess(user)); }) .catch((error) => { - if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + + if (error.translation_key === 'NOT_AUTHORIZED') { + + // invalid credentials + dispatch({ + type: actions.LOGIN_FAILURE, + message: t('error.email_password') + }); + } + else if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { dispatch({ type: actions.LOGIN_MAXIMUM_EXCEEDED, - message: error.translation_key + message: t(`error.${error.translation_key}`), }); } else { - dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); + dispatch({ + type: actions.LOGIN_FAILURE, + message: errorMessage, + }); } }); }; @@ -56,25 +71,30 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => // FORGOT PASSWORD //============================================================================== -const forgotPassowordRequest = () => ({ +const forgotPasswordRequest = () => ({ type: actions.FETCH_FORGOT_PASSWORD_REQUEST }); -const forgotPassowordSuccess = () => ({ +const forgotPasswordSuccess = () => ({ type: actions.FETCH_FORGOT_PASSWORD_SUCCESS }); -const forgotPassowordFailure = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_FAILURE +const forgotPasswordFailure = (error) => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE, + error, }); export const requestPasswordReset = (email) => (dispatch) => { - dispatch(forgotPassowordRequest(email)); + dispatch(forgotPasswordRequest(email)); const redirectUri = location.href; return coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) - .then(() => dispatch(forgotPassowordSuccess())) - .catch((error) => dispatch(forgotPassowordFailure(error))); + .then(() => dispatch(forgotPasswordSuccess())) + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(forgotPasswordFailure(errorMessage)); + }); }; //============================================================================== @@ -112,6 +132,7 @@ export const checkLogin = () => (dispatch) => { }) .catch((error) => { console.error(error); - dispatch(checkLoginFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(checkLoginFailure(errorMessage)); }); }; diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 36b9ffb0a..ddae062a4 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -15,6 +15,7 @@ import { } from '../constants/community'; import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; export const fetchAccounts = (query = {}) => (dispatch) => { @@ -30,7 +31,11 @@ export const fetchAccounts = (query = {}) => (dispatch) => { totalPages }); }) - .catch((error) => dispatch({type: FETCH_COMMENTERS_FAILURE, error})); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: FETCH_COMMENTERS_FAILURE, error: errorMessage}); + }); }; const requestFetchAccounts = () => ({ @@ -47,7 +52,6 @@ export const newPage = () => ({ }); export const setRole = (id, role) => (dispatch) => { - return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}}) .then(() => { return dispatch({type: SET_ROLE, id, role}); diff --git a/client/coral-admin/src/actions/install.js b/client/coral-admin/src/actions/install.js index 15413e220..122ffdc4a 100644 --- a/client/coral-admin/src/actions/install.js +++ b/client/coral-admin/src/actions/install.js @@ -2,6 +2,7 @@ import coralApi from 'coral-framework/helpers/request'; import * as actions from '../constants/install'; import validate from 'coral-framework/helpers/validate'; import errorMsj from 'coral-framework/helpers/error'; +import t from 'coral-framework/services/i18n'; export const nextStep = () => ({type: actions.NEXT_STEP}); export const previousStep = () => ({type: actions.PREVIOUS_STEP}); @@ -17,7 +18,8 @@ const clearErrors = () => ({type: actions.CLEAR_ERRORS}); const validation = (formData, dispatch, next) => { if (!(formData != null)) { - return dispatch(hasError()); + dispatch(hasError()); + return; } const validKeys = Object.keys(formData) @@ -40,7 +42,8 @@ const validation = (formData, dispatch, next) => { }); if (empty.length) { - return dispatch(hasError()); + dispatch(hasError()); + return; } // RegExp Validation @@ -59,7 +62,8 @@ const validation = (formData, dispatch, next) => { }); if (validation.length) { - return dispatch(hasError()); + dispatch(hasError()); + return; } dispatch(clearErrors()); @@ -90,7 +94,8 @@ export const finishInstall = () => (dispatch, getState) => { }) .catch((error) => { console.error(error); - dispatch(installFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(installFailure(errorMessage)); }); }; @@ -113,6 +118,7 @@ export const checkInstall = (next) => (dispatch) => { }) .catch((error) => { console.error(error); - dispatch(checkInstallFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(checkInstallFailure(errorMessage)); }); }; diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index 65c200420..ba8b917ea 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -1,4 +1,5 @@ import coralApi from '../../../coral-framework/helpers/request'; +import t from 'coral-framework/services/i18n'; export const SETTINGS_LOADING = 'SETTINGS_LOADING'; export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED'; @@ -20,7 +21,9 @@ export const fetchSettings = () => (dispatch) => { dispatch({type: SETTINGS_RECEIVED, settings}); }) .catch((error) => { - dispatch({type: SETTINGS_FETCH_ERROR, error}); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: SETTINGS_FETCH_ERROR, error: errorMessage}); }); }; @@ -49,6 +52,8 @@ export const saveSettingsToServer = () => (dispatch, getState) => { dispatch({type: SAVE_SETTINGS_SUCCESS, settings}); }) .catch((error) => { - dispatch({type: SAVE_SETTINGS_FAILED, error}); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: SAVE_SETTINGS_FAILED, error: errorMessage}); }); }; diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index e888d5b74..7ba051a90 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -1,5 +1,6 @@ import coralApi from '../../../coral-framework/helpers/request'; import * as userTypes from '../constants/users'; +import t from 'coral-framework/services/i18n'; /** * Action disptacher related to users @@ -10,7 +11,11 @@ export const userStatusUpdate = (status, userId, commentId) => { dispatch({type: userTypes.UPDATE_STATUS_REQUEST}); return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) .then((res) => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) - .catch((error) => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error})); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error: errorMessage}); + }); }; }; @@ -18,7 +23,11 @@ export const userStatusUpdate = (status, userId, commentId) => { export const sendNotificationEmail = (userId, subject, body) => { return (dispatch) => { return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) - .catch((error) => dispatch({type: userTypes.USER_EMAIL_FAILURE, error})); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: userTypes.USER_EMAIL_FAILURE, error: errorMessage}); + }); }; }; @@ -26,6 +35,10 @@ export const sendNotificationEmail = (userId, subject, body) => { export const enableUsernameEdit = (userId) => { return (dispatch) => { return coralApi(`/users/${userId}/username-enable`, {method: 'POST'}) - .catch((error) => dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error})); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch({type: userTypes.USERNAME_ENABLE_FAILURE, error: errorMessage}); + }); }; }; diff --git a/client/coral-admin/src/components/AdminLogin.js b/client/coral-admin/src/components/AdminLogin.js index 9deee53f7..e75e66d23 100644 --- a/client/coral-admin/src/components/AdminLogin.js +++ b/client/coral-admin/src/components/AdminLogin.js @@ -2,7 +2,6 @@ import React, {PropTypes} from 'react'; import Layout from 'coral-admin/src/components/ui/Layout'; import styles from './NotFound.css'; import {Button, TextField, Alert, Success} from 'coral-ui'; -import t from 'coral-framework/services/i18n'; import Recaptcha from 'react-recaptcha'; class AdminLogin extends React.Component { @@ -35,7 +34,7 @@ class AdminLogin extends React.Component { const {errorMessage, loginMaxExceeded, recaptchaPublic} = this.props; const signInForm = (
- {errorMessage && {t(`error.${errorMessage}`)}} + {errorMessage && {errorMessage}} {t('configure.sign_out')} - - Talk {`v${process.env.VERSION}`} - +
  • + {`v${process.env.VERSION}`} +
  • diff --git a/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js new file mode 100644 index 000000000..5bc5767ba --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js @@ -0,0 +1,16 @@ +import React from 'react'; +import {Button} from 'coral-ui'; +import t from 'coral-framework/services/i18n'; +import {withCopyToClipboard} from 'coral-framework/hocs'; + +class ButtonCopyToClipboard extends React.Component { + render () { + return ( + + ); + } +} + +export default withCopyToClipboard(ButtonCopyToClipboard); diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index a1fa97a2e..8b0b7cb39 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -192,6 +192,7 @@ export default class Moderation extends Component { shortcutsNoteVisible={moderation.shortcutsNoteVisible} open={moderation.modalOpen} onClose={this.onClose}/> + {moderation.userDetailId && ( )} + { - this.props.loadMore(this.props.activeTab); + if (!this.isLoadingMore) { + this.isLoadingMore = true; + this.props.loadMore(this.props.activeTab) + .then(() => this.isLoadingMore = false) + .catch((e) => { + this.isLoadingMore = false; + throw e; + }); + } } constructor(props) { diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.css b/client/coral-admin/src/routes/Moderation/components/StorySearch.css index f0503f6b9..2df130ec4 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.css +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.css @@ -49,12 +49,14 @@ font-weight: 300; } -.cta { - letter-spacing: 1px; +.cta > a { + display: inline-block; + cursor: pointer; + color: #000; + text-decoration: none; font-weight: bold; font-size: 15px; margin: 0; - height: 50px; box-sizing: border-box; font-size: 15px; font-weight: 500; diff --git a/client/coral-admin/src/routes/Moderation/components/StorySearch.js b/client/coral-admin/src/routes/Moderation/components/StorySearch.js index f42ceaadd..5fe82fa97 100644 --- a/client/coral-admin/src/routes/Moderation/components/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/components/StorySearch.js @@ -38,7 +38,11 @@ const StorySearch = (props) => {
    -

    Moderate comments on All Stories

    + {props.assetId && + + }
    { @@ -87,7 +91,8 @@ StorySearch.propTypes = { goToStory: PropTypes.func.isRequired, closeSearch: PropTypes.func.isRequired, moderation: PropTypes.object.isRequired, - handleSearchChange: PropTypes.func.isRequired + handleSearchChange: PropTypes.func.isRequired, + assetId: PropTypes.string }; export default StorySearch; diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.css b/client/coral-admin/src/routes/Moderation/components/UserDetail.css index 710f84759..f0b08bfb7 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.css +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.css @@ -35,9 +35,8 @@ border: none; background-color: transparent; font-size: 16px; - position: absolute; - width: 90%; outline: none; + width: calc(100% - 90px); } .commentStatuses { diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index 883fed53c..59f696b5f 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -1,19 +1,14 @@ import React, {PropTypes} from 'react'; -import {Button, Drawer, Copy} from 'coral-ui'; -import styles from './UserDetail.css'; -import Slot from 'coral-framework/components/Slot'; import Comment from './Comment'; +import styles from './UserDetail.css'; +import {Button, Drawer} from 'coral-ui'; +import {Slot} from 'coral-framework/components'; +import ButtonCopyToClipboard from './ButtonCopyToClipboard'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; +import ClickOutside from 'coral-framework/components/ClickOutside'; export default class UserDetail extends React.Component { - constructor() { - super(); - this.state = { - emailCopied: false - }; - } - static propTypes = { id: PropTypes.string.isRequired, hideUserDetail: PropTypes.func.isRequired, @@ -50,16 +45,6 @@ export default class UserDetail extends React.Component { this.props.changeStatus('rejected'); } - showCopied() { - this.setState({ - emailCopied: true - }, () => { - setTimeout(() => this.setState({ - emailCopied: false - }), 3000); - }); - } - render () { const { root: { @@ -96,92 +81,92 @@ export default class UserDetail extends React.Component { } return ( - -

    {user.username}

    - {profile && this.profile = ref} value={profile} />} + + +

    {user.username}

    - this.showCopied()} text={profile} className={styles.profileEmail}> - - +
    + {profile && this.profile = ref} value={profile} />} + +
    - -

    Member since {new Date(user.created_at).toLocaleString()}

    -
    -

    - Account summary -
    Data represents the last six months of activity -

    -
    -
    -

    Total Comments

    -

    {totalComments}

    -
    -
    -

    Reject Rate

    -

    {`${(rejectedPercent).toFixed(1)}%`}

    -
    -
    - { - selectedIds.length === 0 - ? ( -
      -
    • All
    • -
    • Rejected
    • -
    - ) - : ( -
    - - - {`${selectedIds.length} comments selected`} + +

    Member since {new Date(user.created_at).toLocaleString()}

    +
    +

    + Account summary +
    Data represents the last six months of activity +

    +
    +
    +

    Total Comments

    +

    {totalComments}

    - ) - } - -
    +
    +

    Reject Rate

    +

    {`${(rejectedPercent).toFixed(1)}%`}

    +
    +
    { - nodes.map((comment, i) => { - const status = comment.action_summaries ? 'FLAGGED' : comment.status; - const selected = selectedIds.indexOf(comment.id) !== -1; - return {}} - actions={actionsMap[status]} - showBanUserDialog={showBanUserDialog} - showSuspendUserDialog={showSuspendUserDialog} - acceptComment={this.acceptThenReload} - rejectComment={this.rejectThenReload} - selected={selected} - toggleSelect={toggleSelect} - currentAsset={null} - currentUserId={this.props.id} - minimal={true} />; - }) + selectedIds.length === 0 + ? ( +
      +
    • All
    • +
    • Rejected
    • +
    + ) + : ( +
    + + + {`${selectedIds.length} comments selected`} +
    + ) } -
    - + +
    + { + nodes.map((comment, i) => { + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + const selected = selectedIds.indexOf(comment.id) !== -1; + return {}} + actions={actionsMap[status]} + showBanUserDialog={showBanUserDialog} + showSuspendUserDialog={showSuspendUserDialog} + acceptComment={this.acceptThenReload} + rejectComment={this.rejectThenReload} + selected={selected} + toggleSelect={toggleSelect} + currentAsset={null} + currentUserId={this.props.id} + minimal={true} />; + }) + } +
    + + ); } } diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index 8b4fe971d..1228c61a4 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -190,8 +190,6 @@ span { } &.selected { - max-width: 720px; - max-height: 410px; } .context { diff --git a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js index cf47ca662..f4ab6f23e 100644 --- a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js @@ -41,7 +41,13 @@ class StorySearchContainer extends React.Component { goToStory = (id) => { const {router, closeSearch} = this.props; - router.push(`/admin/moderate/${id}`); + router.push(`/admin/moderate/all/${id}`); + closeSearch(); + } + + goToModerateAll = () => { + const {router, closeSearch} = this.props; + router.push('/admin/moderate/all'); closeSearch(); } @@ -50,6 +56,7 @@ class StorySearchContainer extends React.Component { { }; export default class Comment extends React.Component { + isLoadingReplies = false; + constructor(props) { super(props); @@ -211,14 +213,24 @@ export default class Comment extends React.Component { } loadNewReplies = () => { - const {replies, replyCount, id} = this.props.comment; - if (replyCount > replies.nodes.length) { - this.props.loadMore(id).then(() => { - this.setState(resetCursors(this.state, this.props)); - }); - return; + if (!this.isLoadingReplies) { + this.isLoadingReplies = true; + const {replies, replyCount, id} = this.props.comment; + if (replyCount > replies.nodes.length) { + this.props.loadMore(id) + .then(() => { + this.setState(resetCursors(this.state, this.props)); + this.isLoadingReplies = false; + }) + .catch((e) => { + this.isLoadingReplies = false; + throw e; + }); + return; + } + this.setState(resetCursors); + this.isLoadingReplies = false; } - this.setState(resetCursors); }; showReplyBox = () => { @@ -432,28 +444,28 @@ export default class Comment extends React.Component { inline /> - { (currentUser && - (comment.user.id === currentUser.id)) + { (currentUser && (comment.user.id === currentUser.id)) && - /* User can edit/delete their own comment for a short window after posting */ - ? - { - commentIsStillEditable(comment) && - Edit - } - + /* User can edit/delete their own comment for a short window after posting */ + + { + commentIsStillEditable(comment) && + Edit + } + + } + { (currentUser && (comment.user.id !== currentUser.id)) && /* TopRightMenu allows currentUser to ignore other users' comments */ - : - - + + + } - { this.state.isEditing ? { + if (e.translation_key) { + addNotification('error', t(`error.${e.translation_key}`)); + } else if (error.networkError) { + addNotification('error', t('error.network_error')); + } else { + addNotification('error', t('edit_comment.unexpected_error')); + console.error(e); + } + }); } if (successfullyEdited) { const status = response.data.editComment.comment.status; diff --git a/client/coral-embed-stream/src/components/LoadMore.js b/client/coral-embed-stream/src/components/LoadMore.js index ca2816d10..3d1ad4679 100644 --- a/client/coral-embed-stream/src/components/LoadMore.js +++ b/client/coral-embed-stream/src/components/LoadMore.js @@ -23,15 +23,17 @@ class LoadMore extends React.Component { } } + loadMore = () => { + this.initialState = false; + this.props.loadMore(); + } + render () { - const {topLevel, moreComments, loadMore, replyCount} = this.props; + const {topLevel, moreComments, replyCount} = this.props; return moreComments ?
    diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index 62a58129b..c912d1a72 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -53,6 +53,8 @@ function invalidateCursor(invalidated, state, props) { class Stream extends React.Component { + isLoadingMore = false; + constructor(props) { super(props); this.state = resetCursors(this.state, props); @@ -95,6 +97,18 @@ class Stream extends React.Component { } }; + loadMoreComments = () => { + if (!this.isLoadingMore) { + this.isLoadingMore = true; + this.props.loadMoreComments() + .then(() => this.isLoadingMore = false) + .catch((e) => { + this.isLoadingMore = false; + throw e; + }); + } + } + // getVisibileComments returns a list containing comments // which were authored by current user or comes after the `idCursor`. getVisibleComments() { @@ -183,6 +197,7 @@ class Stream extends React.Component { } {loggedIn && !banned && @@ -288,7 +303,7 @@ class Stream extends React.Component {
    }
    diff --git a/client/coral-embed-stream/src/components/SuspendedAccount.js b/client/coral-embed-stream/src/components/SuspendedAccount.js index 63fab84fa..1a41bac91 100644 --- a/client/coral-embed-stream/src/components/SuspendedAccount.js +++ b/client/coral-embed-stream/src/components/SuspendedAccount.js @@ -9,7 +9,8 @@ class SuspendedAccount extends Component { static propTypes = { canEditName: PropTypes.bool, - editName: PropTypes.func.isRequired + editName: PropTypes.func.isRequired, + currentUsername: PropTypes.string.isRequired, } state = { @@ -21,7 +22,11 @@ class SuspendedAccount extends Component { const {editName} = this.props; const {username} = this.state; e.preventDefault(); - if (validate.username(username)) { + + if (username === this.props.currentUsername) { + this.setState({alert: t('error.SAME_USERNAME_PROVIDED')}); + } + else if (validate.username(username)) { editName(username) .then(() => location.reload()) .catch((error) => { diff --git a/client/coral-embed-stream/src/components/TopRightMenu.css b/client/coral-embed-stream/src/components/Toggleable.css similarity index 100% rename from client/coral-embed-stream/src/components/TopRightMenu.css rename to client/coral-embed-stream/src/components/Toggleable.css diff --git a/client/coral-embed-stream/src/components/Toggleable.js b/client/coral-embed-stream/src/components/Toggleable.js new file mode 100644 index 000000000..c7996d866 --- /dev/null +++ b/client/coral-embed-stream/src/components/Toggleable.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ClickOutside from 'coral-framework/components/ClickOutside'; +import styles from './Toggleable.css'; +import classnames from 'classnames'; + +const upArrow = ; +const downArrow = ; + +export default class Toggleable extends React.Component { + constructor(props) { + super(props); + this.state = { + isOpen: false + }; + } + + toggle = () => { + this.setState({isOpen: !this.state.isOpen}); + } + + close = () => { + this.setState({isOpen: false}); + } + + render() { + const {children} = this.props; + const {isOpen} = this.state; + return ( + + + {isOpen ? upArrow : downArrow} + {isOpen ? children : null} + + + ); + } +} + diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js index 84d64f061..d65722b6d 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ b/client/coral-embed-stream/src/components/TopRightMenu.js @@ -1,8 +1,6 @@ import React, {PropTypes} from 'react'; -import classnames from 'classnames'; - import {IgnoreUserWizard} from './IgnoreUserWizard'; -import styles from './TopRightMenu.css'; +import Toggleable from './Toggleable'; // TopRightMenu appears as a dropdown in the top right of the comment. // when you click the down cehvron, it expands and shows IgnoreUserWizard @@ -56,38 +54,6 @@ export class TopRightMenu extends React.Component { />
    - ); - } -} - -const upArrow = ; -const downArrow = ; -class Toggleable extends React.Component { - constructor(props) { - super(props); - this.toggle = this.toggle.bind(this); - this.close = this.close.bind(this); - this.state = { - isOpen: false - }; - } - toggle() { - this.setState({isOpen: !this.state.isOpen}); - } - close() { - this.setState({isOpen: false}); - } - render() { - const {children} = this.props; - const {isOpen} = this.state; - return ( - - // /*onBlur={ this.close } */ - - {isOpen ? upArrow : downArrow} - {isOpen ? children : null} - ); } } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 14de2756e..5b42fa541 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -73,6 +73,11 @@ function configurePymParent(pymParent, opts) { window.document.body.appendChild(snackbar); + // Notify embed that there was a click outside. + document.addEventListener('click', () => { + pymParent.sendMessage('click'); + }, true); + // Workaround: IOS Safari ignores `width` but respects `min-width` value. pymParent.el.firstChild.style.width = '1px'; pymParent.el.firstChild.style.minWidth = '100%'; diff --git a/client/coral-framework/actions/asset.js b/client/coral-framework/actions/asset.js index d252bb59f..24739ddde 100644 --- a/client/coral-framework/actions/asset.js +++ b/client/coral-framework/actions/asset.js @@ -10,7 +10,7 @@ export const fetchAssetFailure = (error) => ({type: actions.FETCH_ASSET_FAILURE, const updateAssetSettingsRequest = () => ({type: actions.UPDATE_ASSET_SETTINGS_REQUEST}); const updateAssetSettingsSuccess = (settings) => ({type: actions.UPDATE_ASSET_SETTINGS_SUCCESS, settings}); -const updateAssetSettingsFailure = () => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE}); +const updateAssetSettingsFailure = (error) => ({type: actions.UPDATE_ASSET_SETTINGS_FAILURE, error}); export const updateConfiguration = (newConfig) => (dispatch, getState) => { const assetId = getState().asset.toJS().id; @@ -20,7 +20,10 @@ export const updateConfiguration = (newConfig) => (dispatch, getState) => { dispatch(addNotification('success', t('framework.success_update_settings'))); dispatch(updateAssetSettingsSuccess(newConfig)); }) - .catch((error) => dispatch(updateAssetSettingsFailure(error))); + .catch((error) => { + console.error(error); + dispatch(updateAssetSettingsFailure(error)); + }); }; export const updateOpenStream = (closedBody) => (dispatch, getState) => { @@ -31,7 +34,10 @@ export const updateOpenStream = (closedBody) => (dispatch, getState) => { dispatch(addNotification('success', t('framework.success_update_settings'))); dispatch(fetchAssetSuccess(closedBody)); }) - .catch((error) => dispatch(fetchAssetFailure(error))); + .catch((error) => { + console.error(error); + dispatch(fetchAssetFailure(error)); + }); }; const openStream = () => ({type: actions.OPEN_COMMENTS}); diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 614b87632..05e078010 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -75,7 +75,9 @@ export const createUsername = (userId, formData) => (dispatch) => { dispatch(updateUsername(formData)); }) .catch((error) => { - dispatch(createUsernameFailure(t(`error.${error.translation_key}`))); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(createUsernameFailure(errorMessage)); }); }; @@ -139,16 +141,20 @@ export const fetchSignIn = (formData) => { dispatch(hideSignInDialog()); }) .catch((error) => { + console.error(error); if (error.metadata) { // the user might not have a valid email. prompt the user user re-request the confirmation email dispatch( signInFailure(t('error.email_not_verified', error.metadata)) ); - } else { + } else if (error.translation_key === 'NOT_AUTHORIZED') { // invalid credentials - dispatch(signInFailure(t('error.email_password'))); + dispatch(signInFailure(t('error.email_password'), error.metadata)); + } else { + const str = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(signInFailure(str)); } }); }; @@ -234,12 +240,8 @@ export const fetchSignUp = (formData) => (dispatch, getState) => { dispatch(signUpSuccess(user)); }) .catch((error) => { - let errorMessage = t(`error.${error.message}`); - - // if there is no translation defined, just show the error string - if (errorMessage === `error.${error.message}`) { - errorMessage = error.message; - } + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); dispatch(signUpFailure(errorMessage)); }); }; @@ -256,8 +258,9 @@ const forgotPasswordSuccess = () => ({ type: actions.FETCH_FORGOT_PASSWORD_SUCCESS }); -const forgotPasswordFailure = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_FAILURE +const forgotPasswordFailure = (error) => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE, + error, }); export const fetchForgotPassword = (email) => (dispatch, getState) => { @@ -268,7 +271,11 @@ export const fetchForgotPassword = (email) => (dispatch, getState) => { body: {email, loc: redirectUri} }) .then(() => dispatch(forgotPasswordSuccess())) - .catch((error) => dispatch(forgotPasswordFailure(error))); + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(forgotPasswordFailure(errorMessage)); + }); }; //============================================================================== @@ -326,7 +333,8 @@ export const checkLogin = () => (dispatch) => { }) .catch((error) => { console.error(error); - dispatch(checkLoginFailure(`${error.translation_key}`)); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(checkLoginFailure(errorMessage)); }); }; @@ -360,11 +368,10 @@ export const requestConfirmEmail = (email) => (dispatch, getState) => { .then(() => { dispatch(verifyEmailSuccess()); }) - .catch((err) => { - - // email might have already been verifyed - dispatch(verifyEmailFailure(err)); - throw err; + .catch((error) => { + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(verifyEmailFailure(errorMessage)); }); }; diff --git a/client/coral-framework/actions/user.js b/client/coral-framework/actions/user.js index b978f686d..a6ad45d4a 100644 --- a/client/coral-framework/actions/user.js +++ b/client/coral-framework/actions/user.js @@ -14,6 +14,8 @@ export const editName = (username) => (dispatch) => { dispatch(addNotification('success', t('framework.success_name_update'))); }) .catch((error) => { - dispatch(editUsernameFailure(t(`error.${error.translation_key}`))); + console.error(error); + const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString(); + dispatch(editUsernameFailure(errorMessage)); }); }; diff --git a/client/coral-framework/components/ClickOutside.js b/client/coral-framework/components/ClickOutside.js new file mode 100644 index 000000000..e13ef4472 --- /dev/null +++ b/client/coral-framework/components/ClickOutside.js @@ -0,0 +1,35 @@ +import {Component, cloneElement, Children} from 'react'; +import PropTypes from 'prop-types'; +import {findDOMNode} from 'react-dom'; +import {pym} from 'coral-framework'; + +export default class ClickOutside extends Component { + static propTypes = { + onClickOutside: PropTypes.func.isRequired + }; + + domNode = null; + + handleClick = (e) => { + const {onClickOutside} = this.props; + if (!e || !this.domNode.contains(e.target)) { + onClickOutside(e); + } + }; + + componentDidMount() { + this.domNode = findDOMNode(this); + document.addEventListener('click', this.handleClick, true); + pym.onMessage('click', this.handleClick); + } + + componentWillUnmount() { + document.removeEventListener('click', this.handleClick, true); + pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick); + } + + render() { + const {children, onClickOutside: _, ...rest} = this.props; + return cloneElement(Children.only(children), rest); + } +} diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 01b3daff4..782f840bd 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -1,4 +1,4 @@ export {default as withFragments} from './withFragments'; export {default as withMutation} from './withMutation'; export {default as withQuery} from './withQuery'; - +export {default as withCopyToClipboard} from './withCopyToClipboard'; diff --git a/client/coral-framework/hocs/withCopyToClipboard.js b/client/coral-framework/hocs/withCopyToClipboard.js new file mode 100644 index 000000000..9e8046802 --- /dev/null +++ b/client/coral-framework/hocs/withCopyToClipboard.js @@ -0,0 +1,29 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import Clipboard from 'clipboard'; + +export default (WrappedComponent) => { + class WithCopyToClipboard extends React.Component { + componentDidMount() { + const clipboard = new Clipboard(ReactDOM.findDOMNode(this)); + + clipboard.on('success', (e) => { + e.clearSelection(); + }); + } + + render() { + const {copyTarget = '', copyText = '', className = '', ...rest} = this.props; + + return ; + } + } + + return WithCopyToClipboard; +}; diff --git a/client/coral-plugin-commentbox/__tests__/commentBox.spec.js b/client/coral-plugin-commentbox/__tests__/commentBox.spec.js index 92e085c79..6ae09e786 100644 --- a/client/coral-plugin-commentbox/__tests__/commentBox.spec.js +++ b/client/coral-plugin-commentbox/__tests__/commentBox.spec.js @@ -20,7 +20,7 @@ describe('CommentBox', () => { }); it('should render the CommentBox appropriately', () => { - expect(render.contains('
    { this.closeMenu(); } @@ -138,79 +138,81 @@ class FlagButton extends Component { const flagged = flaggedByCurrentUser || localPost; const popupMenu = getPopupMenu[this.state.step](this.state.itemType); - return
    - - { - this.state.showMenu && -
    this.popup = ref}> - -
    {popupMenu.header}
    + return ( + +
    + + { + this.state.showMenu && +
    this.popup = ref}> + +
    {popupMenu.header}
    { - popupMenu.options.map((option) => -
    - -
    -
    - ) + popupMenu.text && +
    {popupMenu.text}
    } { - this.state.reason &&
    -
    -