Merge master

This commit is contained in:
Mendel Konikov
2018-05-01 20:25:05 -04:00
225 changed files with 5840 additions and 2389 deletions
+18 -1
View File
@@ -1,12 +1,27 @@
# job_environment will setup the environment for any job being executed.
job_environment: &job_environment
NODE_ENV: test
DISABLE_CREATE_MONGO_INDEXES: TRUE
# job_defaults applies all the defaults for each job.
job_defaults: &job_defaults
working_directory: ~/coralproject/talk
docker:
- image: circleci/node:8
environment:
<<: *job_environment
# create_indexes will create the mongo indexes and wait until they have been
# built.
create_indexes: &create_indexes
run:
name: Create the database indexes and wait until they are built
command: ./bin/cli db createIndexes
# integration_environment is the environment that configures the tests.
integration_environment: &integration_environment
NODE_ENV: test
<<: *job_environment
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
E2E_MAX_RETRIES: 3
@@ -25,6 +40,7 @@ integration_job: &integration_job
- checkout
- attach_workspace:
at: ~/coralproject/talk
- <<: *create_indexes
- run:
name: Setup the database with defaults
command: ./bin/cli setup --defaults
@@ -117,6 +133,7 @@ jobs:
environment:
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
JEST_REPORTER: jest-junit
- <<: *create_indexes
- run:
name: Run the server unit tests
command: yarn test:server
+3
View File
@@ -1,3 +1,6 @@
{
"env": {
"jest": true
},
"extends": "@coralproject/eslint-config-talk"
}
+1 -1
View File
@@ -50,7 +50,7 @@ plugins/*
!plugins/talk-plugin-notifications-digest-hourly
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
!plugins/talk-plugin-profile-data
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
+1 -1
View File
@@ -32,7 +32,7 @@ Youve installed Talk on your server, and youre preparing to launch it on y
## End-to-End Testing
Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
Talk uses [Nightwatch](http://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
[![Browserstack](/public/img/browserstack_logo.png)](https://browserstack.com)
+7 -2
View File
@@ -1,5 +1,6 @@
const express = require('express');
const morgan = require('morgan');
const trace = require('./middleware/trace');
const logging = require('./middleware/logging');
const path = require('path');
const merge = require('lodash/merge');
const helmet = require('helmet');
@@ -12,6 +13,10 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
const app = express();
// Add the trace middleware first, it will create a request ID for each request
// downstream.
app.use(trace);
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
@@ -30,7 +35,7 @@ plugins.get('server', 'app').forEach(({ plugin, app: callback }) => {
// Add the logging middleware only if we aren't testing.
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
app.use(logging.log);
}
if (ENABLE_TRACING && APOLLO_ENGINE_KEY) {
+1
View File
@@ -11,6 +11,7 @@ const Matcher = require('did-you-mean');
program
.command('serve', 'serve the application')
.command('db', 'run database commands')
.command('settings', 'interact with the application settings')
.command('assets', 'interact with assets')
.command('setup', 'setup the application')
Executable
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env node
const util = require('./util');
const program = require('commander');
const config = require('../config');
async function createIndexes() {
try {
// Ensure we enable the index creation.
config.CREATE_MONGO_INDEXES = true;
// TODO: handle the plugin index creation?
// Let's register the shutdown hooks.
util.onshutdown([() => require('../services/mongoose').disconnect()]);
// Lets create all the database indexes for the application and wait for all
// them to finish their indexing.
const models = [
require('../models/action'),
require('../models/asset'),
require('../models/comment'),
require('../models/setting'),
require('../models/user'),
require('../models/migration'),
];
// Call the `.init()` method to setup all the indexes on each model.
// `init()` returns a promise that resolves when the indexes have finished
// building successfully. The `init()` function is idempotent, so we don't
// have to worry about triggering an index rebuild.
await Promise.all(models.map(Model => Model.init()));
console.log('Indexes created');
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
program
.command('createIndexes')
.description('creates the database indexes and waits until they are created')
.action(createIndexes);
program.parse(process.argv);
// If there is no command listed, output help.
if (process.argv.length <= 2) {
program.outputHelp();
util.shutdown();
}
+7 -5
View File
@@ -14,7 +14,7 @@ const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const MigrationService = require('../services/migration');
const errors = require('../errors');
const { ErrSettingsInit, ErrSettingsNotInit } = require('../errors');
const Context = require('../graph/context');
// Register the shutdown criteria.
@@ -41,13 +41,15 @@ const performSetup = async () => {
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
} catch (e) {
throw new ErrSettingsInit();
} catch (err) {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (e !== errors.ErrSettingsNotInit) {
throw e;
if (err instanceof ErrSettingsNotInit) {
return;
}
throw err;
}
if (program.defaults) {
+8 -1
View File
@@ -287,8 +287,15 @@ async function createUser() {
const { email, username, password, role } = answers;
const ctx = Context.forSystem();
// Create the user.
const user = await UsersService.createLocalUser(email, password, username);
const user = await UsersService.createLocalUser(
ctx,
email,
password,
username
);
// Set the role.
await UsersService.setRole(user.id, role);
+2
View File
@@ -10,6 +10,7 @@ import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
import OrganizationSettings from './routes/Configure/containers/OrganizationSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
@@ -25,6 +26,7 @@ const routes = (
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<Route path="organization" component={OrganizationSettings} />
<IndexRedirect to="stream" />
</Route>
+1 -8
View File
@@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW });
// hide shortcuts note
export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => {
try {
if (localStorage) {
localStorage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
localStorage.setItem('coral:shortcutsNote', 'hide');
dispatch({ type: actions.HIDE_SHORTCUTS_NOTE });
};
+17 -31
View File
@@ -3,7 +3,7 @@ import cn from 'classnames';
import PropTypes from 'prop-types';
import capitalize from 'lodash/capitalize';
import styles from './UserDetail.css';
import AccountHistory from './AccountHistory';
import UserHistory from './UserHistory';
import { Slot } from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {
@@ -28,26 +28,6 @@ import UserInfoTooltip from './UserInfoTooltip';
import t from 'coral-framework/services/i18n';
class UserDetail extends React.Component {
rejectThenReload = async info => {
await this.props.rejectComment(info);
this.props.data.refetch();
};
acceptThenReload = async info => {
await this.props.acceptComment(info);
this.props.data.refetch();
};
bulkAcceptThenReload = async () => {
await this.props.bulkAccept();
this.props.data.refetch();
};
bulkRejectThenReload = async () => {
await this.props.bulkReject();
this.props.data.refetch();
};
changeTab = tab => {
this.props.changeTab(tab);
};
@@ -110,8 +90,14 @@ class UserDetail extends React.Component {
unbanUser,
unsuspendUser,
modal,
acceptComment,
rejectComment,
bulkAccept,
bulkReject,
} = this.props;
console.log(rejectedComments, totalComments);
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
@@ -286,7 +272,7 @@ class UserDetail extends React.Component {
'talk-admin-user-detail-history-tab'
)}
>
{t('user_detail.account_history')}
{t('user_detail.user_history')}
</Tab>
</TabBar>
@@ -304,12 +290,12 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
@@ -322,19 +308,19 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
tabId={'history'}
className={'talk-admin-user-detail-history-tab-pane'}
>
<AccountHistory user={user} />
<UserHistory user={user} />
</TabPane>
</TabContent>
</Drawer>
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { murmur3 } from 'murmurhash-js';
import styles from './AccountHistory.css';
import styles from './UserHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
@@ -43,15 +43,15 @@ const readableDuration = (startDate, endDate) => {
const buildActionResponse = (typename, created_at, until, status) => {
switch (typename) {
case 'UsernameStatusHistory':
return t('account_history.username_status', status);
return t('user_history.username_status', status);
case 'BannedStatusHistory':
return status
? t('account_history.user_banned')
: t('account_history.ban_removed');
? t('user_history.user_banned')
: t('user_history.ban_removed');
case 'SuspensionStatusHistory':
return until
? t('account_history.suspended', readableDuration(created_at, until))
: t('account_history.suspension_removed');
? t('user_history.suspended', readableDuration(created_at, until))
: t('user_history.suspension_removed');
default:
return '-';
}
@@ -62,43 +62,41 @@ const getModerationValue = assignedBy =>
assignedBy.username
) : (
<span>
<Icon name="computer" /> {t('account_history.system')}
<Icon name="computer" /> {t('user_history.system')}
</span>
);
class AccountHistory extends React.Component {
class UserHistory extends React.Component {
render() {
const { user } = this.props;
const userHistory = buildUserHistory(user.state);
return (
<div>
<div className={cn(styles.table, 'talk-admin-account-history')}>
<div className={cn(styles.table, 'talk-admin-user-history')}>
<div
className={cn(
styles.headerRow,
'talk-admin-account-history-header-row'
'talk-admin-user-history-header-row'
)}
>
<div className={styles.headerRowItem}>{t('user_history.date')}</div>
<div className={styles.headerRowItem}>
{t('account_history.date')}
{t('user_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.taken_by')}
{t('user_history.taken_by')}
</div>
</div>
{userHistory.map(
({ __typename, created_at, assigned_by, until, status }) => (
<div
className={cn(styles.row, 'talk-admin-account-history-row')}
className={cn(styles.row, 'talk-admin-user-history-row')}
key={`${__typename}_${murmur3(created_at)}`}
>
<div
className={cn(
styles.item,
'talk-admin-account-history-row-date'
'talk-admin-user-history-row-date'
)}
>
{moment(new Date(created_at)).format('MMM DD, YYYY')}
@@ -107,7 +105,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.action,
'talk-admin-account-history-row-status'
'talk-admin-user-history-row-status'
)}
>
{buildActionResponse(__typename, created_at, until, status)}
@@ -116,7 +114,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.username,
'talk-admin-account-history-row-assigned-by'
'talk-admin-user-history-row-assigned-by'
)}
>
{getModerationValue(assigned_by)}
@@ -130,8 +128,8 @@ class AccountHistory extends React.Component {
}
}
AccountHistory.propTypes = {
UserHistory.propTypes = {
user: PropTypes.object.isRequired,
};
export default AccountHistory;
export default UserHistory;
@@ -148,6 +148,7 @@ UserDetailContainer.propTypes = {
selectedCommentIds: PropTypes.array,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
userId: PropTypes.string,
};
const LOAD_MORE_QUERY = gql`
@@ -245,7 +246,6 @@ export const withUserDetailQuery = withQuery(
options: ({ userId, statuses }) => {
return {
variables: { author_id: userId, statuses },
fetchPolicy: 'network-only',
};
},
skip: ownProps => !ownProps.userId,
+2 -1
View File
@@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation';
smoothscroll.polyfill();
function init({ store, localStorage }) {
if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') {
const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide';
if (shouldHide) {
store.dispatch(hideShortcutsNote());
}
}
@@ -5,6 +5,7 @@ const initialState = {
isLoading: false,
data: {
settings: {
organizationContactEmail: '',
organizationName: '',
domains: {
whitelist: [],
@@ -19,6 +20,7 @@ const initialState = {
},
errors: {
organizationName: '',
organizationContactEmail: '',
username: '',
email: '',
password: '',
@@ -8,7 +8,14 @@ import SaveChangesDialog from './SaveChangesDialog';
class Configure extends React.Component {
render() {
const { canSave, currentUser, root, savePending, settings } = this.props;
const {
canSave,
currentUser,
root,
savePending,
settings,
clearPending,
} = this.props;
if (!can(currentUser, 'UPDATE_CONFIG')) {
return <p>{t('configure.access_message')}</p>;
@@ -17,6 +24,9 @@ class Configure extends React.Component {
const passProps = {
root,
settings,
savePending,
clearPending,
canSave,
};
return (
@@ -41,6 +51,9 @@ class Configure extends React.Component {
<Item itemId="tech" icon="code">
{t('configure.tech_settings')}
</Item>
<Item itemId="organization" icon="people">
{t('configure.organization_information')}
</Item>
</List>
<div className={styles.saveBox}>
{canSave ? (
@@ -81,6 +94,7 @@ Configure.propTypes = {
children: PropTypes.node.isRequired,
saveDialog: PropTypes.bool,
hideSaveDialog: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
};
export default Configure;
@@ -0,0 +1,87 @@
.label {
display: block;
}
.detailList {
padding: 0;
margin: 0;
}
.detailLabel {
color: #000;
font-size: 1.1em;
font-weight: bold;
display: block;
margin-bottom: 4px;
}
.detailValue {
padding: 6px 0;
border: solid 1px transparent;
display: block;
font-size: 1.1em;
border-radius: 2px;
color: #424242;
box-sizing: border-box;
min-width: 300px;
}
.editable {
padding-left: 10px;
padding-right: 10px;
border-color: grey;
}
.detailItem {
margin-bottom: 16px;
list-style: none;
}
.button, .button:disabled {
background: white;
border: solid 1px grey;
}
.actionBox {
flex-grow: 0;
}
.cancelButton {
padding: 10px;
display: block;
color: #4f5c67;
font-weight: 500;
&:hover {
cursor: pointer;
}
}
.changedSave {
background-color: #00796B;
color: white;
}
.errorList {
list-style: none;
padding: 0;
margin: 0;
}
.errorItem {
padding: 5px 10px;
margin-bottom: 20px;
color: #b71c1c;
border-radius: 2px;
display: inline-block;
background: #F9D3CE;
}
.container {
display: flex;
}
.content {
flex-grow: 1;
padding-right: 40px;
}
@@ -0,0 +1,185 @@
import React from 'react';
import cn from 'classnames';
import { Button } from 'coral-ui';
import PropTypes from 'prop-types';
import styles from './OrganizationSettings.css';
import Slot from 'coral-framework/components/Slot';
import t from 'coral-framework/services/i18n';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
class OrganizationSettings extends React.Component {
state = { editing: false, errors: [] };
addError = err => {
if (this.state.errors.indexOf(err) === -1) {
this.setState(({ errors }) => ({
errors: errors.concat(err),
}));
}
};
removeError = err => {
this.setState(({ errors }) => ({
errors: errors.filter(i => i !== err),
}));
};
toggleEditing = () => {
this.setState(({ editing }) => ({
editing: !editing,
}));
};
disableEditing = () => {
this.setState(() => ({
editing: false,
}));
};
updateName = event => {
const updater = { organizationName: { $set: event.target.value } };
this.props.updatePending({ updater });
};
updateEmail = event => {
let error = null;
const email = event.target.value;
// Add a blocker error
if (!validate.email(email)) {
error = true;
this.addError('email');
} else {
this.removeError('email');
}
const updater = { organizationContactEmail: { $set: email } };
const errorUpdater = { organizationEmail: { $set: error } };
this.props.updatePending({ updater, errorUpdater });
};
cancelEditing = () => {
this.disableEditing();
this.props.clearPending();
};
save = async () => {
await this.props.savePending();
this.disableEditing();
};
displayErrors = (errors = []) => (
<ul className={styles.errorList}>
{errors.map((errKey, i) => (
<li key={`${i}_${errKey}`} className={styles.errorItem}>
{errorMsj[errKey]}
</li>
))}
</ul>
);
render() {
const { settings, slotPassthrough, canSave } = this.props;
const hasErrors = this.state.errors.length;
return (
<ConfigurePage title={t('configure.organization_information')}>
<p>{t('configure.organization_info_copy')}</p>
<p>{t('configure.organization_info_copy_2')}</p>
<ConfigureCard>
<div className={styles.container}>
<div className={styles.content}>
{this.displayErrors(this.state.errors)}
<ul className={styles.detailList}>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_name')}
>
{t('configure.organization_name')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateName}
value={settings.organizationName}
id={t('configure.organization_name')}
readOnly={!this.state.editing}
/>
</li>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_contact_email')}
>
{t('configure.organization_contact_email')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateEmail}
value={settings.organizationContactEmail || ''}
id={t('configure.organization_contact_email')}
readOnly={!this.state.editing}
/>
</li>
</ul>
</div>
{!this.state.editing ? (
<div className={styles.actionBox}>
<Button
className={styles.button}
icon="settings"
onClick={this.toggleEditing}
full
>
{t('configure.edit_info')}
</Button>
</div>
) : (
<div className={styles.actionBox}>
{canSave && !hasErrors ? (
<Button
raised
onClick={this.save}
className={styles.changedSave}
icon="check"
full
>
{t('configure.save')}
</Button>
) : (
<Button className={styles.button} disabled icon="check" full>
{t('configure.save')}
</Button>
)}
<a className={styles.cancelButton} onClick={this.cancelEditing}>
{t('cancel')}
</a>
</div>
)}
</div>
</ConfigureCard>
<Slot fill="adminOrganizationSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
}
OrganizationSettings.propTypes = {
savePending: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
};
export default OrganizationSettings;
@@ -16,10 +16,12 @@ import {
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import OrganizationSettings from './OrganizationSettings';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
state = { nextRoute: '' };
nextRoute = '';
unregisterLeaveHook = null;
savePending = async () => {
await this.props.updateSettings(this.props.pending);
@@ -39,18 +41,16 @@ class ConfigureContainer extends React.Component {
};
gotoNextRoute = () => {
const { nextRoute } = this.state;
if (nextRoute) {
this.props.router.push(nextRoute);
this.setState({ nextRoute: '' });
if (this.nextRoute) {
this.props.router.push(this.nextRoute);
this.nextRoute = '';
}
};
handleSectionChange = async section => {
const nextRoute = `/admin/configure/${section}`;
if (this.shouldShowSaveDialog()) {
await this.setState({ nextRoute });
if (this.hasPendingData()) {
this.nextRoute = nextRoute;
this.props.showSaveDialog();
} else {
// Just go to the section
@@ -58,20 +58,37 @@ class ConfigureContainer extends React.Component {
}
};
shouldShowSaveDialog = () => {
navigationPrompt = e => {
if (this.hasPendingData()) {
const confirmationMessage = 'Changes that you made may not be saved.';
e.returnValue = confirmationMessage; // Gecko, Trident, Chrome 34+
return confirmationMessage; // Gecko, WebKit, Chrome <34
}
};
hasPendingData = () => {
return !!Object.keys(this.props.pending).length;
};
routeLeave = ({ pathname }) => {
if (this.shouldShowSaveDialog()) {
this.setState({ nextRoute: pathname });
if (this.hasPendingData()) {
this.nextRoute = pathname;
this.props.showSaveDialog();
return false;
}
};
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
this.unregisterLeaveHook = this.props.router.setRouteLeaveHook(
this.props.route,
this.routeLeave
);
window.addEventListener('beforeunload', this.navigationPrompt);
}
componentWillUnmount() {
this.unregisterLeaveHook();
window.removeEventListener('beforeunload', this.navigationPrompt);
}
render() {
@@ -83,18 +100,21 @@ class ConfigureContainer extends React.Component {
return <Spinner />;
}
const activeSection = this.props.routes[3].path;
return (
<Configure
saveChanges={this.saveChanges}
discardChanges={this.discardChanges}
saveDialog={this.props.saveDialog}
activeSection={this.props.routes[3].path}
activeSection={activeSection}
hideSaveDialog={this.props.hideSaveDialog}
canSave={this.props.canSave}
currentUser={this.props.currentUser}
root={this.props.root}
settings={this.props.mergedSettings}
handleSectionChange={this.handleSectionChange}
clearPending={this.props.clearPending}
savePending={this.savePending}
>
{this.props.children}
@@ -110,10 +130,12 @@ const withConfigureQuery = withQuery(
...${getDefinitionName(StreamSettings.fragments.settings)}
...${getDefinitionName(TechSettings.fragments.settings)}
...${getDefinitionName(ModerationSettings.fragments.settings)}
...${getDefinitionName(OrganizationSettings.fragments.settings)}
}
...${getDefinitionName(StreamSettings.fragments.root)}
...${getDefinitionName(TechSettings.fragments.root)}
...${getDefinitionName(ModerationSettings.fragments.root)}
...${getDefinitionName(OrganizationSettings.fragments.root)}
}
${StreamSettings.fragments.root}
${StreamSettings.fragments.settings}
@@ -121,6 +143,8 @@ const withConfigureQuery = withQuery(
${TechSettings.fragments.settings}
${ModerationSettings.fragments.root}
${ModerationSettings.fragments.settings}
${OrganizationSettings.fragments.root}
${OrganizationSettings.fragments.settings}
`,
{
options: () => ({
@@ -0,0 +1,53 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
import OrganizationSettings from '../components/OrganizationSettings';
import withFragments from 'coral-framework/hocs/withFragments';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import { updatePending } from '../../../actions/configure';
import { mapProps } from 'recompose';
const slots = ['adminOrganizationSettings'];
const mapStateToProps = state => ({
errors: state.configure.errors,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
updatePending,
},
dispatch
);
export default compose(
withFragments({
root: gql`
fragment TalkAdmin_OrganizationSettings_root on RootQuery {
__typename
${getSlotFragmentSpreads(slots, 'root')}
}
`,
settings: gql`
fragment TalkAdmin_OrganizationSettings_settings on Settings {
organizationName
organizationContactEmail
${getSlotFragmentSpreads(slots, 'settings')}
}
`,
}),
connect(mapStateToProps, mapDispatchToProps),
mapProps(({ root, settings, updatePending, errors, ...rest }) => ({
slotPassthrough: {
root,
settings,
updatePending,
errors,
},
updatePending,
settings,
errors,
...rest,
}))
)(OrganizationSettings);
@@ -1,15 +1,16 @@
import React, { Component } from 'react';
import React from 'react';
import styles from './Install.css';
import { Wizard, WizardNav } from 'coral-ui';
import Layout from 'coral-admin/src/components/Layout';
import PropTypes from 'prop-types';
import InitialStep from './Steps/InitialStep';
import AddOrganizationName from './Steps/AddOrganizationName';
import OrganizationDetails from './Steps/OrganizationDetails';
import CreateYourAccount from './Steps/CreateYourAccount';
import PermittedDomainsStep from './Steps/PermittedDomainsStep';
import FinalStep from './Steps/FinalStep';
export default class Install extends Component {
class Install extends React.Component {
handleDomainsChange = value => {
this.props.updatePermittedDomains(value);
};
@@ -55,7 +56,7 @@ export default class Install extends Component {
goToStep={this.props.goToStep}
>
<InitialStep />
<AddOrganizationName
<OrganizationDetails
install={install}
handleSettingsChange={this.handleSettingsChange}
handleSettingsSubmit={this.handleSettingsSubmit}
@@ -81,3 +82,18 @@ export default class Install extends Component {
);
}
}
Install.propTypes = {
updatePermittedDomains: PropTypes.func.isRequired,
updateSettingsFormData: PropTypes.func.isRequired,
updateUserFormData: PropTypes.func.isRequired,
submitSettings: PropTypes.func.isRequired,
submitUser: PropTypes.func.isRequired,
install: PropTypes.object.isRequired,
nextStep: PropTypes.func.isRequired,
previousStep: PropTypes.func.isRequired,
goToStep: PropTypes.func.isRequired,
finishInstall: PropTypes.func.isRequired,
};
export default Install;
@@ -21,6 +21,17 @@ const AddOrganizationName = props => {
showErrors={install.showErrors}
errorMsg={install.errors.organizationName}
/>
<TextField
className={styles.TextField}
id="organizationContactEmail"
type="email"
label={t('install.create.organization_contact_email')}
onChange={handleSettingsChange}
showErrors={install.showErrors}
errorMsg={install.errors.organizationContactEmail}
/>
<Button
className="talk-install-step-2-save-button"
type="submit"
@@ -1,8 +1,7 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose } from 'react-apollo';
import Install from '../components/Install';
import {
@@ -18,7 +17,7 @@ import {
updatePermittedDomains,
} from '../../../actions/install';
class InstallContainer extends Component {
class InstallContainer extends React.Component {
componentDidMount() {
const { checkInstall } = this.props;
checkInstall(() => {
@@ -27,7 +26,21 @@ class InstallContainer extends Component {
}
render() {
return <Install {...this.props} />;
return (
<Install
install={this.props.install}
goToStep={this.props.goToStep}
nextStep={this.props.nextStep}
submitUser={this.props.submitUser}
checkInstall={this.props.checkInstall}
previousStep={this.props.previousStep}
finishInstall={this.props.finishInstall}
submitSettings={this.props.submitSettings}
updateUserFormData={this.props.updateUserFormData}
updateSettingsFormData={this.props.updateSettingsFormData}
updatePermittedDomains={this.props.updatePermittedDomains}
/>
);
}
}
@@ -35,6 +48,20 @@ InstallContainer.contextTypes = {
router: PropTypes.object,
};
InstallContainer.propTypes = {
install: PropTypes.object.isRequired,
goToStep: PropTypes.func.isRequired,
nextStep: PropTypes.func.isRequired,
submitUser: PropTypes.func.isRequired,
checkInstall: PropTypes.func.isRequired,
previousStep: PropTypes.func.isRequired,
finishInstall: PropTypes.func.isRequired,
submitSettings: PropTypes.func.isRequired,
updateUserFormData: PropTypes.func.isRequired,
updateSettingsFormData: PropTypes.func.isRequired,
updatePermittedDomains: PropTypes.func.isRequired,
};
const mapStateToProps = state => ({
install: state.install,
});
@@ -56,6 +83,4 @@ const mapDispatchToProps = dispatch =>
dispatch
);
export default compose(connect(mapStateToProps, mapDispatchToProps))(
InstallContainer
);
export default connect(mapStateToProps, mapDispatchToProps)(InstallContainer);
-8
View File
@@ -1,8 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import { GraphQLDocs } from 'graphql-docs';
import fetcher from './services/fetcher';
// Render the application into the DOM
render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
-10
View File
@@ -1,10 +0,0 @@
export default function fetcher(query) {
return fetch(`${window.location.origin}/api/v1/graph/ql`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
}).then(res => res.json());
}
@@ -24,14 +24,20 @@ export default class Embed extends React.Component {
>
{t('embed_comments_tab')}
</Tab>,
<Tab
key="profile"
tabId="profile"
className="talk-embed-stream-profile-tab"
>
{t('framework.my_profile')}
</Tab>,
];
if (this.props.currentUser) {
tabs.push(
<Tab
key="profile"
tabId="profile"
className="talk-embed-stream-profile-tab"
>
{t('framework.my_profile')}
</Tab>
);
}
if (can(this.props.currentUser, 'UPDATE_ASSET_CONFIG')) {
tabs.push(
<Tab
@@ -43,6 +49,7 @@ export default class Embed extends React.Component {
</Tab>
);
}
return tabs;
}
@@ -16,9 +16,11 @@ class ExtendableTabPanel extends React.Component {
} = this.props;
return (
<div {...rest}>
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
{tabs && (
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
{tabs}
</TabBar>
)}
{loading ? (
<div className={styles.spinnerContainer}>
<Spinner />
@@ -1,14 +0,0 @@
.message {
padding: 10px 0 20px;
letter-spacing: 0.1px;
font-size: 13px;
line-height: 33px;
}
.message a {
color: black;
font-weight: bold;
cursor: pointer;
margin: 0px;
padding-bottom: 2px;
}
@@ -1,15 +0,0 @@
import React from 'react';
import styles from './NotLoggedIn.css';
import cn from 'classnames';
import t from 'coral-framework/services/i18n';
export default ({ showSignInDialog }) => (
<div className={cn(styles.message, 'talk-embed-stream-not-logged-in')}>
<div>
<a onClick={showSignInDialog}>{t('settings.sign_in')}</a>{' '}
{t('settings.to_access')}
</div>
<div>{t('from_settings_page')}</div>
</div>
);
@@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot';
import styles from './Profile.css';
import TabPanel from '../containers/TabPanel';
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
const DefaultProfileHeader = ({ username, emailAddress }) => (
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
);
DefaultProfileHeader.propTypes = {
username: PropTypes.string,
emailAddress: PropTypes.string,
};
const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => {
return (
<div className="talk-my-profile talk-profile-container">
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
<Slot
fill="profileHeader"
size={1}
defaultComponent={DefaultProfileHeader}
passthrough={{
...slotPassthrough,
id,
username,
emailAddress,
}}
/>
<Slot fill="profileSections" passthrough={slotPassthrough} />
<TabPanel root={root} slotPassthrough={slotPassthrough} />
</div>
@@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
};
Profile.propTypes = {
id: PropTypes.string,
username: PropTypes.string,
emailAddress: PropTypes.string,
root: PropTypes.object,
@@ -2,27 +2,17 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { withQuery } from 'coral-framework/hocs';
import NotLoggedIn from '../components/NotLoggedIn';
import { Spinner } from 'coral-ui';
import Profile from '../components/Profile';
import TabPanel from './TabPanel';
import { getDefinitionName } from 'coral-framework/utils';
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
class ProfileContainer extends Component {
componentWillReceiveProps(nextProps) {
if (!this.props.currentUser && nextProps.currentUser) {
// Refetch after login.
this.props.data.refetch();
}
}
render() {
const { currentUser, showSignInDialog, root } = this.props;
const { currentUser, root } = this.props;
const { me } = this.props.root;
const loading = this.props.data.loading;
@@ -30,10 +20,6 @@ class ProfileContainer extends Component {
return <div>{this.props.data.error.message}</div>;
}
if (!currentUser) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading || !me) {
return <Spinner />;
}
@@ -44,6 +30,7 @@ class ProfileContainer extends Component {
return (
<Profile
id={me.id}
username={me.username}
emailAddress={emailAddress}
root={root}
@@ -57,7 +44,6 @@ ProfileContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
currentUser: PropTypes.object,
showSignInDialog: PropTypes.func,
};
const slots = ['profileSections'];
@@ -68,6 +54,15 @@ const withProfileQuery = withQuery(
me {
id
username
state {
status {
username {
history {
created_at
}
}
}
}
}
...${getDefinitionName(TabPanel.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
@@ -85,10 +80,6 @@ const mapStateToProps = state => ({
currentUser: state.auth.user,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ showSignInDialog }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withProfileQuery
)(ProfileContainer);
export default compose(connect(mapStateToProps), withProfileQuery)(
ProfileContainer
);
@@ -574,7 +574,9 @@ export default class Comment extends React.Component {
'talk-stream-comment-header-tags-container'
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{isStaff(comment.tags) ? (
<TagLabel>{t('community.staff')}</TagLabel>
) : null}
<Slot
className={cn(
@@ -18,6 +18,10 @@ const InactiveCommentLabel = ({ status, className, ...rest }) => {
label = t('modqueue.rejected');
icon = 'close';
break;
case 'SYSTEM_WITHHELD':
label = t('modqueue.system_withheld');
icon = 'flag';
break;
default:
throw new Error(`Unknown inactive status ${status}`);
}
+2 -2
View File
@@ -177,8 +177,8 @@ button.comment__action-button[disabled],
}
.talk-plugin-flags-popup-header {
font-weight: bolder;
font-size: 1.33rem;
font-weight: bold;
font-size: 1rem;
margin-bottom: 10px;
}
+7 -15
View File
@@ -15,9 +15,7 @@ export const checkLogin = () => (
rest('/auth')
.then(result => {
if (!result.user) {
if (localStorage) {
cleanAuthData(localStorage);
}
cleanAuthData(localStorage);
dispatch(checkLoginSuccess(null));
return;
}
@@ -52,10 +50,8 @@ const checkLoginSuccess = user => ({
});
export const setAuthToken = token => (dispatch, _, { localStorage }) => {
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
// Dispatch the set auth token action. For some browsers and situations, we
// may not be able to persist the auth token any other way. Keep it in redux!
@@ -70,11 +66,8 @@ export const handleSuccessfulLogin = (user, token) => (
{ client, localStorage, postMessage }
) => {
const { exp } = jwtDecode(token);
if (localStorage) {
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
// Send the message via the messages service to the window.opener if it
// exists.
@@ -105,9 +98,8 @@ export const logout = () => async (
) => {
await rest('/auth', { method: 'DELETE' });
if (localStorage) {
cleanAuthData(localStorage);
}
// Clear the auth data persisted to localStorage.
cleanAuthData(localStorage);
// Reset the websocket.
client.resetWebsocket();
@@ -43,7 +43,7 @@ const ConfigureCard = ({
);
ConfigureCard.propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.string,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
+2 -1
View File
@@ -25,6 +25,7 @@ export default {
'UnsuspendUserResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
'UpdateSettingsResponse'
'UpdateSettingsResponse',
'ChangePasswordResponse'
),
};
+55 -1
View File
@@ -1,5 +1,6 @@
import { gql } from 'react-apollo';
import withMutation from '../hocs/withMutation';
import update from 'immutability-helper';
function convertItemType(item_type) {
switch (item_type) {
@@ -167,9 +168,39 @@ export const withSetCommentStatus = withMutation(
errors: null,
},
},
updateQueries: {
CoralAdmin_UserDetail: prev => {
const increment = {
rejectedComments: {
$apply: count =>
count < prev.totalComments ? count + 1 : count,
},
};
const decrement = {
rejectedComments: {
$apply: count => (count > 0 ? count - 1 : 0),
},
};
// If rejected then increment rejectedComments by one
if (status === 'REJECTED') {
const updated = update(prev, increment);
return updated;
}
// If approved then decrement rejectedComments by one
if (status === 'ACCEPTED') {
const updated = update(prev, decrement);
return updated;
}
return prev;
},
},
update: proxy => {
const fragment = gql`
fragment Talk_SetCommentStatus on Comment {
fragment Talk_SetCommentStatus_Comment on Comment {
status
status_history {
type
@@ -182,9 +213,11 @@ export const withSetCommentStatus = withMutation(
const data = proxy.readFragment({ fragment, id: fragmentId });
data.status = status;
data.status_history = data.status_history
? data.status_history
: [];
data.status_history.push({
__typename: 'CommentStatusHistory',
type: status,
@@ -590,6 +623,27 @@ export const withUpdateSettings = withMutation(
}
);
export const withChangePassword = withMutation(
gql`
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input) {
...ChangePasswordResponse
}
}
`,
{
props: ({ mutate }) => ({
changePassword: input => {
return mutate({
variables: {
input,
},
});
},
}),
}
);
export const withUpdateAssetSettings = withMutation(
gql`
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
+1
View File
@@ -6,4 +6,5 @@ export default {
username: t('error.username'),
confirmPassword: t('error.confirm_password'),
organizationName: t('error.organization_name'),
organizationContactEmail: t('error.organization_contact_email'),
};
@@ -4,4 +4,5 @@ export default {
confirmPassword: () => true,
username: username => /^[a-zA-Z0-9_]+$/.test(username),
organizationName: org => /^[a-zA-Z0-9_ ]+$/.test(org),
organizationContactEmail: email => /^.+@.+\..+$/.test(email),
};
+38 -20
View File
@@ -10,9 +10,12 @@ import 'moment/locale/de';
import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/he';
import 'moment/locale/nl';
import 'moment/locale/pt-br';
import { createStorage } from 'coral-framework/services/storage';
import {
createStorage
} from 'coral-framework/services/storage';
import arTA from 'timeago.js/locales/ar';
import daTA from 'timeago.js/locales/da';
@@ -20,10 +23,10 @@ import deTA from 'timeago.js/locales/de';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
import heTA from 'timeago.js/locales/he';
import nlTA from 'timeago.js/locales/nl';
import pt_BRTA from 'timeago.js/locales/pt_BR';
import zh_CNTA from 'timeago.js/locales/zh_CN';
import zh_TWTA from 'timeago.js/locales/zh_TW';
import nl from 'timeago.js/locales/nl';
import ar from '../../../locales/ar.yml';
import en from '../../../locales/en.yml';
@@ -32,10 +35,10 @@ import de from '../../../locales/de.yml';
import es from '../../../locales/es.yml';
import fr from '../../../locales/fr.yml';
import he from '../../../locales/he.yml';
import nl_NL from '../../../locales/nl_NL.yml';
import pt_BR from '../../../locales/pt_BR.yml';
import zh_CN from '../../../locales/zh_CN.yml';
import zh_TW from '../../../locales/zh_TW.yml';
import nl_NL from '../../../locales/nl_NL.yml';
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
const translations = {
@@ -56,26 +59,41 @@ let lang;
let timeagoInstance;
function setLocale(storage, locale) {
try {
if (storage) {
storage.setItem('locale', locale);
}
} catch (err) {
console.error(err);
}
storage.setItem('locale', locale);
}
function getLocale(storage) {
// detectLanguage will try to get the locale from storage if available,
// otherwise will try to get it from the navigator, otherwise, it will fallback
// to the default language.
function detectLanguage(storage) {
try {
return (
(storage && storage.getItem('locale')) ||
navigator.language ||
defaultLanguage
).split('-')[0];
const lang = storage.getItem('locale') || navigator.language;
if (lang) {
return lang;
}
} catch (err) {
console.error(err);
return null;
console.warn(
'Error while trying to detect language, will fallback to',
err
);
}
console.warn('Could not detect language, will fallback to', defaultLanguage);
return defaultLanguage;
}
// getLocale will get the users locale from the local detector and parse it to a
// format we can work with.
function getLocale(storage) {
// Get the language from the local detector.
const lang = detectLanguage(storage);
// Some language strings come with additional subtags as defined in:
//
// https://www.ietf.org/rfc/bcp/bcp47.txt
//
// So we should strip that off if we find it.
return lang.split('-')[0];
}
export function setupTranslations() {
@@ -102,10 +120,10 @@ export function setupTranslations() {
ta.register('de', deTA);
ta.register('fr', frTA);
ta.register('he', heTA);
ta.register('nl_NL', nlTA);
ta.register('pt_BR', pt_BRTA);
ta.register('zh_CN', zh_CNTA);
ta.register('zh_TW', zh_TWTA);
ta.register('nl_NL', nl);
timeagoInstance = ta();
}
@@ -152,4 +170,4 @@ export function t(key, ...replacements) {
export default t;
// Setup the translations globally as soon as this module runs.
setupTranslations();
setupTranslations();
+104 -28
View File
@@ -1,40 +1,116 @@
import uuid from 'uuid/v4';
function getStorage(type) {
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
const ignore =
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// SecurityError related to having 3rd party cookies disabled.
e.code === 18 ||
// Firefox
function testStorageAccess(storage) {
const key = '__storage_test__';
e.code === 1014 ||
// test name field too, because code might not be present
// Create a unique test value.
const expectedValue = String(Date.now());
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!ignore) {
console.warn(e);
// Try to set, get, and remove that item.
storage.setItem(key, expectedValue);
const canSetGet = expectedValue === storage.getItem(key);
storage.removeItem(key);
if (!canSetGet) {
// We can't access the desired storage!
throw new Error('Storage access test failed');
}
}
// InMemoryStorage is a dumb implementation of the Storage interface that will
// not persist the data at all. It implements the Storage interface found:
//
// https://developer.mozilla.org/en-US/docs/Web/API/Storage
//
class InMemoryStorage {
constructor() {
this.storage = {};
}
get length() {
return Object.keys(this.storage).length;
}
key(n) {
if (this.length <= n) {
return undefined;
}
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
return getStorage('sessionStorage');
return this.storage[Object.keys(this.storage)[n]];
}
getItem(key) {
return this.storage[key];
}
setItem(key, value) {
this.storage[key] = value;
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Set the item in sessionStorage.
sessionStorage.setItem(key, value);
console.log(
'Attempt to persist InMemoryStorage value to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage value to sessionStorage failed',
err
);
}
}
return storage;
removeItem(key) {
delete this.storage[key];
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Remove the item from sessionStorage.
sessionStorage.removeItem(key);
console.log(
'Attempt to persist InMemoryStorage delete to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage delete to sessionStorage failed',
err
);
}
}
}
// getStorage will test to see if the requested storage type is available, if it
// is not, it will try sessionStorage, and if that is also not available, it
// will fallback to InMemoryStorage.
function getStorage(type) {
try {
// Get the desired storage from the window and test it out.
const storage = window[type];
testStorageAccess(storage);
// Storage test was successful! Return it.
return storage;
} catch (err) {
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
console.warn('Could not access', type, 'trying sessionStorage', err);
return getStorage('sessionStorage');
}
console.warn(
'Could not access sessionStorage falling back to InMemoryStorage',
err
);
}
// No acceptable storage could be found, returning the InMemoryStorage.
return new InMemoryStorage();
}
/**
+5 -1
View File
@@ -237,7 +237,11 @@ export function getTotalReactionsCount(actionSummaries) {
// Like lodash merge but does not recurse into arrays.
export function mergeExcludingArrays(objValue, srcValue) {
if (typeof srcValue === 'object' && !Array.isArray(srcValue)) {
if (
typeof srcValue === 'object' &&
!Array.isArray(srcValue) &&
srcValue !== null
) {
return assignWith({}, objValue, srcValue, mergeExcludingArrays);
}
return srcValue;
+16
View File
@@ -1,4 +1,5 @@
import get from 'lodash/get';
import moment from 'moment';
/**
* getReliability
@@ -33,3 +34,18 @@ export const isSuspended = user => {
export const isBanned = user => {
return get(user, 'state.status.banned.status');
};
/**
* canUsernameBeUpdated
* retrieves boolean whether a username can be updated or not
*/
export const canUsernameBeUpdated = status => {
const oldestEditTime = moment()
.subtract(14, 'days')
.toDate();
return !status.username.history.some(({ created_at }) =>
moment(created_at).isAfter(oldestEditTime)
);
};
+1 -1
View File
@@ -1,5 +1,5 @@
.root {
vertical-align: middle;
vertical-align: sub;
font-size: inherit;
}
+1 -1
View File
@@ -9,7 +9,7 @@
box-sizing: border-box;
background: white;
border-radius: 3px;
padding: 20px 10px;
padding: 10px 10px;
z-index: 300;
right: 1%;
}
+3
View File
@@ -27,11 +27,14 @@ const TextField = ({
);
TextField.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
errorMsg: PropTypes.string,
type: PropTypes.string,
className: PropTypes.string,
showErrors: PropTypes.bool,
};
export default TextField;
+36
View File
@@ -0,0 +1,36 @@
const { pluginsPath } = require('../plugins');
const buildTargets = ['coral-admin'];
const buildEmbeds = ['stream'];
const specPattern = 'client/**/__tests__/**/*.spec.js?(x)';
module.exports = {
rootDir: '../',
testMatch: [
`<rootDir>/${specPattern}`,
`<rootDir>/plugins/**/${specPattern}`,
],
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
modulePaths: [
'<rootDir>/plugins',
'<rootDir>/client',
...buildTargets.map(target => `<rootDir>/client/${target}/src`),
...buildEmbeds.map(embed => `<rootDir>/client/coral-embed-${embed}/src`),
],
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
moduleDirectories: ['node_modules'],
transform: {
'^.+\\.jsx?$': 'babel-jest',
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
'^pluginsConfig$': pluginsPath,
'\\.(scss|css|less)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
},
};
+2
View File
@@ -122,6 +122,8 @@ sidebar:
url: /commenter-features/
- title: Moderator Features
url: /moderator-features/
- title: User Roles in Talk
url: /roles/
- title: Trust
url: /trust/
- title: Toxic Comments
@@ -170,8 +170,9 @@ moderators.
All your team and commenters show in the People sub-tab. From here, you can
manage your team members roles (Admins, Moderators, Staff), as well as search
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
Configure
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.).
### Configure
See [Configuring Talk](/talk/configuring-talk/).
+35
View File
@@ -0,0 +1,35 @@
---
title: User Roles in Talk
permalink: /roles/
---
We have four preset roles in Talk:
**Commenter**
* A standard community member
* Could receive a badge (eg. 'Subscriber') via [a custom newsroom Plugin Recipe](https://docs.coralproject.net/talk/plugin-recipes/#recipe-subscriber)
* No moderation abilities
* No configuration abilities
**Staff**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* No moderation abilities
* No configuration abilities
**Moderator**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* Has full moderation privileges
* Can configure individual articles via the Configure tab on the article page
* No site-wide configuration abilities
**Administrator**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* Has full moderation privileges
* Can configure individual articles via the Configure tab on the article page
* Can configure site settings via the Configure tab in the moderation interface
+5
View File
@@ -81,6 +81,11 @@
description: Shows a Link button on comments for direct-linking to a comment.
tags:
- default
- name: talk-plugin-profile-data
description: Enables users to manage their own data within Talk.
tags:
- default
- gdpr
- name: talk-plugin-remember-sort
description: Remembers the sort selection made by a user.
- name: talk-plugin-respect
+2 -2
View File
@@ -33,8 +33,7 @@ By default, Talk has various plugins provided by default. We can see this in `pl
"talk-plugin-sort-most-respected",
"talk-plugin-sort-newest",
"talk-plugin-sort-oldest",
"talk-plugin-viewing-options",
"talk-plugin-profile-settings"
"talk-plugin-viewing-options"
]
}
```
@@ -81,6 +80,7 @@ You won't have to use this to build plugins, but it's helpful to find where to e
* `adminCommentMoreDetails`
* `adminCommentLabels`
* `adminModerationSettings`
* `adminOrganizationSettings`
* `adminStreamSettings`
* `adminTechSettings`
* `adminCommentInfoBar`
@@ -65,10 +65,6 @@ First, you'll enable `talk-plugin-author-menu`, as this houses the Ignore button
And then we will enable the Ignore User plugin: `talk-plugin-ignore-user`.
And finally, we will need to enable Profile Settings; this is the tab on My Profile > Settings where commenters can manage their Ignored Users list.
`talk-plugin-profile-settings`
### Featured Comments
To enable the featuring of comments, you'll need to activate `talk-plugin-featured-comments`. If you would like the Featured Comments tab to be the default tab you land on for the stream, you will need to set the default tab ENV variable:
-4
View File
@@ -36,10 +36,6 @@ Adding the `talk-plugin-notifications` plugin will also enable the `notification
See https://github.com/coralproject/talk/blob/8b669a31c551a042f0f079d8cfc16825673eb8f0/plugins/talk-plugin-notifications-reply/index.js for an example.
### Commenter Notification Settings
Note that notifications REQUIRE the `talk-plugin-profile-settings` plugin; this is where on My Profile commenters will enable and manage their notification settings.
### Notification Categories
Talk currently supports the following Notifications options out of the box:
+1
View File
@@ -0,0 +1 @@
../../../plugins/talk-plugin-profile-data/README.md
+228 -141
View File
@@ -10,21 +10,21 @@ class ExtendableError {
}
/**
* APIError is the base error that all application issued errors originate, they
* are composed of data used by the front end and backend to handle errors
* TalkError is the base error that all application issued errors originate,
* they are composed of data used by the front end and backend to handle errors
* consistently.
*/
class APIError extends ExtendableError {
class TalkError extends ExtendableError {
constructor(
message,
{ status = 500, translation_key = null },
{ status = 500, translation_key = null } = {},
metadata = {}
) {
super(message);
this.status = status;
this.translation_key = translation_key;
this.metadata = metadata;
this.status = status || 500;
this.translation_key = translation_key || null;
this.metadata = metadata || {};
}
toJSON() {
@@ -38,85 +38,114 @@ class APIError extends ExtendableError {
}
// ErrPasswordTooShort is returned when the password length is too short.
const ErrPasswordTooShort = new APIError(
'password must be at least 8 characters',
{
status: 400,
translation_key: 'PASSWORD_LENGTH',
class ErrPasswordTooShort extends TalkError {
constructor() {
super('password must be at least 8 characters', {
status: 400,
translation_key: 'PASSWORD_LENGTH',
});
}
);
}
const ErrMissingEmail = new APIError('email is required', {
translation_key: 'EMAIL_REQUIRED',
status: 400,
});
const ErrMissingPassword = new APIError('password is required', {
translation_key: 'PASSWORD_REQUIRED',
status: 400,
});
const ErrEmailTaken = new APIError('Email address already in use', {
translation_key: 'EMAIL_IN_USE',
status: 400,
});
const ErrUsernameTaken = new APIError('Username already in use', {
translation_key: 'USERNAME_IN_USE',
status: 400,
});
const ErrSameUsernameProvided = new APIError(
'Username provided for change is the same as current',
{
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400,
class ErrMissingEmail extends TalkError {
constructor() {
super('email is required', {
translation_key: 'EMAIL_REQUIRED',
status: 400,
});
}
);
}
const ErrSpecialChars = new APIError(
'No special characters are allowed in a username',
{
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400,
class ErrMissingPassword extends TalkError {
constructor() {
super('password is required', {
translation_key: 'PASSWORD_REQUIRED',
status: 400,
});
}
);
}
const ErrMissingUsername = new APIError(
'A username is required to create a user',
{
translation_key: 'USERNAME_REQUIRED',
status: 400,
class ErrEmailTaken extends TalkError {
constructor() {
super('Email address already in use', {
translation_key: 'EMAIL_IN_USE',
status: 400,
});
}
);
}
class ErrUsernameTaken extends TalkError {
constructor() {
super('Username already in use', {
translation_key: 'USERNAME_IN_USE',
status: 400,
});
}
}
class ErrSameUsernameProvided extends TalkError {
constructor() {
super('Username provided for change is the same as current', {
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400,
});
}
}
class ErrSpecialChars extends TalkError {
constructor() {
super('No special characters are allowed in a username', {
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400,
});
}
}
class ErrMissingUsername extends TalkError {
constructor() {
super('A username is required to create a user', {
translation_key: 'USERNAME_REQUIRED',
status: 400,
});
}
}
// ErrEmailVerificationToken is returned in the event that the password reset is requested
// without a token.
const ErrEmailVerificationToken = new APIError('token is required', {
translation_key: 'EMAIL_VERIFICATION_TOKEN_INVALID',
status: 400,
});
class ErrEmailVerificationToken extends TalkError {
constructor() {
super('token is required', {
translation_key: 'EMAIL_VERIFICATION_TOKEN_INVALID',
status: 400,
});
}
}
// ErrEmailAlreadyVerified is returned when the user tries to verify an email
// address that has already been verified.
const ErrEmailAlreadyVerified = new APIError(
'email address is already verified',
{
translation_key: 'EMAIL_ALREADY_VERIFIED',
status: 409,
class ErrEmailAlreadyVerified extends TalkError {
constructor() {
super('email address is already verified', {
translation_key: 'EMAIL_ALREADY_VERIFIED',
status: 409,
});
}
);
}
// ErrPasswordResetToken is returned in the event that the password reset is requested
// without a token.
const ErrPasswordResetToken = new APIError('token is required', {
translation_key: 'PASSWORD_RESET_TOKEN_INVALID',
status: 400,
});
class ErrPasswordResetToken extends TalkError {
constructor() {
super('token is required', {
translation_key: 'PASSWORD_RESET_TOKEN_INVALID',
status: 400,
});
}
}
// ErrAssetCommentingClosed is returned when a comment or action is attempted on
// a stream where commenting has been closed.
class ErrAssetCommentingClosed extends APIError {
class ErrAssetCommentingClosed extends TalkError {
constructor(closedMessage = null) {
super(
'asset commenting is closed',
@@ -136,7 +165,7 @@ class ErrAssetCommentingClosed extends APIError {
* ErrAuthentication is returned when there is an error authenticating and the
* message is provided.
*/
class ErrAuthentication extends APIError {
class ErrAuthentication extends TalkError {
constructor(message = null) {
super(
'authentication error occurred',
@@ -154,7 +183,7 @@ class ErrAuthentication extends APIError {
/**
* ErrAlreadyExists is returned when an attempt to create a resource failed due to an existing one.
*/
class ErrAlreadyExists extends APIError {
class ErrAlreadyExists extends TalkError {
constructor(existing = null) {
super(
'resource already exists',
@@ -171,121 +200,179 @@ class ErrAlreadyExists extends APIError {
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/banned/suspect words in the payload.
const ErrContainsProfanity = new APIError(
'This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.',
{
translation_key: 'PROFANITY_ERROR',
status: 400,
class ErrContainsProfanity extends TalkError {
constructor(phrase) {
super(
'This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.',
{
translation_key: 'PROFANITY_ERROR',
status: 400,
},
{ phrase }
);
}
);
}
const ErrNotFound = new APIError('not found', {
translation_key: 'NOT_FOUND',
status: 404,
});
class ErrNotFound extends TalkError {
constructor() {
super('not found', {
translation_key: 'NOT_FOUND',
status: 404,
});
}
}
const ErrInvalidAssetURL = new APIError('asset_url is invalid', {
translation_key: 'INVALID_ASSET_URL',
status: 400,
});
class ErrInvalidAssetURL extends TalkError {
constructor() {
super('asset_url is invalid', {
translation_key: 'INVALID_ASSET_URL',
status: 400,
});
}
}
// ErrNotAuthorized is an error that is returned in the event an operation is
// deemed not authorized.
const ErrNotAuthorized = new APIError('not authorized', {
translation_key: 'NOT_AUTHORIZED',
status: 401,
});
class ErrNotAuthorized extends TalkError {
constructor() {
super('not authorized', {
translation_key: 'NOT_AUTHORIZED',
status: 401,
});
}
}
// ErrSettingsNotInit is returned when the settings are required but not
// initialized.
const ErrSettingsNotInit = new Error(
'Talk is currently not setup. Please proceed to our web installer at $ROOT_URL/admin/install or run ./bin/cli-setup. Visit https://docs.coralproject.net/talk/ for more information on installation and configuration instructions'
);
class ErrSettingsNotInit extends TalkError {
constructor() {
super(
'Talk is currently not setup. Please proceed to our web installer at $ROOT_URL/admin/install or run ./bin/cli-setup. Visit https://docs.coralproject.net/talk/ for more information on installation and configuration instructions'
);
}
}
// ErrSettingsInit is returned when the setup endpoint is hit and we are already
// initialized.
const ErrSettingsInit = new APIError('settings are already initialized', {
status: 500,
});
class ErrSettingsInit extends TalkError {
constructor() {
super('settings are already initialized', {
status: 500,
});
}
}
// ErrInstallLock is returned when the setup endpoint is hit and the install
// lock is present.
const ErrInstallLock = new APIError('install lock active', {
status: 500,
});
class ErrInstallLock extends TalkError {
constructor() {
super('install lock active', {
status: 500,
});
}
}
// 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: 403,
class ErrPermissionUpdateUsername extends TalkError {
constructor() {
super('You do not have permission to update your username.', {
translation_key: 'EDIT_USERNAME_NOT_AUTHORIZED',
status: 403,
});
}
);
}
// ErrLoginAttemptMaximumExceeded is returned when the login maximum is exceeded.
const ErrLoginAttemptMaximumExceeded = new APIError(
'You have made too many incorrect password attempts.',
{
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
status: 429,
class ErrLoginAttemptMaximumExceeded extends TalkError {
constructor() {
super('You have made too many incorrect password attempts.', {
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
status: 429,
});
}
);
}
// ErrEditWindowHasEnded is returned when the edit window has expired.
const ErrEditWindowHasEnded = new APIError('Edit window is over', {
translation_key: 'EDIT_WINDOW_ENDED',
status: 403,
});
class ErrEditWindowHasEnded extends TalkError {
constructor() {
super('Edit window is over', {
translation_key: 'EDIT_WINDOW_ENDED',
status: 403,
});
}
}
// ErrCommentTooShort is returned when the comment is too short.
const ErrCommentTooShort = new APIError('Comment was too short', {
translation_key: 'COMMENT_TOO_SHORT',
status: 400,
});
class ErrCommentTooShort extends TalkError {
constructor(length) {
super(
'Comment was too short',
{
translation_key: 'COMMENT_TOO_SHORT',
status: 400,
},
{ length }
);
}
}
// ErrAssetURLAlreadyExists is returned when a rename operation is requested
// but an asset already exists with the new url.
const ErrAssetURLAlreadyExists = new APIError(
'Asset URL already exists, cannot rename',
{
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409,
class ErrAssetURLAlreadyExists extends TalkError {
constructor() {
super('Asset URL already exists, cannot rename', {
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409,
});
}
);
}
// ErrNotVerified is returned when a user tries to login with valid credentials
// but their email address is not yet verified.
const ErrNotVerified = new APIError(
'User does not have a verified email address',
{
translation_key: 'EMAIL_NOT_VERIFIED',
status: 401,
class ErrNotVerified extends TalkError {
constructor() {
super('User does not have a verified email address', {
translation_key: 'EMAIL_NOT_VERIFIED',
status: 401,
});
}
);
}
const ErrMaxRateLimit = new APIError('Rate limit exceeded', {
translation_key: 'RATE_LIMIT_EXCEEDED',
status: 429,
});
class ErrMaxRateLimit extends TalkError {
constructor(max, tries) {
super(
'Rate limit exceeded',
{
translation_key: 'RATE_LIMIT_EXCEEDED',
status: 429,
},
{ tries, max }
);
}
}
// ErrCannotIgnoreStaff is returned when a user tries to ignore a staff member.
const ErrCannotIgnoreStaff = new APIError('Cannot ignore staff members.', {
translation_key: 'CANNOT_IGNORE_STAFF',
status: 400,
});
class ErrCannotIgnoreStaff extends TalkError {
constructor() {
super('Cannot ignore staff members.', {
translation_key: 'CANNOT_IGNORE_STAFF',
status: 400,
});
}
}
// ErrParentDoesNotVisible is returned when the user tries to reply to a comment
// that isn't visible.
const ErrParentDoesNotVisible = new APIError(
'Cannot reply to a comment that is not visible',
{
translation_key: 'COMMENT_PARENT_NOT_VISIBLE',
class ErrParentDoesNotVisible extends TalkError {
constructor() {
super('Cannot reply to a comment that is not visible', {
translation_key: 'COMMENT_PARENT_NOT_VISIBLE',
});
}
);
}
module.exports = {
APIError,
TalkError,
ErrAlreadyExists,
ErrAssetCommentingClosed,
ErrAssetURLAlreadyExists,
+4
View File
@@ -10,6 +10,9 @@ const secrets = require('../secrets');
// Errors.
const errors = require('../errors');
// URLs.
const url = require('../url');
// Graph.
const { getBroker } = require('./subscriptions/broker');
const { getPubsub } = require('./subscriptions/pubsub');
@@ -58,6 +61,7 @@ const defaultConnectors = {
errors,
config,
secrets,
url,
models: {
Action,
Asset,
+7
View File
@@ -137,6 +137,13 @@ class Context {
);
}
/**
* masqueradeAs will allow a given context to be copied to a new user.
*/
masqueradeAs(user) {
return new Context(merge({}, this, { user }));
}
/**
* forSystem returns a system context object that can be used for internal
* operations.
+2 -2
View File
@@ -1,6 +1,6 @@
const { forEachField } = require('./utils');
const { maskErrors } = require('graphql-errors');
const errors = require('../errors');
const { TalkError } = require('../errors');
const { Error: { ValidationError } } = require('mongoose');
// If an APIError happens in a mutation, then respond with `{errors: Array}`
@@ -11,7 +11,7 @@ const decorateWithMutationErrorHandler = field => {
try {
return await fieldResolver(obj, args, ctx, info);
} catch (err) {
if (err instanceof errors.APIError) {
if (err instanceof TalkError) {
return {
errors: [err],
};
+3 -3
View File
@@ -57,7 +57,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
try {
new URL(url);
} catch (err) {
throw ErrInvalidAssetURL;
throw new ErrInvalidAssetURL(url);
}
// Try the easy lookup first.
@@ -76,7 +76,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
// If the domain wasn't whitelisted, then we shouldn't create this asset!
if (!whitelisted) {
throw ErrInvalidAssetURL;
throw new ErrInvalidAssetURL(url);
}
// Construct the update operator that we'll use to create the asset.
@@ -135,7 +135,7 @@ const findByUrl = async (
try {
new URL(asset_url);
} catch (err) {
throw errors.ErrInvalidAssetURL;
throw new errors.ErrInvalidAssetURL(asset_url);
}
return Assets.findByUrl(asset_url);
+5 -5
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotFound, ErrNotAuthorized } = require('../../errors');
const { CREATE_ACTION, DELETE_ACTION } = require('../../perms/constants');
const { IGNORE_FLAGS_AGAINST_STAFF } = require('../../config');
@@ -40,7 +40,7 @@ const createAction = async (
// Gets the item referenced by the action.
const item = await getActionItem(ctx, { item_id, item_type });
if (!item || item === null) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
// If we are ignoring flags against staff, ensure that the target isn't a
@@ -59,7 +59,7 @@ const createAction = async (
// The item is a user, and this is a flag. Check to see if they are staff,
// if they are, don't permit the flag.
if (item.isStaff()) {
throw errors.ErrNotAuthorized;
throw new ErrNotAuthorized();
}
}
@@ -108,8 +108,8 @@ const deleteAction = (ctx, { id }) => {
module.exports = ctx => {
let mutators = {
Action: {
create: () => Promise.reject(errors.ErrNotAuthorized),
delete: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
delete: () => Promise.reject(new ErrNotAuthorized()),
},
};
+5 -5
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const {
UPDATE_ASSET_SETTINGS,
UPDATE_ASSET_STATUS,
@@ -71,10 +71,10 @@ const scrapeAsset = async (ctx, id) => {
module.exports = ctx => {
let mutators = {
Asset: {
updateSettings: () => Promise.reject(errors.ErrNotAuthorized),
updateStatus: () => Promise.reject(errors.ErrNotAuthorized),
closeNow: () => Promise.reject(errors.ErrNotAuthorized),
scrape: () => Promise.reject(errors.ErrNotAuthorized),
updateSettings: () => Promise.reject(new ErrNotAuthorized()),
updateStatus: () => Promise.reject(new ErrNotAuthorized()),
closeNow: () => Promise.reject(new ErrNotAuthorized()),
scrape: () => Promise.reject(new ErrNotAuthorized()),
},
};
+4 -4
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const TagsService = require('../../services/tags');
@@ -312,9 +312,9 @@ const editComment = async (
module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
setStatus: () => Promise.reject(errors.ErrNotAuthorized),
edit: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
setStatus: () => Promise.reject(new ErrNotAuthorized()),
edit: () => Promise.reject(new ErrNotAuthorized()),
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const { UPDATE_SETTINGS } = require('../../perms/constants');
@@ -9,7 +9,7 @@ const update = async (ctx, settings) => SettingsService.update(settings);
module.exports = ctx => {
let mutators = {
Settings: {
update: () => Promise.reject(errors.ErrNotAuthorized),
update: () => Promise.reject(new ErrNotAuthorized()),
},
};
+3 -3
View File
@@ -1,5 +1,5 @@
const TagsService = require('../../services/tags');
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const {
ADD_COMMENT_TAG,
REMOVE_COMMENT_TAG,
@@ -31,8 +31,8 @@ const modify = async (
module.exports = context => {
let mutators = {
Tag: {
add: () => Promise.reject(errors.ErrNotAuthorized),
remove: () => Promise.reject(errors.ErrNotAuthorized),
add: () => Promise.reject(new ErrNotAuthorized()),
remove: () => Promise.reject(new ErrNotAuthorized()),
},
};
+3 -3
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const TokensService = require('../../services/tokens');
const { CREATE_TOKEN, REVOKE_TOKEN } = require('../../perms/constants');
@@ -21,8 +21,8 @@ const revokeToken = async ({ user }, { id }) => {
module.exports = context => {
let mutators = {
Token: {
create: () => Promise.reject(errors.ErrNotAuthorized),
revoke: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
revoke: () => Promise.reject(new ErrNotAuthorized()),
},
};
+59 -30
View File
@@ -1,5 +1,5 @@
const errors = require('../../errors');
const UsersService = require('../../services/users');
const { ErrNotFound, ErrNotAuthorized } = require('../../errors');
const Users = require('../../services/users');
const migrationHelpers = require('../../services/migration/helpers');
const {
CHANGE_USERNAME,
@@ -9,10 +9,11 @@ const {
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
DELETE_USER,
CHANGE_PASSWORD,
} = require('../../perms/constants');
const setUserUsernameStatus = async (ctx, id, status) => {
const user = await UsersService.setUsernameStatus(id, status, ctx.user.id);
const user = await Users.setUsernameStatus(id, status, ctx.user.id);
if (status === 'REJECTED') {
ctx.pubsub.publish('usernameRejected', user);
} else if (status === 'APPROVED') {
@@ -21,12 +22,7 @@ const setUserUsernameStatus = async (ctx, id, status) => {
};
const setUserBanStatus = async (ctx, id, status = false, message = null) => {
const user = await UsersService.setBanStatus(
id,
status,
ctx.user.id,
message
);
const user = await Users.setBanStatus(id, status, ctx.user.id, message);
if (user.banned) {
ctx.pubsub.publish('userBanned', user);
}
@@ -38,38 +34,33 @@ const setUserSuspensionStatus = async (
until = null,
message = null
) => {
const user = await UsersService.setSuspensionStatus(
id,
until,
ctx.user.id,
message
);
const user = await Users.setSuspensionStatus(id, until, ctx.user.id, message);
if (user.suspended) {
ctx.pubsub.publish('userSuspended', user);
}
};
const ignoreUser = ({ user }, userToIgnore) => {
return UsersService.ignoreUsers(user.id, [userToIgnore.id]);
return Users.ignoreUsers(user.id, [userToIgnore.id]);
};
const stopIgnoringUser = ({ user }, userToStopIgnoring) => {
return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
return Users.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
const changeUsername = async (ctx, id, username) => {
const user = await UsersService.changeUsername(id, username, ctx.user.id);
const user = await Users.changeUsername(id, username, ctx.user.id);
const previousUsername = ctx.user.username;
ctx.pubsub.publish('usernameChanged', { previousUsername, user });
return user;
};
const setUsername = async (ctx, id, username) => {
return UsersService.setUsername(id, username, ctx.user.id);
return Users.setUsername(id, username, ctx.user.id);
};
const setRole = (ctx, id, role) => {
return UsersService.setRole(id, role);
return Users.setRole(id, role);
};
/**
@@ -92,7 +83,7 @@ const delUser = async (ctx, id) => {
// Find the user we're removing.
const user = await User.findOne({ id });
if (!user) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
// Get the query transformer we'll use to help batch process the user
@@ -153,18 +144,51 @@ const delUser = async (ctx, id) => {
await user.remove();
};
const changeUserPassword = async (ctx, oldPassword, newPassword) => {
const {
user,
loaders: { Settings },
connectors: { services: { I18n } },
} = ctx;
// Verify the old password.
const validPassword = await user.verifyPassword(oldPassword);
if (!validPassword) {
throw new ErrNotAuthorized();
}
// Change the users password now.
await Users.changePassword(user.id, newPassword);
// Get some context for the email to be sent.
const { organizationName, organizationContactEmail } = await Settings.load([
'organizationName',
'organizationContactEmail',
]);
// Send the password change email.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t('email.password_change.body', organizationContactEmail),
},
subject: I18n.t('email.password_change.subject', organizationName),
});
};
module.exports = ctx => {
let mutators = {
User: {
changeUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: () => Promise.reject(errors.ErrNotAuthorized),
setRole: () => Promise.reject(errors.ErrNotAuthorized),
setUserBanStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserSuspensionStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserUsernameStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUsername: () => Promise.reject(errors.ErrNotAuthorized),
stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized),
del: () => Promise.reject(errors.ErrNotAuthorized),
changeUsername: () => Promise.reject(new ErrNotAuthorized()),
ignoreUser: () => Promise.reject(new ErrNotAuthorized()),
setRole: () => Promise.reject(new ErrNotAuthorized()),
setUserBanStatus: () => Promise.reject(new ErrNotAuthorized()),
setUserSuspensionStatus: () => Promise.reject(new ErrNotAuthorized()),
setUserUsernameStatus: () => Promise.reject(new ErrNotAuthorized()),
setUsername: () => Promise.reject(new ErrNotAuthorized()),
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
del: () => Promise.reject(new ErrNotAuthorized()),
changePassword: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -204,6 +228,11 @@ module.exports = ctx => {
if (ctx.user.can(DELETE_USER)) {
mutators.User.del = id => delUser(ctx, id);
}
if (ctx.user.can(CHANGE_PASSWORD)) {
mutators.User.changePassword = ({ oldPassword, newPassword }) =>
changeUserPassword(ctx, oldPassword, newPassword);
}
}
return mutators;
+32 -3
View File
@@ -1,6 +1,15 @@
const { URL } = require('url');
const { property } = require('lodash');
const { SEARCH_ACTIONS } = require('../../perms/constants');
const { decorateWithTags, decorateWithPermissionCheck } = require('./util');
const {
SEARCH_ACTIONS,
SEARCH_COMMENT_STATUS_HISTORY,
VIEW_BODY_HISTORY,
} = require('../../perms/constants');
const {
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
} = require('./util');
const Comment = {
hasParent({ parent_id }) {
@@ -55,14 +64,34 @@ const Comment = {
editableUntil: editableUntil,
};
},
async url(comment, args, { loaders: { Assets } }) {
const asset = await Assets.getByID.load(comment.asset_id);
if (!asset) {
return null;
}
const assetURL = new URL(asset.url);
assetURL.searchParams.set('commentId', comment.id);
return assetURL.href;
},
};
// Decorate the Comment type resolver with a tags field.
decorateWithTags(Comment);
// Protect direct action access.
// Protect direct action and status history access.
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
status_history: [SEARCH_COMMENT_STATUS_HISTORY],
});
// Protect privileged fields.
decorateWithPermissionCheck(
Comment,
{
body_history: [VIEW_BODY_HISTORY],
},
checkSelfField('author_id')
);
module.exports = Comment;
+3
View File
@@ -139,6 +139,9 @@ const RootMutation = {
delUser: async (_, { id }, { mutators: { User } }) => {
await User.del(id);
},
changePassword: async (_, { input }, { mutators: { User } }) => {
await User.changePassword(input);
},
};
module.exports = RootMutation;
+2 -2
View File
@@ -29,9 +29,9 @@ const User = {
return Comments.getByQuery(query);
},
ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) {
ignoredUsers({ ignoresUsers }, args, { loaders: { Users } }) {
// Return nothing if there is nothing to query for.
if (!user.ignoresUsers || user.ignoresUsers.length <= 0) {
if (!ignoresUsers || ignoresUsers.length <= 0) {
return [];
}
+31 -2
View File
@@ -505,8 +505,9 @@ type Comment {
# The actual comment data.
body: String!
# The body history of the comment.
body_history: [CommentBodyHistory!]!
# The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or
# the author.
body_history: [CommentBodyHistory!]
# The tags on the comment
tags: [TagLink!]
@@ -547,6 +548,9 @@ type Comment {
# Indicates if it has a parent
hasParent: Boolean
# url is the permalink to this particular Comment on the Asset.
url: String
}
# CommentConnection represents a paginable subset of a comment list.
@@ -834,6 +838,9 @@ type Settings {
# organizationName is the name of the organization.
organizationName: String
# organizationContactEmail is the email of the organization.
organizationContactEmail: String
# wordlist will return a given list of words.
wordlist: Wordlist
@@ -1290,6 +1297,9 @@ input UpdateSettingsInput {
# organizationName is the name of the organization.
organizationName: String
# organizationContactEmail is the email of the organization.
organizationContactEmail: String
# editCommentWindowLength is the length of time (in milliseconds) after a
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
@@ -1432,6 +1442,21 @@ type DelUserResponse implements Response {
errors: [UserError!]
}
input ChangePasswordInput {
# oldPassword is the previous password set on the account. An incorrect
# password here will result in an unauthorized error being thrown.
oldPassword: String!
# newPassword is the password we're changing it to.
newPassword: String!
}
type ChangePasswordResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1532,6 +1557,10 @@ type RootMutation {
# delUser will delete the user with the specified id.
delUser(id: ID!): DelUserResponse
# changePassword allows the current user to change their password that have an
# associated local user account.
changePassword(input: ChangePasswordInput!): ChangePasswordResponse
}
type UsernameChangedPayload {
+5 -37
View File
@@ -1,40 +1,8 @@
const path = require('path');
const { pluginsPath } = require('./plugins');
const buildTargets = ['coral-admin', 'coral-docs'];
const buildEmbeds = ['stream'];
// jest.config.js
module.exports = {
testMatch: ['**/client/**/__tests__/**/*.js?(x)'],
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
modulePaths: [
'<rootDir>/plugins',
'<rootDir>/client',
...buildTargets.map(target =>
path.join('<rootDir>', 'client', target, 'src')
),
...buildEmbeds.map(embed =>
path.join('<rootDir>', 'client', `coral-embed-${embed}`, 'src')
),
],
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
moduleDirectories: ['node_modules'],
transform: {
'^.+\\.jsx?$': 'babel-jest',
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
},
projects: ['<rootDir>', '<rootDir>/client'],
testPathIgnorePatterns: ['client'],
setupTestFrameworkScriptFile: '<rootDir>/test/setupJest.js',
testResultsProcessor: process.env.JEST_REPORTER,
moduleNameMapper: {
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
'^pluginsConfig$': pluginsPath,
'\\.(scss|css|less)$': 'identity-obj-proxy',
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
},
testEnvironment: 'node',
modulePaths: ['<rootDir>'],
};
+2 -2
View File
@@ -4,7 +4,6 @@ const { createLogger } = require('../services/logging');
const logger = createLogger('jobs:mailer');
const Context = require('../graph/context');
const { get } = require('lodash');
const {
SMTP_HOST,
SMTP_USERNAME,
@@ -12,6 +11,7 @@ const {
SMTP_PASSWORD,
SMTP_FROM_ADDRESS,
} = require('../config');
const { ErrMissingEmail } = require('../errors');
// parseSMTPPort will return the port for SMTP.
const parseSMTPPort = () => {
@@ -99,7 +99,7 @@ const getEmailAddress = async ({ email, user }) => {
const email = get(data, 'user.email');
if (!email) {
throw errors.ErrMissingEmail;
throw new ErrMissingEmail();
}
return email;
+3 -2
View File
@@ -438,8 +438,8 @@ ar:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -466,6 +466,7 @@ ar:
username: "Username"
password: "Password"
confirm_password: "Confirm Password"
organization_contact_email: "Organization Contact Email"
save: "Save"
permitted_domains:
title: "Permitted domains"
+3 -2
View File
@@ -431,8 +431,8 @@ da:
reports: "Rapporter"
all: "Alle"
rejected: "Afvist"
account_history: "Konto historik"
account_history:
user_history: "Konto historik"
user_history:
user_banned: "Bruger bannet"
ban_removed: "Ban fjernet"
username_status: "Brugernavn {0}"
@@ -459,6 +459,7 @@ da:
username: "Brugernavn"
password: "Kodeord"
confirm_password: "Bekræft kodeord"
organization_contact_email: "Organization Contact Email"
save: "Gem"
permitted_domains:
title: "Tilladte domæner"
+3 -2
View File
@@ -430,8 +430,8 @@ de:
reports: "Meldungen"
all: "Alle"
rejected: "Abgelehnte"
account_history: "Konto-Verlauf"
account_history:
user_history: "Konto-Verlauf"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -458,6 +458,7 @@ de:
username: "Nutzername"
password: "Passwort"
confirm_password: "Passwort bestätigen"
organization_contact_email: "Organization Contact Email"
save: "Speichern"
permitted_domains:
title: "Zugelassene Domains"
+26 -9
View File
@@ -20,11 +20,13 @@ en:
bio_offensive: "This bio is offensive"
cancel: "Cancel"
confirm_email:
click_to_confirm: "Click below to confirm your email address"
email_confirmation: "Email Confirmation"
click_to_confirm: "Click below to confirm your email address."
confirm: "Confirm"
password_reset:
mail_sent: 'If you have a registered account, a password reset link was sent to that email'
set_new_password: "Change Your Password"
change_password_help: "Please enter a new password to use to login. Make it secure!"
new_password: "New Password"
new_password_help: "Password must be at least 8 characters"
confirm_new_password: "Confirm New Password"
@@ -121,9 +123,10 @@ en:
custom_css_url: "Custom CSS URL"
custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external."
days: Days
description: "As an admin, you can customize the settings for the comment stream for this story:"
description: "Change the comment settings on this story."
domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)."
domain_list_title: "Permitted Domains"
edit_info: "Edit Info"
edit_comment_timeframe_heading: "Edit Comment Timeframe"
edit_comment_timeframe_text_pre: "Commenters will have"
edit_comment_timeframe_text_post: "seconds to edit their comments."
@@ -149,6 +152,7 @@ en:
open_stream_configuration: "This comment stream is currently open. By closing this comment stream no new comments may be submitted and all previous comments will still be displayed."
require_email_verification: "Require Email Verification"
require_email_verification_text: "New Users must verify their email before commenting"
save: Save
save_changes: "Save Changes"
shortcuts: Shortcuts
sign_out: "Sign Out"
@@ -158,6 +162,12 @@ en:
suspect_word_title: "Suspect words list"
suspect_word_text: "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list."
tech_settings: "Tech Settings"
organization_information: "Organization information"
organization_info_copy: "We use this information in email notifications generated by Talk. This connects the messages to your organization, and provides a way for users to contact you if they have an issue with their account."
organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpose. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked."
organization_details: "Organization Details"
organization_name: "Organization Name"
organization_contact_email: "Organization Contact Email"
title: "Configure Comment Stream"
weeks: Weeks
wordlist: "Banned Words"
@@ -208,6 +218,10 @@ en:
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
if_you_did: "If you did,"
please_click: "please click here to reset password"
subject: "Password reset"
password_change:
subject: "{0} password change"
body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}."
embedlink:
copy: "Copy to Clipboard"
error:
@@ -223,7 +237,7 @@ en:
RATE_LIMIT_EXCEEDED: "Rate limit exceeded"
USERNAME_IN_USE: "Username already in use"
USERNAME_REQUIRED: "Must input a username"
EMAIL_NOT_VERIFIED: "E-mail address not verified"
EMAIL_NOT_VERIFIED: "Email address not verified"
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."
@@ -236,12 +250,13 @@ en:
ALREADY_EXISTS: "Resource already exists"
INVALID_ASSET_URL: "Assert URL is invalid"
CANNOT_IGNORE_STAFF: "Cannot ignore Staff members."
email: "Not a valid E-Mail"
email: "Please enter a valid email."
confirm_password: "Passwords don't match. Please check again"
network_error: "Failed to connect to server. Check your internet connection and try again."
email_not_verified: "E-mail address {0} not verified."
email_password: "E-mail and/or password combination incorrect."
email_not_verified: "Email address {0} not verified."
email_password: "Email and/or password combination incorrect."
organization_name: "Organization name must only contain letters or numbers."
organization_contact_email: "Organization email is not valid."
password: "Password must be at least 8 characters"
username: "Usernames can contain letters numbers and _ only"
unexpected: "Unexpected error occurred. Sorry!"
@@ -344,6 +359,7 @@ en:
sort: "Sort"
show_shortcuts: "Show Shortcuts"
singleview: "Zen mode"
system_withheld: "System Withheld"
thismenu: "Open this menu"
jump_to_queue: "Jump to specific queue"
thousand: k
@@ -426,7 +442,7 @@ en:
title_reject: "We noticed you rejected a username"
suspend_user: "Suspend User"
yes_suspend: "Yes suspend"
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns."
write_message: "Write a message"
send: Send
thank_you: "We value your safety and feedback. A moderator will review your report."
@@ -446,8 +462,8 @@ en:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -474,6 +490,7 @@ en:
username: "Username"
password: "Password"
confirm_password: "Confirm Password"
organization_contact_email: "Organization Contact Email"
save: "Save"
permitted_domains:
title: "Permitted domains"
+14 -3
View File
@@ -120,9 +120,10 @@ es:
custom_css_url: "URL CSS a medida"
custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa."
days: Días
description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo"
description: "Modificar la configuración de los comentarios en este artículo."
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)."
domain_list_title: "Dominios Permitidos"
edit_info: "Editar Información"
edit_comment_timeframe_heading: "Periodo de Tiempo para Edición del Comentario"
edit_comment_timeframe_text_pre: "Los comentaristas tendrán"
edit_comment_timeframe_text_post: "segundos para editar sus comentarios."
@@ -148,6 +149,7 @@ es:
open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo no se podrán publicar nuevos comentarios pero todos los comentarios anteriores aún serán mostrados."
require_email_verification: "Necesita confirmación su correo"
require_email_verification_text: "Nuevos usuarios deben confirmar sus correos antes de comentar"
save: Guardar
save_changes: "Guardar Cambios"
shortcuts: Atajos
sign_out: "Desconectar"
@@ -157,6 +159,12 @@ es:
suspect_word_title: "Lista de palabras sospechosas"
suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma."
tech_settings: "Configuración Técnica"
organization_information: "Información de la Organización"
organization_details: "Detalles de la Organización"
organization_info_copy: "Nosotros usamos esta información en las notificaciones de email generadas por Talk. Esto conecta los mensajes de tu organización, y provee una forma para que los usuarios se comuniquen si tienen un inconveniente con su cuenta."
organization_info_copy_2: "Recomendamos crear un email genérico (ej: community@yournewsroom.com) for this purpose. Esto significa que puede permanecer consistente con el tiempo y no expone un nombre que los usuarios puedan atacar si su cuenta fue bloqueada."
organization_name: "Nombre de la Organización"
organization_contact_email: "Email de la Organización"
title: "Configurar los comentarios"
weeks: Semanas
wordlist: "Palabras Suspendidas"
@@ -207,6 +215,7 @@ es:
we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. "
if_you_did: "Si lo hizo,"
please_click: "por favor cliquea aqui para resetear la contraseña."
subject: "Recuperar contraseña"
embedlink:
copy: "Copiar al portapapeles"
error:
@@ -240,6 +249,7 @@ es:
email_not_verified: "Correo {0} no confirmado."
email_password: "Correo y/o contraseña incorrecta."
organization_name: "El nombre de la organización debe contener letras y/o números."
organization_contact_email: "El email de la organización no es válido."
password: "La contraseña debe tener por lo menos 8 caracteres"
username: "Los nombres pueden contener letras números y _"
required_field: "Este campo es requerido"
@@ -439,8 +449,8 @@ es:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -467,6 +477,7 @@ es:
username: "Nombre de Usuario"
password: "Contraseña"
confirm_password: "Confirmar Contraseña"
organization_contact_email: "Organización: Email de contacto"
save: "Guardar"
permitted_domains:
title: "Dominios permitidos"
+2 -1
View File
@@ -121,7 +121,7 @@ fr:
custom_css_url: "URL CSS personnalisée"
custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe."
days: Journées
description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément."
description: "Personnaliser les paramètres du fil de commentaires pour cet élément."
domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)."
domain_list_title: "Domaines autorisés"
edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires"
@@ -474,6 +474,7 @@ fr:
username: "Nom d'utilisateur"
password: "Mot de passe"
confirm_password: "Confirmez Le mot de passe"
organization_contact_email: "Organization Contact Email"
save: "Sauvegarder"
permitted_domains:
title: "Domaines autorisés"
+7 -7
View File
@@ -4,11 +4,11 @@ en:
your_username_has_been_rejected: החשבון שלך הושעה מפני ששם המשתמש שלך נחשב לא הולם. כדי לשחזר את החשבון שלך, הזן שם משתמש חדש.
embed_comments_tab: תגובות
bandialog:
are_you_sure: "Are you sure you would like to ban {0}?"
are_you_sure: "האם אתה בטוח שאתה רוצה לאסור {0}?"
ban_user: "חסום משתמש?"
banned_user: "משתמש מורשה"
cancel: "בטל"
note: "Note: {0}"
note: "הערה: {0}"
note_reject_comment: "חסימת משתמש זה תציב גם תגובה זו בתור נדחה."
note_ban_user: "חסימת משתמש זה לא תאפשר לו להגיב על תגובות או לדווח עליהן."
yes_ban_user: "כן. חסום משתמש"
@@ -21,7 +21,7 @@ en:
cancel: "בטל"
confirm_email:
click_to_confirm: 'לחץ למטה כדי לאשר את כתובת הדוא"ל שלך'
confirm: "Confirm"
confirm: "אשר"
password_reset:
mail_sent: 'אם יש לך חשבון רשום, נשלח קישור לאיפוס סיסמה לאותו דוא"ל'
set_new_password: "שנה את סיסמתך"
@@ -31,7 +31,7 @@ en:
change_password: "שנה סיסמא"
characters_remaining: "תווים נותרים"
comment:
anon: "Anonymous"
anon: "אנונימי"
undo_reject: "בטל"
ban_user: "חסום משתמש"
comment: "פרסם תגובה"
@@ -73,9 +73,9 @@ en:
admin: מנהל
ads_marketing: "זה נראה כמו מודעה / שיווק"
are_you_sure: "Are you sure you would like to ban {0}?"
ban_user: "Ban User?"
banned: Banned
banned_user: "Banned User"
ban_user: "חסום משתמש?"
banned: אסור
banned_user: "משתמש מורשה"
cancel: בטל
dont_like_username: "Dislike username"
flaggedaccounts: "Reported Usernames"
+2 -2
View File
@@ -431,8 +431,8 @@ nl_NL:
reports: "Rapportages"
all: "Alle"
rejected: "Afgewezen"
account_history: "Accountgeschiedenis"
account_history:
user_history: "Accountgeschiedenis"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+4 -3
View File
@@ -120,7 +120,7 @@ pt_BR:
custom_css_url: "URL para CSS customizado"
custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo."
days: Dias
description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:"
description: "Personalizar as configurações da lista de comentários para esta história."
domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)."
domain_list_title: "Domínios permitidos"
edit_comment_timeframe_heading: "Período de tempo para editar comentários"
@@ -430,8 +430,8 @@ pt_BR:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -458,6 +458,7 @@ pt_BR:
username: "Nome de usuário"
password: "Senha"
confirm_password: "Confirme a senha"
organization_contact_email: "Organization Contact Email"
save: "Salvar"
permitted_domains:
title: "Domínios permitidos"
+2 -2
View File
@@ -432,8 +432,8 @@ zh_CN:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+2 -2
View File
@@ -432,8 +432,8 @@ zh_TW:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+1 -1
View File
@@ -7,7 +7,7 @@ const authorization = (module.exports = {
});
const debug = require('debug')('talk:middleware:authorization');
const ErrNotAuthorized = require('../errors').ErrNotAuthorized;
const { ErrNotAuthorized } = require('../errors');
/**
* has returns true if the user has at least one of the roles specified,
+40
View File
@@ -0,0 +1,40 @@
const { logger } = require('../services/logging');
const now = require('performance-now');
const log = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = function(chunk, encoding) {
// Compute the end time.
const responseTime = Math.round(now() - startTime);
// Get some extra goodies from the request.
const userAgent = req.get('User-Agent');
// Reattach the old end, and finish.
res.end = end;
res.end(chunk, encoding);
// Log this out.
logger.info(
{
traceID: req.id,
url: req.originalUrl || req.url,
method: req.method,
statusCode: res.statusCode,
userAgent,
responseTime,
},
'http request'
);
};
next();
};
const error = (err, req, res, next) => {
logger.error({ err }, 'http error');
next(err);
};
module.exports = { log, error };
+7
View File
@@ -0,0 +1,7 @@
const uuid = require('uuid/v1');
// Trace middleware attaches a request id to each incoming request.
module.exports = (req, res, next) => {
req.id = uuid();
next();
};
+2 -51
View File
@@ -1,53 +1,4 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
const Schema = mongoose.Schema;
const ACTION_TYPES = require('./enum/action_types');
const ITEM_TYPES = require('./enum/item_types');
const { Action } = require('./schema');
const ActionSchema = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
},
action_type: {
type: String,
enum: ACTION_TYPES,
},
item_type: {
type: String,
enum: ITEM_TYPES,
},
item_id: String,
user_id: String,
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
// item_id.
group_id: String,
// Additional metadata stored on the field.
metadata: Schema.Types.Mixed,
},
{
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
}
);
// Create an index on the `item_id` field so that queries looking for
// actions based on the item id can resolve faster.
ActionSchema.index(
{
item_id: 1,
},
{
background: true,
}
);
const Action = mongoose.model('Action', ActionSchema);
module.exports = Action;
module.exports = mongoose.model('Action', Action);
+2 -97
View File
@@ -1,99 +1,4 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./schema/tag_link');
const get = require('lodash/get');
const { Asset } = require('./schema');
const AssetSchema = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
index: true,
},
url: {
type: String,
unique: true,
index: true,
},
type: {
type: String,
default: 'assets',
},
scraped: {
type: Date,
default: null,
},
closedAt: {
type: Date,
default: null,
},
closedMessage: {
type: String,
default: null,
},
title: String,
description: String,
image: String,
section: String,
subsection: String,
author: String,
publication_date: Date,
modified_date: Date,
// This object is used exclusively for storing settings that are to override
// the base settings from the base Settings object. This is to be accessed
// always after running `rectifySettings` against it.
settings: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object,
},
},
{
versionKey: false,
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
}
);
AssetSchema.index(
{
title: 'text',
url: 'text',
description: 'text',
section: 'text',
subsection: 'text',
author: 'text',
},
{
background: true,
}
);
/**
* Returns true if the asset is closed, false else.
*/
AssetSchema.virtual('isClosed').get(function() {
const closedAt = get(this, 'closedAt', null);
if (closedAt === null) {
return false;
}
return closedAt.getTime() <= new Date().getTime();
});
const Asset = mongoose.model('Asset', AssetSchema);
module.exports = Asset;
module.exports = mongoose.model('Asset', Asset);
+2 -233
View File
@@ -1,235 +1,4 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const TagLinkSchema = require('./schema/tag_link');
const uuid = require('uuid');
const COMMENT_STATUS = require('./enum/comment_status');
const { Comment } = require('./schema');
/**
* The Mongo schema for a Comment Status.
* @type {Schema}
*/
const StatusSchema = new Schema(
{
type: {
type: String,
enum: COMMENT_STATUS,
},
// The User ID of the user that assigned the status.
assigned_by: {
type: String,
default: null,
},
created_at: Date,
},
{
_id: false,
}
);
/**
* A record of old body values for a Comment
*/
const BodyHistoryItemSchema = new Schema({
body: {
required: true,
type: String,
},
// datetime until the comment body value was this.body
created_at: {
required: true,
type: Date,
default: Date,
},
});
/**
* The Mongo schema for a Comment.
* @type {Schema}
*/
const CommentSchema = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
},
body: {
type: String,
required: [true, 'The body is required.'],
minlength: 2,
},
body_history: [BodyHistoryItemSchema],
asset_id: String,
author_id: String,
status_history: [StatusSchema],
status: {
type: String,
enum: COMMENT_STATUS,
default: 'NONE',
},
// parent_id is the id of the parent comment (null if there is none).
parent_id: String,
// The number of replies to this comment directly.
reply_count: {
type: Number,
default: 0,
},
// Counts to store related to actions taken on the given comment.
action_counts: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object,
},
},
{
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
toJSON: {
virtuals: true,
},
}
);
// Add the indexes for the id of the comment.
CommentSchema.index(
{
id: 1,
},
{
unique: true,
background: false,
}
);
CommentSchema.index(
{
status: 1,
created_at: 1,
},
{
background: true,
}
);
CommentSchema.index(
{
status: 1,
created_at: 1,
asset_id: 1,
},
{
background: true,
}
);
// Create a sparse index to search across.
CommentSchema.index(
{
created_at: 1,
'action_counts.flag': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Create a sparse index to search across.
CommentSchema.index(
{
'action_counts.flag': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Add an index that is optimized for finding flagged comments.
CommentSchema.index(
{
asset_id: 1,
created_at: 1,
'action_counts.flag': 1,
},
{
background: true,
}
);
// Add an index for the reply sort.
CommentSchema.index(
{
asset_id: 1,
created_at: -1,
reply_count: -1,
},
{
background: true,
}
);
// Optimize for tag searches/counts.
CommentSchema.index(
{
asset_id: 1,
'tags.tag.name': 1,
status: 1,
},
{
background: true,
}
);
// Optimize for tag searches/counts.
CommentSchema.index(
{
'tags.tag.name': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Add an index that is optimized for sorting based on the created_at timestamp
// but also good at locating comments that have a specific asset id.
CommentSchema.index(
{
asset_id: 1,
created_at: 1,
},
{
background: true,
}
);
CommentSchema.virtual('edited').get(function() {
return this.body_history.length > 1;
});
// Visable is true when the comment is visible to the public.
CommentSchema.virtual('visible').get(function() {
return ['ACCEPTED', 'NONE'].includes(this.status);
});
module.exports = mongoose.model('Comment', CommentSchema);
module.exports = mongoose.model('Comment', Comment);
+2 -8
View File
@@ -1,10 +1,4 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const { Migration } = require('./schema');
const MigrationSchema = new Schema({
version: Number,
});
const Migration = mongoose.model('Migration', MigrationSchema);
module.exports = Migration;
module.exports = mongoose.model('Migration', Migration);
+51
View File
@@ -0,0 +1,51 @@
const mongoose = require('../../services/mongoose');
const uuid = require('uuid');
const Schema = mongoose.Schema;
const ACTION_TYPES = require('../enum/action_types');
const ITEM_TYPES = require('../enum/item_types');
const Action = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
},
action_type: {
type: String,
enum: ACTION_TYPES,
},
item_type: {
type: String,
enum: ITEM_TYPES,
},
item_id: String,
user_id: String,
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
// item_id.
group_id: String,
// Additional metadata stored on the field.
metadata: Schema.Types.Mixed,
},
{
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
}
);
// Create an index on the `item_id` field so that queries looking for
// actions based on the item id can resolve faster.
Action.index(
{
item_id: 1,
},
{
background: true,
}
);
module.exports = Action;
+97
View File
@@ -0,0 +1,97 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const uuid = require('uuid');
const TagLinkSchema = require('./tag_link');
const { get } = require('lodash');
const Asset = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
index: true,
},
url: {
type: String,
unique: true,
index: true,
},
type: {
type: String,
default: 'assets',
},
scraped: {
type: Date,
default: null,
},
closedAt: {
type: Date,
default: null,
},
closedMessage: {
type: String,
default: null,
},
title: String,
description: String,
image: String,
section: String,
subsection: String,
author: String,
publication_date: Date,
modified_date: Date,
// This object is used exclusively for storing settings that are to override
// the base settings from the base Settings object. This is to be accessed
// always after running `rectifySettings` against it.
settings: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object,
},
},
{
versionKey: false,
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
}
);
Asset.index(
{
title: 'text',
url: 'text',
description: 'text',
section: 'text',
subsection: 'text',
author: 'text',
},
{
background: true,
}
);
/**
* Returns true if the asset is closed, false else.
*/
Asset.virtual('isClosed').get(function() {
const closedAt = get(this, 'closedAt', null);
if (closedAt === null) {
return false;
}
return closedAt.getTime() <= new Date().getTime();
});
module.exports = Asset;
+246
View File
@@ -0,0 +1,246 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const TagLinkSchema = require('./tag_link');
const uuid = require('uuid');
const COMMENT_STATUS = require('../enum/comment_status');
/**
* The Mongo schema for a Comment Status.
* @type {Schema}
*/
const Status = new Schema(
{
type: {
type: String,
enum: COMMENT_STATUS,
},
// The User ID of the user that assigned the status.
assigned_by: {
type: String,
default: null,
},
created_at: Date,
},
{
_id: false,
}
);
/**
* A record of old body values for a Comment
*/
const BodyHistoryItemSchema = new Schema({
body: {
required: true,
type: String,
},
// datetime until the comment body value was this.body
created_at: {
required: true,
type: Date,
default: Date,
},
});
/**
* The Mongo schema for a Comment.
* @type {Schema}
*/
const Comment = new Schema(
{
id: {
type: String,
default: uuid.v4,
unique: true,
},
body: {
type: String,
required: [true, 'The body is required.'],
minlength: 2,
},
body_history: [BodyHistoryItemSchema],
asset_id: String,
author_id: String,
status_history: [Status],
status: {
type: String,
enum: COMMENT_STATUS,
default: 'NONE',
},
// parent_id is the id of the parent comment (null if there is none).
parent_id: String,
// The number of replies to this comment directly.
reply_count: {
type: Number,
default: 0,
},
// Counts to store related to actions taken on the given comment.
action_counts: {
default: {},
type: Object,
},
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// Additional metadata stored on the field.
metadata: {
default: {},
type: Object,
},
},
{
timestamps: {
createdAt: 'created_at',
updatedAt: 'updated_at',
},
toJSON: {
virtuals: true,
},
}
);
// Add the indexes for the id of the comment.
Comment.index(
{
id: 1,
},
{
unique: true,
background: false,
}
);
Comment.index(
{
status: 1,
created_at: 1,
},
{
background: true,
}
);
Comment.index(
{
status: 1,
created_at: 1,
asset_id: 1,
},
{
background: true,
}
);
// Create a sparse index to search across.
Comment.index(
{
created_at: 1,
'action_counts.flag': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Create a sparse index to search across.
Comment.index(
{
'action_counts.flag': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Add an index that is optimized for finding flagged comments.
Comment.index(
{
asset_id: 1,
created_at: 1,
'action_counts.flag': 1,
},
{
background: true,
}
);
// Add an index for the reply sort.
Comment.index(
{
asset_id: 1,
created_at: -1,
reply_count: -1,
},
{
background: true,
}
);
// Add an index that is optimized for finding a user's comments.
Comment.index(
{
author_id: 1,
created_at: -1,
},
{
background: true,
}
);
// Optimize for tag searches/counts.
Comment.index(
{
asset_id: 1,
'tags.tag.name': 1,
status: 1,
},
{
background: true,
}
);
// Optimize for tag searches/counts.
Comment.index(
{
'tags.tag.name': 1,
status: 1,
},
{
background: true,
sparse: true,
}
);
// Add an index that is optimized for sorting based on the created_at timestamp
// but also good at locating comments that have a specific asset id.
Comment.index(
{
asset_id: 1,
created_at: 1,
},
{
background: true,
}
);
Comment.virtual('edited').get(function() {
return this.body_history.length > 1;
});
// Visible is true when the comment is visible to the public.
Comment.virtual('visible').get(function() {
return ['ACCEPTED', 'NONE'].includes(this.status);
});
module.exports = Comment;
+8
View File
@@ -0,0 +1,8 @@
const Action = require('./action');
const Asset = require('./asset');
const Comment = require('./comment');
const Migration = require('./migration');
const Setting = require('./setting');
const User = require('./user');
module.exports = { Action, Asset, Comment, Migration, Setting, User };
+8
View File
@@ -0,0 +1,8 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const Migration = new Schema({
version: Number,
});
module.exports = Migration;

Some files were not shown because too many files have changed in this diff Show More