Merge branch 'master' into bugs-fix

This commit is contained in:
Kim Gardner
2017-06-22 12:30:49 +01:00
committed by GitHub
67 changed files with 1158 additions and 453 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -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')
Executable
+100
View File
@@ -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 <userID>')
.description('list tokens for a user')
.action(listTokens);
program
.command('revoke <tokenID>')
.description('revokes a token with a given id')
.action(revokeToken);
program
.command('create <userID> <tokenName>')
.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();
}
+1 -1
View File
@@ -50,7 +50,7 @@ const routes = (
<Route path=':id' components={Moderation} />
</Route>
<Route path=':id' components={Moderation} />
<IndexRedirect to='premod' />
<IndexRedirect to='all' />
</Route>
</Route>
</div>
+17 -9
View File
@@ -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) => {
+32 -11
View File
@@ -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));
});
};
+6 -2
View File
@@ -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});
+11 -5
View File
@@ -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));
});
};
+7 -2
View File
@@ -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});
});
};
+16 -3
View File
@@ -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});
});
};
};
@@ -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 = (
<form onSubmit={this.handleSignIn}>
{errorMessage && <Alert>{t(`error.${errorMessage}`)}</Alert>}
{errorMessage && <Alert>{errorMessage}</Alert>}
<TextField
label='Email Address'
value={this.state.email}
@@ -84,12 +84,12 @@ const CoralHeader = ({
<MenuItem onClick={handleLogout}>
{t('configure.sign_out')}
</MenuItem>
<MenuItem>
Talk {`v${process.env.VERSION}`}
</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
</div>
@@ -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 (
<Button {...this.props} >
{t('common.copy')}
</Button>
);
}
}
export default withCopyToClipboard(ButtonCopyToClipboard);
@@ -192,6 +192,7 @@ export default class Moderation extends Component {
shortcutsNoteVisible={moderation.shortcutsNoteVisible}
open={moderation.modalOpen}
onClose={this.onClose}/>
{moderation.userDetailId && (
<UserDetail
id={moderation.userDetailId}
@@ -203,7 +204,9 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment} />
)}
<StorySearch
assetId={assetId}
moderation={this.props.moderation}
closeSearch={this.closeSearch}
storySearchChange={this.props.storySearchChange}
@@ -9,6 +9,7 @@ import t from 'coral-framework/services/i18n';
import {CSSTransitionGroup} from 'react-transition-group';
class ModerationQueue extends React.Component {
isLoadingMore = false;
static propTypes = {
viewUserDetail: PropTypes.func.isRequired,
@@ -23,7 +24,15 @@ class ModerationQueue extends React.Component {
}
loadMore = () => {
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) {
@@ -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;
@@ -38,7 +38,11 @@ const StorySearch = (props) => {
</Button>
</div>
<div className={styles.results}>
<p className={styles.cta}>Moderate comments on All Stories</p>
{props.assetId &&
<div className={styles.cta}>
<a onClick={props.goToModerateAll}>Moderate comments on All Stories</a>
</div>
}
<div className={styles.storyList}>
{
@@ -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;
@@ -35,9 +35,8 @@
border: none;
background-color: transparent;
font-size: 16px;
position: absolute;
width: 90%;
outline: none;
width: calc(100% - 90px);
}
.commentStatuses {
@@ -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 (
<Drawer handleClickOutside={hideUserDetail}>
<h3>{user.username}</h3>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ClickOutside onClickOutside={hideUserDetail}>
<Drawer onClose={hideUserDetail}>
<h3>{user.username}</h3>
<Copy onCopy={() => this.showCopied()} text={profile} className={styles.profileEmail}>
<Button className={styles.copyButton}>
{this.state.emailCopied ? 'Copied!' : 'Copy'}
</Button>
</Copy>
<div>
{profile && <input className={styles.profileEmail} readOnly type="text" ref={(ref) => this.profile = ref} value={profile} />}
<ButtonCopyToClipboard className={styles.copyButton} copyText={profile} />
</div>
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
selectedIds.length === 0
? (
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{`${selectedIds.length} comments selected`}
<Slot
fill="userProfile"
data={this.props.data}
root={this.props.root}
user={user}
/>
<p className={styles.memberSince}><strong>Member since</strong> {new Date(user.created_at).toLocaleString()}</p>
<hr/>
<p>
<strong>Account summary</strong>
<br/><small className={styles.small}>Data represents the last six months of activity</small>
</p>
<div className={styles.stats}>
<div className={styles.stat}>
<p>Total Comments</p>
<p>{totalComments}</p>
</div>
)
}
<div>
<div className={styles.stat}>
<p>Reject Rate</p>
<p>{`${(rejectedPercent).toFixed(1)}%`}</p>
</div>
</div>
{
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
index={i}
comment={comment}
selected={false}
suspectWords={suspectWords}
bannedWords={bannedWords}
viewUserDetail={() => {}}
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
? (
<ul className={styles.commentStatuses}>
<li className={tab === 'all' ? styles.active : ''} onClick={this.showAll}>All</li>
<li className={tab === 'rejected' ? styles.active : ''} onClick={this.showRejected}>Rejected</li>
</ul>
)
: (
<div className={styles.bulkActionGroup}>
<Button
onClick={bulkAccept}
className={styles.bulkAction}
cStyle='approve'
icon='done'>
</Button>
<Button
onClick={bulkReject}
className={styles.bulkAction}
cStyle='reject'
icon='close'>
</Button>
{`${selectedIds.length} comments selected`}
</div>
)
}
</div>
</Drawer>
<div>
{
nodes.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={comment.id}
index={i}
comment={comment}
selected={false}
suspectWords={suspectWords}
bannedWords={bannedWords}
viewUserDetail={() => {}}
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} />;
})
}
</div>
</Drawer>
</ClickOutside>
);
}
}
@@ -190,8 +190,6 @@ span {
}
&.selected {
max-width: 720px;
max-height: 410px;
}
.context {
@@ -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 {
<StorySearch
search={this.search}
goToStory={this.goToStory}
goToModerateAll={this.goToModerateAll}
handleEsc={this.handleEsc}
handleEnter={this.handleEnter}
searchValue={this.state.searchValue}
@@ -76,6 +76,8 @@ const ActionButton = ({children}) => {
};
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 */
? <span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
/* User can edit/delete their own comment for a short window after posting */
<span className={cn(styles.topRight)}>
{
commentIsStillEditable(comment) &&
<a
className={cn(styles.link, {[styles.active]: this.state.isEditing})}
onClick={this.onClickEdit}>Edit</a>
}
</span>
}
{ (currentUser && (comment.user.id !== currentUser.id)) &&
/* TopRightMenu allows currentUser to ignore other users' comments */
: <span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
<span className={cn(styles.topRight, styles.topRightMenu)}>
<TopRightMenu
comment={comment}
ignoreUser={ignoreUser}
addNotification={addNotification} />
</span>
}
{
this.state.isEditing
? <EditableCommentContent
@@ -80,14 +80,17 @@ export class EditableCommentContent extends React.Component {
}
successfullyEdited = true;
} catch (error) {
if (error.translation_key) {
addNotification('error', t(`error.${error.translation_key}`));
} else if (error.networkError) {
addNotification('error', t('error.network_error'));
} else {
addNotification('error', t('edit_comment.unexpected_error'));
throw error;
}
const errors = error.errors || [error];
errors.forEach((e) => {
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;
@@ -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
? <div className='coral-load-more'>
<Button
onClick={() => {
this.initialState = false;
loadMore();
}}>
onClick={this.loadMore}>
{topLevel ? t('framework.view_more_comments') : this.replyCountFormat(replyCount)}
</Button>
</div>
@@ -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 {
<SuspendedAccount
canEditName={user && user.canEditName}
editName={editName}
currentUsername={user.username}
/>}
{loggedIn &&
!banned &&
@@ -288,7 +303,7 @@ class Stream extends React.Component {
<LoadMore
topLevel={true}
moreComments={asset.comments.hasNextPage}
loadMore={this.props.loadMoreComments}
loadMore={this.loadMoreComments}
/>
</div>}
</div>
@@ -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) => {
@@ -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 = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
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 (
<ClickOutside onClickOutside={this.close}>
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
</ClickOutside>
);
}
}
@@ -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 {
/>
</div>
</Toggleable>
);
}
}
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
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 } */
<span className={styles.Toggleable} tabIndex="0" >
<span className={styles.toggler}
onClick={this.toggle}>{isOpen ? upArrow : downArrow}</span>
{isOpen ? children : null}
</span>
);
}
}
+5
View File
@@ -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%';
+9 -3
View File
@@ -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});
+25 -18
View File
@@ -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));
});
};
+3 -1
View File
@@ -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));
});
};
@@ -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);
}
}
+1 -1
View File
@@ -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';
@@ -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 <WrappedComponent
className={className}
data-clipboard-action="copy"
data-clipboard-text={copyText}
data-clipboard-target={copyTarget}
{...rest}
/>;
}
}
return WithCopyToClipboard;
};
@@ -20,7 +20,7 @@ describe('CommentBox', () => {
});
it('should render the CommentBox appropriately', () => {
expect(render.contains('<div class="CommentBox"')).to.be.truthy;
expect(render.contains('<button class="postCommentButton"')).to.be.truthy;
expect(render.contains('<div class="CommentBox"')).to.be.true;
expect(render.contains('<button class="postCommentButton"')).to.be.true;
});
});
+67 -65
View File
@@ -4,11 +4,11 @@ import t from 'coral-framework/services/i18n';
import {can} from 'coral-framework/services/perms';
import {PopupMenu, Button} from 'coral-ui';
import onClickOutside from 'react-onclickoutside';
import ClickOutside from 'coral-framework/components/ClickOutside';
const name = 'coral-plugin-flags';
class FlagButton extends Component {
export default class FlagButton extends Component {
state = {
showMenu: false,
@@ -128,7 +128,7 @@ class FlagButton extends Component {
this.setState({message: e.target.value});
}
handleClickOutside () {
handleClickOutside = () => {
this.closeMenu();
}
@@ -138,79 +138,81 @@ class FlagButton extends Component {
const flagged = flaggedByCurrentUser || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
<button
ref={(ref) => this.flagButton = ref}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-button`}>
{
flagged
? <span className={`${name}-button-text`}>{t('reported')}</span>
: <span className={`${name}-button-text`}>{t('report')}</span>
}
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged ? styles.flaggedIcon : {}}
aria-hidden={true}>flag</i>
</button>
{
this.state.showMenu &&
<div className={`${name}-popup`} ref={(ref) => this.popup = ref}>
<PopupMenu>
<div className={`${name}-popup-header`}>{popupMenu.header}</div>
return (
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className={`${name}-container`}>
<button
ref={(ref) => this.flagButton = ref}
onClick={!this.props.banned && !flaggedByCurrentUser && !localPost ? this.onReportClick : null}
className={`${name}-button`}>
{
popupMenu.text &&
<div className={`${name}-popup-text`}>{popupMenu.text}</div>
flagged
? <span className={`${name}-button-text`}>{t('reported')}</span>
: <span className={`${name}-button-text`}>{t('report')}</span>
}
{
popupMenu.options && <form className={`${name}-popup-form`}>
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged ? styles.flaggedIcon : {}}
aria-hidden={true}>flag</i>
</button>
{
this.state.showMenu &&
<div className={`${name}-popup`} ref={(ref) => this.popup = ref}>
<PopupMenu>
<div className={`${name}-popup-header`}>{popupMenu.header}</div>
{
popupMenu.options.map((option) =>
<div key={option.val}>
<input
className={`${name}-popup-radio`}
type="radio"
id={option.val}
checked={this.state[popupMenu.sets] === option.val}
onClick={this.onPopupOptionClick(popupMenu.sets)}
value={option.val}/>
<label htmlFor={option.val} className={`${name}-popup-radio-label`}>{option.text}</label><br/>
</div>
)
popupMenu.text &&
<div className={`${name}-popup-text`}>{popupMenu.text}</div>
}
{
this.state.reason && <div>
<label htmlFor={'message'} className={`${name}-popup-radio-label`}>
{t('flag_reason')}
</label><br/>
<textarea
className={`${name}-reason-text`}
id="message"
rows={4}
onChange={this.onNoteTextChange}
value={this.state.message}/>
</div>
popupMenu.options && <form className={`${name}-popup-form`}>
{
popupMenu.options.map((option) =>
<div key={option.val}>
<input
className={`${name}-popup-radio`}
type="radio"
id={option.val}
checked={this.state[popupMenu.sets] === option.val}
onClick={this.onPopupOptionClick(popupMenu.sets)}
value={option.val}/>
<label htmlFor={option.val} className={`${name}-popup-radio-label`}>{option.text}</label><br/>
</div>
)
}
{
this.state.reason && <div>
<label htmlFor={'message'} className={`${name}-popup-radio-label`}>
{t('flag_reason')}
</label><br/>
<textarea
className={`${name}-reason-text`}
id="message"
rows={4}
onChange={this.onNoteTextChange}
value={this.state.message}/>
</div>
}
</form>
}
</form>
}
<div className={`${name}-popup-counter`}>
{this.state.step + 1} of {getPopupMenu.length}
<div className={`${name}-popup-counter`}>
{this.state.step + 1} of {getPopupMenu.length}
</div>
{
popupMenu.button && <Button
className={`${name}-popup-button`}
onClick={this.onPopupContinue}>
{popupMenu.button}
</Button>
}
</PopupMenu>
</div>
{
popupMenu.button && <Button
className={`${name}-popup-button`}
onClick={this.onPopupContinue}>
{popupMenu.button}
</Button>
}
</PopupMenu>
}
</div>
}
</div>;
</ClickOutside>
);
}
}
export default onClickOutside(FlagButton);
const styles = {
flaggedIcon: {
color: '#F00'
+1 -1
View File
@@ -5,7 +5,7 @@ const renderer = new marked.Renderer();
// Set link target to `_parent` to work properly in an embed.
renderer.link = (href, title, text) =>
`<a target="_parent" href="${href}" title="${title}">${text}</a>`;
`<a target="_parent" href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
marked.setOptions({renderer});
@@ -5,7 +5,7 @@ import t from 'coral-framework/services/i18n';
const ModerationLink = (props) => props.isAdmin ? (
<div className={styles.moderationLink}>
<a href={`/admin/moderate/${props.assetId}`} target="_blank">
<a href={`/admin/moderate/all/${props.assetId}`} target="_blank">
{t('moderate_this_stream')}
</a>
</div>
@@ -1,12 +1,12 @@
import React, {PropTypes} from 'react';
import onClickOutside from 'react-onclickoutside';
const name = 'coral-plugin-permalinks';
import {Button} from 'coral-ui';
import styles from './styles.css';
import ClickOutside from 'coral-framework/components/ClickOutside';
import t from 'coral-framework/services/i18n';
class PermalinkButton extends React.Component {
export default class PermalinkButton extends React.Component {
static propTypes = {
articleURL: PropTypes.string.isRequired,
@@ -28,7 +28,7 @@ class PermalinkButton extends React.Component {
this.setState({popoverOpen: !this.state.popoverOpen});
}
handleClickOutside () {
handleClickOutside = () => {
this.setState({popoverOpen: false});
}
@@ -49,33 +49,33 @@ class PermalinkButton extends React.Component {
render () {
const {copySuccessful, copyFailure} = this.state;
return (
<div className={`${name}-container`}>
<button
ref={(ref) => this.linkButton = ref}
onClick={this.toggle}
className={`${name}-button`}>
{t('permalink')}
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
</button>
<div
ref={(ref) => this.popover = ref}
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
<input
className={`${name}-copy-field`}
type='text'
ref={(input) => this.permalinkInput = input}
value={`${this.props.articleURL}#${this.props.commentId}`}
onChange={() => {}} />
<Button className={`${name}-copy-button ${copySuccessful ? styles.success : ''} ${copyFailure ? styles.failure : ''}`}
onClick={this.copyPermalink} >
{!copyFailure && !copySuccessful && 'Copy'}
{copySuccessful && 'Copied'}
{copyFailure && 'Not supported'}
</Button>
<ClickOutside onClickOutside={this.handleClickOutside}>
<div className={`${name}-container`}>
<button
ref={(ref) => this.linkButton = ref}
onClick={this.toggle}
className={`${name}-button`}>
{t('permalink')}
<i className={`${name}-icon material-icons`} aria-hidden={true}>link</i>
</button>
<div
ref={(ref) => this.popover = ref}
className={`${name}-popover ${styles.container} ${this.state.popoverOpen ? 'active' : ''}`}>
<input
className={`${name}-copy-field`}
type='text'
ref={(input) => this.permalinkInput = input}
value={`${this.props.articleURL}#${this.props.commentId}`}
onChange={() => {}} />
<Button className={`${name}-copy-button ${copySuccessful ? styles.success : ''} ${copyFailure ? styles.failure : ''}`}
onClick={this.copyPermalink} >
{!copyFailure && !copySuccessful && 'Copy'}
{copySuccessful && 'Copied'}
{copyFailure && 'Not supported'}
</Button>
</div>
</div>
</div>
</ClickOutside>
);
}
}
export default onClickOutside(PermalinkButton);
+10
View File
@@ -53,6 +53,16 @@
color: #212121;
}
.type--local:hover {
background: #d6d5d5;
color: #212121;
}
.type--local:active {
background: #cccccc;
color: #212121;
}
.type--facebook {
background-color: #4267b2;
border-color: #4267b2;
+20 -17
View File
@@ -2,20 +2,23 @@ import React from 'react';
import styles from './Button.css';
import Icon from './Icon';
const Button = ({cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props}) => (
<button
className={`
${styles.button}
${styles[`type--${cStyle}`]}
${className}
${full ? styles.full : ''}
${raised ? styles.raised : ''}
`}
{...props}
>
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</button>
);
export default Button;
export default class Button extends React.Component {
render() {
const {cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props} = this.props;
return (
<button
className={`
${styles.button}
${styles[`type--${cStyle}`]}
${className}
${full ? styles.full : ''}
${raised ? styles.raised : ''}
`}
{...props}
>
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</button>
);
}
}
-38
View File
@@ -1,38 +0,0 @@
import React, {Component} from 'react';
import Clipboard from 'clipboard';
export default class Copy extends Component {
constructor(props) {
super(props);
this.refCopyButton = this.refCopyButton.bind(this);
}
componentDidMount() {
const clipboard = new Clipboard(this.copyButtonEl);
clipboard.on('success', (e) => {
this.props.onCopy();
e.clearSelection();
});
}
refCopyButton(button) {
this.copyButtonEl = button;
}
render() {
const {children, target = '', text = ''} = this.props;
return (
<span
ref={this.refCopyButton}
data-clipboard-action="copy"
data-clipboard-text={text}
data-clipboard-target={target}
>
{children}
</span>
);
}
}
+4 -5
View File
@@ -1,11 +1,10 @@
import React, {PropTypes} from 'react';
import styles from './Drawer.css';
import onClickOutside from 'react-onclickoutside';
const Drawer = ({children, handleClickOutside}) => {
const Drawer = ({children, onClose}) => {
return (
<div className={styles.drawer}>
<div className={styles.closeButton} onClick={handleClickOutside}>×</div>
<div className={styles.closeButton} onClick={onClose}>×</div>
<div className={styles.content}>
{children}
</div>
@@ -15,7 +14,7 @@ const Drawer = ({children, handleClickOutside}) => {
Drawer.propTypes = {
active: PropTypes.bool,
handleClickOutside: PropTypes.func.isRequired
onClose: PropTypes.func.isRequired
};
export default onClickOutside(Drawer);
export default Drawer;
-1
View File
@@ -24,4 +24,3 @@ export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';
export {default as Copy} from './components/Copy';
+8 -1
View File
@@ -161,7 +161,13 @@ const ErrInstallLock = new APIError('install lock active', {
// ErrPermissionUpdateUsername is returned when the user does not have permission to update their username.
const ErrPermissionUpdateUsername = new APIError('You do not have permission to update your username.', {
translation_key: 'EDIT_USERNAME_NOT_AUTHORIZED',
status: 500
status: 403
});
// ErrSameUsernameProvided is returned when attempting to update a username to the same username.
const ErrSameUsernameProvided = new APIError('Same username provided.', {
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400
});
// ErrLoginAttemptMaximumExceeded is returned when the login maximum is exceeded.
@@ -209,6 +215,7 @@ module.exports = {
ErrAuthentication,
ErrNotAuthorized,
ErrPermissionUpdateUsername,
ErrSameUsernameProvided,
ErrSettingsInit,
ErrInstallLock,
ErrLoginAttemptMaximumExceeded,
+2
View File
@@ -4,6 +4,7 @@ const debug = require('debug')('talk:graph:mutators');
const Comment = require('./comment');
const Action = require('./action');
const Tag = require('./tag');
const Token = require('./token');
const User = require('./user');
const plugins = require('../../services/plugins');
@@ -14,6 +15,7 @@ let mutators = [
Comment,
Action,
Tag,
Token,
User,
// Load the plugin mutators from the manager.
+41
View File
@@ -0,0 +1,41 @@
const errors = require('../../errors');
const TokensService = require('../../services/tokens');
const {
CREATE_TOKEN,
REVOKE_TOKEN
} = require('../../perms/constants');
// Creates a new token for a user.
const createToken = async ({user}, {name}) => {
let {pat, jwt} = await TokensService.create(user.id, name);
// Attach the token to the PAT.
pat.jwt = jwt;
// Return that PAT!
return pat;
};
// Revokes the token from the user.
const revokeToken = async ({user}, {id}) => {
return TokensService.revoke(user.id, id);
};
module.exports = (context) => {
let mutators = {
Token: {
create: () => Promise.reject(errors.ErrNotAuthorized),
revoke: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (context.user && context.user.can(CREATE_TOKEN)) {
mutators.Token.create = (input) => createToken(context, input);
}
if (context.user && context.user.can(REVOKE_TOKEN)) {
mutators.Token.revoke = (input) => revokeToken(context, input);
}
return mutators;
};
+6
View File
@@ -39,6 +39,12 @@ const RootMutation = {
},
removeTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.remove(tag));
},
createToken(_, {input}, {mutators: {Token}}) {
return wrapResponse('token')(Token.create(input));
},
revokeToken(_, {input}, {mutators: {Token}}) {
return wrapResponse(null)(Token.revoke(input));
}
};
+9 -1
View File
@@ -5,7 +5,8 @@ const {
SEARCH_OTHER_USERS,
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS
SEARCH_COMMENT_METRICS,
LIST_OWN_TOKENS
} = require('../../perms/constants');
const User = {
@@ -46,6 +47,13 @@ const User = {
return null;
},
tokens({id, tokens}, args, {user}) {
if (!user || ((user.id !== id) && !user.can(LIST_OWN_TOKENS))) {
return null;
}
return tokens;
},
ignoredUsers({id}, args, {user, loaders: {Users}}) {
// Only allow a logged in user that is either the current user or is a staff
+57
View File
@@ -36,6 +36,23 @@ enum USER_ROLES {
MODERATOR
}
# Token is a personal access token associated with a given user.
type Token {
# ID is the unique identifier for the token.
id: ID!
# Name is the description for the token.
name: String!
# Active determines if the token is available to hit the API.
active: Boolean!
# JWT is the actual token to use for authentication, this is only available
# on token creation, otherwise it will be null.
jwt: String
}
type UserProfile {
# the id is an identifier for the user profile (email, facebook id, etc)
id: String!
@@ -78,6 +95,9 @@ type User {
# ignored users.
ignoredUsers: [User!]
# Tokens are the personal access tokens for a given user.
tokens: [Token!]
# returns all comments based on a query.
comments(query: CommentsQuery): CommentConnection!
@@ -906,6 +926,37 @@ type EditCommentResponse implements Response {
errors: [UserError!]
}
# CreateTokenInput contains the input to create the token.
input CreateTokenInput {
# Name is the description for the token.
name: String!
}
# CreateTokenResponse contains the errors related to creating a token.
type CreateTokenResponse implements Response {
# Token is the Token that was created, or null if it failed.
token: Token
# An array of errors relating to the mutation that occured.
errors: [UserError!]
}
# RevokeTokenInput contains the input to revoke the token.
input RevokeTokenInput {
# ID is the JTI for the token.
id: ID!
}
# RevokeTokenResponse contains the errors related to revoking a token.
type RevokeTokenResponse implements Response {
# An array of errors relating to the mutation that occured.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -945,6 +996,12 @@ type RootMutation {
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
# CreateToken will create a token that is attached to the current user.
createToken(input: CreateTokenInput!): CreateTokenResponse!
# RevokeToken will revoke an existing token.
revokeToken(input: RevokeTokenInput!): RevokeTokenResponse!
# Stop Ignoring comments by another user
stopIgnoringUser(id: ID!): StopIgnoringUserResponse
}
+3
View File
@@ -33,6 +33,8 @@ en:
comment_post_banned_word: "Your comment contains one or more words that are not permitted, so it will not be published. If you think this message is incorrect, please contact our moderation team."
comment_post_notif: "Your comment has been posted."
comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly."
common:
copy: 'Copy'
community:
account_creation_date: "Account Creation Date"
active: Active
@@ -184,6 +186,7 @@ en:
USERNAME_REQUIRED: "Must input a username"
EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired."
EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username."
SAME_USERNAME_PROVIDED: "You must submit a different username."
EMAIL_IN_USE: "Email address already in use"
EMAIL_REQUIRED: "An email address is required"
LOGIN_MAXIMUM_EXCEEDED: "You have made too many unsuccessful password attempts. Please wait."
+2
View File
@@ -33,6 +33,8 @@ es:
comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación."
comment_post_notif: "Tu comentario ha sido publicado."
comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto."
common:
copy: 'Copiar'
community:
account_creation_date: "Fecha de creación de la cuenta"
active: Activa
+16
View File
@@ -0,0 +1,16 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const TokenSchema = new Schema({
// ID is the JTI of a given JWT's identifier.
id: String,
// Name is given by the user on token creation.
name: String,
// Active is used to determine if the token is valid.
active: Boolean
});
module.exports = TokenSchema;
+4
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./schema/tag_link');
const TokenSchema = require('./schema/token');
const intersection = require('lodash/intersection');
const can = require('../perms');
@@ -86,6 +87,9 @@ const UserSchema = new Schema({
// addresses.
profiles: [ProfileSchema],
// Tokens are the individual personal access tokens for a given user.
tokens: [TokenSchema],
// Roles provides an array of roles (as strings) that is associated with a
// user.
roles: [{
-1
View File
@@ -193,7 +193,6 @@
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
"react-onclickoutside": "^5.11.1",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
+3
View File
@@ -14,6 +14,8 @@ module.exports = {
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
UPDATE_USER_ROLES: 'UPDATE_USER_ROLES',
UPDATE_CONFIG: 'UPDATE_CONFIG',
CREATE_TOKEN: 'CREATE_TOKEN',
REVOKE_TOKEN: 'REVOKE_TOKEN',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
@@ -22,6 +24,7 @@ module.exports = {
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
// subscriptions
+4
View File
@@ -29,6 +29,10 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.UPDATE_CONFIG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.CREATE_TOKEN:
return check(user, ['ADMIN']);
case types.REVOKE_TOKEN:
return check(user, ['ADMIN']);
default:
break;
}
+2
View File
@@ -15,6 +15,8 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
default:
+19 -4
View File
@@ -1,6 +1,7 @@
const passport = require('passport');
const UsersService = require('./users');
const SettingsService = require('./settings');
const TokensService = require('./tokens');
const fetch = require('node-fetch');
const FormData = require('form-data');
const JWT = require('jsonwebtoken');
@@ -157,10 +158,7 @@ const HandleLogout = (req, res, next) => {
});
};
/**
* Check if the given token is already blacklisted, throw an error if it is.
*/
const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
const checkGeneralTokenBlacklist = (jwt) => new Promise((resolve, reject) => {
client.get(`jtir[${jwt.jti}]`, (err, expiry) => {
if (err) {
return reject(err);
@@ -174,6 +172,20 @@ const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => {
});
});
/**
* Check if the given token is already blacklisted, throw an error if it is.
*/
const CheckBlacklisted = async (jwt) => {
// Check to see if this is a PAT.
if (jwt.pat) {
return TokensService.validate(jwt.sub, jwt.jti);
}
// It wasn't a PAT! Check to see if it is valid anyways.
return checkGeneralTokenBlacklist(jwt);
};
const jwt = require('jsonwebtoken');
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
@@ -340,6 +352,9 @@ passport.use(new LocalStrategy({
passReqToCallback: true
}, async (req, email, password, done) => {
// Normalize email
email = email.toLowerCase();
// 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.
+127
View File
@@ -0,0 +1,127 @@
const errors = require('../errors');
const UserModel = require('../models/user');
const JWT = require('jsonwebtoken');
const uuid = require('uuid');
const {
JWT_SECRET,
JWT_ISSUER,
JWT_AUDIENCE
} = require('../config');
/**
* TokenService manages Personal Access Tokens for users. These tokens are
* persisted in the database and attached to the user.
*/
module.exports = class TokenService {
/**
* Creates a token for a user with a given name.
*
* @param {String} userID the id of the user owning the token
* @param {String} tokenName the name of the token to be created
*/
static async create(userID, tokenName) {
// Create the token.
const payload = {
jti: uuid.v4(),
iss: JWT_ISSUER,
aud: JWT_AUDIENCE,
sub: userID,
pat: true
};
// Sign the payload.
const jwt = JWT.sign(payload, JWT_SECRET, {});
// Create the PAT.
let pat = {
id: payload.jti,
name: tokenName,
active: true
};
// Wait to update the user model with the new PAT.
await UserModel.update({id: userID}, {
$push: {
tokens: pat
}
});
return {payload, jwt, pat};
}
/**
* Revokes a token and prevents the token from being used. Once a token has
* been revoked, it cannot be re-enabled.
*
* @param {String} userID the id of the user owning the token
* @param {String} tokenID the jti of the token to revoke
*/
static async revoke(userID, tokenID) {
let query = {
tokens: {
$elemMatch: {
id: tokenID
}
}
};
if (userID) {
query.id = userID;
}
// Revoke the token id.
await UserModel.update(query, {
$set: {
'tokens.$.active': false
}
});
}
/**
* Validate that a given Token is valid.
*
* @param {String} userID the user's id that owns the token
* @param {String} tokenID the id of the token
*/
static async validate(userID, tokenID) {
// Find the user.
let user = await UserModel.findOne({
id: userID
}).select('tokens');
if (!user || !user.tokens) {
throw new errors.ErrAuthentication('user does not exist');
}
// Extract the token from the user.
let token = user.tokens.find(({id}) => id === tokenID);
if (!token) {
throw new errors.ErrAuthentication('token does not exist');
}
// Check to see if it is active.
if (!token.active) {
throw new errors.ErrAuthentication('token is not active');
}
}
/**
* Lists the tokens owned by the user.
*
* @param {String} userID the id of the user owning the token
*/
static async list(userID) {
// Get the user specified by the id.
let user = await UserModel.findOne({id: userID}).select('tokens');
if (!user || !user.tokens) {
return [];
}
return user.tokens;
}
};
+39 -23
View File
@@ -528,7 +528,7 @@ module.exports = class UsersService {
}
/**
*
*
* @param {String} id the id of the current user
* @param {Object} token a jwt token used to sign in the user
*/
@@ -858,36 +858,52 @@ module.exports = class UsersService {
/**
* Updates the user's username.
* @param {String} id the id of the user to be enabled.
* @param {String} username The new username for the user.
* @param {String} id The id of the user.
* @param {String} username The new username for the user.
* @return {Promise}
*/
static editName(id, username) {
return UserModel.update({
id,
canEditName: true
}, {
$set: {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING',
}
})
.then((result) => {
if (result.nModified <= 0) {
return Promise.reject(errors.ErrPermissionUpdateUsername);
static async editName(id, username) {
try {
const result = await UserModel.findOneAndUpdate({
id,
username: {$ne: username},
canEditName: true
}, {
$set: {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING',
}
}, {
new: true,
});
if (!result) {
const user = await UsersService.findById(id);
if (user === null) {
throw errors.ErrNotFound;
}
if (!user.canEditName) {
throw errors.ErrPermissionUpdateUsername;
}
if (user.username === username) {
throw errors.ErrSameUsernameProvided;
}
throw new Error('edit username failed for an unexpected reason');
}
return result;
})
.catch((err) => {
}
catch(err) {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
});
}
}
/**
@@ -928,7 +944,7 @@ module.exports = class UsersService {
}
};
// Extract all the tokenUserNotFound plugins so we can integrate with other
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
let tokenUserNotFoundHooks = null;
+21 -4
View File
@@ -46,10 +46,27 @@ describe('/api/v1/account/username', () => {
.set(passport.inject({id: 'wrongid', roles: []}))
.send({username: 'MojoJojo'}))
.then(() => {
done(new Error('Exected Error'));
done(new Error('Expected Error'));
})
.catch((err) => {
expect(err).to.be.truthy;
expect(err).to.be.ok;
done();
});
});
it('it should return an error when the user submits the same username', (done) => {
chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then(() => chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'Ana'}))
.then(() => {
done(new Error('Expected Error'));
})
.catch((err) => {
expect(err).to.be.ok;
done();
});
});
@@ -60,10 +77,10 @@ describe('/api/v1/account/username', () => {
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'})
.then(() => {
done(new Error('Exected Error'));
done(new Error('Expected Error'));
})
.catch((err) => {
expect(err).to.be.truthy;
expect(err).to.be.ok;
done();
});
});
+99
View File
@@ -0,0 +1,99 @@
const TokensService = require('../../../services/tokens');
const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised');
chai.use(chaiAsPromised);
const expect = chai.expect;
describe('services.TokensService', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('sockmonster@gmail.com', '2Coral!!', 'Sockmonster');
});
describe('#create', () => {
it('can create the token without error', async () => {
let token = await TokensService.create(user.id, 'Github Token');
expect(token).to.be.an.object;
expect(token.jwt).to.be.a.string;
expect(token.pat).to.be.an.object;
let pat = token.pat;
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', pat.id);
expect(tokens[0]).to.have.property('name', pat.name);
});
});
describe('#revoke', () => {
it('can revoke a token', async () => {
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
expect(tokens[0]).to.have.property('active', true);
await TokensService.revoke(user.id, id);
tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
expect(tokens[0]).to.have.property('active', false);
});
});
describe('#validate', () => {
it('will allow a valid token', async () => {
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
// Validate it.
await TokensService.validate(user.id, id);
});
it('will not allow an invalid token', async () => {
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
// Revoke it.
await TokensService.revoke(user.id, id);
// Validate it.
return TokensService.validate(user.id, id).should.eventually.be.rejected;
});
});
describe('#list', () => {
it('lists the tokens for a user', async () => {
let tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(0);
// Create a token.
let {pat: {id}} = await TokensService.create(user.id, 'Github Token');
tokens = await TokensService.list(user.id);
expect(tokens).to.have.length(1);
expect(tokens[0]).to.have.property('id', id);
});
});
});
+3 -3
View File
@@ -246,7 +246,7 @@ describe('services.UsersService', () => {
done(new Error('Error expected'));
})
.catch((err) => {
expect(err).to.be.truthy;
expect(err).to.be.ok;
done();
});
});
@@ -260,7 +260,7 @@ describe('services.UsersService', () => {
done(new Error('Error expected'));
})
.catch((err) => {
expect(err).to.be.truthy;
expect(err).to.be.ok;
done();
});
});
@@ -272,7 +272,7 @@ describe('services.UsersService', () => {
expect(false).to.be.true;
})
.catch((err) => {
expect(err).to.be.truthy;
expect(err).to.be.ok;
});
});
});
+1 -7
View File
@@ -2041,7 +2041,7 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
create-hash "^1.1.0"
inherits "^2.0.1"
create-react-class@^15.5.1, create-react-class@^15.5.x:
create-react-class@^15.5.1:
version "15.5.2"
resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.5.2.tgz#6a8758348df660b88326a0e764d569f274aad681"
dependencies:
@@ -6904,12 +6904,6 @@ react-mdl@^1.7.1, react-mdl@^1.7.2:
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
react-onclickoutside@^5.11.1:
version "5.11.1"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
dependencies:
create-react-class "^15.5.x"
react-recaptcha@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.2.6.tgz#bb44c1948a39b37d5a41920c73db833e5d8524f9"