Merge branch 'master' into master

This commit is contained in:
Kim Gardner
2018-04-24 14:12:46 -04:00
committed by GitHub
118 changed files with 2322 additions and 314 deletions
+1
View File
@@ -51,6 +51,7 @@ plugins/*
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
!plugins/talk-plugin-profile-data
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
+8 -1
View File
@@ -287,8 +287,15 @@ async function createUser() {
const { email, username, password, role } = answers;
const ctx = Context.forSystem();
// Create the user.
const user = await UsersService.createLocalUser(email, password, username);
const user = await UsersService.createLocalUser(
ctx,
email,
password,
username
);
// Set the role.
await UsersService.setRole(user.id, role);
+2
View File
@@ -10,6 +10,7 @@ import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
import OrganizationSettings from './routes/Configure/containers/OrganizationSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
@@ -25,6 +26,7 @@ const routes = (
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<Route path="organization" component={OrganizationSettings} />
<IndexRedirect to="stream" />
</Route>
+17 -31
View File
@@ -3,7 +3,7 @@ import cn from 'classnames';
import PropTypes from 'prop-types';
import capitalize from 'lodash/capitalize';
import styles from './UserDetail.css';
import AccountHistory from './AccountHistory';
import UserHistory from './UserHistory';
import { Slot } from 'coral-framework/components';
import UserDetailCommentList from '../components/UserDetailCommentList';
import {
@@ -28,26 +28,6 @@ import UserInfoTooltip from './UserInfoTooltip';
import t from 'coral-framework/services/i18n';
class UserDetail extends React.Component {
rejectThenReload = async info => {
await this.props.rejectComment(info);
this.props.data.refetch();
};
acceptThenReload = async info => {
await this.props.acceptComment(info);
this.props.data.refetch();
};
bulkAcceptThenReload = async () => {
await this.props.bulkAccept();
this.props.data.refetch();
};
bulkRejectThenReload = async () => {
await this.props.bulkReject();
this.props.data.refetch();
};
changeTab = tab => {
this.props.changeTab(tab);
};
@@ -110,8 +90,14 @@ class UserDetail extends React.Component {
unbanUser,
unsuspendUser,
modal,
acceptComment,
rejectComment,
bulkAccept,
bulkReject,
} = this.props;
console.log(rejectedComments, totalComments);
// if totalComments is 0, you're dividing by zero
let rejectedPercent = rejectedComments / totalComments * 100;
@@ -286,7 +272,7 @@ class UserDetail extends React.Component {
'talk-admin-user-detail-history-tab'
)}
>
{t('user_detail.account_history')}
{t('user_detail.user_history')}
</Tab>
</TabBar>
@@ -304,12 +290,12 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
@@ -322,19 +308,19 @@ class UserDetail extends React.Component {
loadMore={loadMore}
toggleSelect={toggleSelect}
viewUserDetail={viewUserDetail}
acceptComment={this.acceptThenReload}
rejectComment={this.rejectThenReload}
acceptComment={acceptComment}
rejectComment={rejectComment}
selectedCommentIds={selectedCommentIds}
toggleSelectAll={toggleSelectAll}
bulkAcceptThenReload={this.bulkAcceptThenReload}
bulkRejectThenReload={this.bulkRejectThenReload}
bulkAcceptThenReload={bulkAccept}
bulkRejectThenReload={bulkReject}
/>
</TabPane>
<TabPane
tabId={'history'}
className={'talk-admin-user-detail-history-tab-pane'}
>
<AccountHistory user={user} />
<UserHistory user={user} />
</TabPane>
</TabContent>
</Drawer>
@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { murmur3 } from 'murmurhash-js';
import styles from './AccountHistory.css';
import styles from './UserHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
@@ -43,15 +43,15 @@ const readableDuration = (startDate, endDate) => {
const buildActionResponse = (typename, created_at, until, status) => {
switch (typename) {
case 'UsernameStatusHistory':
return t('account_history.username_status', status);
return t('user_history.username_status', status);
case 'BannedStatusHistory':
return status
? t('account_history.user_banned')
: t('account_history.ban_removed');
? t('user_history.user_banned')
: t('user_history.ban_removed');
case 'SuspensionStatusHistory':
return until
? t('account_history.suspended', readableDuration(created_at, until))
: t('account_history.suspension_removed');
? t('user_history.suspended', readableDuration(created_at, until))
: t('user_history.suspension_removed');
default:
return '-';
}
@@ -62,43 +62,41 @@ const getModerationValue = assignedBy =>
assignedBy.username
) : (
<span>
<Icon name="computer" /> {t('account_history.system')}
<Icon name="computer" /> {t('user_history.system')}
</span>
);
class AccountHistory extends React.Component {
class UserHistory extends React.Component {
render() {
const { user } = this.props;
const userHistory = buildUserHistory(user.state);
return (
<div>
<div className={cn(styles.table, 'talk-admin-account-history')}>
<div className={cn(styles.table, 'talk-admin-user-history')}>
<div
className={cn(
styles.headerRow,
'talk-admin-account-history-header-row'
'talk-admin-user-history-header-row'
)}
>
<div className={styles.headerRowItem}>{t('user_history.date')}</div>
<div className={styles.headerRowItem}>
{t('account_history.date')}
{t('user_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.taken_by')}
{t('user_history.taken_by')}
</div>
</div>
{userHistory.map(
({ __typename, created_at, assigned_by, until, status }) => (
<div
className={cn(styles.row, 'talk-admin-account-history-row')}
className={cn(styles.row, 'talk-admin-user-history-row')}
key={`${__typename}_${murmur3(created_at)}`}
>
<div
className={cn(
styles.item,
'talk-admin-account-history-row-date'
'talk-admin-user-history-row-date'
)}
>
{moment(new Date(created_at)).format('MMM DD, YYYY')}
@@ -107,7 +105,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.action,
'talk-admin-account-history-row-status'
'talk-admin-user-history-row-status'
)}
>
{buildActionResponse(__typename, created_at, until, status)}
@@ -116,7 +114,7 @@ class AccountHistory extends React.Component {
className={cn(
styles.item,
styles.username,
'talk-admin-account-history-row-assigned-by'
'talk-admin-user-history-row-assigned-by'
)}
>
{getModerationValue(assigned_by)}
@@ -130,8 +128,8 @@ class AccountHistory extends React.Component {
}
}
AccountHistory.propTypes = {
UserHistory.propTypes = {
user: PropTypes.object.isRequired,
};
export default AccountHistory;
export default UserHistory;
@@ -148,6 +148,7 @@ UserDetailContainer.propTypes = {
selectedCommentIds: PropTypes.array,
unbanUser: PropTypes.func.isRequired,
unsuspendUser: PropTypes.func.isRequired,
userId: PropTypes.string,
};
const LOAD_MORE_QUERY = gql`
@@ -245,7 +246,6 @@ export const withUserDetailQuery = withQuery(
options: ({ userId, statuses }) => {
return {
variables: { author_id: userId, statuses },
fetchPolicy: 'network-only',
};
},
skip: ownProps => !ownProps.userId,
@@ -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,186 @@
import React from 'react';
import cn from 'classnames';
import { Button } from 'coral-ui';
import PropTypes from 'prop-types';
import styles from './OrganizationSettings.css';
import Slot from 'coral-framework/components/Slot';
import t from 'coral-framework/services/i18n';
import ConfigurePage from './ConfigurePage';
import ConfigureCard from 'coral-framework/components/ConfigureCard';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
class OrganizationSettings extends React.Component {
state = { editing: false, errors: [] };
addError = err => {
if (this.state.errors.indexOf(err) === -1) {
this.setState(({ errors }) => ({
errors: errors.concat(err),
}));
}
};
removeError = err => {
this.setState(({ errors }) => ({
errors: errors.filter(i => i !== err),
}));
};
toggleEditing = () => {
this.setState(({ editing }) => ({
editing: !editing,
}));
};
disableEditing = () => {
this.setState(() => ({
editing: false,
}));
};
updateName = event => {
const updater = { organizationName: { $set: event.target.value } };
this.props.updatePending({ updater });
};
updateEmail = event => {
let error = null;
const email = event.target.value;
// Add a blocker error
if (!validate.email(email)) {
error = true;
this.addError('email');
} else {
this.removeError('email');
}
const updater = { organizationContactEmail: { $set: email } };
const errorUpdater = { organizationEmail: { $set: error } };
this.props.updatePending({ updater, errorUpdater });
};
cancelEditing = () => {
this.disableEditing();
this.props.clearPending();
};
save = async () => {
await this.props.savePending();
this.disableEditing();
};
displayErrors = (errors = []) => (
<ul className={styles.errorList}>
{errors.map((errKey, i) => (
<li key={`${i}_${errKey}`} className={styles.errorItem}>
{errorMsj[errKey]}
</li>
))}
</ul>
);
render() {
const { settings, slotPassthrough, canSave } = this.props;
const hasErrors = this.state.errors.length;
return (
<ConfigurePage title={t('configure.organization_information')}>
<p>{t('configure.organization_info_copy')}</p>
<p>{t('configure.organization_info_copy_2')}</p>
<ConfigureCard>
<div className={styles.container}>
<div className={styles.content}>
{this.displayErrors(this.state.errors)}
<ul className={styles.detailList}>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_name')}
>
{t('configure.organization_name')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateName}
value={settings.organizationName}
id={t('configure.organization_name')}
readOnly={!this.state.editing}
/>
</li>
<li className={styles.detailItem}>
<label
className={styles.detailLabel}
id={t('configure.organization_contact_email')}
>
{t('configure.organization_contact_email')}
</label>
<input
type="text"
className={cn(styles.detailValue, {
[styles.editable]: this.state.editing,
})}
onChange={this.updateEmail}
value={settings.organizationContactEmail}
id={t('configure.organization_contact_email')}
readOnly={!this.state.editing}
/>
</li>
</ul>
</div>
{!this.state.editing ? (
<div className={styles.actionBox}>
<Button
className={styles.button}
icon="settings"
onClick={this.toggleEditing}
full
>
{t('configure.edit_info')}
</Button>
</div>
) : (
<div className={styles.actionBox}>
{canSave && !hasErrors ? (
<Button
raised
onClick={this.save}
className={styles.changedSave}
icon="check"
full
>
{t('configure.save')}
</Button>
) : (
<Button className={styles.button} disabled icon="check" full>
{t('configure.save')}
</Button>
)}
<a className={styles.cancelButton} onClick={this.cancelEditing}>
{t('cancel')}
</a>
</div>
)}
</div>
</ConfigureCard>
<Slot fill="adminOrganizationSettings" passthrough={slotPassthrough} />
</ConfigurePage>
);
}
}
OrganizationSettings.propTypes = {
savePending: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
updatePending: PropTypes.func.isRequired,
errors: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
slotPassthrough: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
};
export default OrganizationSettings;
@@ -16,6 +16,7 @@ import {
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import OrganizationSettings from './OrganizationSettings';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
@@ -83,18 +84,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 +114,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 +127,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);
@@ -574,7 +574,9 @@ export default class Comment extends React.Component {
'talk-stream-comment-header-tags-container'
)}
>
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
{isStaff(comment.tags) ? (
<TagLabel>{t('community.staff')}</TagLabel>
) : null}
<Slot
className={cn(
@@ -18,6 +18,10 @@ const InactiveCommentLabel = ({ status, className, ...rest }) => {
label = t('modqueue.rejected');
icon = 'close';
break;
case 'SYSTEM_WITHHELD':
label = t('modqueue.system_withheld');
icon = 'flag';
break;
default:
throw new Error(`Unknown inactive status ${status}`);
}
@@ -43,7 +43,7 @@ const ConfigureCard = ({
);
ConfigureCard.propTypes = {
title: PropTypes.string.isRequired,
title: PropTypes.string,
className: PropTypes.string,
onCheckbox: PropTypes.func,
checked: PropTypes.bool,
+2 -1
View File
@@ -25,6 +25,7 @@ export default {
'UnsuspendUserResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
'UpdateSettingsResponse'
'UpdateSettingsResponse',
'ChangePasswordResponse'
),
};
+55 -1
View File
@@ -1,5 +1,6 @@
import { gql } from 'react-apollo';
import withMutation from '../hocs/withMutation';
import update from 'immutability-helper';
function convertItemType(item_type) {
switch (item_type) {
@@ -167,9 +168,39 @@ export const withSetCommentStatus = withMutation(
errors: null,
},
},
updateQueries: {
CoralAdmin_UserDetail: prev => {
const increment = {
rejectedComments: {
$apply: count =>
count < prev.totalComments ? count + 1 : count,
},
};
const decrement = {
rejectedComments: {
$apply: count => (count > 0 ? count - 1 : 0),
},
};
// If rejected then increment rejectedComments by one
if (status === 'REJECTED') {
const updated = update(prev, increment);
return updated;
}
// If approved then decrement rejectedComments by one
if (status === 'ACCEPTED') {
const updated = update(prev, decrement);
return updated;
}
return prev;
},
},
update: proxy => {
const fragment = gql`
fragment Talk_SetCommentStatus on Comment {
fragment Talk_SetCommentStatus_Comment on Comment {
status
status_history {
type
@@ -182,9 +213,11 @@ export const withSetCommentStatus = withMutation(
const data = proxy.readFragment({ fragment, id: fragmentId });
data.status = status;
data.status_history = data.status_history
? data.status_history
: [];
data.status_history.push({
__typename: 'CommentStatusHistory',
type: status,
@@ -590,6 +623,27 @@ export const withUpdateSettings = withMutation(
}
);
export const withChangePassword = withMutation(
gql`
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input) {
...ChangePasswordResponse
}
}
`,
{
props: ({ mutate }) => ({
changePassword: input => {
return mutate({
variables: {
input,
},
});
},
}),
}
);
export const withUpdateAssetSettings = withMutation(
gql`
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
+1
View File
@@ -6,4 +6,5 @@ export default {
username: t('error.username'),
confirmPassword: t('error.confirm_password'),
organizationName: t('error.organization_name'),
organizationContactEmail: t('error.organization_contact_email'),
};
@@ -4,4 +4,5 @@ export default {
confirmPassword: () => true,
username: username => /^[a-zA-Z0-9_]+$/.test(username),
organizationName: org => /^[a-zA-Z0-9_ ]+$/.test(org),
organizationContactEmail: email => /^.+@.+\..+$/.test(email),
};
+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();
}
+1 -1
View File
@@ -1,5 +1,5 @@
.root {
vertical-align: middle;
vertical-align: sub;
font-size: inherit;
}
+5
View File
@@ -81,6 +81,11 @@
description: Shows a Link button on comments for direct-linking to a comment.
tags:
- default
- name: talk-plugin-profile-data
description: Enables users to manage their own data within Talk.
tags:
- default
- gdpr
- name: talk-plugin-remember-sort
description: Remembers the sort selection made by a user.
- name: talk-plugin-respect
+1
View File
@@ -81,6 +81,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
View File
@@ -0,0 +1 @@
../../../plugins/talk-plugin-profile-data/README.md
+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.
+48 -19
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,
@@ -9,10 +9,11 @@ const {
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
DELETE_USER,
CHANGE_PASSWORD,
} = require('../../perms/constants');
const setUserUsernameStatus = async (ctx, id, status) => {
const user = await UsersService.setUsernameStatus(id, status, ctx.user.id);
const user = await Users.setUsernameStatus(id, status, ctx.user.id);
if (status === 'REJECTED') {
ctx.pubsub.publish('usernameRejected', user);
} else if (status === 'APPROVED') {
@@ -21,12 +22,7 @@ const setUserUsernameStatus = async (ctx, id, status) => {
};
const setUserBanStatus = async (ctx, id, status = false, message = null) => {
const user = await UsersService.setBanStatus(
id,
status,
ctx.user.id,
message
);
const user = await Users.setBanStatus(id, status, ctx.user.id, message);
if (user.banned) {
ctx.pubsub.publish('userBanned', user);
}
@@ -38,38 +34,33 @@ const setUserSuspensionStatus = async (
until = null,
message = null
) => {
const user = await UsersService.setSuspensionStatus(
id,
until,
ctx.user.id,
message
);
const user = await Users.setSuspensionStatus(id, until, ctx.user.id, message);
if (user.suspended) {
ctx.pubsub.publish('userSuspended', user);
}
};
const ignoreUser = ({ user }, userToIgnore) => {
return UsersService.ignoreUsers(user.id, [userToIgnore.id]);
return Users.ignoreUsers(user.id, [userToIgnore.id]);
};
const stopIgnoringUser = ({ user }, userToStopIgnoring) => {
return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
return Users.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
const changeUsername = async (ctx, id, username) => {
const user = await UsersService.changeUsername(id, username, ctx.user.id);
const user = await Users.changeUsername(id, username, ctx.user.id);
const previousUsername = ctx.user.username;
ctx.pubsub.publish('usernameChanged', { previousUsername, user });
return user;
};
const setUsername = async (ctx, id, username) => {
return UsersService.setUsername(id, username, ctx.user.id);
return Users.setUsername(id, username, ctx.user.id);
};
const setRole = (ctx, id, role) => {
return UsersService.setRole(id, role);
return Users.setRole(id, role);
};
/**
@@ -153,6 +144,38 @@ const delUser = async (ctx, id) => {
await user.remove();
};
const changeUserPassword = async (ctx, oldPassword, newPassword) => {
const {
user,
loaders: { Settings },
connectors: { services: { I18n } },
} = ctx;
// Verify the old password.
const validPassword = await user.verifyPassword(oldPassword);
if (!validPassword) {
throw new ErrNotAuthorized();
}
// Change the users password now.
await Users.changePassword(user.id, newPassword);
// Get some context for the email to be sent.
const { organizationName, organizationContactEmail } = await Settings.load([
'organizationName',
'organizationContactEmail',
]);
// Send the password change email.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t('email.password_change.body', organizationContactEmail),
},
subject: I18n.t('email.password_change.subject', organizationName),
});
};
module.exports = ctx => {
let mutators = {
User: {
@@ -165,6 +188,7 @@ module.exports = ctx => {
setUsername: () => Promise.reject(new ErrNotAuthorized()),
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
del: () => Promise.reject(new ErrNotAuthorized()),
changePassword: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -204,6 +228,11 @@ module.exports = ctx => {
if (ctx.user.can(DELETE_USER)) {
mutators.User.del = id => delUser(ctx, id);
}
if (ctx.user.can(CHANGE_PASSWORD)) {
mutators.User.changePassword = ({ oldPassword, newPassword }) =>
changeUserPassword(ctx, oldPassword, newPassword);
}
}
return mutators;
+11
View File
@@ -1,3 +1,4 @@
const { URL } = require('url');
const { property } = require('lodash');
const {
SEARCH_ACTIONS,
@@ -63,6 +64,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.
+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;
+28
View File
@@ -548,6 +548,9 @@ type Comment {
# Indicates if it has a parent
hasParent: Boolean
# url is the permalink to this particular Comment on the Asset.
url: String
}
# CommentConnection represents a paginable subset of a comment list.
@@ -835,6 +838,9 @@ type Settings {
# organizationName is the name of the organization.
organizationName: String
# organizationContactEmail is the email of the organization.
organizationContactEmail: String
# wordlist will return a given list of words.
wordlist: Wordlist
@@ -1291,6 +1297,9 @@ input UpdateSettingsInput {
# organizationName is the name of the organization.
organizationName: String
# organizationContactEmail is the email of the organization.
organizationContactEmail: String
# editCommentWindowLength is the length of time (in milliseconds) after a
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
@@ -1433,6 +1442,21 @@ type DelUserResponse implements Response {
errors: [UserError!]
}
input ChangePasswordInput {
# oldPassword is the previous password set on the account. An incorrect
# password here will result in an unauthorized error being thrown.
oldPassword: String!
# newPassword is the password we're changing it to.
newPassword: String!
}
type ChangePasswordResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1533,6 +1557,10 @@ type RootMutation {
# delUser will delete the user with the specified id.
delUser(id: ID!): DelUserResponse
# changePassword allows the current user to change their password that have an
# associated local user account.
changePassword(input: ChangePasswordInput!): ChangePasswordResponse
}
type UsernameChangedPayload {
+3 -2
View File
@@ -438,8 +438,8 @@ ar:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -466,6 +466,7 @@ ar:
username: "Username"
password: "Password"
confirm_password: "Confirm Password"
organization_contact_email: "Organization Contact Email"
save: "Save"
permitted_domains:
title: "Permitted domains"
+3 -2
View File
@@ -431,8 +431,8 @@ da:
reports: "Rapporter"
all: "Alle"
rejected: "Afvist"
account_history: "Konto historik"
account_history:
user_history: "Konto historik"
user_history:
user_banned: "Bruger bannet"
ban_removed: "Ban fjernet"
username_status: "Brugernavn {0}"
@@ -459,6 +459,7 @@ da:
username: "Brugernavn"
password: "Kodeord"
confirm_password: "Bekræft kodeord"
organization_contact_email: "Organization Contact Email"
save: "Gem"
permitted_domains:
title: "Tilladte domæner"
+3 -2
View File
@@ -430,8 +430,8 @@ de:
reports: "Meldungen"
all: "Alle"
rejected: "Abgelehnte"
account_history: "Konto-Verlauf"
account_history:
user_history: "Konto-Verlauf"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -458,6 +458,7 @@ de:
username: "Nutzername"
password: "Passwort"
confirm_password: "Passwort bestätigen"
organization_contact_email: "Organization Contact Email"
save: "Speichern"
permitted_domains:
title: "Zugelassene Domains"
+19 -3
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"
@@ -124,6 +126,7 @@ en:
description: "As an admin, you can customize the settings for the comment stream for 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 purpuse. 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,9 @@ 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"
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:
@@ -242,6 +255,7 @@ en:
email_not_verified: "E-mail address {0} not verified."
email_password: "E-mail and/or password combination incorrect."
organization_name: "Organization name must only contain letters or numbers."
organization_contact_email: "Organization email is not valid."
password: "Password must be at least 8 characters"
username: "Usernames can contain letters numbers and _ only"
unexpected: "Unexpected error occurred. Sorry!"
@@ -344,6 +358,7 @@ en:
sort: "Sort"
show_shortcuts: "Show Shortcuts"
singleview: "Zen mode"
system_withheld: "System Withheld"
thismenu: "Open this menu"
jump_to_queue: "Jump to specific queue"
thousand: k
@@ -446,8 +461,8 @@ en:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -474,6 +489,7 @@ en:
username: "Username"
password: "Password"
confirm_password: "Confirm Password"
organization_contact_email: "Organization Contact Email"
save: "Save"
permitted_domains:
title: "Permitted domains"
+12 -2
View File
@@ -123,6 +123,7 @@ es:
description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo"
domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)."
domain_list_title: "Dominios Permitidos"
edit_info: "Editar Información"
edit_comment_timeframe_heading: "Periodo de Tiempo para Edición del Comentario"
edit_comment_timeframe_text_pre: "Los comentaristas tendrán"
edit_comment_timeframe_text_post: "segundos para editar sus comentarios."
@@ -148,6 +149,7 @@ es:
open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo no se podrán publicar nuevos comentarios pero todos los comentarios anteriores aún serán mostrados."
require_email_verification: "Necesita confirmación su correo"
require_email_verification_text: "Nuevos usuarios deben confirmar sus correos antes de comentar"
save: Guardar
save_changes: "Guardar Cambios"
shortcuts: Atajos
sign_out: "Desconectar"
@@ -157,6 +159,12 @@ es:
suspect_word_title: "Lista de palabras sospechosas"
suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma."
tech_settings: "Configuración Técnica"
organization_information: "Información de la Organización"
organization_details: "Detalles de la Organización"
organization_info_copy: "Nosotros usamos esta información en las notificaciones de email generadas por Talk. Esto conecta los mensajes de tu organización, y provee una forma para que los usuarios se comuniquen si tienen un inconveniente con su cuenta."
organization_info_copy_2: "Recomendamos crear un email genérico (ej: community@yournewsroom.com) for this purpose. Esto significa que puede permanecer consistente con el tiempo y no expone un nombre que los usuarios puedan atacar si su cuenta fue bloqueada."
organization_name: "Nombre de la Organización"
organization_contact_email: "Email de la Organización"
title: "Configurar los comentarios"
weeks: Semanas
wordlist: "Palabras Suspendidas"
@@ -240,6 +248,7 @@ es:
email_not_verified: "Correo {0} no confirmado."
email_password: "Correo y/o contraseña incorrecta."
organization_name: "El nombre de la organización debe contener letras y/o números."
organization_contact_email: "El email de la organización no es válido."
password: "La contraseña debe tener por lo menos 8 caracteres"
username: "Los nombres pueden contener letras números y _"
required_field: "Este campo es requerido"
@@ -439,8 +448,8 @@ es:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -467,6 +476,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"
+1
View File
@@ -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 -2
View File
@@ -431,8 +431,8 @@ nl_NL:
reports: "Rapportages"
all: "Alle"
rejected: "Afgewezen"
account_history: "Accountgeschiedenis"
account_history:
user_history: "Accountgeschiedenis"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+3 -2
View File
@@ -430,8 +430,8 @@ pt_BR:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
@@ -458,6 +458,7 @@ pt_BR:
username: "Nome de usuário"
password: "Senha"
confirm_password: "Confirme a senha"
organization_contact_email: "Organization Contact Email"
save: "Salvar"
permitted_domains:
title: "Domínios permitidos"
+2 -2
View File
@@ -432,8 +432,8 @@ zh_CN:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+2 -2
View File
@@ -432,8 +432,8 @@ zh_TW:
reports: "Reports"
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
+11
View File
@@ -187,6 +187,17 @@ Comment.index(
}
);
// Add an index that is optimized for finding a user's comments.
Comment.index(
{
author_id: 1,
created_at: -1,
},
{
background: true,
}
);
// Optimize for tag searches/counts.
Comment.index(
{
+3
View File
@@ -49,6 +49,9 @@ const Setting = new Schema(
organizationName: {
type: String,
},
organizationContactEmail: {
type: String,
},
autoCloseStream: {
type: Boolean,
default: false,
+27
View File
@@ -0,0 +1,27 @@
{
"name": "talk",
"version": "4.3.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"exenv": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
"integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50="
},
"react-side-effect": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.5.tgz",
"integrity": "sha512-Z2ZJE4p/jIfvUpiUMRydEVpQRf2f8GMHczT6qLcARmX7QRb28JDBTpnM2g/i5y/p7ZDEXYGHWg0RbhikE+hJRw==",
"requires": {
"exenv": "1.2.2",
"shallowequal": "1.0.2"
}
},
"shallowequal": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.0.2.tgz",
"integrity": "sha512-zlVXeVUKvo+HEv1e2KQF/csyeMKx2oHvatQ9l6XjCUj3agvC8XGf6R9HvIPDSmp8FNPvx7b5kaEJTRi7CqxtEw=="
}
}
}
+1 -1
View File
@@ -79,6 +79,7 @@
"babel-polyfill": "^6.26.0",
"babel-preset-es2015": "6.24.1",
"babel-preset-react": "^6.23.0",
"bunyan-debug-stream": "^1.0.8",
"bcryptjs": "^2.4.3",
"bowser": "^1.7.2",
"brotli-webpack-plugin": "^0.5.0",
@@ -218,7 +219,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",
+1
View File
@@ -19,4 +19,5 @@ module.exports = {
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
DELETE_USER: 'DELETE_USER',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
};
+9
View File
@@ -1,8 +1,17 @@
const { isString } = require('lodash');
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';
+1
View File
@@ -25,5 +25,6 @@ export {
withUnbanUser,
withStopIgnoringUser,
withSetCommentStatus,
withChangePassword,
} from 'coral-framework/graphql/mutations';
export { compose } from 'recompose';
+4 -2
View File
@@ -2,7 +2,8 @@
"server": [
"talk-plugin-auth",
"talk-plugin-featured-comments",
"talk-plugin-respect"
"talk-plugin-respect",
"talk-plugin-profile-data"
],
"client": [
"talk-plugin-auth",
@@ -18,6 +19,7 @@
"talk-plugin-sort-most-respected",
"talk-plugin-sort-newest",
"talk-plugin-sort-oldest",
"talk-plugin-viewing-options"
"talk-plugin-viewing-options",
"talk-plugin-profile-data"
]
}
+2
View File
@@ -4,6 +4,7 @@ import SetUsernameDialog from './stream/containers/SetUsernameDialog';
import translations from './translations.yml';
import Login from './login/containers/Main';
import reducer from './login/reducer';
import ChangePassword from './profile-settings/containers/ChangePassword';
export default {
reducer,
@@ -11,5 +12,6 @@ export default {
slots: {
stream: [UserBox, SignInButton, SetUsernameDialog],
login: [Login],
profileSettings: [ChangePassword],
},
};
@@ -0,0 +1,87 @@
.container {
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
&.editing {
border-color: #979797;
background-color: #EDEDED;
}
}
.actions {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
color: #202020;
margin: 0 0 20px;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 280px;
}
.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: 1em;
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,224 @@
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 Form from './Form';
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-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-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 () => {
const { oldPassword, newPassword } = this.state.formData;
try {
await this.props.changePassword({
oldPassword,
newPassword,
});
this.props.notify(
'success',
t('talk-plugin-auth.change_password.changed_password_msg')
);
} 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-auth--change-password', styles.container, {
[styles.editing]: editing,
})}
>
<h3 className={styles.title}>
{t('talk-plugin-auth.change_password.change_password')}
</h3>
{editing && (
<Form className="talk-plugin-auth--change-password-form">
<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}>
{t('talk-plugin-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
/>
</Form>
)}
{editing ? (
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
onClick={this.onSave}
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-auth.change_password.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-auth.change_password.cancel')}
</a>
</div>
) : (
<div className={styles.actions}>
<Button className={styles.button} onClick={this.enableEditing}>
{t('talk-plugin-auth.change_password.edit')}
</Button>
</div>
)}
</section>
);
}
}
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangePassword;
@@ -0,0 +1,9 @@
.errorMsg {
color: #FA4643;
padding-left: 4px;
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,5 @@
.detailList {
padding: 0;
margin: 0;
list-style: none;
}
@@ -0,0 +1,16 @@
import React from 'react';
import styles from './Form.css';
import PropTypes from 'prop-types';
const Form = ({ children, className = '' }) => (
<form className={className}>
<ul className={styles.detailList}>{children}</ul>
</form>
);
Form.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default Form;
@@ -0,0 +1,51 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
}
.detailItemContent {
min-width: 280px;
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
padding: 6px 2px;
border: solid 1px #979797;
display: block;
font-size: 1.1em;
border-radius: 2px;
background-color: #ffffff;
color: #979797;
box-sizing: border-box;
width: 100%;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 2px;
padding-top: 16px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
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 = () => {},
value = '',
showError = true,
hasError = false,
errorMsg = '',
children,
}) => {
return (
<li className={styles.detailItem}>
<div className={styles.detailItemContainer}>
<div className={styles.detailItemContent}>
<label className={styles.detailLabel} id={id}>
{label}
</label>
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
value={value}
autoComplete="off"
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
value && <Icon className={styles.checkIcon} name="check_circle" />}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
{children}
</li>
);
};
InputField.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
};
export default InputField;
@@ -0,0 +1,12 @@
import { compose } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect } from 'plugin-api/beta/client/hocs';
import ChangePassword from '../components/ChangePassword';
import { notify } from 'coral-framework/actions/notification';
import { withChangePassword } from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
export default compose(connect(null, mapDispatchToProps), withChangePassword)(
ChangePassword
);
@@ -131,6 +131,15 @@ 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"
de:
talk-plugin-auth:
login:
@@ -222,6 +231,15 @@ 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"
fr:
talk-plugin-auth:
login:
@@ -3,9 +3,9 @@
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<%- include(root + '/partials/head') %>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
@@ -0,0 +1,24 @@
---
title: talk-plugin-profile-data
layout: plugin
permalink: /plugin/talk-plugin-profile-data/
plugin:
name: talk-plugin-profile-data
default: true
provides:
- Client
- Server
---
Provides a series of profile data management utilities to users via their
profile tab.
## Download My Profile
Enables the ability for users to download their profile data in a zip file from
their profile tab in the comment stream. Once clicked, an email will be sent
that contains a download link. Only one link can be generated every 7 days, and
the link will be valid for 24 hours.
The downloaded zip file will contain all the users comments in a CSV format
including those that have been rejected, withheld, or still in premod.
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,12 @@
.button {
margin: 0;
i {
font-size: inherit;
vertical-align: sub;
}
}
.most_recent {
color: #808080;
}
@@ -0,0 +1,74 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { t } from 'plugin-api/beta/client/services';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DownloadCommentHistory.css';
export const readableDuration = durAsHours => {
const durAsDays = Math.ceil(durAsHours / 24);
return durAsHours > 23
? durAsDays > 1
? t('download_request.days', durAsDays)
: t('download_request.day', durAsDays)
: durAsHours > 1
? t('download_request.hours', durAsHours)
: t('download_request.hour', durAsHours);
};
class DownloadCommentHistory extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
render() {
const {
root: { me: { lastAccountDownload } },
requestDownloadLink,
} = this.props;
const now = new Date();
const lastAccountDownloadDate =
lastAccountDownload && new Date(lastAccountDownload);
const hoursLeft = lastAccountDownloadDate
? Math.ceil(
7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
)
: 0;
const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0;
return (
<section className={'talk-plugin-ignore-user-section'}>
<h3>{t('download_request.section_title')}</h3>
<p>
{t('download_request.you_will_get_a_copy')}{' '}
<b>{t('download_request.download_rate')}</b>.
</p>
{lastAccountDownloadDate && (
<p className={styles.most_recent}>
{t('download_request.most_recent_request')}:{' '}
{lastAccountDownloadDate.toLocaleString()}
</p>
)}
{canRequestDownload ? (
<Button className={styles.button} onClick={requestDownloadLink}>
<i className="material-icons" aria-hidden={true}>
file_download
</i>{' '}
{t('download_request.request')}
</Button>
) : (
<Button className={styles.button} disabled>
<i className="material-icons" aria-hidden={true}>
access_time
</i>{' '}
{t('download_request.rate_limit', readableDuration(hoursLeft))}
</Button>
)}
</section>
);
}
}
export default DownloadCommentHistory;
@@ -0,0 +1,39 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { compose, gql } from 'react-apollo';
import DownloadCommentHistory from '../components/DownloadCommentHistory';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { withRequestDownloadLink } from '../mutations';
class DownloadCommentHistoryContainer extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
render() {
return (
<DownloadCommentHistory
root={this.props.root}
requestDownloadLink={this.props.requestDownloadLink}
/>
);
}
}
const enhance = compose(
withFragments({
root: gql`
fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
__typename
me {
id
lastAccountDownload
}
}
`,
}),
withRequestDownloadLink
);
export default enhance(DownloadCommentHistoryContainer);
@@ -0,0 +1,18 @@
import update from 'immutability-helper';
export default {
mutations: {
DownloadCommentHistory: () => ({
updateQueries: {
CoralEmbedStream_Profile: previousData =>
update(previousData, {
me: {
lastAccountDownload: {
$set: new Date().toISOString(),
},
},
}),
},
}),
},
};
@@ -0,0 +1,11 @@
import DownloadCommentHistory from './containers/DownloadCommentHistory';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
profileSettings: [DownloadCommentHistory],
},
translations,
...graphql,
};
@@ -0,0 +1,19 @@
import { withMutation } from 'plugin-api/beta/client/hocs';
import { gql } from 'react-apollo';
export const withRequestDownloadLink = withMutation(
gql`
mutation DownloadCommentHistory {
requestDownloadLink {
errors {
translation_key
}
}
}
`,
{
props: ({ mutate }) => ({
requestDownloadLink: () => mutate({ variables: {} }),
}),
}
);
@@ -0,0 +1,12 @@
en:
download_request:
section_title: "Download My Comment History"
you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make"
download_rate: "one download request every 7 days"
most_recent_request: "Your most recent request"
request: "Request Comment History"
rate_limit: "You can submit another Comment History request in {0}"
hours: "{0} hours"
days: "{0} days"
hour: "{0} hour"
day: "{0} day"
+15
View File
@@ -0,0 +1,15 @@
const path = require('path');
const router = require('./server/router');
const mutators = require('./server/mutators');
const typeDefs = require('./server/typeDefs');
const connect = require('./server/connect');
const resolvers = require('./server/resolvers');
module.exports = {
mutators,
router,
connect,
typeDefs,
translations: path.join(__dirname, 'translations.yml'),
resolvers,
};
@@ -0,0 +1,12 @@
{
"name": "@coralproject/talk-plugin-profile-data",
"version": "1.0.0",
"description": "Adds profile data management for Talk",
"main": "index.js",
"license": "Apache-2.0",
"private": false,
"dependencies": {
"archiver": "^2.1.1",
"csv-stringify": "^3.0.0"
}
}
@@ -0,0 +1,14 @@
const path = require('path');
module.exports = connectors => {
const { services: { Mailer } } = connectors;
// Setup the mail templates.
['txt', 'html'].forEach(format => {
Mailer.templates.register(
path.join(__dirname, 'emails', `download.${format}.ejs`),
'download',
format
);
});
};
@@ -0,0 +1,3 @@
module.exports = {
DOWNLOAD_LINK_SUBJECT: 'download_link',
};
@@ -0,0 +1 @@
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
@@ -0,0 +1,3 @@
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
<%= downloadLandingURL %>
@@ -0,0 +1,18 @@
const { TalkError } = require('errors');
// ErrDownloadToken is returned in the event that the download is requested
// without a valid token.
class ErrDownloadToken extends TalkError {
constructor(err) {
super(
'Token is invalid',
{
translation_key: 'DOWNLOAD_TOKEN_INVALID',
status: 400,
},
{ err }
);
}
}
module.exports = { ErrDownloadToken };
@@ -0,0 +1,106 @@
const moment = require('moment');
const uuid = require('uuid/v4');
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
const { URL } = require('url');
// generateDownloadLinks will generate a signed set of links for a given user to
// download an archive of their data.
async function generateDownloadLinks(ctx, userID) {
const { connectors: { url: { BASE_URL }, secrets } } = ctx;
// Generate a token for the download link.
const token = await secrets.jwt.sign(
{ user: userID },
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
);
// Generate the url that a user can land on.
const downloadLandingURL = new URL('account/download', BASE_URL);
downloadLandingURL.hash = token;
// Generate the url that the API calls to download the actual zip.
const downloadFileURL = new URL('api/v1/account/download', BASE_URL);
downloadFileURL.searchParams.set('token', token);
return {
downloadLandingURL: downloadLandingURL.href,
downloadFileURL: downloadFileURL.href,
};
}
async function sendDownloadLink(ctx) {
const {
user,
loaders: { Settings },
connectors: { services: { Users, I18n, Limit }, models: { User } },
} = ctx;
// downloadLinkLimiter can be used to limit downloads for the user's data to
// once every 7 days.
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
// Check that the user has not already requested a download within the last
// 7 days.
const attempts = await downloadLinkLimiter.get(user.id);
if (attempts && attempts >= 1) {
throw new ErrMaxRateLimit();
}
// Check if the lastAccountDownload time is within 7 days.
if (
user.lastAccountDownload &&
moment(user.lastAccountDownload)
.add(7, 'days')
.isAfter(moment())
) {
throw new ErrMaxRateLimit();
}
// The account currently does not have a download link, let's record the
// download. This will throw an error if a race ocurred and we should stop
// now.
await downloadLinkLimiter.test(user.id);
const now = new Date();
// Generate the download links.
const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
const { organizationName } = await Settings.load('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'download',
locals: {
downloadLandingURL,
organizationName,
now,
},
subject: I18n.t('email.download.subject', organizationName),
});
// Amend the lastAccountDownload on the user.
await User.update(
{ id: user.id },
{ $set: { 'metadata.lastAccountDownload': now } }
);
}
// downloadUser will return the download file url that can be used to directly
// download the archive.
async function downloadUser(ctx, userID) {
const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
return downloadFileURL;
}
module.exports = ctx => ({
User: {
requestDownloadLink: () => sendDownloadLink(ctx),
download:
// Only ADMIN users can execute an account download.
ctx.user && ctx.user.role === 'ADMIN'
? userID => downloadUser(ctx, userID)
: () => Promise.reject(new ErrNotAuthorized()),
},
});
@@ -0,0 +1,23 @@
const { get } = require('lodash');
module.exports = {
RootMutation: {
requestDownloadLink: async (_, args, { mutators: { User } }) => {
await User.requestDownloadLink();
},
downloadUser: async (_, { id }, { mutators: { User } }) => ({
archiveURL: await User.download(id),
}),
},
User: {
lastAccountDownload: (user, args, { user: currentUser }) => {
// If the current user is not the requesting user, and the user is not
// an admin, return nothing.
if (user.id !== currentUser.id && user.role !== 'ADMIN') {
return null;
}
return get(user, 'metadata.lastAccountDownload', null);
},
},
};
@@ -0,0 +1,200 @@
const path = require('path');
const express = require('express');
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
const { get, pick, kebabCase } = require('lodash');
const moment = require('moment');
const archiver = require('archiver');
const stringify = require('csv-stringify');
const { ErrDownloadToken } = require('./errors');
async function verifyDownloadToken(
{ connectors: { services: { Users } } },
token
) {
const jwt = await Users.verifyToken(token, {
subject: DOWNLOAD_LINK_SUBJECT,
});
return jwt;
}
// loadCommentsBatch will load a batch of the comments and write them to the
// stream.
async function loadCommentsBatch(ctx, csv, variables) {
let result = await ctx.graphql(
`
query GetMyComments($userID: ID!, $cursor: Cursor) {
user(id: $userID) {
comments(query: {
limit: 100,
cursor: $cursor,
statuses: null
}) {
hasNextPage
endCursor
nodes {
id
created_at
asset {
url
}
body
url
}
}
}
}
`,
variables
);
if (result.errors) {
throw result.errors;
}
for (const comment of get(result, 'data.user.comments.nodes', [])) {
csv.write([
comment.id,
moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'),
get(comment, 'asset.url'),
comment.url,
comment.body,
]);
}
return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']);
}
// loadComments will load batches of the comments and write them to the csv
// stream. Once the comments have finished writing, it will close the stream.
async function loadComments(ctx, userID, archive, latestContentDate) {
// Create all the csv writers that'll write the data to the archive.
const csv = stringify();
// Add all the streams as files to the archive.
archive.append(csv, { name: 'talk-export/my_comments.csv' });
csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']);
// Load the first batch's comments from the latest date that we were provided
// from the token.
let connection = await loadCommentsBatch(ctx, csv, {
cursor: latestContentDate,
userID,
});
// As long as there's more comments, keep paginating.
while (connection.hasNextPage) {
connection = await loadCommentsBatch(ctx, csv, {
cursor: connection.endCursor,
userID,
});
}
csv.end();
}
module.exports = router => {
// /account/download will render the download page.
router.get('/account/download', (req, res) => {
res.render(path.join(__dirname, 'views', 'download'));
});
// /api/v1/account/download will send back a zipped archive of the users
// account.
router.all(
'/api/v1/account/download',
express.urlencoded({ extended: false }),
async (req, res, next) => {
let { token = null, check = false } = req.body;
if (!token) {
// If the token wasn't found in the body, then we should check the query
// to see if it was passed that way.
token = req.query.token;
}
if (!token) {
return res.status(400).end();
}
if (check) {
// This request is checking to see if the token is valid.
try {
// Verify the token
await verifyDownloadToken(req.context, token);
} catch (err) {
return next(new ErrDownloadToken(err));
}
res.status(204).end();
// Don't continue to pass it onto the next middleware, as we've only been
// asked to verify the token.
return;
}
const { connectors: { graph: { Context }, errors } } = req.context;
try {
// Pull the userID and the date that the token was issued out of the
// provided token.
const { user: userID, iat } = await verifyDownloadToken(
req.context,
token
);
// Create a system context used to get all comments for that user.
const ctx = Context.forSystem();
// Get the current user's username. We need it for the generated filenames.
const result = await ctx.graphql(
`query GetUser($userID: ID!) {
user(id: $userID) { username }
}`,
{ userID }
);
if (result.errors) {
throw result.errors;
}
const user = get(result, 'data.user');
if (!user) {
throw new errors.ErrNotFound();
}
// Unpack the date that the token was issued, and use it as a source for the
// earliest comment we should include in the download.
const latestContentDate = new Date(iat * 1000);
// Generate the filename of the file that the user will download.
const username = get(user, 'username');
const filename = `talk-${kebabCase(username)}-${kebabCase(
moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss')
)}.zip`;
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=${filename}`,
});
// Create the zip archive we'll use to write all the exported files to.
const archive = archiver('zip', {
zlib: { level: 9 },
});
// Pipe this to the response writer directly.
archive.pipe(res);
// Load the comments csv up with the user's comments.
await loadComments(ctx, userID, archive, latestContentDate);
// Mark the end of adding files, no more files can be added after this. Once
// all the stream readers have finished writing, and have closed, the
// archiver will close which will finish the HTTP request.
archive.finalize();
} catch (err) {
return next(err);
}
}
);
};
@@ -0,0 +1,33 @@
type User {
# lastAccountDownload is the date that the user last requested a comment
# download.
lastAccountDownload: Date
}
type RequestDownloadLinkResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type DownloadUserResponse implements Response {
# archiveURL is the link that can be used within the next 1 hour to download a
# users archive.
archiveURL: String
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type RootMutation {
# requestDownloadLink will request a download link be sent to the primary
# users email address.
requestDownloadLink: RequestDownloadLinkResponse
# downloadUser will provide an account download for the indicated User. This
# mutation requires the ADMIN role.
downloadUser(id: ID!): DownloadUserResponse
}
@@ -0,0 +1,7 @@
const path = require('path');
const fs = require('fs');
module.exports = fs.readFileSync(
path.join(__dirname, 'typeDefs.graphql'),
'utf8'
);
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<title><%= t('download_landing.download_your_account') %></title>
<%- include(root + '/partials/account') %>
</head>
<body>
<div id="root">
<section class="container">
<h1><%= t('download_landing.download_your_account') %></h1>
<p><%= t('download_landing.download_details') %></p>
<p><%= t('download_landing.all_information_included') %></p>
<ul class="check_list">
<li><%= t('download_landing.information_included.date') %></li>
<li><%= t('download_landing.information_included.url') %></li>
<li><%= t('download_landing.information_included.body') %></li>
<li><%= t('download_landing.information_included.asset_url') %></li>
</ul>
<div class="error-console"><span></span></div>
<form id="download-form" method="post" action="<%= BASE_PATH %>api/v1/account/download">
<button type="submit"><%= t('download_landing.confirm') %></button>
</form>
</section>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script type="text/javascript">
$(function() {
function showError(error) {
try {
let err = JSON.parse(error);
$('.error-console span').text(err.message);
$('.error-console').fadeIn();
} catch (err) {
$('.error-console span').text(error);
$('.error-console').fadeIn();
}
}
var token = location.hash.replace('#', '');
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/download',
contentType: 'application/json',
method: 'POST',
data: JSON.stringify({token: token, check: true})
})
.then(function () {
$('#download-form').append('<input name="token" type="hidden" value="' + token + '"/>').fadeIn();
})
.catch(function (error) {
showError(error.responseText);
});
});
</script>
</body>
</html>
@@ -0,0 +1,18 @@
en:
download_landing:
download_your_account: "Download Your Comment History"
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application."
all_information_included: "For each of your comments the following information is included:"
information_included:
date: "When you wrote the comment"
url: "The permalink URL for the comment"
body: "The comment text"
asset_url: "The URL on the article or story where the comment appears"
confirm: "Download My Comment History"
email:
download:
subject: "Your comments are ready for download from {0}"
download_link_ready: "Click here to download your comments from {0} as of {1}:"
download_archive: "Download Archive"
error:
DOWNLOAD_TOKEN_INVALID: "Your download link is not valid."
+61 -16
View File
@@ -3,16 +3,19 @@ body, #root {
height: 100%;
margin: 0;
background: #fff;
color: #3B4A53;
}
.container {
max-width: 300px;
max-width: 675px;
margin: 50px auto;
}
#root form {
display: none;
padding: 15px;
padding: 15px 0;
/* max-width: 400px;
margin: 0 auto; */
}
.legend {
@@ -21,6 +24,22 @@ body, #root {
font-weight: bold;
}
section p, ul {
font-family: Source Sans Pro;
font-style: normal;
font-weight: normal;
line-height: 34px;
font-size: 24px;
letter-spacing: 0.3px;
}
h1 {
font-family: Source Sans Pro;
font-size: 48px;
font-weight: 600;
color: #000000;
}
label {
display: block;
margin-top: 10px;
@@ -44,28 +63,54 @@ input {
}
button[type="submit"] {
border-radius: 4px;
font-family: Source Sans Pro;
font-style: normal;
font-weight: 600;
line-height: normal;
font-size: 18px;
text-align: center;
color: white;
border-radius: 2px;
border: none;
display: block;
background-color: #333;
color: white;
text-align: center;
width: 100%;
padding: 10px;
margin-top: 10px;
background-color: #3498DB;
margin: 10px auto;
padding: 13px;
cursor: pointer;
}
.error-console {
display: none;
margin-top: 10px;
border-radius: 4px;
background-color: pink;
color: red;
border: 1px solid red;
border-radius: 2px;
background-color: rgba(242, 101, 99, 0.1);
border: 1px solid #F26563;
padding: 10px;
}
.error-console.active {
display: block;
}
.error-console span:before {
font-family: 'Material Icons';
content: '\E000';
color: #000;
display: inline-block;
vertical-align: sub;
width: 1.4em;
}
ul.check_list {
list-style-type: none;
margin: 0 0 0 0.5em;
}
ul.check_list li {
text-indent: -1.4em;
}
ul.check_list li:before {
font-family: 'Material Icons';
content: '\E5CA';
color: #000;
float: left;
width: 1.4em;
}
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const router = express.Router();
router.get('/email/confirm', (req, res) => {
res.render('account/email/confirm');
});
router.get('/password/reset', (req, res) => {
res.render('account/password/reset');
});
module.exports = router;
-8
View File
@@ -1,14 +1,6 @@
const express = require('express');
const router = express.Router();
router.get('/confirm-email', (req, res) => {
res.render('admin/confirm-email');
});
router.get('/password-reset', (req, res) => {
res.render('admin/password-reset');
});
router.get('*', (req, res) => {
res.render('admin');
});
+4 -13
View File
@@ -37,7 +37,7 @@ const tokenCheck = (verifier, error, ...whitelistedErrors) => async (
// Log out the error, slurp it and send out the predefined error to the
// error handler.
console.error(err);
return next(error);
return next(new error());
}
res.status(204).end();
@@ -109,20 +109,11 @@ router.put(
async (req, res, next) => {
const { token, password } = req.body;
if (!password || password.length < 8) {
return next(errors.ErrPasswordTooShort);
}
try {
let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
// Change the users' password.
await UsersService.changePassword(user.id, password);
const { redirect } = await UsersService.resetPassword(token, password);
res.json({ redirect });
} catch (e) {
console.error(e);
return next(errors.ErrNotAuthorized);
} catch (err) {
return next(err);
}
}
);
+1 -1
View File
@@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/iql', staticTemplate, (req, res) => {
res.render('graphiql', {
res.render('api/graphiql', {
endpointURL: 'api/v1/graph/ql',
});
});
@@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
return next(errors.ErrNotFound);
}
res.render('article', {
res.render('dev/article', {
title: asset.title,
asset_id: asset.id,
asset_url: asset.url,
@@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
});
router.get('/title/:asset_title', (req, res) => {
return res.render('article', {
return res.render('dev/article', {
title: req.params.asset_title.split('-').join(' '),
asset_url: '',
asset_id: null,
@@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => {
try {
const assets = await Assets.all(skip, limit);
res.render('articles', {
res.render('dev/articles', {
assets: assets,
});
} catch (err) {
+25
View File
@@ -0,0 +1,25 @@
const express = require('express');
const url = require('url');
const router = express.Router();
const { MOUNT_PATH } = require('../../url');
const SetupService = require('../../services/setup');
const staticTemplate = require('../../middleware/staticTemplate');
router.use('/assets', staticTemplate, require('./assets'));
router.get('/', staticTemplate, async (req, res) => {
try {
await SetupService.isAvailable();
return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
} catch (e) {
return res.render('dev/article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/static/embed/stream',
});
}
});
module.exports = router;
+7 -14
View File
@@ -75,6 +75,7 @@ router.use(compression());
//==============================================================================
router.use('/admin', staticTemplate, require('./admin'));
router.use('/account', staticTemplate, require('./account'));
router.use('/login', staticTemplate, require('./login'));
router.use('/embed', staticTemplate, require('./embed'));
@@ -114,22 +115,14 @@ router.use('/api', require('./api'));
//==============================================================================
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', staticTemplate, require('./assets'));
router.get('/', staticTemplate, async (req, res) => {
try {
await SetupService.isAvailable();
return res.redirect('/admin/install');
} catch (e) {
return res.render('article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/static/embed/stream',
});
}
// In development, mount the /dev routes, as well as redirect the root url to
// the development route.
router.use('/dev', require('./dev'));
router.get('/', (req, res) => {
res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302);
});
} else {
// In production, optionally redirect to the install if not ran, or the admin.
router.get('/', async (req, res, next) => {
try {
await SetupService.isAvailable();
+1 -1
View File
@@ -2,13 +2,13 @@ const { version } = require('../package.json');
const path = require('path');
const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan');
const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
const debug = require('bunyan-debug-stream');
// Streams enables the ability for development logs to be readable to a human,
// but will send JSON logs in production that's parsable by a system like ELK.
const streams = (() => {
// In development, use the debug stream printer.
if (process.env.NODE_ENV !== 'production') {
const debug = require('bunyan-debug-stream');
return [
{
level: LOGGING_LEVEL,
@@ -1,3 +1,3 @@
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>account/email/confirm#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
<p><%= t('email.confirm.if_you_did_not') %></p>
@@ -4,6 +4,6 @@
<%= t('email.confirm.to_confirm') %>
<%= BASE_URL %>admin/confirm-email#<%= token %>
<%= BASE_URL %>account/email/confirm#<%= token %>
<%= t('email.confirm.if_you_did_not') %>
@@ -1,2 +1,2 @@
<p><%= t('email.password_reset.we_received_a_request') %><br />
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>admin/password-reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>account/password/reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
@@ -1,3 +1,3 @@
<%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>:
<%= BASE_URL %>admin/password-reset#<%= token %>
<%= BASE_URL %>account/password/reset#<%= token %>

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