Merge branch 'master' into es-translations

This commit is contained in:
Kim Gardner
2018-05-04 15:48:56 -04:00
committed by GitHub
208 changed files with 6852 additions and 517 deletions
+3 -2
View File
@@ -38,6 +38,7 @@ plugins/*
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
!plugins/talk-plugin-local-auth
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!plugins/talk-plugin-mod
@@ -50,9 +51,10 @@ 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-rich-text
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-downvoted
!plugins/talk-plugin-sort-most-liked
@@ -66,7 +68,6 @@ plugins/*
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-upvote
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-rich-text
**/node_modules/*
yarn-error.log
+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)
+5 -24
View File
@@ -1,14 +1,12 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
require('./util');
const program = require('commander');
const { head, map } = require('lodash');
const Matcher = require('did-you-mean');
// We're requiring this here so it'll setup some promise rejection hooks to log
// out.
require('./util');
// Setup the program.
program
.command('serve', 'serve the application')
.command('db', 'run database commands')
@@ -24,20 +22,3 @@ program
'provides utilities for interacting with the plugin system'
)
.parse(process.argv);
// If the command wasn't found, output help.
const commands = map(program.commands, '_name');
const command = head(program.args);
if (!commands.includes(command)) {
const m = new Matcher(commands);
const similarCommands = m.list(command);
console.error(
`cli '${command}' is not a talk cli command. See 'cli --help'.`
);
if (similarCommands.length > 0) {
const sc = similarCommands.map(({ value }) => `\t${value}\n`).join('');
console.error(`\nThe most similar commands are\n${sc}`);
}
process.exit(1);
}
+11 -8
View File
@@ -4,7 +4,8 @@ const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
const SettingsService = require('../services/settings');
const Settings = require('../services/settings');
const cache = require('../services/cache');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -14,9 +15,12 @@ util.onshutdown([() => mongoose.disconnect()]);
*/
async function changeOrgName() {
try {
let settings = await SettingsService.retrieve();
await cache.init();
let { organizationName } = await inquirer.prompt([
// Get the original settings.
const settings = await Settings.retrieve('organizationName');
const { organizationName } = await inquirer.prompt([
{
name: 'organizationName',
message: 'Organization Name',
@@ -25,9 +29,8 @@ async function changeOrgName() {
]);
if (settings.organizationName !== organizationName) {
settings.organizationName = organizationName;
await SettingsService.update(settings);
// Set the organization name if there was a mutation to it.
await Settings.update({ organizationName });
console.log('Settings were updated.');
} else {
@@ -36,9 +39,9 @@ async function changeOrgName() {
} catch (err) {
console.error(err);
util.shutdown(1);
} finally {
util.shutdown();
}
util.shutdown();
}
//==============================================================================
+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);
+5 -2
View File
@@ -4,7 +4,8 @@ require('../services/env');
const debug = require('debug')('talk:util');
const { uniq } = require('lodash');
const util = (module.exports = {});
// Setup the utilities.
const util = {};
/**
* Stores an array of functions that should be executed in the event that the
@@ -15,7 +16,7 @@ util.toshutdown = [];
/**
* Calls all the shutdown functions and then ends the process.
* @param {Number} [defaultCode=0] default return code upon sucesfull shutdown.
* @param {Number} [defaultCode=0] default return code upon successful shutdown.
*/
util.shutdown = (defaultCode = 0, signal = null) => {
if (signal) {
@@ -63,3 +64,5 @@ process.on('unhandledRejection', err => {
console.error(err);
process.exit(1);
});
module.exports = util;
+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>
@@ -96,8 +96,6 @@ class UserDetail extends React.Component {
bulkReject,
} = this.props;
console.log(rejectedComments, totalComments);
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
+31
View File
@@ -291,5 +291,36 @@ export default {
},
},
}),
SetCommentStatus: ({ variables: { status } }) => ({
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;
},
},
}),
},
};
@@ -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 = { organizationContactEmail: { $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);
@@ -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,
@@ -30,6 +30,7 @@ class ProfileContainer extends Component {
return (
<Profile
id={me.id}
username={me.username}
emailAddress={emailAddress}
root={root}
@@ -45,7 +46,7 @@ ProfileContainer.propTypes = {
currentUser: PropTypes.object,
};
const slots = ['profileSections'];
const slots = ['profileSections', 'profileSettings', 'profileHeader'];
const withProfileQuery = withQuery(
gql`
@@ -53,6 +54,15 @@ const withProfileQuery = withQuery(
me {
id
username
state {
status {
username {
history {
created_at
}
}
}
}
}
...${getDefinitionName(TabPanel.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
@@ -27,6 +27,7 @@ import {
getActionSummary,
iPerformedThisAction,
isCommentActive,
isCommentDeleted,
getShallowChanges,
} from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
@@ -574,7 +575,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(
@@ -742,8 +745,14 @@ export default class Comment extends React.Component {
return (
<div className={rootClassName} id={id}>
{this.renderComment()}
{activeReplyBox === comment.id && this.renderReplyBox()}
{isCommentDeleted(comment) ? (
<CommentTombstone action="deleted" />
) : (
<div>
{this.renderComment()}
{activeReplyBox === comment.id && this.renderReplyBox()}
</div>
)}
{this.renderRepliesContainer()}
</div>
);
@@ -13,6 +13,8 @@ class CommentTombstone extends React.Component {
return t('framework.comment_is_ignored');
case 'reject':
return t('framework.comment_is_rejected');
case 'deleted':
return t('framework.comment_is_deleted');
default:
return t('framework.comment_is_hidden');
}
@@ -372,6 +372,7 @@ const slots = [
'streamTabsPrepend',
'streamTabPanes',
'streamFilter',
'stream',
];
const fragments = {
+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;
}
@@ -43,7 +43,7 @@ const ConfigureCard = ({
);
ConfigureCard.propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.string,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
+4 -1
View File
@@ -25,6 +25,9 @@ export default {
'UnsuspendUserResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
'UpdateSettingsResponse'
'UpdateSettingsResponse',
'ChangePasswordResponse',
'UpdateEmailAddressResponse',
'AttachLocalAuthResponse'
),
};
+21 -31
View File
@@ -1,6 +1,5 @@
import { gql } from 'react-apollo';
import withMutation from '../hocs/withMutation';
import update from 'immutability-helper';
function convertItemType(item_type) {
switch (item_type) {
@@ -168,36 +167,6 @@ 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_Comment on Comment {
@@ -623,6 +592,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),
};
+4 -3
View File
@@ -9,6 +9,7 @@ import 'moment/locale/da';
import 'moment/locale/de';
import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/nl';
import 'moment/locale/pt-br';
import { createStorage } from 'coral-framework/services/storage';
@@ -18,10 +19,10 @@ import daTA from 'timeago.js/locales/da';
import deTA from 'timeago.js/locales/de';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
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';
@@ -29,10 +30,10 @@ import da from '../../../locales/da.yml';
import de from '../../../locales/de.yml';
import es from '../../../locales/es.yml';
import fr from '../../../locales/fr.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 = {
@@ -112,10 +113,10 @@ export function setupTranslations() {
ta.register('da', daTA);
ta.register('de', deTA);
ta.register('fr', frTA);
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();
}
+13 -1
View File
@@ -1,6 +1,7 @@
import { gql } from 'react-apollo';
import t from 'coral-framework/services/i18n';
import union from 'lodash/union';
import get from 'lodash/get';
import { capitalize } from 'coral-framework/helpers/strings';
import assignWith from 'lodash/assignWith';
import mapValues from 'lodash/mapValues';
@@ -221,6 +222,13 @@ export function isCommentActive(commentStatus) {
return ['NONE', 'ACCEPTED'].indexOf(commentStatus) >= 0;
}
export function isCommentDeleted(comment) {
return (
get(comment, 'body', null) === null ||
get(comment, 'deleted_at', null) !== null
);
}
export function getShallowChanges(a, b) {
return union(Object.keys(a), Object.keys(b)).filter(key => a[key] !== b[key]);
}
@@ -237,7 +245,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%;
}
+7
View File
@@ -212,6 +212,13 @@ const CONFIG = {
RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC,
RECAPTCHA_SECRET: process.env.TALK_RECAPTCHA_SECRET,
// RECAPTCHA_WINDOW is the rate limit's time interval
RECAPTCHA_WINDOW: process.env.TALK_RECAPTCHA_WINDOW || '10m',
// After RECAPTCHA_INCORRECT_TRIGGER incorrect attempts, recaptcha will be required.
RECAPTCHA_INCORRECT_TRIGGER:
process.env.TALK_RECAPTCHA_INCORRECT_TRIGGER || 5,
// WEBSOCKET_LIVE_URI is the absolute url to the live endpoint.
WEBSOCKET_LIVE_URI: process.env.TALK_WEBSOCKET_LIVE_URI || null,
+3 -1
View File
@@ -41,7 +41,7 @@ relative_link: false
future: true
highlight:
enable: false
# Home page setting
# path: Root path for your blogs index page. (default = '')
# per_page: Posts displayed per page. (0 = disable pagination)
@@ -114,6 +114,8 @@ sidebar:
url: /integrating/styling-css/
- title: Translations and i18n
url: /integrating/translations-i18n/
- title: GDPR Compliance
url: /integrating/gdpr/
- title: Product Guide
children:
- title: How Talk Works
+13 -1
View File
@@ -316,6 +316,18 @@ default to providing only a time based lockout. Refer to
[reCAPTCHA](https://www.google.com/recaptcha/intro/index.html) for information
on getting an account setup.
## TALK_RECAPTCHA_WINDOW
The rate limit time interval that there can be [TALK_RECAPTCHA_INCORRECT_TRIGGER](#talk_recaptcha_incorrect_trigger) incorrect attempts until the reCAPTCHA is
marked as required, parsed by
[ms](https://www.npmjs.com/package/ms). (Default `10m`)
## TALK_RECAPTCHA_INCORRECT_TRIGGER
The number of times that an incorrect login can be entered before within a time
perioud indicated by [TALK_RECAPTCHA_WINDOW](#talk_recaptcha_window) until the
reCAPTCHA is marked as required. (Default `5`)
## TALK_REDIS_CLIENT_CONFIGURATION
Configuration overrides for the redis client configuration in a JSON encoded
@@ -531,4 +543,4 @@ Sets the logging level for the context logger (from [Bunyan](https://github.com/
A JSON string representing the configuration passed to the
[fetch](https://www.npmjs.com/package/node-fetch) call for the scraper. It
can be used to set an authorization header, or change the user agent. (Default
`{}`)
`{}`)
@@ -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/).
+53
View File
@@ -0,0 +1,53 @@
---
title: GDPR Compliance
permalink: /integrating/gdpr/
---
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
can enable the following plugins:
- [talk-plugin-auth](/talk/plugin/talk-plugin-auth) - to facilitate username and password changes
- [talk-plugin-local-auth](/talk/plugin/talk-plugin-local-auth) - to facilitate email changes and email association
- [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data) - to facilitate account download and deletion
Even if you don't reside in a location where GDPR will apply, it is recommended
to enable these features as a best practice to provide your users with control over their
own data.
## GPDR Feature Overview
Integrating our GDPR tools will give your users and organizations the following benefits:
- **Download my comment data**: Users can request a download of their comments. An email with a link is emailed to them to download a CSV with each comment they've made, what story it was made on, and the comment's ID and timestamp.
- **Delete my acccount**: Users can request deletion of their account. Deleted account requests are pending for 24 hours to allow the user to download their comments, or to change their mind and reactivate their account before the expiry. Account deletions remove all of their comments from the site, all their comments and actions from the database, and their account info from our system.
- **Add an email to an Oauth/external account**: Users are prompted to add an email to their non-Talk account (Facebook, Google, external, etc) so that they can take part in GDPR and other features requiring email communication.
- **Change my username**: Users can update their username. This is capped at once every 2 weeks.
- **Change my email**: Users can change their email.
## Custom Authentication Solutions
As many of the newsrooms who have integrated Talk have followed our guides on
[Integrating Authentication](/talk/integrating/authentication/), we have also
provided tools for those newsrooms to integrate GDPR features into their
existing workflows.
### Account Data
Through the [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data)
plugin we allow users to download and delete their account data easily. For
custom integrations, this isn't always possible, so we instead provide some
GraphQL mutations designed to allow you to integrate it into your existing user
interfaces or exports.
- `downloadUser(id: ID!)` - lets you grab the direct link to download a users
account in a zip format. From there, you can integrate it into your existing
data export or simply proxy it to the user to allow them to download it
elsewhere in your UI.
- `delUser(id: ID!)` - lets you delete the specified user
**Note: These mutations require an administrative token**
If you would prefer to write your own user interfaces or integrate it into your
own, you can disable the client plugin for [talk-plugin-profile-data](/talk/plugin/talk-plugin-profile-data)
but keep the server side plugin active (See [Server and Client Plugins](/talk/plugins/#server-and-client-plugins) for more information).
+13 -1
View File
@@ -3,10 +3,17 @@
tags:
- moderation
- name: talk-plugin-auth
description: Enables internal authentication from Coral.
description: Enables internal sign-in authentication.
tags:
- default
- auth
- gdpr
- name: talk-plugin-local-auth
description: Enables email and password based authentication.
tags:
- default
- auth
- gdpr
- name: talk-plugin-author-menu
description: Enables the comment author name plugin area.
tags:
@@ -81,6 +88,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`
+1 -2
View File
@@ -74,7 +74,6 @@ example issuer and Talk must match:
|------|----------------------|
|`JWT_ISSUER`|`JWT_ISSUER`|
|`JWT_AUDIENCE`|`JWT_AUDIENCE`|
|`JWT_AUDIENCE`|`JWT_AUDIENCE`|
|`SECRET`|`JWT_SECRET`*|
\* Note that secrets is a pretty complex topic, refer to the
@@ -83,4 +82,4 @@ reference, the basic takeaway is that the secret used to sign the tokens issued
by the issuer must be able to be verified by Talk.
For an example of implementing the plugin, refer to [`tokenUserNotFound`](/talk/reference/server/#tokenUserNotFound)
reference.
reference.
@@ -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-local-auth/README.md
+1
View File
@@ -0,0 +1 @@
../../../plugins/talk-plugin-profile-data/README.md
+15 -4
View File
@@ -9,11 +9,16 @@ functionality. We provide methods to inject behavior into the server side and
the client side application to affect different parts of the application
life cycle.
## Recipes
## Server and Client Plugins
Recipes are plugin templates provided by the Coral Core team. Developers can use
these recipes to build their own plugins. You can find all the Talk recipes
here: [github.com/coralproject/talk-recipes](https://github.com/coralproject/talk-recipes/).
When you're adding a plugin to Talk, you can specify it in the `client` and/or
the `server` section. If you only want to enable the server side component of a
plugin, you simply only specify the plugin in the `server` section. If you only
want the client side plugin, the `client` section.
Plugins listed in the [Plugins Directory](/talk/plugins-directory/) will
indicate if they have/support a client/server plugin, and should be activated
accordingly.
## Plugin Registration
@@ -117,3 +122,9 @@ assets inside the image as well.
For more information on the onbuild image, refer to the
[Installation from Docker](/talk/installation-from-docker/) documentation.
## Recipes
Recipes are plugin templates provided by the Coral Core team. Developers can use
these recipes to build their own plugins. You can find all the Talk recipes
here: [github.com/coralproject/talk-recipes](https://github.com/coralproject/talk-recipes/).
+2 -1
View File
@@ -3,8 +3,9 @@ title: Plugins Directory
permalink: /plugins-directory/
layout: plugins
data: plugins
class: plugins
---
Talk provides a growing ecosystem of plugins that interact with our extensive
Server and Client API's. Below you can search for a plugin to use with Talk and
discover what their requirements and configuration are.
discover what their requirements and configuration are.
+5 -4
View File
@@ -4,9 +4,9 @@
<h1>{{ page.title }}</h1>
<hr/>
{% endif %}
{{ page.content }}
<div class="form-group mt-5">
<div class="clearfix">
<label for="exampleInputEmail1">Plugin Search</label>
@@ -15,6 +15,7 @@
<input type="text" class="form-control" id="plugin-search-input" aria-describedby="pluginSearchHelp">
<small id="pluginSearchHelp" class="form-text text-muted">Enter a few keywords about the plugin to filter the list</small>
</div>
<div class="row plugins">
{% for plugin in _.sortBy(site.data[page.data], 'name') %}
<div class="col-sm-6 plugin d-block">
@@ -25,7 +26,7 @@
{% if plugin.tags %}
<p class="card-text">
{% for tag in plugin.tags %}
<span class="badge badge-{% if tag == "default" %}success{% else %}light{% endif %}">{{ tag }}</span>
<a class="plain-link" title="Search for plugins with the {{ tag }} tag" href="?q={{ tag }}"><span class="badge badge-light badge-tag badge-tag-{{ _.kebabCase(tag) }}">{{ tag }}</span></a>
{% endfor %}
</p>
{% endif %}
@@ -35,4 +36,4 @@
{% endfor %}
</ul>
</article>
<script>window.SEARCH_INDEX = {{ lunr_index(site.data[page.data]) }}</script>
<script>window.SEARCH_INDEX = {{ lunr_index(site.data[page.data]) }}</script>
+14
View File
@@ -448,3 +448,17 @@ a.brand {
.plugin {
display: none;
}
.badge-tag {
font-family: monospace;
}
.badge-tag-default {
background: #28a745;
color: #fff;
}
.badge-tag-gdpr {
background: rgb(0, 102, 176);
color: #fff;
}
+17
View File
@@ -1,6 +1,17 @@
/* global lunr */
/* eslint-env browser */
// Sourced from https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Sourced from https://github.com/hexojs/site/blob/8e8ed4901769abbf76263125f82832df76ced58b/themes/navy/source/js/plugins.js.
(function() {
'use strict';
@@ -60,6 +71,12 @@
updateCount(elements.length);
}
var searchParam = getParameterByName('q');
if (searchParam && searchParam.length > 0) {
$input.value = searchParam;
search(searchParam);
}
$input.addEventListener('input', function() {
var value = this.value;
+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.
+89 -44
View File
@@ -1,5 +1,5 @@
const { ErrNotFound, ErrNotAuthorized } = require('../../errors');
const UsersService = require('../../services/users');
const Users = require('../../services/users');
const migrationHelpers = require('../../services/migration/helpers');
const {
CHANGE_USERNAME,
@@ -8,11 +8,12 @@ const {
SET_USER_BAN_STATUS,
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
DELETE_USER,
DELETE_OTHER_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);
};
/**
@@ -102,7 +93,7 @@ const delUser = async (ctx, id) => {
updateBatchSize: 10000,
});
// Remove all actions against comments.
// Remove all actions against this users comments.
await transformSingleWithCursor(
Action.collection.find({ user_id: user.id, item_type: 'COMMENTS' }),
actionDecrTransformer,
@@ -121,38 +112,86 @@ const delUser = async (ctx, id) => {
.setOptions({ multi: true })
.remove();
// Removes all the user's reply counts on each of the comments that they
// have commented on.
// Remove the user from all other user's ignore lists.
await User.update(
{ ignoresUsers: user.id },
{
$pull: { ignoresUsers: user.id },
},
{ multi: true }
);
// For each comment that the user has authored, purge the comment data from it
// and unset their id from those comments.
await transformSingleWithCursor(
Comment.collection.aggregate([
{ $match: { author_id: user.id } },
{
$group: {
_id: '$parent_id',
count: { $sum: 1 },
},
},
]),
({ _id: parent_id, count }) => ({
query: { id: parent_id },
update: {
$inc: {
reply_count: -1 * count,
},
Comment.collection.find({ author_id: user.id }),
({
id,
asset_id,
status,
parent_id,
reply_count,
created_at,
updated_at,
}) => ({
query: { id },
replace: {
id,
body: null,
body_history: [],
asset_id,
author_id: null,
status_history: [],
status,
parent_id,
reply_count,
action_counts: {},
tags: [],
metadata: {},
deleted_at: new Date(),
created_at,
updated_at,
},
}),
Comment
);
// Remove all the user's comments.
await Comment.where({ author_id: user.id })
.setOptions({ multi: true })
.remove();
// Remove the user.
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: {
@@ -165,6 +204,7 @@ module.exports = ctx => {
setUsername: () => Promise.reject(new ErrNotAuthorized()),
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
del: () => Promise.reject(new ErrNotAuthorized()),
changePassword: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -201,9 +241,14 @@ module.exports = ctx => {
setUserSuspensionStatus(ctx, id, until, message);
}
if (ctx.user.can(DELETE_USER)) {
if (ctx.user.can(DELETE_OTHER_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;
+16 -1
View File
@@ -1,8 +1,10 @@
const { URL } = require('url');
const { property } = require('lodash');
const {
SEARCH_ACTIONS,
SEARCH_COMMENT_STATUS_HISTORY,
VIEW_BODY_HISTORY,
VIEW_COMMENT_DELETED_AT,
} = require('../../perms/constants');
const {
decorateWithTags,
@@ -22,7 +24,9 @@ const Comment = {
return Comments.get.load(parent_id);
},
user({ author_id }, _, { loaders: { Users } }) {
return Users.getByID.load(author_id);
if (author_id) {
return Users.getByID.load(author_id);
}
},
replies({ id, asset_id, reply_count }, { query }, { loaders: { Comments } }) {
// Don't bother looking up replies if there aren't any there!
@@ -63,6 +67,16 @@ 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.
@@ -72,6 +86,7 @@ decorateWithTags(Comment);
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
status_history: [SEARCH_COMMENT_STATUS_HISTORY],
deleted_at: [VIEW_COMMENT_DELETED_AT],
});
// Protect privileged fields.
+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;
+32 -1
View File
@@ -503,7 +503,7 @@ type Comment {
id: ID!
# The actual comment data.
body: String!
body: String
# The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or
# the author.
@@ -537,6 +537,9 @@ type Comment {
# The status history of the comment. Requires the `ADMIN` or `MODERATOR` role.
status_history: [CommentStatusHistory!]
# The date that the comment was deleted at if it was.
deleted_at: Date
# The time when the comment was created
created_at: Date!
@@ -548,6 +551,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.
@@ -835,6 +841,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
@@ -1291,6 +1300,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
@@ -1433,6 +1445,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 {
@@ -1533,6 +1560,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 {
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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 -7
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,15 @@ 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"
INCORRECT_PASSWORD: "Incorrect Password"
email: "Please enter a valid email."
DELETION_NOT_SCHEDULED: "Deletion was not scheduled"
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!"
@@ -256,6 +273,7 @@ en:
comment: comment
comment_is_ignored: "This comment is hidden because you ignored this user."
comment_is_rejected: "You have rejected this comment."
comment_is_deleted: "This comment was deleted."
comment_is_hidden: "This comment is not available."
comments: comments
configure_stream: "Configure"
@@ -427,7 +445,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."
@@ -475,6 +493,7 @@ en:
username: "Username"
password: "Password"
confirm_password: "Confirm Password"
organization_contact_email: "Organization Contact Email"
save: "Save"
permitted_domains:
title: "Permitted domains"
+13 -1
View File
@@ -121,9 +121,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."
@@ -149,6 +150,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"
@@ -158,6 +160,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"
@@ -208,6 +216,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:
@@ -235,12 +244,14 @@ es:
ALREADY_EXISTS: "El recurso ya existe"
INVALID_ASSET_URL: "La URL del articulo no es valida"
CANNOT_IGNORE_STAFF: "No puede ignorar a miembros del Staff."
INCORRECT_PASSWORD: "Contraseña Incorrecta"
email: "No es un correo válido"
confirm_password: "Las contraseñas no coinciden. Inténtelo nuevamente"
network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo."
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"
@@ -468,6 +479,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"
+2 -1
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"
@@ -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"
+6 -2
View File
@@ -58,8 +58,6 @@ const Comment = new Schema(
},
body: {
type: String,
required: [true, 'The body is required.'],
minlength: 2,
},
body_history: [BodyHistoryItemSchema],
asset_id: String,
@@ -89,6 +87,12 @@ const Comment = new Schema(
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
// deleted_at stores the date that the given comment was deleted.
deleted_at: {
type: Date,
default: null,
},
// Additional metadata stored on the field.
metadata: {
default: {},
+1 -14
View File
@@ -1,5 +1,3 @@
const { CREATE_MONGO_INDEXES } = require('../../config');
const Action = require('./action');
const Asset = require('./asset');
const Comment = require('./comment');
@@ -7,15 +5,4 @@ const Migration = require('./migration');
const Setting = require('./setting');
const User = require('./user');
const schema = { Action, Asset, Comment, Migration, Setting, User };
// Provide the schema to each of the plugins so that they can add in indexes if
// it is enabled.
if (CREATE_MONGO_INDEXES) {
const plugins = require('../../services/plugins');
plugins.get('server', 'indexes').map(({ indexes }) => {
indexes(schema);
});
}
module.exports = schema;
module.exports = { Action, Asset, Comment, Migration, Setting, User };
+3
View File
@@ -49,6 +49,9 @@ const Setting = new Schema(
organizationName: {
type: String,
},
organizationContactEmail: {
type: String,
},
autoCloseStream: {
type: Boolean,
default: false,
+3 -4
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "4.3.2",
"version": "4.4.0",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -18,7 +18,7 @@
"lint:js": "eslint bin/cli* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:jest test:mocha",
"test": "npm-run-all test:jest test:server:mocha",
"test:jest": "NODE_ENV=test jest --runInBand",
"test:client": "NODE_ENV=test jest --projects client",
"test:server": "npm-run-all test:server:jest test:server:mocha",
@@ -83,6 +83,7 @@
"bowser": "^1.7.2",
"brotli-webpack-plugin": "^0.5.0",
"bunyan": "^1.8.12",
"bunyan-debug-stream": "^1.0.8",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
"colors": "^1.1.2",
@@ -97,7 +98,6 @@
"dataloader": "^1.3.0",
"debug": "3.1.0",
"dialog-polyfill": "^0.4.9",
"did-you-mean": "^0.0.1",
"dotenv": "^4.0.0",
"ejs": "^2.5.7",
"env-rewrite": "^1.0.2",
@@ -218,7 +218,6 @@
"babel-plugin-dynamic-import-node": "^1.1.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"browserstack-local": "^1.3.0",
"bunyan-debug-stream": "^1.0.8",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-datetime": "^1.5.0",
+2 -1
View File
@@ -18,5 +18,6 @@ module.exports = {
UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS',
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
DELETE_USER: 'DELETE_USER',
DELETE_OTHER_USER: 'DELETE_OTHER_USER',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
};
+1
View File
@@ -11,4 +11,5 @@ module.exports = {
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
VIEW_BODY_HISTORY: 'VIEW_BODY_HISTORY',
VIEW_COMMENT_DELETED_AT: 'VIEW_COMMENT_DELETED_AT',
};
+23 -2
View File
@@ -1,13 +1,33 @@
const { get, isString } = require('lodash');
const moment = require('moment');
const { check } = require('../utils');
const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.CHANGE_PASSWORD:
// Only users with a local account where they have a password set can
// actually change their password.
return (
user.profiles.some(({ provider }) => provider === 'local') &&
isString(user.password) &&
user.password.length > 0
);
case types.CHANGE_USERNAME:
return user.status.username.status === 'REJECTED';
case types.SET_USERNAME:
return user.status.username.status === 'UNSET';
case types.SET_USERNAME: {
// Only users who have their usernames rejected or those users who
// not changed their usernames within 14 days can change their usernames.
const deadline = moment().subtract(14, 'days');
return (
user.status.username.status === 'UNSET' ||
get(user, 'status.username.history', []).every(({ created_at }) =>
moment(created_at).isBefore(deadline)
)
);
}
case types.CREATE_COMMENT:
case types.CREATE_ACTION:
@@ -36,6 +56,7 @@ module.exports = (user, perm) => {
case types.UPDATE_USER_ROLES:
case types.CREATE_TOKEN:
case types.REVOKE_TOKEN:
case types.DELETE_OTHER_USER:
return check(user, ['ADMIN']);
default:
+1
View File
@@ -14,6 +14,7 @@ module.exports = (user, perm) => {
case types.VIEW_USER_ROLE:
case types.VIEW_USER_EMAIL:
case types.VIEW_BODY_HISTORY:
case types.VIEW_COMMENT_DELETED_AT:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
+2
View File
@@ -25,5 +25,7 @@ export {
withUnbanUser,
withStopIgnoringUser,
withSetCommentStatus,
withChangePassword,
withChangeUsername,
} from 'coral-framework/graphql/mutations';
export { compose } from 'recompose';
+1
View File
@@ -8,4 +8,5 @@ export {
getErrorMessages,
getDefinitionName,
getShallowChanges,
createDefaultResponseFragments,
} from 'coral-framework/utils';
+15 -24
View File
@@ -2,23 +2,25 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants');
const { ErrNotFound, ErrAlreadyExists } = require('../../../errors');
const pluralize = require('pluralize');
const sc = require('snake-case');
// const { CREATE_MONGO_INDEXES } = require('../../../config');
const { CREATE_MONGO_INDEXES } = require('../../../config');
const Comment = require('models/comment');
function getReactionConfig(reaction) {
// Ensure that the reaction is a lowercase string.
reaction = reaction.toLowerCase();
// if (CREATE_MONGO_INDEXES) {
// // Create the index on the comment model based on the reaction config.
// CommentModel.collection.createIndex(
// {
// created_at: 1,
// [`action_counts.${sc(reaction)}`]: 1,
// },
// {
// background: true,
// }
// );
// }
if (CREATE_MONGO_INDEXES) {
// Create the index on the comment model based on the reaction config.
Comment.collection.createIndex(
{
created_at: 1,
[`action_counts.${sc(reaction)}`]: 1,
},
{
background: true,
}
);
}
const reactionPlural = pluralize(reaction);
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
@@ -127,17 +129,6 @@ function getReactionConfig(reaction) {
return {
typeDefs,
indexes: ({ Comment }) => {
Comment.index(
{
created_at: 1,
[`action_counts.${sc(reaction)}`]: 1,
},
{
background: true,
}
);
},
context: {
Sort: () => ({
Comments: {
+4 -1
View File
@@ -1,7 +1,8 @@
{
"server": [
"talk-plugin-auth",
"talk-plugin-featured-comments",
"talk-plugin-local-auth",
"talk-plugin-profile-data",
"talk-plugin-respect"
],
"client": [
@@ -10,9 +11,11 @@
"talk-plugin-featured-comments",
"talk-plugin-flag-details",
"talk-plugin-ignore-user",
"talk-plugin-local-auth",
"talk-plugin-member-since",
"talk-plugin-moderation-actions",
"talk-plugin-permalink",
"talk-plugin-profile-data",
"talk-plugin-respect",
"talk-plugin-sort-most-replied",
"talk-plugin-sort-most-respected",
+6
View File
@@ -14,3 +14,9 @@ utilize our internal authentication system.
To sync Talk auth with your own auth systems, you can use this plugin as a
template.
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -103,8 +103,7 @@ class SignUp extends React.Component {
/>
{passwordError && (
<span className={styles.hint}>
{' '}
Password must be at least 8 characters.{' '}
{t('talk-plugin-auth.login.password_error')}
</span>
)}
<TextField
@@ -58,8 +58,9 @@ da:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
password_error: "Password must be at least 8 characters."
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
register: "Register"
@@ -101,8 +102,9 @@ en:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
password_error: "Password must be at least 8 characters."
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
register: "Register"
@@ -131,6 +133,41 @@ en:
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
change_password:
change_password: "Change Password"
passwords_dont_match: "Passwords don`t match"
required_field: "This field is required"
forgot_password: "Forgot your password?"
save: "Save"
cancel: "Cancel"
edit: "Edit"
changed_password_msg: "Changed Password - Your password has been successfully changed"
change_username:
change_username_note: "Usernames can be changed every 14 days"
save: "Save"
edit_profile: "Edit Profile"
cancel: "Cancel"
confirm_username_change: "Confirm Username Change"
description: "You are attempting to change your username. Your new username will appear on all of your past and future comments."
old_username: "Old Username"
new_username: "New Username"
bottom_note: "Note: You will not be able to change your username again for 14 days"
confirm_changes: "Confirm Changes"
username_does_not_match: "Username does not match"
cant_be_equal: "Your new {0} must be different to your current one"
change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days"
change_email:
confirm_email_change: "Confirm Email Address Change"
description: "You are attempting to change your email address. Your new email address will be used for your login and to receive account notifications."
old_email: "Old Email Address"
new_email: "New Email Address"
enter_password: "Enter Password"
incorrect_password: "Incorrect Password"
confirm_change: "Confirm Change"
cancel: "Cancel"
change_email_msg: "Email Address Changed - Your email address has been successfully changed. This email address will now be used for signing in and email notifications."
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
change_username_attempt: "Username can't be updated. Usernames can only be changed every 14 days."
de:
talk-plugin-auth:
login:
@@ -192,6 +229,7 @@ es:
or: "O"
email: "Dirección de Correo"
password: "Contraseña"
password_error: "La contraseña debe tener al menos 8 caracteres."
forgot_your_pass: "¿Has olvidado tu contraseña?"
need_an_account: "¿Necesitas una cuenta?"
register: "Registrar"
@@ -222,6 +260,29 @@ es:
username: Nombre
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
change_password:
change_password: "Cambiar Contraseña"
passwords_dont_match: "Las contraseñas no coinciden"
required_field: "Este campo es requerido"
forgot_password: "Olvidaste tu contraseña?"
save: "Guardar"
cancel: "Cancelar"
edit: "Editar"
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
change_username:
change_username_note: "El usuario puede ser cambiado cada 14 días"
save: "Guardar"
edit_profile: "Editar Perfil"
cancel: "Cancelar"
confirm_username_change: "Confirmar Cambio de Usuario"
description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios."
old_username: "Usuario viejo"
new_username: "Usuario nuevo"
bottom_note: "Nota: No podrás cambiar tu usuario por 14 días"
confirm_changes: "Confirmar Cambios"
username_does_not_match: "El usuario no coincide"
changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días."
change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días."
fr:
talk-plugin-auth:
login:
@@ -324,7 +385,7 @@ pt_BR:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
+7 -1
View File
@@ -27,4 +27,10 @@ Configuration:
or by visiting the
[Creating an App ID](https://developers.facebook.com/docs/apps/register)
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
enabled.
enabled.
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -0,0 +1,8 @@
.errorMsg {
color: #FA4643;
font-size: 0.9em;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ErrorMessage.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const ErrorMessage = ({ children }) => (
<div className={styles.errorMsg}>
<Icon className={styles.warningIcon} name="warning" />
<span>{children}</span>
</div>
);
ErrorMessage.propTypes = {
children: PropTypes.node,
};
export default ErrorMessage;
@@ -0,0 +1,80 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
}
.columnDisplay {
flex-direction: column;
.detailItemMessage {
padding: 4px 0 0;
}
}
.detailItemContent {
border: solid 1px #787D80;
border-radius: 2px;
background-color: white;
height: 30px;
display: inline-block;
width: 230px;
display: flex;
box-sizing: border-box;
> .detailIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
}
&.error {
border: solid 2px #FA4643;
}
&.disabled {
background-color: #E0E0E0;
}
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
background: transparent;
border: none;
font-size: 1em;
color: #000;
outline: none;
flex: 1;
height: 100%;
box-sizing: border-box;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 6px;
padding-top: 16px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './InputField.css';
import ErrorMessage from './ErrorMessage';
import { Icon } from 'plugin-api/beta/client/components/ui';
const InputField = ({
id = '',
label = '',
type = 'text',
name = '',
onChange = () => {},
showError = true,
hasError = false,
errorMsg = '',
children,
columnDisplay = false,
showSuccess = false,
validationType = '',
icon = '',
value = '',
defaultValue = '',
disabled = false,
}) => {
const inputValue = {
...(value ? { value } : {}),
...(defaultValue ? { defaultValue } : {}),
};
return (
<div className={styles.detailItem}>
<div
className={cn(styles.detailItemContainer, {
[styles.columnDisplay]: columnDisplay,
})}
>
{label && (
<label className={styles.detailLabel} id={id}>
{label}
</label>
)}
<div
className={cn(
styles.detailItemContent,
{ [styles.error]: hasError && showError },
{ [styles.disabled]: disabled }
)}
>
{icon && <Icon name={icon} className={styles.detailIcon} />}
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
autoComplete="off"
data-validation-type={validationType}
disabled={disabled}
{...inputValue}
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
showSuccess &&
value && <Icon className={styles.checkIcon} name="check_circle" />}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
{children}
</div>
);
};
InputField.propTypes = {
id: PropTypes.string,
disabled: PropTypes.bool,
label: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
defaultValue: PropTypes.string,
icon: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
columnDisplay: PropTypes.bool,
showSuccess: PropTypes.bool,
validationType: PropTypes.string,
};
export default InputField;
@@ -27,3 +27,9 @@ Configuration:
- `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for
your Google login web app. You can learn more about getting a Google Client
ID at the [Google API Console](https://console.developers.google.com/apis/).
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
+26
View File
@@ -0,0 +1,26 @@
---
title: talk-plugin-local-auth
permalink: /plugin/talk-plugin-local-auth/
layout: plugin
plugin:
name: talk-plugin-local-auth
default: true
provides:
- Client
- Server
---
This plugin will eventually contain all the local authentication code that is
responsible for creating, resetting, and managing accounts provided locally
through an email and password based login.
## Features
- *Email Change*: Allows users to change their existing email address on their account.
- *Local Account Association*: Allows users that have signed up with an external auth strategy (such as Google) the ability to associate a email address and password for login. **Note: Existing users with external authentication will be prompted to setup a local account when they sign in and when new users create an account.**
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,82 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
font-family: Helvetica,Helvetica Neue,Verdana,sans-serif;
color:#3B4A53;
}
.title {
font-size: 1.3em;
margin: 15px 0;
text-align: center;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
margin-bottom: 15px;
}
.list {
padding: 0;
margin: 20px 0;
list-style: none;
}
.item {
display: flex;
margin-bottom: 20px;
.itemIcon {
flex-grow: 0;
}
.text {
flex-grow: 1;
padding-left: 10px;
}
> i.itemIcon {
font-size: 1.3em;
}
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
width: 100%;
display: inline-block;
text-align: center;
line-height: 30px;
font-size: 1em;
&:hover {
background-color: #eaeaea;
cursor: pointer;
}
&.cancel {
background-color: transparent;
color: #787D80;
}
&.proceed {
background-color: #3498DB;
color: white;
}
&.danger {
background-color: #FA4643;
color: white;
}
}
@@ -0,0 +1,153 @@
import React from 'react';
import isMatch from 'lodash/isEqual';
import isEqualWith from 'lodash/isEqual';
import PropTypes from 'prop-types';
import { Dialog } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import { getErrorMessages } from 'coral-framework/utils';
import styles from './AddEmailAddressDialog.css';
import AddEmailContent from './AddEmailContent';
import VerifyEmailAddress from './VerifyEmailAddress';
import EmailAddressAdded from './EmailAddressAdded';
const initialState = {
step: 0,
showErrors: false,
errors: {},
formData: {},
};
class AddEmailAddressDialog extends React.Component {
state = initialState;
validKeys = ['emailAddress', 'confirmPassword', 'confirmEmailAddress'];
onChange = e => {
const { name, value, type } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, type, name);
}
);
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: 'Field is required',
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
formHasError = () => {
const formHasErrors = !!Object.keys(this.state.errors).length;
const formIncomplete = !isEqualWith(
Object.keys(this.state.formData),
this.validKeys,
isMatch
);
return formHasErrors || formIncomplete;
};
showErrors = () => {
this.setState({
showErrors: true,
});
};
confirmChanges = async () => {
if (this.formHasError()) {
this.showErrors();
return;
}
const { emailAddress, confirmPassword } = this.state.formData;
const { attachLocalAuth } = this.props;
try {
await attachLocalAuth({
email: emailAddress,
password: confirmPassword,
});
this.props.notify('success', 'Email Added!');
this.goToNextStep();
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
};
goToNextStep = () => {
this.setState(({ step }) => ({
step: step + 1,
}));
};
render() {
const { errors, formData, showErrors, step } = this.state;
const { root: { settings } } = this.props;
return (
<Dialog className={styles.dialog} open={true}>
{step === 0 && (
<AddEmailContent
formData={formData}
errors={errors}
showErrors={showErrors}
confirmChanges={this.confirmChanges}
onChange={this.onChange}
/>
)}
{step === 1 &&
!settings.requireEmailConfirmation && (
<EmailAddressAdded done={() => {}} />
)}
{step === 1 &&
settings.requireEmailConfirmation && (
<VerifyEmailAddress
emailAddress={formData.emailAddress}
done={() => {}}
/>
)}
</Dialog>
);
}
}
AddEmailAddressDialog.propTypes = {
attachLocalAuth: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default AddEmailAddressDialog;
@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './AddEmailAddressDialog.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
import cn from 'classnames';
import InputField from './InputField';
import { t } from 'plugin-api/beta/client/services';
const AddEmailContent = ({
formData,
errors,
showErrors,
confirmChanges,
onChange,
}) => (
<div>
<h4 className={styles.title}>
{t('talk-plugin-local-auth.add_email.content.title')}
</h4>
<p className={styles.description}>
{t('talk-plugin-local-auth.add_email.content.description')}
</p>
<ul className={styles.list}>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_1')}
</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_2')}
</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_3')}
</span>
</li>
</ul>
<form autoComplete="off">
<InputField
id="emailAddress"
label={t('talk-plugin-local-auth.add_email.enter_email_address')}
name="emailAddress"
type="email"
onChange={onChange}
defaultValue=""
hasError={!formData.emailAddress || errors.emailAddress}
errorMsg={t('talk-plugin-local-auth.add_email.invalid_email_address')}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<InputField
id="confirmEmailAddress"
label={t('talk-plugin-local-auth.add_email.confirm_email_address')}
name="confirmEmailAddress"
type="email"
onChange={onChange}
defaultValue=""
hasError={
!formData.emailAddress ||
formData.emailAddress !== formData.confirmEmailAddress
}
errorMsg={t('talk-plugin-local-auth.add_email.email_does_not_match')}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<InputField
id="confirmPassword"
label={t('talk-plugin-local-auth.add_email.insert_password')}
name="confirmPassword"
type="password"
onChange={onChange}
defaultValue=""
hasError={!formData.confirmPassword}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<div className={styles.actions}>
<a
className={cn(styles.button, styles.proceed)}
onClick={confirmChanges}
>
{t('talk-plugin-local-auth.add_email.add_email_address')}
</a>
</div>
</form>
</div>
);
AddEmailContent.propTypes = {
formData: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
showErrors: PropTypes.bool.isRequired,
confirmChanges: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
export default AddEmailContent;
@@ -0,0 +1,77 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 12px 20px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.3em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
.item {
display: block;
color: #4C4C4D;
font-size: 1em;
margin-bottom: 2px;
}
.bottomActions {
text-align: right;
}
.emailChange {
margin: 18px 0;
}
.cancel {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
}
.confirmChanges {
background-color: #3498DB;
border-color: #3498DB;
color: white;
height: 30px;
font-size: 0.9em;
&:hover {
background-color: #3ba3ec;
color: white;
}
}
@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ChangeEmailContentDialog.css';
import InputField from './InputField';
import { Button } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeEmailContentDialog extends React.Component {
state = {
showError: false,
};
showError = () => {
this.setState({
showError: true,
});
};
confirmChanges = async e => {
e.preventDefault();
await this.props.save();
this.props.next();
};
render() {
return (
<div>
<span className={styles.close} onClick={this.props.cancel}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-local-auth.change_email.confirm_email_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-local-auth.change_email.description')}
</p>
<div className={styles.emailChange}>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_email.old_email')}:{' '}
{this.props.email}
</span>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_email.new_email')}:{' '}
{this.props.formData.newEmail}
</span>
</div>
<form onSubmit={this.confirmChanges}>
<InputField
id="confirmPassword"
label={t('talk-plugin-local-auth.change_email.enter_password')}
name="confirmPassword"
type="password"
onChange={this.props.onChange}
defaultValue=""
hasError={
!this.props.formData.confirmPassword && this.state.showError
}
errorMsg={t(
'talk-plugin-local-auth.change_email.incorrect_password'
)}
showError={this.state.showError}
columnDisplay
showSuccess={false}
/>
<div className={styles.bottomActions}>
<Button className={styles.cancel} onClick={this.props.cancel}>
{t('talk-plugin-local-auth.change_email.cancel')}
</Button>
<Button className={styles.confirmChanges} type="submit">
{t('talk-plugin-local-auth.change_email.confirm_change')}
</Button>
</div>
</form>
</div>
</div>
);
}
}
ChangeEmailContentDialog.propTypes = {
save: PropTypes.func,
next: PropTypes.func,
cancel: PropTypes.func,
onChange: PropTypes.func,
formData: PropTypes.object,
email: PropTypes.string,
};
export default ChangeEmailContentDialog;
@@ -0,0 +1,95 @@
.container {
position: relative;
color: #202020;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
margin: 16px 0;
&.editing {
padding: 10px;
background-color: #EDEDED;
.actions {
top: 10px;
right: 10px;
}
.title {
margin-bottom: 1em;
}
}
}
.actions {
position: absolute;
top: -6px;
right: 0px;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
color: #202020;
margin: 0;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 230px;
}
.detailLink {
color: #00538A;
text-decoration: none;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
.button {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
}
.saveButton {
background-color: #3498DB;
border-color: #3498DB;
color: white;
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
}
&:disabled {
border-color: #e0e0e0;
&:hover {
background-color: #e0e0e0;
color: #4f5c67;
cursor: default;
}
}
}
.cancelButton {
color:#787D80;
margin-top: 6px;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
@@ -0,0 +1,257 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangePassword.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import isEqual from 'lodash/isEqual';
import { t } from 'plugin-api/beta/client/services';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
const initialState = {
editing: false,
showErrors: true,
errors: {},
formData: {},
};
class ChangePassword extends React.Component {
state = initialState;
validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword'];
onChange = e => {
const { name, value, type } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, type, name);
// Perform equality validation if password fields have changed
if (name === 'newPassword' || name === 'confirmNewPassword') {
this.equalityValidation('newPassword', 'confirmNewPassword');
}
}
);
};
equalityValidation = (field, field2) => {
const cond = this.state.formData[field] === this.state.formData[field2];
if (!cond) {
this.addError({
[field2]: t(
'talk-plugin-local-auth.change_password.passwords_dont_match'
),
});
} else {
this.removeError(field2);
}
return cond;
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-local-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
enableEditing = () => {
this.setState({
editing: true,
});
};
isSubmitBlocked = () => {
const formHasErrors = !!Object.keys(this.state.errors).length;
const formIncomplete = !isEqual(
Object.keys(this.state.formData),
this.validKeys
);
return formHasErrors || formIncomplete;
};
clearForm = () => {
this.setState(initialState);
};
onSave = async e => {
e.preventDefault();
if (this.isSubmitBlocked()) {
return;
}
const { oldPassword, newPassword } = this.state.formData;
try {
await this.props.changePassword({
oldPassword,
newPassword,
});
this.props.notify(
'success',
t('talk-plugin-local-auth.change_password.changed_password_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
onForgotPassword = async () => {
const { root: { me: { email } } } = this.props;
try {
await this.props.forgotPassword(email);
this.props.notify(
'success',
t('talk-plugin-local-auth.change_password.forgot_password_sent')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
render() {
const { editing, errors } = this.state;
return (
<section
className={cn(
'talk-plugin-local-auth--change-password',
styles.container,
{
[styles.editing]: editing,
}
)}
>
<h3 className={styles.title}>
{t('talk-plugin-local-auth.change_password.change_password')}
</h3>
{editing ? (
<form
className="talk-plugin-local-auth--change-password-form"
onSubmit={this.onSave}
>
<InputField
id="oldPassword"
label="Old Password"
name="oldPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.oldPassword}
hasError={this.hasError('oldPassword')}
errorMsg={errors['oldPassword']}
showErrors
>
<span className={styles.detailBottomBox}>
<a
className={styles.detailLink}
onClick={this.onForgotPassword}
>
{t('talk-plugin-local-auth.change_password.forgot_password')}
</a>
</span>
</InputField>
<InputField
id="newPassword"
label="New Password"
name="newPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.newPassword}
hasError={this.hasError('newPassword')}
errorMsg={errors['newPassword']}
showErrors
/>
<InputField
id="confirmNewPassword"
label="Confirm New Password"
name="confirmNewPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.confirmNewPassword}
hasError={this.hasError('confirmNewPassword')}
errorMsg={errors['confirmNewPassword']}
showErrors
/>
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
type="submit"
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-local-auth.change_password.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-local-auth.change_password.cancel')}
</a>
</div>
</form>
) : (
<div className={styles.actions}>
<Button className={styles.button} onClick={this.enableEditing}>
{t('talk-plugin-local-auth.change_password.edit')}
</Button>
</div>
)}
</section>
);
}
}
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
forgotPassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangePassword;
@@ -0,0 +1,73 @@
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.3em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
.item {
display: block;
color: #4C4C4D;
font-size: 1em;
margin-bottom: 2px;
}
.bottomNote {
font-size: 0.9em;
line-height: 20px;
padding-top: 10px;
display: block;
}
.bottomActions {
text-align: right;
}
.usernamesChange {
margin: 18px 0;
}
.cancel {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
}
.confirmChanges {
background-color: #3498DB;
border-color: #3498DB;
color: white;
height: 30px;
font-size: 0.9em;
&:hover {
background-color: #3ba3ec;
color: white;
}
}
@@ -0,0 +1,112 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ChangeUsernameContentDialog.css';
import InputField from './InputField';
import { Button } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeUsernameContentDialog extends React.Component {
state = {
showError: false,
};
showError = () => {
this.setState({
showError: true,
});
};
confirmChanges = async e => {
e.preventDefault();
if (this.formHasError()) {
this.showError();
return;
}
if (!this.props.canUsernameBeUpdated) {
this.props.notify(
'error',
t('talk-plugin-local-auth.change_username.change_username_attempt')
);
return;
}
await this.props.save();
this.props.next();
};
formHasError = () =>
this.props.formData.confirmNewUsername !== this.props.formData.newUsername;
render() {
return (
<div>
<span className={styles.close} onClick={this.props.cancel}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-local-auth.change_username.confirm_username_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-local-auth.change_username.description')}
</p>
<div className={styles.usernamesChange}>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_username.old_username')}:{' '}
{this.props.username}
</span>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_username.new_username')}:{' '}
{this.props.formData.newUsername}
</span>
</div>
<form onSubmit={this.confirmChanges}>
<InputField
id="confirmNewUsername"
label="Re-enter new username"
name="confirmNewUsername"
type="text"
onChange={this.props.onChange}
defaultValue=""
hasError={this.formHasError() && this.state.showError}
errorMsg={t(
'talk-plugin-local-auth.change_username.username_does_not_match'
)}
showError={this.state.showError}
columnDisplay
showSuccess={false}
validationType="username"
>
<span className={styles.bottomNote}>
{t('talk-plugin-local-auth.change_username.bottom_note')}
</span>
</InputField>
<div className={styles.bottomActions}>
<Button className={styles.cancel} onClick={this.props.cancel}>
{t('talk-plugin-local-auth.change_username.cancel')}
</Button>
<Button className={styles.confirmChanges} type="submit">
{t('talk-plugin-local-auth.change_username.confirm_changes')}
</Button>
</div>
</form>
</div>
</div>
);
}
}
ChangeUsernameContentDialog.propTypes = {
save: PropTypes.func,
next: PropTypes.func,
cancel: PropTypes.func,
onChange: PropTypes.func,
formData: PropTypes.object,
username: PropTypes.string,
canUsernameBeUpdated: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangeUsernameContentDialog;
@@ -0,0 +1,84 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 12px 20px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.3em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
.item {
display: block;
color: #4C4C4D;
font-size: 1em;
margin-bottom: 2px;
}
.bottomNote {
font-size: 0.9em;
line-height: 20px;
padding-top: 10px;
display: block;
}
.bottomActions {
text-align: right;
}
.usernamesChange {
margin: 18px 0;
}
.cancel {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
}
.confirmChanges {
background-color: #3498DB;
border-color: #3498DB;
color: white;
height: 30px;
font-size: 0.9em;
&:hover {
background-color: #3ba3ec;
color: white;
}
}
@@ -0,0 +1,120 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangeUsernameDialog.css';
import InputField from './InputField';
import { Button, Dialog } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeUsernameDialog extends React.Component {
state = {
showError: false,
};
showError = () => {
this.setState({
showError: true,
});
};
confirmChanges = async () => {
if (this.formHasError()) {
this.showError();
return;
}
if (!this.props.canUsernameBeUpdated) {
this.props.notify(
'error',
t('talk-plugin-local-auth.change_username.change_username_attempt')
);
return;
}
await this.props.saveChanges();
this.props.closeDialog();
};
formHasError = () =>
this.props.formData.confirmNewUsername !== this.props.formData.newUsername;
render() {
return (
<Dialog
open={this.props.showDialog}
className={cn(
styles.dialog,
'talk-plugin-local-auth--edit-profile-dialog'
)}
>
<span className={styles.close} onClick={this.props.closeDialog}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-local-auth.change_username.confirm_username_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-local-auth.change_username.description')}
</p>
<div className={styles.usernamesChange}>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_username.old_username')}:{' '}
{this.props.username}
</span>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_username.new_username')}:{' '}
{this.props.formData.newUsername}
</span>
</div>
<form>
<InputField
id="confirmNewUsername"
label="Re-enter new username"
name="confirmNewUsername"
type="text"
onChange={this.props.onChange}
defaultValue=""
hasError={this.formHasError() && this.state.showError}
errorMsg={t(
'talk-plugin-local-auth.change_username.username_does_not_match'
)}
showError={this.state.showError}
columnDisplay
showSuccess={false}
validationType="username"
>
<span className={styles.bottomNote}>
{t('talk-plugin-local-auth.change_username.bottom_note')}
</span>
</InputField>
</form>
<div className={styles.bottomActions}>
<Button className={styles.cancel}>
{t('talk-plugin-local-auth.change_username.cancel')}
</Button>
<Button
className={styles.confirmChanges}
onClick={this.confirmChanges}
>
{t('talk-plugin-local-auth.change_username.confirm_changes')}
</Button>
</div>
</div>
</Dialog>
);
}
}
ChangeUsernameDialog.propTypes = {
saveChanges: PropTypes.func,
closeDialog: PropTypes.func,
showDialog: PropTypes.bool,
onChange: PropTypes.func,
username: PropTypes.string,
formData: PropTypes.object,
canUsernameBeUpdated: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangeUsernameDialog;
@@ -0,0 +1,10 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 12px 20px;
}
@@ -0,0 +1,75 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './ConfirmChangesDialog.css';
import { Dialog } from 'plugin-api/beta/client/components/ui';
const initialState = { step: 0 };
class ConfirmChangesDialog extends React.Component {
state = initialState;
goToNextStep = () => {
this.setState(({ step }) => ({
step: step + 1,
}));
};
clear = () => {
this.setState(initialState);
};
cancel = () => {
this.clear();
this.props.closeDialog();
};
continue = () => {
this.goToNextStep();
};
finish = () => {
this.clear();
this.props.closeDialog();
this.props.finish();
};
renderSteps = () => {
const steps = React.Children.toArray(this.props.children)
.filter(child => child.props.enable)
.filter((_, i) => i === this.state.step);
return steps.map(child => {
return React.cloneElement(child, {
goToNextStep: this.goToNextStep,
clear: this.clear,
cancel: this.cancel,
next:
this.state.step === steps.length - 1 ? this.finish : this.continue,
});
});
};
render() {
return (
<Dialog
open={this.props.showDialog}
className={cn(
styles.dialog,
'talk-plugin-local-auth--edit-profile-dialog'
)}
>
{this.renderSteps()}
</Dialog>
);
}
}
ConfirmChangesDialog.propTypes = {
children: PropTypes.node,
closeDialog: PropTypes.func,
showDialog: PropTypes.bool,
finish: PropTypes.func,
};
export default ConfirmChangesDialog;

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