mirror of
https://github.com/wassname/talk.git
synced 2026-08-12 12:30:39 +08:00
Merge branch 'master' into popupmenu-style
This commit is contained in:
+18
-1
@@ -1,12 +1,27 @@
|
||||
|
||||
# job_environment will setup the environment for any job being executed.
|
||||
job_environment: &job_environment
|
||||
NODE_ENV: test
|
||||
DISABLE_CREATE_MONGO_INDEXES: TRUE
|
||||
|
||||
# job_defaults applies all the defaults for each job.
|
||||
job_defaults: &job_defaults
|
||||
working_directory: ~/coralproject/talk
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
environment:
|
||||
<<: *job_environment
|
||||
|
||||
# create_indexes will create the mongo indexes and wait until they have been
|
||||
# built.
|
||||
create_indexes: &create_indexes
|
||||
run:
|
||||
name: Create the database indexes and wait until they are built
|
||||
command: ./bin/cli db createIndexes
|
||||
|
||||
# integration_environment is the environment that configures the tests.
|
||||
integration_environment: &integration_environment
|
||||
NODE_ENV: test
|
||||
<<: *job_environment
|
||||
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
|
||||
E2E_MAX_RETRIES: 3
|
||||
|
||||
@@ -25,6 +40,7 @@ integration_job: &integration_job
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: ~/coralproject/talk
|
||||
- <<: *create_indexes
|
||||
- run:
|
||||
name: Setup the database with defaults
|
||||
command: ./bin/cli setup --defaults
|
||||
@@ -117,6 +133,7 @@ jobs:
|
||||
environment:
|
||||
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
|
||||
JEST_REPORTER: jest-junit
|
||||
- <<: *create_indexes
|
||||
- run:
|
||||
name: Run the server unit tests
|
||||
command: yarn test:server
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"extends": "@coralproject/eslint-config-talk"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ const Matcher = require('did-you-mean');
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('db', 'run database commands')
|
||||
.command('settings', 'interact with the application settings')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('setup', 'setup the application')
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const util = require('./util');
|
||||
const program = require('commander');
|
||||
const config = require('../config');
|
||||
|
||||
async function createIndexes() {
|
||||
try {
|
||||
// Ensure we enable the index creation.
|
||||
config.CREATE_MONGO_INDEXES = true;
|
||||
|
||||
// TODO: handle the plugin index creation?
|
||||
|
||||
// Let's register the shutdown hooks.
|
||||
util.onshutdown([() => require('../services/mongoose').disconnect()]);
|
||||
|
||||
// Lets create all the database indexes for the application and wait for all
|
||||
// them to finish their indexing.
|
||||
const models = [
|
||||
require('../models/action'),
|
||||
require('../models/asset'),
|
||||
require('../models/comment'),
|
||||
require('../models/setting'),
|
||||
require('../models/user'),
|
||||
require('../models/migration'),
|
||||
];
|
||||
|
||||
// Call the `.init()` method to setup all the indexes on each model.
|
||||
// `init()` returns a promise that resolves when the indexes have finished
|
||||
// building successfully. The `init()` function is idempotent, so we don't
|
||||
// have to worry about triggering an index rebuild.
|
||||
await Promise.all(models.map(Model => Model.init()));
|
||||
|
||||
console.log('Indexes created');
|
||||
util.shutdown(0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('createIndexes')
|
||||
.description('creates the database indexes and waits until they are created')
|
||||
.action(createIndexes);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
+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);
|
||||
|
||||
@@ -24,14 +24,20 @@ export default class Embed extends React.Component {
|
||||
>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>,
|
||||
<Tab
|
||||
key="profile"
|
||||
tabId="profile"
|
||||
className="talk-embed-stream-profile-tab"
|
||||
>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>,
|
||||
];
|
||||
|
||||
if (this.props.currentUser) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
key="profile"
|
||||
tabId="profile"
|
||||
className="talk-embed-stream-profile-tab"
|
||||
>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
if (can(this.props.currentUser, 'UPDATE_ASSET_CONFIG')) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
@@ -43,6 +49,7 @@ export default class Embed extends React.Component {
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ class ExtendableTabPanel extends React.Component {
|
||||
} = this.props;
|
||||
return (
|
||||
<div {...rest}>
|
||||
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
|
||||
{tabs}
|
||||
</TabBar>
|
||||
{tabs && (
|
||||
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
|
||||
{tabs}
|
||||
</TabBar>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className={styles.spinnerContainer}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.message {
|
||||
padding: 10px 0 20px;
|
||||
letter-spacing: 0.1px;
|
||||
font-size: 13px;
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
.message a {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './NotLoggedIn.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default ({ showSignInDialog }) => (
|
||||
<div className={cn(styles.message, 'talk-embed-stream-not-logged-in')}>
|
||||
<div>
|
||||
<a onClick={showSignInDialog}>{t('settings.sign_in')}</a>{' '}
|
||||
{t('settings.to_access')}
|
||||
</div>
|
||||
<div>{t('from_settings_page')}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2,27 +2,17 @@ import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { withQuery } from 'coral-framework/hocs';
|
||||
import NotLoggedIn from '../components/NotLoggedIn';
|
||||
import { Spinner } from 'coral-ui';
|
||||
import Profile from '../components/Profile';
|
||||
import TabPanel from './TabPanel';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
|
||||
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
|
||||
import { getSlotFragmentSpreads } from 'coral-framework/utils';
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!this.props.currentUser && nextProps.currentUser) {
|
||||
// Refetch after login.
|
||||
this.props.data.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { currentUser, showSignInDialog, root } = this.props;
|
||||
const { currentUser, root } = this.props;
|
||||
const { me } = this.props.root;
|
||||
const loading = this.props.data.loading;
|
||||
|
||||
@@ -30,10 +20,6 @@ class ProfileContainer extends Component {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
if (loading || !me) {
|
||||
return <Spinner />;
|
||||
}
|
||||
@@ -57,7 +43,6 @@ ProfileContainer.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
currentUser: PropTypes.object,
|
||||
showSignInDialog: PropTypes.func,
|
||||
};
|
||||
|
||||
const slots = ['profileSections'];
|
||||
@@ -85,10 +70,6 @@ const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ showSignInDialog }, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withProfileQuery
|
||||
)(ProfileContainer);
|
||||
export default compose(connect(mapStateToProps), withProfileQuery)(
|
||||
ProfileContainer
|
||||
);
|
||||
|
||||
@@ -574,7 +574,9 @@ export default class Comment extends React.Component {
|
||||
'talk-stream-comment-header-tags-container'
|
||||
)}
|
||||
>
|
||||
{isStaff(comment.tags) ? <TagLabel>Staff</TagLabel> : null}
|
||||
{isStaff(comment.tags) ? (
|
||||
<TagLabel>{t('community.staff')}</TagLabel>
|
||||
) : null}
|
||||
|
||||
<Slot
|
||||
className={cn(
|
||||
|
||||
@@ -18,6 +18,10 @@ const InactiveCommentLabel = ({ status, className, ...rest }) => {
|
||||
label = t('modqueue.rejected');
|
||||
icon = 'close';
|
||||
break;
|
||||
case 'SYSTEM_WITHHELD':
|
||||
label = t('modqueue.system_withheld');
|
||||
icon = 'flag';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown inactive status ${status}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { pluginsPath } = require('../plugins');
|
||||
|
||||
const buildTargets = ['coral-admin'];
|
||||
|
||||
const buildEmbeds = ['stream'];
|
||||
|
||||
const specPattern = 'client/**/__tests__/**/*.spec.js?(x)';
|
||||
|
||||
module.exports = {
|
||||
rootDir: '../',
|
||||
testMatch: [
|
||||
`<rootDir>/${specPattern}`,
|
||||
`<rootDir>/plugins/**/${specPattern}`,
|
||||
],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
|
||||
modulePaths: [
|
||||
'<rootDir>/plugins',
|
||||
'<rootDir>/client',
|
||||
...buildTargets.map(target => `<rootDir>/client/${target}/src`),
|
||||
...buildEmbeds.map(embed => `<rootDir>/client/coral-embed-${embed}/src`),
|
||||
],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
|
||||
moduleDirectories: ['node_modules'],
|
||||
transform: {
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
|
||||
},
|
||||
testResultsProcessor: process.env.JEST_REPORTER,
|
||||
moduleNameMapper: {
|
||||
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
|
||||
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
|
||||
'^pluginsConfig$': pluginsPath,
|
||||
'\\.(scss|css|less)$': 'identity-obj-proxy',
|
||||
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
|
||||
},
|
||||
};
|
||||
@@ -170,8 +170,9 @@ moderators.
|
||||
|
||||
All your team and commenters show in the People sub-tab. From here, you can
|
||||
manage your team members’ roles (Admins, Moderators, Staff), as well as search
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
|
||||
Configure
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.).
|
||||
|
||||
### Configure
|
||||
|
||||
See [Configuring Talk](/talk/configuring-talk/).
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-37
@@ -1,40 +1,8 @@
|
||||
const path = require('path');
|
||||
const { pluginsPath } = require('./plugins');
|
||||
|
||||
const buildTargets = ['coral-admin'];
|
||||
|
||||
const buildEmbeds = ['stream'];
|
||||
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
testMatch: ['**/client/**/__tests__/**/*.js?(x)'],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
|
||||
modulePaths: [
|
||||
'<rootDir>/plugins',
|
||||
'<rootDir>/client',
|
||||
...buildTargets.map(target =>
|
||||
path.join('<rootDir>', 'client', target, 'src')
|
||||
),
|
||||
...buildEmbeds.map(embed =>
|
||||
path.join('<rootDir>', 'client', `coral-embed-${embed}`, 'src')
|
||||
),
|
||||
],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
|
||||
moduleDirectories: ['node_modules'],
|
||||
|
||||
transform: {
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
|
||||
},
|
||||
|
||||
projects: ['<rootDir>', '<rootDir>/client'],
|
||||
testPathIgnorePatterns: ['client'],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/setupJest.js',
|
||||
testResultsProcessor: process.env.JEST_REPORTER,
|
||||
|
||||
moduleNameMapper: {
|
||||
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
|
||||
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
|
||||
'^pluginsConfig$': pluginsPath,
|
||||
|
||||
'\\.(scss|css|less)$': 'identity-obj-proxy',
|
||||
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
modulePaths: ['<rootDir>'],
|
||||
};
|
||||
|
||||
+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"
|
||||
|
||||
+23
-7
@@ -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:
|
||||
@@ -223,7 +236,7 @@ en:
|
||||
RATE_LIMIT_EXCEEDED: "Rate limit exceeded"
|
||||
USERNAME_IN_USE: "Username already in use"
|
||||
USERNAME_REQUIRED: "Must input a username"
|
||||
EMAIL_NOT_VERIFIED: "E-mail address not verified"
|
||||
EMAIL_NOT_VERIFIED: "Email address not verified"
|
||||
EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username."
|
||||
SAME_USERNAME_PROVIDED: "You must submit a different username."
|
||||
@@ -239,9 +252,10 @@ en:
|
||||
email: "Not a valid E-Mail"
|
||||
confirm_password: "Passwords don't match. Please check again"
|
||||
network_error: "Failed to connect to server. Check your internet connection and try again."
|
||||
email_not_verified: "E-mail address {0} not verified."
|
||||
email_password: "E-mail and/or password combination incorrect."
|
||||
email_not_verified: "Email address {0} not verified."
|
||||
email_password: "Email and/or password combination incorrect."
|
||||
organization_name: "Organization name must only contain letters or numbers."
|
||||
organization_contact_email: "Organization email is not valid."
|
||||
password: "Password must be at least 8 characters"
|
||||
username: "Usernames can contain letters numbers and _ only"
|
||||
unexpected: "Unexpected error occurred. Sorry!"
|
||||
@@ -344,6 +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
|
||||
@@ -426,7 +441,7 @@ en:
|
||||
title_reject: "We noticed you rejected a username"
|
||||
suspend_user: "Suspend User"
|
||||
yes_suspend: "Yes suspend"
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns."
|
||||
write_message: "Write a message"
|
||||
send: Send
|
||||
thank_you: "We value your safety and feedback. A moderator will review your report."
|
||||
@@ -446,8 +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}"
|
||||
|
||||
+2
-51
@@ -1,53 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const uuid = require('uuid');
|
||||
const Schema = mongoose.Schema;
|
||||
const ACTION_TYPES = require('./enum/action_types');
|
||||
const ITEM_TYPES = require('./enum/item_types');
|
||||
const { Action } = require('./schema');
|
||||
|
||||
const ActionSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
action_type: {
|
||||
type: String,
|
||||
enum: ACTION_TYPES,
|
||||
},
|
||||
item_type: {
|
||||
type: String,
|
||||
enum: ITEM_TYPES,
|
||||
},
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
|
||||
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
|
||||
// item_id.
|
||||
group_id: String,
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: Schema.Types.Mixed,
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Create an index on the `item_id` field so that queries looking for
|
||||
// actions based on the item id can resolve faster.
|
||||
ActionSchema.index(
|
||||
{
|
||||
item_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
const Action = mongoose.model('Action', ActionSchema);
|
||||
|
||||
module.exports = Action;
|
||||
module.exports = mongoose.model('Action', Action);
|
||||
|
||||
+2
-97
@@ -1,99 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const get = require('lodash/get');
|
||||
const { Asset } = require('./schema');
|
||||
|
||||
const AssetSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'assets',
|
||||
},
|
||||
scraped: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: String,
|
||||
description: String,
|
||||
image: String,
|
||||
section: String,
|
||||
subsection: String,
|
||||
author: String,
|
||||
publication_date: Date,
|
||||
modified_date: Date,
|
||||
|
||||
// This object is used exclusively for storing settings that are to override
|
||||
// the base settings from the base Settings object. This is to be accessed
|
||||
// always after running `rectifySettings` against it.
|
||||
settings: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
versionKey: false,
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AssetSchema.index(
|
||||
{
|
||||
title: 'text',
|
||||
url: 'text',
|
||||
description: 'text',
|
||||
section: 'text',
|
||||
subsection: 'text',
|
||||
author: 'text',
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
AssetSchema.virtual('isClosed').get(function() {
|
||||
const closedAt = get(this, 'closedAt', null);
|
||||
if (closedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return closedAt.getTime() <= new Date().getTime();
|
||||
});
|
||||
|
||||
const Asset = mongoose.model('Asset', AssetSchema);
|
||||
|
||||
module.exports = Asset;
|
||||
module.exports = mongoose.model('Asset', Asset);
|
||||
|
||||
+2
-233
@@ -1,235 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const uuid = require('uuid');
|
||||
const COMMENT_STATUS = require('./enum/comment_status');
|
||||
const { Comment } = require('./schema');
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment Status.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const StatusSchema = new Schema(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
},
|
||||
|
||||
// The User ID of the user that assigned the status.
|
||||
assigned_by: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
created_at: Date,
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const CommentSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2,
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [StatusSchema],
|
||||
status: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
default: 'NONE',
|
||||
},
|
||||
|
||||
// parent_id is the id of the parent comment (null if there is none).
|
||||
parent_id: String,
|
||||
|
||||
// The number of replies to this comment directly.
|
||||
reply_count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
// Counts to store related to actions taken on the given comment.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the indexes for the id of the comment.
|
||||
CommentSchema.index(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
asset_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
CommentSchema.index(
|
||||
{
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
CommentSchema.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for finding flagged comments.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index for the reply sort.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: -1,
|
||||
reply_count: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
CommentSchema.index(
|
||||
{
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for sorting based on the created_at timestamp
|
||||
// but also good at locating comments that have a specific asset id.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.virtual('edited').get(function() {
|
||||
return this.body_history.length > 1;
|
||||
});
|
||||
|
||||
// Visable is true when the comment is visible to the public.
|
||||
CommentSchema.virtual('visible').get(function() {
|
||||
return ['ACCEPTED', 'NONE'].includes(this.status);
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Comment', CommentSchema);
|
||||
module.exports = mongoose.model('Comment', Comment);
|
||||
|
||||
+2
-8
@@ -1,10 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const { Migration } = require('./schema');
|
||||
|
||||
const MigrationSchema = new Schema({
|
||||
version: Number,
|
||||
});
|
||||
|
||||
const Migration = mongoose.model('Migration', MigrationSchema);
|
||||
|
||||
module.exports = Migration;
|
||||
module.exports = mongoose.model('Migration', Migration);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const uuid = require('uuid');
|
||||
const Schema = mongoose.Schema;
|
||||
const ACTION_TYPES = require('../enum/action_types');
|
||||
const ITEM_TYPES = require('../enum/item_types');
|
||||
|
||||
const Action = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
action_type: {
|
||||
type: String,
|
||||
enum: ACTION_TYPES,
|
||||
},
|
||||
item_type: {
|
||||
type: String,
|
||||
enum: ITEM_TYPES,
|
||||
},
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
|
||||
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
|
||||
// item_id.
|
||||
group_id: String,
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: Schema.Types.Mixed,
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Create an index on the `item_id` field so that queries looking for
|
||||
// actions based on the item id can resolve faster.
|
||||
Action.index(
|
||||
{
|
||||
item_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = Action;
|
||||
@@ -0,0 +1,97 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./tag_link');
|
||||
const { get } = require('lodash');
|
||||
|
||||
const Asset = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'assets',
|
||||
},
|
||||
scraped: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: String,
|
||||
description: String,
|
||||
image: String,
|
||||
section: String,
|
||||
subsection: String,
|
||||
author: String,
|
||||
publication_date: Date,
|
||||
modified_date: Date,
|
||||
|
||||
// This object is used exclusively for storing settings that are to override
|
||||
// the base settings from the base Settings object. This is to be accessed
|
||||
// always after running `rectifySettings` against it.
|
||||
settings: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
versionKey: false,
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Asset.index(
|
||||
{
|
||||
title: 'text',
|
||||
url: 'text',
|
||||
description: 'text',
|
||||
section: 'text',
|
||||
subsection: 'text',
|
||||
author: 'text',
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
Asset.virtual('isClosed').get(function() {
|
||||
const closedAt = get(this, 'closedAt', null);
|
||||
if (closedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return closedAt.getTime() <= new Date().getTime();
|
||||
});
|
||||
|
||||
module.exports = Asset;
|
||||
@@ -0,0 +1,246 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagLinkSchema = require('./tag_link');
|
||||
const uuid = require('uuid');
|
||||
const COMMENT_STATUS = require('../enum/comment_status');
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment Status.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Status = new Schema(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
},
|
||||
|
||||
// The User ID of the user that assigned the status.
|
||||
assigned_by: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
created_at: Date,
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Comment = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2,
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [Status],
|
||||
status: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
default: 'NONE',
|
||||
},
|
||||
|
||||
// parent_id is the id of the parent comment (null if there is none).
|
||||
parent_id: String,
|
||||
|
||||
// The number of replies to this comment directly.
|
||||
reply_count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
// Counts to store related to actions taken on the given comment.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the indexes for the id of the comment.
|
||||
Comment.index(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
asset_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
Comment.index(
|
||||
{
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
Comment.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for finding flagged comments.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index for the reply sort.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: -1,
|
||||
reply_count: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for finding a user's comments.
|
||||
Comment.index(
|
||||
{
|
||||
author_id: 1,
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
Comment.index(
|
||||
{
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for sorting based on the created_at timestamp
|
||||
// but also good at locating comments that have a specific asset id.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.virtual('edited').get(function() {
|
||||
return this.body_history.length > 1;
|
||||
});
|
||||
|
||||
// Visible is true when the comment is visible to the public.
|
||||
Comment.virtual('visible').get(function() {
|
||||
return ['ACCEPTED', 'NONE'].includes(this.status);
|
||||
});
|
||||
|
||||
module.exports = Comment;
|
||||
@@ -0,0 +1,21 @@
|
||||
const { CREATE_MONGO_INDEXES } = require('../../config');
|
||||
|
||||
const Action = require('./action');
|
||||
const Asset = require('./asset');
|
||||
const Comment = require('./comment');
|
||||
const Migration = require('./migration');
|
||||
const Setting = require('./setting');
|
||||
const User = require('./user');
|
||||
|
||||
const schema = { Action, Asset, Comment, Migration, Setting, User };
|
||||
|
||||
// Provide the schema to each of the plugins so that they can add in indexes if
|
||||
// it is enabled.
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
const plugins = require('../../services/plugins');
|
||||
plugins.get('server', 'indexes').map(({ indexes }) => {
|
||||
indexes(schema);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = schema;
|
||||
@@ -0,0 +1,8 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
const Migration = new Schema({
|
||||
version: Number,
|
||||
});
|
||||
|
||||
module.exports = Migration;
|
||||
@@ -0,0 +1,145 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagSchema = require('./tag');
|
||||
const MODERATION_OPTIONS = require('../enum/moderation_options');
|
||||
|
||||
/**
|
||||
* Setting manages application settings that get used on front and backend.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Setting = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: '1',
|
||||
},
|
||||
moderation: {
|
||||
type: String,
|
||||
enum: MODERATION_OPTIONS,
|
||||
default: 'POST',
|
||||
},
|
||||
infoBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customCssUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
infoBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
questionBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
questionBoxIcon: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
questionBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
organizationName: {
|
||||
type: String,
|
||||
},
|
||||
organizationContactEmail: {
|
||||
type: String,
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closedTimeout: {
|
||||
type: Number,
|
||||
|
||||
// Two weeks default expiry.
|
||||
default: 60 * 60 * 24 * 7 * 2,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: 'Expired',
|
||||
},
|
||||
wordlist: {
|
||||
banned: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
suspect: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
charCount: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
charCountEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
requireEmailConfirmation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
domains: {
|
||||
whitelist: {
|
||||
type: Array,
|
||||
default: ['localhost'],
|
||||
},
|
||||
},
|
||||
|
||||
// Length of time (in milliseconds) after a comment is posted that it can still be edited by the author
|
||||
editCommentWindowLength: {
|
||||
type: Number,
|
||||
min: [0, 'Edit Comment Window length must be greater than zero'],
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema],
|
||||
|
||||
// Additional metadata to let plugins write settings.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toObject: {
|
||||
transform: (doc, ret) => {
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merges two settings objects.
|
||||
*/
|
||||
Setting.method('merge', function(src) {
|
||||
Setting.eachPath(path => {
|
||||
// Exclude internal fields...
|
||||
if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the source object contains the path, shallow copy it.
|
||||
if (path in src) {
|
||||
this[path] = src[path];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = Setting;
|
||||
@@ -0,0 +1,375 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLink = require('./tag_link');
|
||||
const Token = require('./token');
|
||||
const can = require('../../perms');
|
||||
const { get } = require('lodash');
|
||||
|
||||
// USER_ROLES is the array of roles that is permissible as a user role.
|
||||
const USER_ROLES = require('../enum/user_roles');
|
||||
|
||||
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
|
||||
// the username state.
|
||||
const USER_STATUS_USERNAME = require('../enum/user_status_username');
|
||||
|
||||
// Profile is the mongoose schema defined as the representation of a
|
||||
// User's profile stored in MongoDB.
|
||||
const Profile = new Schema(
|
||||
{
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Metadata provides a place to put provider specific details. An example of
|
||||
// something that could be stored here is the `metadata.confirmed_at` could be
|
||||
// used by the `local` provider to indicate when the email address was
|
||||
// confirmed.
|
||||
metadata: {
|
||||
type: Schema.Types.Mixed,
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
// User is the mongoose schema defined as the representation of a User in
|
||||
// MongoDB.
|
||||
const User = new Schema(
|
||||
{
|
||||
// This ID represents the most unique identifier for a user, it is generated
|
||||
// when the user is created as a random uuid.
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// This is sourced from the social provider or set manually during user setup
|
||||
// and simply provides a name to display for the given user.
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// TODO: find a way that we can instead utilize MongoDB 3.4's collation
|
||||
// options to build the index in a case insenstive manner:
|
||||
// https://docs.mongodb.com/manual/reference/collation/
|
||||
lowercaseUsername: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// This provides a source of identity proof for users who login using the
|
||||
// local provider. A local provider will be assumed for users who do not
|
||||
// have any social profiles.
|
||||
password: String,
|
||||
|
||||
// Profiles describes the array of identities for a given user. Any one user
|
||||
// can have multiple profiles associated with them, including multiple email
|
||||
// addresses.
|
||||
profiles: [Profile],
|
||||
|
||||
// Tokens are the individual personal access tokens for a given user.
|
||||
tokens: [Token],
|
||||
|
||||
// Role is the specific user role that the user holds.
|
||||
role: {
|
||||
type: String,
|
||||
enum: USER_ROLES,
|
||||
required: true,
|
||||
default: 'COMMENTER',
|
||||
},
|
||||
|
||||
// Status stores the user status information regarding permissions,
|
||||
// capabilities and moderation state.
|
||||
status: {
|
||||
// Username stores the current user status for the username as well as the
|
||||
// history of changes.
|
||||
username: {
|
||||
// Status stores the current username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// History stores the history of username status changes.
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Banned stores the current user banned status as well as the history of
|
||||
// changes.
|
||||
banned: {
|
||||
// Status stores the current user banned status.
|
||||
status: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical banned status.
|
||||
status: Boolean,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Suspension stores the current user suspension status as well as the
|
||||
// history of changes.
|
||||
suspension: {
|
||||
// until is the date that the user is suspended until.
|
||||
until: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// until is the date that the user is suspended until.
|
||||
until: Date,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// IgnoresUsers is an array of user id's that the current user is ignoring.
|
||||
ignoresUsers: [String],
|
||||
|
||||
// Counts to store related to actions taken on the given user.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLink],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
// This will ensure that we have proper timestamps available on this model.
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.__v;
|
||||
delete ret._id;
|
||||
delete ret.password;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the index on the user profile data.
|
||||
User.index(
|
||||
{
|
||||
'profiles.id': 1,
|
||||
'profiles.provider': 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
User.index(
|
||||
{
|
||||
lowercaseUsername: 1,
|
||||
'profiles.id': 1,
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// This query is executed often, to count the number of flagged accounts with
|
||||
// usernames.
|
||||
User.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
'status.username.status': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Sorting users by created at is the default people search.
|
||||
User.index(
|
||||
{
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* returns true if a commenter is staff
|
||||
*/
|
||||
User.method('isStaff', function() {
|
||||
return this.role !== 'COMMENTER';
|
||||
});
|
||||
|
||||
/**
|
||||
* This verifies that a password is valid.
|
||||
*/
|
||||
User.method('verifyPassword', function(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.compare(password, this.password, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Can returns true if the user is allowed to perform a specific graph
|
||||
* operation.
|
||||
*/
|
||||
User.method('can', function(...actions) {
|
||||
return can(this, ...actions);
|
||||
});
|
||||
|
||||
/**
|
||||
* firstEmail will return the first email on the user.
|
||||
*/
|
||||
User.virtual('firstEmail').get(function() {
|
||||
const emails = this.emails;
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return emails[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* emails will return all the emails on a user.
|
||||
*/
|
||||
User.virtual('emails').get(function() {
|
||||
return (this.profiles || [])
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.map(({ id }) => id);
|
||||
});
|
||||
|
||||
/**
|
||||
* hasVerifiedEmail will return true if at least one of the local email accounts
|
||||
* have their email verified.
|
||||
*/
|
||||
User.virtual('hasVerifiedEmail').get(function() {
|
||||
return this.profiles
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.some(profile => {
|
||||
const confirmedAt = get(profile, 'metadata.confirmed_at') || null;
|
||||
|
||||
// If the profile doesn't have a metadata field, or it does not have a
|
||||
// confirmed_at field, or that field is null, then send them back.
|
||||
return confirmedAt !== null;
|
||||
});
|
||||
});
|
||||
|
||||
User.virtual('system')
|
||||
.get(function() {
|
||||
return this._system;
|
||||
})
|
||||
.set(function(system) {
|
||||
this._system = system;
|
||||
});
|
||||
|
||||
/**
|
||||
* banned returns true when the user is currently banned, and sets the banned
|
||||
* status locally.
|
||||
*/
|
||||
User.virtual('banned')
|
||||
.get(function() {
|
||||
return this.status.banned.status;
|
||||
})
|
||||
.set(function(status) {
|
||||
this.status.banned.status = status;
|
||||
this.status.banned.history.push({
|
||||
status,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* suspended returns true when the user is currently suspended, and sets the
|
||||
* suspension status locally.
|
||||
*/
|
||||
User.virtual('suspended')
|
||||
.get(function() {
|
||||
return Boolean(
|
||||
this.status.suspension.until && this.status.suspension.until > new Date()
|
||||
);
|
||||
})
|
||||
.set(function(until) {
|
||||
this.status.suspension.until = until;
|
||||
this.status.suspension.history.push({
|
||||
until,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = User;
|
||||
+2
-145
@@ -1,147 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagSchema = require('./schema/tag');
|
||||
const MODERATION_OPTIONS = require('./enum/moderation_options');
|
||||
const { Setting } = require('./schema');
|
||||
|
||||
/**
|
||||
* SettingSchema manages application settings that get used on front and backend.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const SettingSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: '1',
|
||||
},
|
||||
moderation: {
|
||||
type: String,
|
||||
enum: MODERATION_OPTIONS,
|
||||
default: 'POST',
|
||||
},
|
||||
infoBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customCssUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
infoBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
questionBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
questionBoxIcon: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
questionBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
organizationName: {
|
||||
type: String,
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closedTimeout: {
|
||||
type: Number,
|
||||
|
||||
// Two weeks default expiry.
|
||||
default: 60 * 60 * 24 * 7 * 2,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: 'Expired',
|
||||
},
|
||||
wordlist: {
|
||||
banned: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
suspect: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
charCount: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
charCountEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
requireEmailConfirmation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
domains: {
|
||||
whitelist: {
|
||||
type: Array,
|
||||
default: ['localhost'],
|
||||
},
|
||||
},
|
||||
|
||||
// Length of time (in milliseconds) after a comment is posted that it can still be edited by the author
|
||||
editCommentWindowLength: {
|
||||
type: Number,
|
||||
min: [0, 'Edit Comment Window length must be greater than zero'],
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema],
|
||||
|
||||
// Additional metadata to let plugins write settings.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toObject: {
|
||||
transform: (doc, ret) => {
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merges two settings objects.
|
||||
*/
|
||||
SettingSchema.method('merge', function(src) {
|
||||
SettingSchema.eachPath(path => {
|
||||
// Exclude internal fields...
|
||||
if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the source object contains the path, shallow copy it.
|
||||
if (path in src) {
|
||||
this[path] = src[path];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo Mongoose object.
|
||||
*/
|
||||
const Setting = mongoose.model('Setting', SettingSchema);
|
||||
|
||||
module.exports = Setting;
|
||||
module.exports = mongoose.model('Setting', Setting);
|
||||
|
||||
+2
-376
@@ -1,378 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const TokenSchema = require('./schema/token');
|
||||
const can = require('../perms');
|
||||
const { get } = require('lodash');
|
||||
const { User } = require('./schema');
|
||||
|
||||
// USER_ROLES is the array of roles that is permissible as a user role.
|
||||
const USER_ROLES = require('./enum/user_roles');
|
||||
|
||||
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
|
||||
// the username state.
|
||||
const USER_STATUS_USERNAME = require('./enum/user_status_username');
|
||||
|
||||
// ProfileSchema is the mongoose schema defined as the representation of a
|
||||
// User's profile stored in MongoDB.
|
||||
const ProfileSchema = new Schema(
|
||||
{
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Metadata provides a place to put provider specific details. An example of
|
||||
// something that could be stored here is the `metadata.confirmed_at` could be
|
||||
// used by the `local` provider to indicate when the email address was
|
||||
// confirmed.
|
||||
metadata: {
|
||||
type: Schema.Types.Mixed,
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
// UserSchema is the mongoose schema defined as the representation of a User in
|
||||
// MongoDB.
|
||||
const UserSchema = new Schema(
|
||||
{
|
||||
// This ID represents the most unique identifier for a user, it is generated
|
||||
// when the user is created as a random uuid.
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// This is sourced from the social provider or set manually during user setup
|
||||
// and simply provides a name to display for the given user.
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// TODO: find a way that we can instead utilize MongoDB 3.4's collation
|
||||
// options to build the index in a case insenstive manner:
|
||||
// https://docs.mongodb.com/manual/reference/collation/
|
||||
lowercaseUsername: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// This provides a source of identity proof for users who login using the
|
||||
// local provider. A local provider will be assumed for users who do not
|
||||
// have any social profiles.
|
||||
password: String,
|
||||
|
||||
// Profiles describes the array of identities for a given user. Any one user
|
||||
// can have multiple profiles associated with them, including multiple email
|
||||
// addresses.
|
||||
profiles: [ProfileSchema],
|
||||
|
||||
// Tokens are the individual personal access tokens for a given user.
|
||||
tokens: [TokenSchema],
|
||||
|
||||
// Role is the specific user role that the user holds.
|
||||
role: {
|
||||
type: String,
|
||||
enum: USER_ROLES,
|
||||
required: true,
|
||||
default: 'COMMENTER',
|
||||
},
|
||||
|
||||
// Status stores the user status information regarding permissions,
|
||||
// capabilities and moderation state.
|
||||
status: {
|
||||
// Username stores the current user status for the username as well as the
|
||||
// history of changes.
|
||||
username: {
|
||||
// Status stores the current username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// History stores the history of username status changes.
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Banned stores the current user banned status as well as the history of
|
||||
// changes.
|
||||
banned: {
|
||||
// Status stores the current user banned status.
|
||||
status: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical banned status.
|
||||
status: Boolean,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Suspension stores the current user suspension status as well as the
|
||||
// history of changes.
|
||||
suspension: {
|
||||
// until is the date that the user is suspended until.
|
||||
until: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// until is the date that the user is suspended until.
|
||||
until: Date,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// IgnoresUsers is an array of user id's that the current user is ignoring.
|
||||
ignoresUsers: [String],
|
||||
|
||||
// Counts to store related to actions taken on the given user.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
// This will ensure that we have proper timestamps available on this model.
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.__v;
|
||||
delete ret._id;
|
||||
delete ret.password;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the index on the user profile data.
|
||||
UserSchema.index(
|
||||
{
|
||||
'profiles.id': 1,
|
||||
'profiles.provider': 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
UserSchema.index(
|
||||
{
|
||||
lowercaseUsername: 1,
|
||||
'profiles.id': 1,
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// This query is executed often, to count the number of flagged accounts with
|
||||
// usernames.
|
||||
UserSchema.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
'status.username.status': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Sorting users by created at is the default people search.
|
||||
UserSchema.index(
|
||||
{
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* returns true if a commenter is staff
|
||||
*/
|
||||
UserSchema.method('isStaff', function() {
|
||||
return this.role !== 'COMMENTER';
|
||||
});
|
||||
|
||||
/**
|
||||
* This verifies that a password is valid.
|
||||
*/
|
||||
UserSchema.method('verifyPassword', function(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.compare(password, this.password, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Can returns true if the user is allowed to perform a specific graph
|
||||
* operation.
|
||||
*/
|
||||
UserSchema.method('can', function(...actions) {
|
||||
return can(this, ...actions);
|
||||
});
|
||||
|
||||
/**
|
||||
* firstEmail will return the first email on the user.
|
||||
*/
|
||||
UserSchema.virtual('firstEmail').get(function() {
|
||||
const emails = this.emails;
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return emails[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* emails will return all the emails on a user.
|
||||
*/
|
||||
UserSchema.virtual('emails').get(function() {
|
||||
return (this.profiles || [])
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.map(({ id }) => id);
|
||||
});
|
||||
|
||||
/**
|
||||
* hasVerifiedEmail will return true if at least one of the local email accounts
|
||||
* have their email verified.
|
||||
*/
|
||||
UserSchema.virtual('hasVerifiedEmail').get(function() {
|
||||
return this.profiles
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.some(profile => {
|
||||
const confirmedAt = get(profile, 'metadata.confirmed_at') || null;
|
||||
|
||||
// If the profile doesn't have a metadata field, or it does not have a
|
||||
// confirmed_at field, or that field is null, then send them back.
|
||||
return confirmedAt !== null;
|
||||
});
|
||||
});
|
||||
|
||||
UserSchema.virtual('system')
|
||||
.get(function() {
|
||||
return this._system;
|
||||
})
|
||||
.set(function(system) {
|
||||
this._system = system;
|
||||
});
|
||||
|
||||
/**
|
||||
* banned returns true when the user is currently banned, and sets the banned
|
||||
* status locally.
|
||||
*/
|
||||
UserSchema.virtual('banned')
|
||||
.get(function() {
|
||||
return this.status.banned.status;
|
||||
})
|
||||
.set(function(status) {
|
||||
this.status.banned.status = status;
|
||||
this.status.banned.history.push({
|
||||
status,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* suspended returns true when the user is currently suspended, and sets the
|
||||
* suspension status locally.
|
||||
*/
|
||||
UserSchema.virtual('suspended')
|
||||
.get(function() {
|
||||
return Boolean(
|
||||
this.status.suspension.until && this.status.suspension.until > new Date()
|
||||
);
|
||||
})
|
||||
.set(function(until) {
|
||||
this.status.suspension.until = until;
|
||||
this.status.suspension.history.push({
|
||||
until,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
// Create the User model.
|
||||
const UserModel = mongoose.model('User', UserSchema);
|
||||
|
||||
module.exports = UserModel;
|
||||
module.exports = mongoose.model('User', User);
|
||||
|
||||
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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -18,10 +18,12 @@
|
||||
"lint:js": "eslint bin/cli* .",
|
||||
"lint": "npm-run-all lint:*",
|
||||
"plugins:reconcile": "./bin/cli plugins reconcile",
|
||||
"test": "npm-run-all test:client test:server",
|
||||
"test:server": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test:client": "TEST_MODE=unit NODE_ENV=test jest",
|
||||
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
|
||||
"test": "npm-run-all test:jest test:mocha",
|
||||
"test:jest": "NODE_ENV=test jest --runInBand",
|
||||
"test:client": "NODE_ENV=test jest --projects client",
|
||||
"test:server": "npm-run-all test:server:jest test:server:mocha",
|
||||
"test:server:mocha": "NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test:server:jest": "NODE_ENV=test jest --runInBand --projects .",
|
||||
"e2e": "./scripts/e2e.js",
|
||||
"e2e:ci": "./scripts/e2e-ci.sh",
|
||||
"heroku-postbuild": "npm-run-all plugins:reconcile build",
|
||||
@@ -77,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",
|
||||
@@ -127,6 +130,7 @@
|
||||
"inquirer-autocomplete-prompt": "^0.12.1",
|
||||
"ioredis": "3.1.4",
|
||||
"ip": "^1.1.5",
|
||||
"jest": "^22.4.3",
|
||||
"joi": "^13.0.0",
|
||||
"jsonwebtoken": "^8.0.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
@@ -215,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",
|
||||
@@ -226,7 +229,6 @@
|
||||
"eslint-plugin-mocha": "^4.11.0",
|
||||
"husky": "^0.14.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^21.2.1",
|
||||
"jest-junit": "^3.6.0",
|
||||
"lint-staged": "^7.0.0",
|
||||
"mocha": "^3.1.2",
|
||||
|
||||
@@ -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,24 +2,23 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants');
|
||||
const { ErrNotFound, ErrAlreadyExists } = require('../../../errors');
|
||||
const pluralize = require('pluralize');
|
||||
const sc = require('snake-case');
|
||||
const CommentModel = require('../../../models/comment');
|
||||
const { CREATE_MONGO_INDEXES } = require('../../../config');
|
||||
// const { CREATE_MONGO_INDEXES } = require('../../../config');
|
||||
|
||||
function getReactionConfig(reaction) {
|
||||
reaction = reaction.toLowerCase();
|
||||
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
// Create the index on the comment model based on the reaction config.
|
||||
CommentModel.collection.createIndex(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
// if (CREATE_MONGO_INDEXES) {
|
||||
// // Create the index on the comment model based on the reaction config.
|
||||
// CommentModel.collection.createIndex(
|
||||
// {
|
||||
// created_at: 1,
|
||||
// [`action_counts.${sc(reaction)}`]: 1,
|
||||
// },
|
||||
// {
|
||||
// background: true,
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
const reactionPlural = pluralize(reaction);
|
||||
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
|
||||
@@ -128,8 +127,8 @@ function getReactionConfig(reaction) {
|
||||
|
||||
return {
|
||||
typeDefs,
|
||||
schemas: ({ CommentSchema }) => {
|
||||
CommentSchema.index(
|
||||
indexes: ({ Comment }) => {
|
||||
Comment.index(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
@@ -239,26 +238,14 @@ function getReactionConfig(reaction) {
|
||||
hooks: {
|
||||
Action: {
|
||||
__resolveType: {
|
||||
post({ action_type }) {
|
||||
switch (action_type) {
|
||||
case REACTION:
|
||||
return `${Reaction}Action`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
post: ({ action_type }) =>
|
||||
action_type === REACTION ? `${Reaction}Action` : undefined,
|
||||
},
|
||||
},
|
||||
ActionSummary: {
|
||||
__resolveType: {
|
||||
post({ action_type }) {
|
||||
switch (action_type) {
|
||||
case REACTION:
|
||||
return `${Reaction}ActionSummary`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
post: ({ action_type = '' } = {}) =>
|
||||
action_type === REACTION ? `${Reaction}ActionSummary` : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const getReactionConfig = require('./getReactionConfig');
|
||||
|
||||
describe('plugins-api', () => {
|
||||
describe('getReactionConfig', () => {
|
||||
let config;
|
||||
beforeEach(() => {
|
||||
config = getReactionConfig('heart');
|
||||
});
|
||||
|
||||
describe('context', () => {
|
||||
it('provides a sort function', () => {
|
||||
expect(config.context.Sort).toBeInstanceOf(Function);
|
||||
const sort = config.context.Sort();
|
||||
expect(sort.Comments).toHaveProperty('hearts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks', () => {
|
||||
it('handles the __resolveType properly', () => {
|
||||
expect(config.hooks.ActionSummary.__resolveType).toHaveProperty('post');
|
||||
expect(config.hooks.ActionSummary.__resolveType.post).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({ action_type: 'LOVE' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({
|
||||
action_type: 'HEART',
|
||||
})
|
||||
).toEqual('HeartActionSummary');
|
||||
});
|
||||
it('handles the __resolveType properly', () => {
|
||||
expect(config.hooks.Action.__resolveType).toHaveProperty('post');
|
||||
expect(config.hooks.Action.__resolveType.post).toBeInstanceOf(Function);
|
||||
expect(config.hooks.Action.__resolveType.post({})).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.Action.__resolveType.post({ action_type: 'LOVE' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.Action.__resolveType.post({
|
||||
action_type: 'HEART',
|
||||
})
|
||||
).toEqual('HeartAction');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,126 +1,5 @@
|
||||
const debug = require('debug')('talk:plugin:akismet');
|
||||
const { ErrSpam } = require('./errors');
|
||||
const akismet = require('akismet-api');
|
||||
const { get, merge } = require('lodash');
|
||||
const { KEY, SITE } = require('./config');
|
||||
const client = akismet.client({
|
||||
key: KEY,
|
||||
blog: SITE,
|
||||
});
|
||||
const typeDefs = require('./server/typeDefs');
|
||||
const hooks = require('./server/hooks');
|
||||
const resolvers = require('./server/resolvers');
|
||||
|
||||
let enabled = true;
|
||||
|
||||
// TODO: when using a developer key, this is possible, the plus plan does not
|
||||
// allow us to check the key.
|
||||
// let enabled = false;
|
||||
// client.verifyKey((err, valid) => {
|
||||
// if (err) {
|
||||
// throw err;
|
||||
// }
|
||||
|
||||
// if (valid) {
|
||||
// enabled = true;
|
||||
// } else {
|
||||
// throw new Error('Akismet key is invalid');
|
||||
// }
|
||||
// });
|
||||
|
||||
module.exports = {
|
||||
typeDefs: `
|
||||
input CreateCommentInput {
|
||||
|
||||
# If true, the mutation will fail when the
|
||||
# body contains detected spam.
|
||||
checkSpam: Boolean
|
||||
}
|
||||
|
||||
type Comment {
|
||||
spam: Boolean
|
||||
}
|
||||
`,
|
||||
hooks: {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
async pre(_, { input }, { loaders, parent: req }) {
|
||||
// If the key validation failed, then we can't run with the client.
|
||||
if (!enabled) {
|
||||
debug('not enabled, passing');
|
||||
return;
|
||||
}
|
||||
|
||||
let spam = false;
|
||||
try {
|
||||
const user_ip = get(req, 'ip', false);
|
||||
if (!user_ip) {
|
||||
debug('no ip on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some headers from the request.
|
||||
const user_agent = req.get('User-Agent');
|
||||
if (!user_agent || user_agent.length === 0) {
|
||||
debug('no user agent on request');
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get('Referrer');
|
||||
if (!referrer || referrer.length === 0) {
|
||||
debug('no referrer on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Asset that the comment is being made against.
|
||||
const asset = await loaders.Assets.getByID.load(input.asset_id);
|
||||
if (!asset) {
|
||||
debug('asset not found for new comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send off the comment to Akismet to check to see what they say.
|
||||
spam = await client.checkSpam({
|
||||
user_ip,
|
||||
user_agent,
|
||||
referrer,
|
||||
permalink: asset.url,
|
||||
comment_type: 'comment',
|
||||
comment_content: input.body,
|
||||
is_test: true,
|
||||
});
|
||||
|
||||
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach scores to metadata.
|
||||
input.metadata = merge({}, input.metadata || {}, {
|
||||
akismet: spam,
|
||||
});
|
||||
|
||||
if (spam) {
|
||||
if (input.checkSpam) {
|
||||
throw new ErrSpam();
|
||||
}
|
||||
|
||||
// Attach reason information for the flag being added.
|
||||
input.status = 'SYSTEM_WITHHELD';
|
||||
input.actions =
|
||||
input.actions && input.actions.length >= 0 ? input.actions : [];
|
||||
input.actions.push({
|
||||
action_type: 'FLAG',
|
||||
user_id: null,
|
||||
group_id: 'SPAM_COMMENT',
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolvers: {
|
||||
Comment: {
|
||||
spam: comment => get(comment, 'metadata.akismet', null),
|
||||
},
|
||||
},
|
||||
};
|
||||
module.exports = { typeDefs, hooks, resolvers };
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const debug = require('debug')('talk:plugin:akismet');
|
||||
const { ErrSpam } = require('./errors');
|
||||
const akismet = require('akismet-api');
|
||||
const { get, merge } = require('lodash');
|
||||
const { KEY, SITE } = require('./config');
|
||||
const client = akismet.client({
|
||||
key: KEY,
|
||||
blog: SITE,
|
||||
});
|
||||
|
||||
let enabled = true;
|
||||
|
||||
// TODO: when using a developer key, this is possible, the plus plan does not
|
||||
// allow us to check the key.
|
||||
// let enabled = false;
|
||||
// client.verifyKey((err, valid) => {
|
||||
// if (err) {
|
||||
// throw err;
|
||||
// }
|
||||
|
||||
// if (valid) {
|
||||
// enabled = true;
|
||||
// } else {
|
||||
// throw new Error('Akismet key is invalid');
|
||||
// }
|
||||
// });
|
||||
|
||||
module.exports = {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
async pre(_, { input }, { loaders, parent: req }) {
|
||||
// If the key validation failed, then we can't run with the client.
|
||||
if (!enabled) {
|
||||
debug('not enabled, passing');
|
||||
return;
|
||||
}
|
||||
|
||||
let spam = false;
|
||||
try {
|
||||
const user_ip = get(req, 'ip', false);
|
||||
if (!user_ip) {
|
||||
debug('no ip on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some headers from the request.
|
||||
const user_agent = req.get('User-Agent');
|
||||
if (!user_agent || user_agent.length === 0) {
|
||||
debug('no user agent on request');
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get('Referrer');
|
||||
if (!referrer || referrer.length === 0) {
|
||||
debug('no referrer on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Asset that the comment is being made against.
|
||||
const asset = await loaders.Assets.getByID.load(input.asset_id);
|
||||
if (!asset) {
|
||||
debug('asset not found for new comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send off the comment to Akismet to check to see what they say.
|
||||
spam = await client.checkSpam({
|
||||
user_ip,
|
||||
user_agent,
|
||||
referrer,
|
||||
permalink: asset.url,
|
||||
comment_type: 'comment',
|
||||
comment_content: input.body,
|
||||
is_test: true,
|
||||
});
|
||||
|
||||
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach scores to metadata.
|
||||
input.metadata = merge({}, input.metadata || {}, {
|
||||
akismet: spam,
|
||||
});
|
||||
|
||||
if (spam) {
|
||||
if (input.checkSpam) {
|
||||
throw new ErrSpam();
|
||||
}
|
||||
|
||||
// Attach reason information for the flag being added.
|
||||
input.status = 'SYSTEM_WITHHELD';
|
||||
input.actions =
|
||||
input.actions && input.actions.length >= 0 ? input.actions : [];
|
||||
input.actions.push({
|
||||
action_type: 'FLAG',
|
||||
user_id: null,
|
||||
group_id: 'SPAM_COMMENT',
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { get } = require('lodash');
|
||||
|
||||
module.exports = {
|
||||
Comment: {
|
||||
spam: comment => get(comment, 'metadata.akismet', null),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const resolvers = require('./resolvers');
|
||||
|
||||
describe('talk-plugin-akismet', () => {
|
||||
describe('resolvers', () => {
|
||||
it('resolves when there is a akismet value', () => {
|
||||
const spam = resolvers.Comment.spam({ metadata: { akismet: true } });
|
||||
expect(spam).toEqual(true);
|
||||
});
|
||||
it('resolves when there not is a akismet value', () => {
|
||||
const spam = resolvers.Comment.spam({});
|
||||
expect(spam).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
input CreateCommentInput {
|
||||
|
||||
# If true, the mutation will fail when the
|
||||
# body contains detected spam.
|
||||
checkSpam: Boolean
|
||||
}
|
||||
|
||||
type Comment {
|
||||
spam: Boolean
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = fs.readFileSync(
|
||||
path.join(__dirname, 'typeDefs.graphql'),
|
||||
'utf8'
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -58,7 +58,7 @@ da:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
@@ -101,7 +101,7 @@ en:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
@@ -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:
|
||||
@@ -324,7 +342,7 @@ pt_BR:
|
||||
sign_in: "Sign in"
|
||||
sign_in_to_join: "Sign in to join the conversation"
|
||||
or: "Or"
|
||||
email: "E-mail Address"
|
||||
email: "Email Address"
|
||||
password: "Password"
|
||||
forgot_your_pass: "Forgot your password?"
|
||||
need_an_account: "Need an account?"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user