Merge branch 'master' into 1939

This commit is contained in:
Kim Gardner
2018-10-19 18:45:46 +01:00
committed by GitHub
20 changed files with 2079 additions and 95 deletions
+5 -6
View File
@@ -7,9 +7,9 @@ import t from 'coral-framework/services/i18n';
import { can } from 'coral-framework/services/perms';
import cn from 'classnames';
const CoralDrawer = ({ handleLogout, currentUser }) => (
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
{currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
const CoralDrawer = ({ handleLogout, currentUser }) =>
currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
<Drawer className={cn('talk-admin-drawer-nav', styles.drawer)}>
<div>
<Navigation className={styles.nav}>
{can(currentUser, 'MODERATE_COMMENTS') && (
@@ -48,9 +48,8 @@ const CoralDrawer = ({ handleLogout, currentUser }) => (
<span>{`v${process.env.VERSION}`}</span>
</Navigation>
</div>
) : null}
</Drawer>
);
</Drawer>
) : null;
CoralDrawer.propTypes = {
handleLogout: PropTypes.func.isRequired,
@@ -34,6 +34,21 @@
}
}
.filterHeader {
margin-top: 30px;
font-size: 16px;
font-weight: 900;
}
.filterDetail {
margin-top: 15px;
font-size: 16px;
}
.radioGroup {
margin: 5px 0;
}
.mainContent {
width: calc(100% - 300px);
padding: 34px 14px;
@@ -9,6 +9,7 @@ import PropTypes from 'prop-types';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import { isSuspended, isBanned } from 'coral-framework/utils/user';
import { RadioGroup, Radio } from 'react-mdl';
import moment from 'moment';
import {
ADMIN,
@@ -64,11 +65,12 @@ class People extends React.Component {
render() {
const {
onSearchChange,
onFilterChange,
users = [],
setUserRole,
viewUserDetail,
loadMore,
filters,
} = this.props;
const hasResults = !!users.nodes.length;
@@ -87,11 +89,43 @@ class People extends React.Component {
id="commenters-search"
type="text"
className={styles.searchBoxInput}
defaultValue=""
onChange={onSearchChange}
value={filters.search}
onChange={onFilterChange('search')}
placeholder={t('streams.search')}
/>
</div>
<div className={styles.filterHeader}>
{t('community.filter_users')}
</div>
<div className={styles.filterDetail}>{t('community.status')}</div>
<RadioGroup
name="statusFilter"
value={filters.status}
childContainer="div"
onChange={onFilterChange('status')}
className={styles.radioGroup}
>
<Radio value="">{t('community.all')}</Radio>
<Radio value="active">{t('community.active')}</Radio>
<Radio value="suspended">{t('community.suspended')}</Radio>
<Radio value="banned">{t('community.banned')}</Radio>
</RadioGroup>
<div className={styles.filterDetail}>
{t('community.filter_role')}
</div>
<RadioGroup
name="roleFilter"
value={filters.role}
childContainer="div"
onChange={onFilterChange('role')}
className={styles.radioGroup}
>
<Radio value="">{t('community.all')}</Radio>
<Radio value={ADMIN}>{t('community.admin')}</Radio>
<Radio value={STAFF}>{t('community.staff')}</Radio>
<Radio value={MODERATOR}>{t('community.moderator')}</Radio>
<Radio value={COMMENTER}>{t('community.commenter')}</Radio>
</RadioGroup>
</div>
<div className={styles.mainContent}>
{hasResults ? (
@@ -254,7 +288,8 @@ class People extends React.Component {
People.propTypes = {
users: PropTypes.object.isRequired,
onSearchChange: PropTypes.func.isRequired,
filters: PropTypes.object.isRequired,
onFilterChange: PropTypes.func.isRequired,
setUserRole: PropTypes.func.isRequired,
viewUserDetail: PropTypes.func.isRequired,
unbanUser: PropTypes.func.isRequired,
@@ -17,59 +17,65 @@ import update from 'immutability-helper';
import { Spinner } from 'coral-ui';
import withQuery from 'coral-framework/hocs/withQuery';
class PeopleContainer extends React.Component {
class PeopleContainer extends React.PureComponent {
timer = null;
state = {
searchValue: '',
search: '',
role: '',
status: '',
};
onSearchChange = e => {
const { value } = e.target;
this.setState({ searchValue: value }, () => {
statusToQuery = {
active: { suspended: false, banned: false },
suspended: { suspended: true },
banned: { banned: true },
};
onFilterChange = filter => e =>
this.setState({ [filter]: e.target.value }, () => {
clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.search(value);
}, 350);
this.timer = setTimeout(this.filter, 350);
});
getFilterState = () => {
const { role, status } = this.state;
return {
status: this.statusToQuery[status] || null,
role: role || null,
};
};
search = async value => {
return this.props.data.fetchMore({
query: SEARCH_QUERY,
filter = () =>
this.props.data.fetchMore({
query: FILTER_QUERY,
variables: {
value,
state: this.getFilterState(),
value: this.state.search,
limit: 10,
},
updateQuery: (previous, { fetchMoreResult: { users } }) => {
const updated = update(previous, {
updateQuery: (previous, { fetchMoreResult: { users } }) =>
update(previous, {
users: {
nodes: {
$set: users.nodes,
},
nodes: { $set: users.nodes },
hasNextPage: { $set: users.hasNextPage },
endCursor: { $set: users.endCursor },
},
});
return updated;
},
}),
});
};
setUserRole = async (id, role) => {
await this.props.setUserRole(id, role);
};
loadMore = () => {
return this.props.data.fetchMore({
loadMore = () =>
this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
value: this.state.searchValue,
limit: 5,
cursor: this.props.root.users.endCursor,
state: this.getFilterState(),
value: this.state.search,
limit: 5,
},
updateQuery: (previous, { fetchMoreResult: { users } }) => {
const updated = update(previous, {
updateQuery: (previous, { fetchMoreResult: { users } }) =>
update(previous, {
users: {
nodes: {
$apply: nodes => appendNewNodes(nodes, users.nodes),
@@ -77,11 +83,8 @@ class PeopleContainer extends React.Component {
hasNextPage: { $set: users.hasNextPage },
endCursor: { $set: users.endCursor },
},
});
return updated;
},
}),
});
};
render() {
if (this.props.data.error) {
@@ -98,9 +101,9 @@ class PeopleContainer extends React.Component {
return (
<People
onSearchChange={this.onSearchChange}
onFilterChange={this.onFilterChange}
viewUserDetail={this.props.viewUserDetail}
setUserRole={this.setUserRole}
setUserRole={this.props.setUserRole}
showSuspendUserDialog={this.props.showSuspendUserDialog}
showBanUserDialog={this.props.showBanUserDialog}
unbanUser={this.props.unbanUser}
@@ -109,6 +112,7 @@ class PeopleContainer extends React.Component {
root={this.props.root}
users={this.props.root.users}
loadMore={this.loadMore}
filters={this.state}
/>
);
}
@@ -125,16 +129,6 @@ PeopleContainer.propTypes = {
root: PropTypes.object,
};
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
viewUserDetail,
showSuspendUserDialog,
showBanUserDialog,
},
dispatch
);
const LOAD_MORE_QUERY = gql`
query TalkAdmin_Community_People_LoadMoreUsers(
$limit: Int
@@ -169,9 +163,13 @@ const LOAD_MORE_QUERY = gql`
}
`;
const SEARCH_QUERY = gql`
query TalkAdmin_Community_People_SearchUsers($value: String, $limit: Int) {
users(query: { value: $value, limit: $limit }) {
const FILTER_QUERY = gql`
query TalkAdmin_Community_People_FilterUsers(
$state: UserStateInput
$value: String
$limit: Int
) {
users(query: { state: $state, value: $value, limit: $limit }) {
hasNextPage
endCursor
nodes {
@@ -199,6 +197,16 @@ const SEARCH_QUERY = gql`
}
`;
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
viewUserDetail,
showSuspendUserDialog,
showBanUserDialog,
},
dispatch
);
export default compose(
connect(
null,
+14 -2
View File
@@ -23,7 +23,11 @@ export const checkLogin = () => (
dispatch(checkLoginSuccess(result.user));
pym.sendMessage('coral-auth-changed', JSON.stringify(result.user));
client.resetWebsocket();
// We don't need to reset the websocket here because if the request
// returned that there was a user (which is the case here), then the
// original request has already succeeded, or a previous call to a token
// set handler has already reset it.
})
.catch(error => {
if (error.status && error.status === 401 && localStorage) {
@@ -49,7 +53,11 @@ const checkLoginSuccess = user => ({
user,
});
export const setAuthToken = token => (dispatch, _, { localStorage }) => {
export const setAuthToken = token => (
dispatch,
_,
{ localStorage, client }
) => {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
@@ -57,6 +65,9 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
// may not be able to persist the auth token any other way. Keep it in redux!
dispatch({ type: actions.SET_AUTH_TOKEN, token });
// Now that we set a token, let's reset the subscriptions.
client.resetWebsocket();
dispatch(checkLogin());
};
@@ -79,6 +90,7 @@ export const handleSuccessfulLogin = (user, token) => (
);
}
// Now that we just set a token, set the token!
client.resetWebsocket();
dispatch({
+15 -13
View File
@@ -88,21 +88,23 @@ export function createClient(options = {}) {
});
client.resetWebsocket = () => {
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close();
if (wsClient.client) {
// Close socket connection which will also unregister subscriptions on the server-side.
wsClient.close(true);
// Reconnect to the server.
wsClient.connect();
// Reconnect to the server.
wsClient.connect();
// Reregister all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach(id => {
wsClient.sendMessage(
id,
MessageTypes.GQL_START,
wsClient.operations[id].options
);
});
// Re-register all subscriptions (uses non public api).
// See: https://github.com/apollographql/subscriptions-transport-ws/issues/171
Object.keys(wsClient.operations).forEach(id => {
wsClient.sendMessage(
id,
MessageTypes.GQL_START,
wsClient.operations[id].options
);
});
}
};
return client;
+5 -1
View File
@@ -30,7 +30,7 @@ const CONFIG = {
ENABLE_TRACING: Boolean(process.env.APOLLO_ENGINE_KEY),
// EMAIL_SUBJECT_PREFIX is the string before emails in the subject.
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX || '[Talk]',
EMAIL_SUBJECT_PREFIX: process.env.TALK_EMAIL_SUBJECT_PREFIX,
// DEFAULT_LANG is the default language used for server sent emails and
// rendered text.
@@ -271,6 +271,10 @@ const CONFIG = {
// CONFIG VALIDATION
//==============================================================================
if (typeof CONFIG.EMAIL_SUBJECT_PREFIX === 'undefined') {
CONFIG.EMAIL_SUBJECT_PREFIX = '[Talk]';
}
if (process.env.NODE_ENV === 'test') {
if (!CONFIG.ROOT_URL) {
CONFIG.ROOT_URL = `http://${localAddress}:3001`;
+15 -2
View File
@@ -246,6 +246,21 @@ Refer to the documentation for [TALK_JWT_ALG](#talk-jwt-alg) for other signing
methods and other forms of the `TALK_JWT_SECRET`. If you are interested in using
multiple keys, then refer to [TALK_JWT_SECRETS](#talk-jwt-secrets).
You can also encode your secret as a base64 string (if you are using a symmetric
algorithm) as long as you prefix it with `base64:`. For example:
```plain
TALK_JWT_SECRET={"secret": "base64:dGVzdA=="}
```
Would be the same as:
```plain
TALK_JWT_SECRET={"secret": "test"}
```
As `dGVzdA==` is just `test` encoded using base64.
## TALK_JWT_SECRETS
Used when specifying multiple secrets used for key rotations. This is a JSON
@@ -271,7 +286,6 @@ Note that the secret is stored in a JSON object, keyed by `secret`. This is only
needed when specifying in the multiple secrets for `TALK_JWT_SECRETS`, but may
be used to specify the single [TALK_JWT_SECRET](#talk-jwt-secret).
When the value of [TALK_JWT_ALG](#talk-jwt-alg) is **not** a `HS*` value, then
the value of the `TALK_JWT_SECRETS` should take the form:
@@ -282,7 +296,6 @@ TALK_JWT_SECRETS=[{"kid": "1", "private": "<my private key>", "public": "<my pub
Refer to the documentation on the [TALK_JWT_ALG](#talk-jwt-alg) for more
information on what to store in these parameters.
## TALK_JWT_SIGNING_COOKIE_NAME
The default cookie name that is use to set a cookie containing a JWT that was
+1 -1
View File
@@ -15,7 +15,7 @@ Even if GDPR will not apply to you, it is recommended to enable these
features as a best practice to provide your users with control over their own
data.
## GPDR Feature Overview
## GDPR Feature Overview
Integrating our GDPR tools will give your users and organizations the following benefits:
+5 -1
View File
@@ -4,7 +4,11 @@ const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const { escapeRegExp } = require('../../services/regex');
const mergeState = (query, state) => {
const { status } = state;
const { role, status } = state;
if (role) {
query.merge({ role });
}
if (status) {
const { username, banned, suspended } = status;
+1
View File
@@ -189,6 +189,7 @@ type UserStatus {
}
input UserStateInput {
role: USER_ROLES
status: UserStatusInput
}
+4
View File
@@ -62,6 +62,7 @@ en:
active: Active
admin: Administrator
ads_marketing: 'This looks like an ad/marketing'
all: 'All'
are_you_sure: 'Are you sure you would like to ban {0}?'
ban_user: 'Ban User?'
banned: Banned
@@ -69,6 +70,8 @@ en:
cancel: Cancel
commenter: Commenter
dont_like_username: 'Dislike username'
filter_users: 'Filter users'
filter_role: Role
flaggedaccounts: 'Reported Usernames'
flags: Flags
impersonating: Impersonation
@@ -85,6 +88,7 @@ en:
spam_ads: Spam/Ads
staff: Staff
status: Status
suspended: Suspended
username_and_email: 'Username and Email'
yes_ban_user: 'Yes Ban User'
configure:
+4
View File
@@ -57,6 +57,7 @@ es:
active: Activa
admin: Administrator
ads_marketing: 'Esto parece ser un ad/marketing'
all: 'Todos'
are_you_sure: '¿Estás segura que quieres suspender a {0}?'
ban_user: '¿Quieres suspender al Usuario?'
banned: Suspendido
@@ -64,6 +65,8 @@ es:
cancel: Cancelar
commenter: Comentarista
dont_like_username: 'No me gusta este nombre de usuario'
filter_users: 'Filter users'
filter_role: Rol
flaggedaccounts: 'Nombres de Usuario Reportados'
flags: Reportes
impersonating: Impersonando
@@ -80,6 +83,7 @@ es:
spam_ads: Spam/Publicidad
staff: Personal
status: Estado
suspended: Suspendido
username_and_email: 'Usuario y Correo'
yes_ban_user: 'Si, Suspendan el usuario'
configure:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "4.6.4",
"version": "4.6.5",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -201,7 +201,7 @@
"smoothscroll-polyfill": "^0.3.5",
"snake-case": "2.1.0",
"style-loader": "^0.16.0",
"subscriptions-transport-ws": "^0.8.3",
"subscriptions-transport-ws": "^0.7.2",
"supports-color": "^4",
"timeago.js": "^2.0.3",
"timekeeper": "^1.0.0",
+5 -4
View File
@@ -10,9 +10,10 @@ plugin:
---
Using the [Perspective API](http://perspectiveapi.com/), this
plugin will warn users and reject comments that exceed the predefined toxicity
threshold. For more information on what Toxic Comments are, check out the
[Toxic Comments](/talk/toxic-comments/) documentation.
plugin will warn users when comments exceed the predefined toxicity
threshold. Toxic comments will be flagged and are held back from being posted until reviewed by a moderator.
For more information on what Toxic Comments are, check out the [Toxic Comments](/talk/toxic-comments/) documentation, and you can see how the plugin works on [this blog post](https://coralproject.net/blog/toxic-avenging/).
Configuration:
@@ -25,4 +26,4 @@ Configuration:
- `TALK_PERSPECTIVE_TIMEOUT` - The timeout for sending a comment to
be processed before it will skip the toxicity analysis, parsed by
[ms](https://www.npmjs.com/package/ms). (Default `300ms`)
- `TALK_PERSPECTIVE_DO_NOT_STORE` - Whether the API is permitted to store comment and context from this request. Stored comments will be used for future research and community model building purposes to improve the API over time. (Default `true`) [Perspective API - Analize Comment Request](https://github.com/conversationai/perspectiveapi/blob/master/api_reference.md#analyzecomment-request)
- `TALK_PERSPECTIVE_DO_NOT_STORE` - Whether the API stores or deletes the comment text and context from this request after it has been evaluated. Stored comments will be used for future research and community model building purposes to improve the API over time. (Default `true`) [Perspective API - Analyze Comment Request](https://github.com/conversationai/perspectiveapi/blob/master/api_reference.md#analyzecomment-request)
@@ -75,7 +75,7 @@ es:
Puede editar el comentario o enviarlo para la revisión del moderador.
talk-plugin-toxic-comments:
unlikely: "Improbable"
highly_likely: "Altamente Improbable"
highly_likely: "Altamente Probable"
possibly: "Posiblemente"
likely: "Probable"
toxic_comment: "Comentario Tóxico"
+5
View File
@@ -120,6 +120,11 @@ function SharedSecret({ kid = undefined, secret = null }, algorithm) {
throw new Error('Secret cannot have a zero length');
}
// If the secret is base64 encoded, then decode it!
if (secret.startsWith('base64:')) {
secret = Buffer.from(secret.substring(7), 'base64').toString();
}
return new Secret({
kid,
signingKey: secret,
+4
View File
@@ -30,6 +30,10 @@ module.exports = {
.waitForElementVisible('@signOutButton')
.click('@signOutButton');
},
login(user) {
this.expect.section('@login').to.be.visible;
return this.section.login.login(user);
},
navigateAndLogin(user) {
this.navigate().expect.section('@login').to.be.visible;
return this.section.login.login(user);
+6 -1
View File
@@ -20,13 +20,18 @@ module.exports = {
client.end();
},
'Admin goes to login': client => {
const adminPage = client.page.admin();
adminPage.navigate().expect.element('drawerButton').to.not.be.present;
},
'Admin logs in': client => {
const adminPage = client.page.admin();
const {
testData: { admin },
} = client.globals;
adminPage.navigateAndLogin(admin);
adminPage.login(admin);
},
'Admin goes to Stories': client => {
+1875 -7
View File
File diff suppressed because it is too large Load Diff