mirror of
https://github.com/wassname/talk.git
synced 2026-07-10 10:45:25 +08:00
Merge branch 'master' into master
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+19
-21
@@ -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;
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
@@ -25,6 +25,7 @@ export default {
|
||||
'UnsuspendUserResponse',
|
||||
'UpdateAssetSettingsResponse',
|
||||
'UpdateAssetStatusResponse',
|
||||
'UpdateSettingsResponse'
|
||||
'UpdateSettingsResponse',
|
||||
'ChangePasswordResponse'
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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!) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
|
||||
.root {
|
||||
vertical-align: middle;
|
||||
vertical-align: sub;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../plugins/talk-plugin-profile-data/README.md
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -49,6 +49,9 @@ const Setting = new Schema(
|
||||
organizationName: {
|
||||
type: String,
|
||||
},
|
||||
organizationContactEmail: {
|
||||
type: String,
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
Generated
+27
@@ -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
@@ -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",
|
||||
|
||||
@@ -19,4 +19,5 @@ module.exports = {
|
||||
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
|
||||
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
|
||||
DELETE_USER: 'DELETE_USER',
|
||||
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -25,5 +25,6 @@ export {
|
||||
withUnbanUser,
|
||||
withStopIgnoringUser,
|
||||
withSetCommentStatus,
|
||||
withChangePassword,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
export { compose } from 'recompose';
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user