mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b60c879599 | ||
|
|
0f93d6ce8a | ||
|
|
b8ffe51812 | ||
|
|
bf298b311a | ||
|
|
b1d9ca0640 | ||
|
|
c0ac5b0799 | ||
|
|
2039cc5b9e | ||
|
|
bb2284909f | ||
|
|
f018d7f597 | ||
|
|
6794827a77 | ||
|
|
6956b4cd31 | ||
|
|
83ded4bda9 | ||
|
|
1fea2f77c1 | ||
|
|
80cfc663e3 | ||
|
|
a3cda1cf1c | ||
|
|
0013c36686 | ||
|
|
d947e103fc | ||
|
|
6d70a7b20e | ||
|
|
b9243938bd | ||
|
|
ca422f0109 | ||
|
|
c62e7328c5 | ||
|
|
df286a31ad | ||
|
|
ca069317e9 | ||
|
|
7c7799d2c0 | ||
|
|
8d30129f47 | ||
|
|
a9ec160f95 | ||
|
|
85836a3965 | ||
|
|
b10ed4e43e | ||
|
|
1e00d75e3c | ||
|
|
66b3d4ab00 | ||
|
|
1ab662efda | ||
|
|
d100128843 | ||
|
|
89e7f54a32 | ||
|
|
e14d380d66 | ||
|
|
fd87ef3b85 | ||
|
|
b260945822 | ||
|
|
1521ec43eb | ||
|
|
5ebdfa8c23 | ||
|
|
6913fe05ae | ||
|
|
41b2af76c4 | ||
|
|
9f1bce1221 | ||
|
|
528c2a9491 | ||
|
|
948d362c40 | ||
|
|
30842362cf | ||
|
|
976bd1e9ad | ||
|
|
7bdd28cb9c | ||
|
|
e16dd14e47 | ||
|
|
c9f2c27a69 | ||
|
|
c4a91d225e | ||
|
|
d1fc5668fb | ||
|
|
aad245fcbf | ||
|
|
49ad48d70f | ||
|
|
800fd63549 | ||
|
|
c08d040d1d | ||
|
|
1e8d2ce3a6 | ||
|
|
a22d0f7536 | ||
|
|
53cac48e3b | ||
|
|
27216cbfdb | ||
|
|
ddadd6c5d3 | ||
|
|
10936d9f41 | ||
|
|
1d5f1e676f | ||
|
|
c2f8f41e59 | ||
|
|
567f8bf94e | ||
|
|
11b2bc265a | ||
|
|
76b125f042 | ||
|
|
d2a2f94531 | ||
|
|
0df4fd1e37 | ||
|
|
5ceda4f2d2 | ||
|
|
0523a1f828 | ||
|
|
12d3273d3d | ||
|
|
e6330cd8c3 | ||
|
|
bc67bc7b78 | ||
|
|
a91a2d3fa3 | ||
|
|
32a8d78eb8 | ||
|
|
203707124c | ||
|
|
a39b7237e6 | ||
|
|
190e93c8d7 | ||
|
|
02a75c590f | ||
|
|
8cdf289b13 | ||
|
|
e435c323ec | ||
|
|
f92754a700 | ||
|
|
5c8130bece | ||
|
|
8ceeb4b275 | ||
|
|
3ec81099f7 | ||
|
|
c9b82008de | ||
|
|
5d2b9439d8 | ||
|
|
b8f24b952b | ||
|
|
b6d284495e | ||
|
|
a527990413 | ||
|
|
325626fecf | ||
|
|
15c7a7cad7 | ||
|
|
b972f4305e |
+32
-6
@@ -94,7 +94,7 @@ const performSetup = async () => {
|
||||
name: 'requireEmailConfirmation',
|
||||
default: settings.requireEmailConfirmation,
|
||||
message: 'Should emails always be confirmed'
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
// Update the settings that were changed.
|
||||
@@ -104,6 +104,32 @@ const performSetup = async () => {
|
||||
}
|
||||
});
|
||||
|
||||
answers = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'inputWhitelistedDomains',
|
||||
default: true,
|
||||
message: 'Would you like to specify a whitelisted domain'
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'whitelistedDomain',
|
||||
message: 'Whitelisted Domain',
|
||||
when: ({inputWhitelistedDomains}) => inputWhitelistedDomains,
|
||||
validate: (input) => {
|
||||
if (input && input.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Whitelisted Domain cannot be empty.';
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
if (answers.inputWhitelistedDomains) {
|
||||
settings.domains.whitelist = [answers.whitelistedDomain];
|
||||
}
|
||||
|
||||
console.log('\nWe\'ll ask you some questions about your first admin user.\n');
|
||||
|
||||
let user = await inquirer.prompt([
|
||||
@@ -147,7 +173,11 @@ const performSetup = async () => {
|
||||
name: 'confirmPassword',
|
||||
message: 'Confirm Password',
|
||||
type: 'password',
|
||||
filter: (confirmPassword) => {
|
||||
filter: (confirmPassword, {password}) => {
|
||||
if (password !== confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
return UsersService
|
||||
.isValidPassword(confirmPassword)
|
||||
.catch((err) => {
|
||||
@@ -157,10 +187,6 @@ const performSetup = async () => {
|
||||
},
|
||||
]);
|
||||
|
||||
if (user.password !== user.confirmPassword) {
|
||||
return Promise.reject(new Error('Passwords do not match'));
|
||||
}
|
||||
|
||||
let {user: newUser} = await SetupService.setup({
|
||||
settings: settings.toObject(),
|
||||
user: {
|
||||
|
||||
@@ -37,27 +37,11 @@ const routes = (
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
<IndexRoute components={Moderation} />
|
||||
|
||||
<Route path='all' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='new' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='approved' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='premod' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='rejected' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
<Route path='reported' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
|
||||
<Route path=':id' components={Moderation} />
|
||||
<Route path=':tabOrId' components={Moderation} />
|
||||
|
||||
<Route path=':tab' components={Moderation}>
|
||||
<Route path=':id' components={Moderation} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
color: white;
|
||||
background: grey;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 8px;
|
||||
padding: 0px 5px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
|
||||
height: 24px;
|
||||
letter-spacing: 0.4px;
|
||||
margin-bottom: 1px;
|
||||
|
||||
> i {
|
||||
font-size: 14px;
|
||||
vertical-align: text-top;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.flagBox {
|
||||
border-top: 1px solid rgba(66, 66, 66, 0.12);
|
||||
|
||||
margin-top: 10px;
|
||||
.container {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
@@ -61,9 +61,11 @@ class FlagBox extends Component {
|
||||
<ul>
|
||||
{actionList.map((action, j) =>
|
||||
<li key={`${i}_${j}`} className={styles.subDetail}>
|
||||
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
|
||||
{action.user.username}
|
||||
</a>
|
||||
{action.user &&
|
||||
<a className={styles.username} onClick={() => viewUserDetail(action.user.id)}>
|
||||
{action.user.username}
|
||||
</a>
|
||||
}
|
||||
{action.message}
|
||||
</li>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
max-width: 660px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
|
||||
+4
-3
@@ -1,9 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
import styles from './LoadMore.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
const LoadMore = ({loadMore, showLoadMore}) =>
|
||||
<div className={styles.loadMoreContainer}>
|
||||
const LoadMore = ({loadMore, showLoadMore, className, ...rest}) =>
|
||||
<div {...rest} className={cn(className, styles.loadMoreContainer)}>
|
||||
{
|
||||
showLoadMore && <Button
|
||||
className={styles.loadMore}
|
||||
@@ -186,7 +186,6 @@
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.minimal {
|
||||
|
||||
@@ -79,3 +79,12 @@
|
||||
margin-left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadMore > button {
|
||||
background-color: #696969;
|
||||
|
||||
&:hover {
|
||||
background-color: #404040;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {Slot} from 'coral-framework/components';
|
||||
import ButtonCopyToClipboard from './ButtonCopyToClipboard';
|
||||
import {actionsMap} from '../utils/moderationQueueActionsMap';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import LoadMore from '../components/LoadMore';
|
||||
|
||||
export default class UserDetail extends React.Component {
|
||||
|
||||
@@ -59,7 +60,7 @@ export default class UserDetail extends React.Component {
|
||||
user,
|
||||
totalComments,
|
||||
rejectedComments,
|
||||
comments: {nodes}
|
||||
comments: {nodes, hasNextPage}
|
||||
},
|
||||
activeTab,
|
||||
selectedCommentIds,
|
||||
@@ -70,6 +71,7 @@ export default class UserDetail extends React.Component {
|
||||
bulkReject,
|
||||
hideUserDetail,
|
||||
viewUserDetail,
|
||||
loadMore,
|
||||
} = this.props;
|
||||
|
||||
const localProfile = user.profiles.find((p) => p.provider === 'local');
|
||||
@@ -167,6 +169,11 @@ export default class UserDetail extends React.Component {
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<LoadMore
|
||||
className={styles.loadMore}
|
||||
loadMore={loadMore}
|
||||
showLoadMore={hasNextPage}
|
||||
/>
|
||||
</Drawer>
|
||||
</ClickOutside>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.root:last-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rootSelected {
|
||||
background-color: #ecf4ff;
|
||||
}
|
||||
|
||||
@@ -10,16 +10,18 @@
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
font-size: 26px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: #E5E5E5;
|
||||
background: #696969;
|
||||
height: 100%;
|
||||
width: 128px;
|
||||
z-index: 10;
|
||||
border-right: 1px #757575 solid;
|
||||
}
|
||||
|
||||
.base {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'coral-admin/src/actions/userDetail';
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import UserDetailComment from './UserDetailComment';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const commentConnectionFragment = gql`
|
||||
fragment CoralAdmin_Moderation_CommentConnection on CommentConnection {
|
||||
@@ -32,6 +33,7 @@ const slots = [
|
||||
];
|
||||
|
||||
class UserDetailContainer extends React.Component {
|
||||
isLoadingMore = false;
|
||||
|
||||
// status can be 'ACCEPTED' or 'REJECTED'
|
||||
bulkSetCommentStatus = (status) => {
|
||||
@@ -40,7 +42,6 @@ class UserDetailContainer extends React.Component {
|
||||
});
|
||||
|
||||
Promise.all(changes).then(() => {
|
||||
this.props.data.refetch(); // some comments may have moved out of this tab
|
||||
this.props.clearUserDetailSelections(); // un-select everything
|
||||
});
|
||||
}
|
||||
@@ -61,12 +62,53 @@ class UserDetailContainer extends React.Component {
|
||||
return this.props.setCommentStatus({commentId, status: 'REJECTED'});
|
||||
}
|
||||
|
||||
loadMore = () => {
|
||||
if (this.isLoadingMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoadingMore = true;
|
||||
const variables = {
|
||||
limit: 10,
|
||||
cursor: this.props.root.comments.endCursor,
|
||||
author_id: this.props.data.variables.author_id,
|
||||
statuses: this.props.data.variables.statuses,
|
||||
};
|
||||
this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables,
|
||||
updateQuery: (prev, {fetchMoreResult:{comments}}) => {
|
||||
return update(prev, {
|
||||
comments: {
|
||||
nodes: {$push: comments.nodes},
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
startCursor: {$set: comments.startCursor},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
this.isLoadingMore = false;
|
||||
})
|
||||
.catch((err) => {
|
||||
this.isLoadingMore = false;
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (this.props.userId === null && next.userId) {
|
||||
next.data.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
if (!this.props.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const loading = !('user' in this.props.root) || this.props.root.user.id !== this.props.userId;
|
||||
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
|
||||
|
||||
return <UserDetail
|
||||
bulkReject={this.bulkReject}
|
||||
@@ -76,10 +118,20 @@ class UserDetailContainer extends React.Component {
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
loading={loading}
|
||||
loadMore={this.loadMore}
|
||||
{...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`;
|
||||
|
||||
export const withUserDetailQuery = withQuery(gql`
|
||||
query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) {
|
||||
user(id: $author_id) {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
.count {
|
||||
display: inline-block;
|
||||
background: #989797;
|
||||
background: #616161;
|
||||
margin: 2px;
|
||||
vertical-align: middle;
|
||||
padding: 1px 7px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 2px;
|
||||
margin-left: 2px;
|
||||
line-height: 20px;
|
||||
line-height: 18px;
|
||||
box-sizing: border-box;
|
||||
height: 21px;
|
||||
height: 18px;
|
||||
right: 0;
|
||||
margin-top: -2px;
|
||||
margin-top: 0px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -101,33 +101,19 @@ export default class Moderation extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, premodEnabled, ...props} = this.props;
|
||||
const assetId = this.props.params.id;
|
||||
const {root, data, moderation, settings, viewUserDetail, hideUserDetail, activeTab, getModPath, queueConfig, handleCommentChange, ...props} = this.props;
|
||||
const {asset} = root;
|
||||
const assetId = asset && asset.id;
|
||||
|
||||
const comments = root[activeTab];
|
||||
|
||||
let activeTabCount;
|
||||
switch(activeTab) {
|
||||
case 'all':
|
||||
activeTabCount = root.allCount;
|
||||
break;
|
||||
case 'new':
|
||||
activeTabCount = root.newCount;
|
||||
break;
|
||||
case 'approved':
|
||||
activeTabCount = root.approvedCount;
|
||||
break;
|
||||
case 'premod':
|
||||
activeTabCount = root.premodCount;
|
||||
break;
|
||||
case 'reported':
|
||||
activeTabCount = root.reportedCount;
|
||||
break;
|
||||
case 'rejected':
|
||||
activeTabCount = root.rejectedCount;
|
||||
break;
|
||||
}
|
||||
const activeTabCount = root[`${activeTab}Count`];
|
||||
const menuItems = Object.keys(queueConfig).map((queue) => ({
|
||||
key: queue,
|
||||
name: queueConfig[queue].name,
|
||||
icon: queueConfig[queue].icon,
|
||||
count: root[`${queue}Count`]
|
||||
}));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -139,16 +125,10 @@ export default class Moderation extends Component {
|
||||
/>
|
||||
<ModerationMenu
|
||||
asset={asset}
|
||||
allCount={root.allCount}
|
||||
newCount={root.newCount}
|
||||
getModPath={getModPath}
|
||||
approvedCount={root.approvedCount}
|
||||
premodCount={root.premodCount}
|
||||
rejectedCount={root.rejectedCount}
|
||||
reportedCount={root.reportedCount}
|
||||
items={menuItems}
|
||||
selectSort={this.props.setSortOrder}
|
||||
sort={this.props.moderation.sortOrder}
|
||||
premodEnabled={premodEnabled}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
<ModerationQueue
|
||||
@@ -191,6 +171,7 @@ export default class Moderation extends Component {
|
||||
root={root}
|
||||
assset={asset}
|
||||
activeTab={activeTab}
|
||||
handleCommentChange={handleCommentChange}
|
||||
fill='adminModeration'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,16 +9,10 @@ import cn from 'classnames';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const ModerationMenu = ({
|
||||
asset = {},
|
||||
allCount,
|
||||
approvedCount,
|
||||
premodCount,
|
||||
newCount,
|
||||
rejectedCount,
|
||||
reportedCount,
|
||||
asset = {},
|
||||
items,
|
||||
selectSort,
|
||||
sort,
|
||||
premodEnabled,
|
||||
getModPath,
|
||||
activeTab
|
||||
}) => {
|
||||
@@ -27,49 +21,15 @@ const ModerationMenu = ({
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div className={styles.tabBarPadding} />
|
||||
<div>
|
||||
|
||||
{
|
||||
premodEnabled ? (
|
||||
<Link
|
||||
to={getModPath('premod', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'premod'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='access_time' className={styles.tabIcon} /> {t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={getModPath('new', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'new'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.new')} <CommentCount count={newCount} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
<Link
|
||||
to={getModPath('reported', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'reported'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='flag' className={styles.tabIcon} /> {t('modqueue.reported')} <CommentCount count={reportedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getModPath('approved', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'approved'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='check' className={styles.tabIcon} /> {t('modqueue.approved')} <CommentCount count={approvedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getModPath('rejected', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'rejected'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='close' className={styles.tabIcon} /> {t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
<Link
|
||||
to={getModPath('all', asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === 'all'})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name='question_answer' className={styles.tabIcon} /> {t('modqueue.all')} <CommentCount count={allCount} />
|
||||
</Link>
|
||||
{items.map((queue) =>
|
||||
<Link
|
||||
key={queue.key}
|
||||
to={getModPath(queue.key, asset.id)}
|
||||
className={cn('mdl-tabs__tab', styles.tab, {[styles.active]: activeTab === queue.key})}
|
||||
activeClassName={styles.active}>
|
||||
<Icon name={queue.icon} className={styles.tabIcon} /> {queue.name} <CommentCount count={queue.count} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
@@ -85,10 +45,7 @@ const ModerationMenu = ({
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
allCount: PropTypes.number.isRequired,
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
reportedCount: PropTypes.number.isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import Comment from '../containers/Comment';
|
||||
import styles from './styles.css';
|
||||
import EmptyCard from '../../../components/EmptyCard';
|
||||
import {actionsMap} from '../../../utils/moderationQueueActionsMap';
|
||||
import LoadMore from './LoadMore';
|
||||
import LoadMore from '../../../components/LoadMore';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {CSSTransitionGroup} from 'react-transition-group';
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: #C0C0C0;
|
||||
color: #BDBDBD;
|
||||
text-transform: capitalize;
|
||||
font-weight: 100;
|
||||
font-size: 14px;
|
||||
@@ -29,7 +29,7 @@
|
||||
margin-right: 20px;
|
||||
&:hover {
|
||||
color: white;
|
||||
border-bottom: solid 2px #F36451;
|
||||
/*border-bottom: solid 2px #F36451;*/
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ span {
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
transition: background-color 200ms;
|
||||
opacity: 1;
|
||||
@@ -173,7 +173,7 @@ span {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 18px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-width: 650px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -397,26 +397,6 @@ span {
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
};
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
max-width: 660px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
@@ -490,7 +470,7 @@ span {
|
||||
|
||||
.searchTrigger {
|
||||
position: relative;
|
||||
top: .3em;
|
||||
top: .2em;
|
||||
}
|
||||
|
||||
.adminCommentInfoBar {
|
||||
@@ -499,4 +479,4 @@ span {
|
||||
right: 0px;
|
||||
top: 0px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import NotFoundAsset from '../components/NotFoundAsset';
|
||||
import {isPremod, getModPath} from '../../../utils';
|
||||
|
||||
import {withSetCommentStatus} from 'coral-framework/graphql/mutations';
|
||||
import {handleCommentChange} from '../../../graphql/utils';
|
||||
import {handleCommentChange} from '../graphql';
|
||||
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import {showBanUserDialog} from 'actions/banUserDialog';
|
||||
@@ -30,23 +30,44 @@ import {
|
||||
import {Spinner} from 'coral-ui';
|
||||
import Moderation from '../components/Moderation';
|
||||
import Comment from './Comment';
|
||||
import queueConfig from '../queueConfig';
|
||||
|
||||
function prepareNotificationText(text) {
|
||||
return truncate(text, {length: 50}).replace('\n', ' ');
|
||||
}
|
||||
|
||||
function getAssetId(props) {
|
||||
if (props.params.tabOrId && !(props.params.tabOrId in queueConfig)) {
|
||||
return props.params.tabOrId;
|
||||
}
|
||||
return props.params.id || null;
|
||||
}
|
||||
|
||||
function getTab(props) {
|
||||
if (props.params.tabOrId && props.params.tabOrId in queueConfig) {
|
||||
return props.params.tabOrId;
|
||||
}
|
||||
return props.params.tab || null;
|
||||
}
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
subscriptions = [];
|
||||
|
||||
handleCommentChange = (root, comment, notify) => {
|
||||
return handleCommentChange(root, comment, this.props.data.variables.sort, notify, queueConfig, this.activeTab);
|
||||
};
|
||||
|
||||
get activeTab() {
|
||||
|
||||
const {root: {asset, settings}, router, route} = this.props;
|
||||
const {root: {asset, settings}} = this.props;
|
||||
const id = getAssetId(this.props);
|
||||
const tab = getTab(this.props);
|
||||
|
||||
// Grab premod from asset or from settings
|
||||
const premod = !router.params.id ? settings.moderation : asset.settings.moderation;
|
||||
const premod = !id ? settings.moderation : asset.settings.moderation;
|
||||
|
||||
const queue = isPremod(premod) ? 'premod' : 'new';
|
||||
const activeTab = route.path && route.path !== ':id' ? route.path : queue;
|
||||
const activeTab = tab ? tab : queue;
|
||||
|
||||
return activeTab;
|
||||
}
|
||||
@@ -57,15 +78,10 @@ class ModerationContainer extends Component {
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => {
|
||||
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = this.props.auth.user.id === user.id
|
||||
? {}
|
||||
: {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
? ''
|
||||
: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,15 +90,10 @@ class ModerationContainer extends Component {
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
|
||||
const user = comment.status_history[comment.status_history.length - 1].assigned_by;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = this.props.auth.user.id === user.id
|
||||
? {}
|
||||
: {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
? ''
|
||||
: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,13 +101,8 @@ class ModerationContainer extends Component {
|
||||
document: COMMENT_EDITED_SUBSCRIPTION,
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: false,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
const notify = t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,13 +111,8 @@ class ModerationContainer extends Component {
|
||||
variables,
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
|
||||
const user = comment.actions[comment.actions.length - 1].user;
|
||||
const sort = this.props.moderation.sortOrder;
|
||||
const notify = {
|
||||
activeQueue: this.activeTab,
|
||||
text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
|
||||
anyQueue: true,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
const notify = t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body));
|
||||
return this.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,28 +161,9 @@ class ModerationContainer extends Component {
|
||||
cursor: this.props.root[tab].endCursor,
|
||||
sort: this.props.data.variables.sort,
|
||||
asset_id: this.props.data.variables.asset_id,
|
||||
statuses: queueConfig[tab].statuses,
|
||||
action_type: queueConfig[tab].action_type,
|
||||
};
|
||||
switch(tab) {
|
||||
case 'all':
|
||||
variables.statuses = null;
|
||||
break;
|
||||
case 'new':
|
||||
variables.statuses = ['NONE', 'PREMOD'];
|
||||
break;
|
||||
case 'approved':
|
||||
variables.statuses = ['ACCEPTED'];
|
||||
break;
|
||||
case 'premod':
|
||||
variables.statuses = ['PREMOD'];
|
||||
break;
|
||||
case 'reported':
|
||||
variables.statuses = ['NONE', 'PREMOD'];
|
||||
variables.action_type = 'FLAG';
|
||||
break;
|
||||
case 'rejected':
|
||||
variables.statuses = ['REJECTED'];
|
||||
break;
|
||||
}
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables,
|
||||
@@ -199,7 +181,8 @@ class ModerationContainer extends Component {
|
||||
};
|
||||
|
||||
render () {
|
||||
const {root, root: {asset, settings}, data, params: {id: assetId}} = this.props;
|
||||
const {root, root: {asset, settings}, data} = this.props;
|
||||
const assetId = getAssetId(this.props);
|
||||
|
||||
if (data.error) {
|
||||
return <div>Error</div>;
|
||||
@@ -222,6 +205,14 @@ class ModerationContainer extends Component {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
const premodEnabled = assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation);
|
||||
const currentQueueConfig = Object.assign({}, queueConfig);
|
||||
if (premodEnabled) {
|
||||
delete currentQueueConfig.new;
|
||||
} else {
|
||||
delete currentQueueConfig.premod;
|
||||
}
|
||||
|
||||
return <Moderation
|
||||
{...this.props}
|
||||
getModPath={getModPath}
|
||||
@@ -229,7 +220,8 @@ class ModerationContainer extends Component {
|
||||
acceptComment={this.acceptComment}
|
||||
rejectComment={this.rejectComment}
|
||||
activeTab={this.activeTab}
|
||||
premodEnabled={assetId ? isPremod(asset.settings.moderation) : isPremod(settings.moderation)}
|
||||
queueConfig={currentQueueConfig}
|
||||
handleCommentChange={this.handleCommentChange}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -314,49 +306,25 @@ const commentConnectionFragment = gql`
|
||||
|
||||
const withModQueueQuery = withQuery(gql`
|
||||
query CoralAdmin_Moderation($asset_id: ID, $sort: SORT_ORDER, $allAssets: Boolean!) {
|
||||
all: comments(query: {
|
||||
statuses: [NONE, PREMOD, ACCEPTED, REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
new: comments(query: {
|
||||
statuses: [NONE, PREMOD],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
approved: comments(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
premod: comments(query: {
|
||||
statuses: [PREMOD],
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}: comments(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
reported: comments(query: {
|
||||
action_type: FLAG,
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
`)}
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}Count: commentCount(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD],
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
rejected: comments(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id,
|
||||
sort: $sort
|
||||
}) {
|
||||
...CoralAdmin_Moderation_CommentConnection
|
||||
}
|
||||
})
|
||||
`)}
|
||||
asset(id: $asset_id) @skip(if: $allAssets) {
|
||||
id
|
||||
title
|
||||
@@ -365,30 +333,6 @@ const withModQueueQuery = withQuery(gql`
|
||||
moderation
|
||||
}
|
||||
}
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
newCount: commentCount(query: {
|
||||
statuses: [NONE, PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
approvedCount: commentCount(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
rejectedCount: commentCount(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
reportedCount: commentCount(query: {
|
||||
action_type: FLAG,
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD]
|
||||
})
|
||||
settings {
|
||||
organizationName
|
||||
moderation
|
||||
@@ -396,11 +340,12 @@ const withModQueueQuery = withQuery(gql`
|
||||
}
|
||||
${commentConnectionFragment}
|
||||
`, {
|
||||
options: ({params: {id = null}, moderation: {sortOrder}}) => {
|
||||
options: (props) => {
|
||||
const id = getAssetId(props);
|
||||
return {
|
||||
variables: {
|
||||
asset_id: id,
|
||||
sort: sortOrder,
|
||||
sort: props.moderation.sortOrder,
|
||||
allAssets: id === null
|
||||
}
|
||||
};
|
||||
@@ -409,33 +354,18 @@ const withModQueueQuery = withQuery(gql`
|
||||
|
||||
const withQueueCountPolling = withQuery(gql`
|
||||
query CoralAdmin_ModerationCountPoll($asset_id: ID) {
|
||||
allCount: commentCount(query: {
|
||||
asset_id: $asset_id
|
||||
})
|
||||
newCount: commentCount(query: {
|
||||
statuses: [NONE, PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
approvedCount: commentCount(query: {
|
||||
statuses: [ACCEPTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
premodCount: commentCount(query: {
|
||||
statuses: [PREMOD],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
rejectedCount: commentCount(query: {
|
||||
statuses: [REJECTED],
|
||||
asset_id: $asset_id
|
||||
})
|
||||
reportedCount: commentCount(query: {
|
||||
action_type: FLAG,
|
||||
asset_id: $asset_id,
|
||||
statuses: [NONE, PREMOD]
|
||||
})
|
||||
${Object.keys(queueConfig).map((queue) => `
|
||||
${queue}Count: commentCount(query: {
|
||||
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
|
||||
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
|
||||
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
|
||||
asset_id: $asset_id,
|
||||
})
|
||||
`)}
|
||||
}
|
||||
`, {
|
||||
options: ({params: {id = null}}) => {
|
||||
options: (props) => {
|
||||
const id = getAssetId(props);
|
||||
return {
|
||||
pollInterval: 5000,
|
||||
variables: {
|
||||
|
||||
+24
-33
@@ -1,7 +1,6 @@
|
||||
import update from 'immutability-helper';
|
||||
import * as notification from 'coral-admin/src/services/notification';
|
||||
|
||||
const queues = ['all', 'premod', 'reported', 'approved', 'rejected', 'new'];
|
||||
const limit = 10;
|
||||
|
||||
const ascending = (a, b) => {
|
||||
@@ -67,32 +66,24 @@ function addCommentToQueue(root, queue, comment, sort) {
|
||||
/**
|
||||
* getCommentQueues determines in which queues a comment should be placed.
|
||||
*/
|
||||
function getCommentQueues(comment) {
|
||||
const queues = ['all'];
|
||||
const isFlagged = comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction');
|
||||
|
||||
switch(comment.status) {
|
||||
case 'ACCEPTED':
|
||||
queues.push('approved');
|
||||
break;
|
||||
case 'REJECTED':
|
||||
queues.push('rejected');
|
||||
break;
|
||||
case 'PREMOD':
|
||||
queues.push('premod');
|
||||
queues.push('new');
|
||||
if (isFlagged) {
|
||||
queues.push('reported');
|
||||
function getCommentQueues(comment, queueConfig) {
|
||||
const queues = [];
|
||||
Object.keys(queueConfig).forEach((key) => {
|
||||
const {action_type, statuses, tags} = queueConfig[key];
|
||||
let addToQueues = false;
|
||||
if (statuses && statuses.indexOf(comment.status) >= 0) {
|
||||
addToQueues = true;
|
||||
}
|
||||
break;
|
||||
case 'NONE':
|
||||
queues.push('new');
|
||||
if (isFlagged) {
|
||||
queues.push('reported');
|
||||
if (tags && comment.tags && comment.tags.some((tagLink) => tags.indexOf(tagLink.tag.name) >= 0)) {
|
||||
addToQueues = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (action_type && comment.actions && comment.actions.some((a) => a.__typename.toLowerCase() === `${action_type}action`)) {
|
||||
addToQueues = true;
|
||||
}
|
||||
if (addToQueues) {
|
||||
queues.push(key);
|
||||
}
|
||||
});
|
||||
return queues;
|
||||
}
|
||||
|
||||
@@ -106,42 +97,42 @@ function getCommentQueues(comment) {
|
||||
* @param {string} notify.text notification text to show
|
||||
* @param {bool} notify.anyQueue if true show the notification when the comment is shown
|
||||
* in the current active queue besides the 'all' queue.
|
||||
* @param {Object} queueConfig queue configuration
|
||||
* @return {Object} next state of the store
|
||||
*/
|
||||
export function handleCommentChange(root, comment, sort, notify) {
|
||||
export function handleCommentChange(root, comment, sort, notify, queueConfig, activeQueue) {
|
||||
let next = root;
|
||||
|
||||
const nextQueues = getCommentQueues(comment);
|
||||
const nextQueues = getCommentQueues(comment, queueConfig);
|
||||
|
||||
let notificationShown = false;
|
||||
const showNotificationOnce = () => {
|
||||
if (notificationShown) {
|
||||
return;
|
||||
}
|
||||
notification.info(notify.text);
|
||||
notification.info(notify);
|
||||
notificationShown = true;
|
||||
};
|
||||
|
||||
queues.forEach((queue) => {
|
||||
Object.keys(queueConfig).forEach((queue) => {
|
||||
if (nextQueues.indexOf(queue) >= 0) {
|
||||
if (!queueHasComment(next, queue, comment.id)) {
|
||||
next = addCommentToQueue(next, queue, comment, sort);
|
||||
if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
|
||||
if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
}
|
||||
} else if(queueHasComment(next, queue, comment.id)){
|
||||
next = removeCommentFromQueue(next, queue, comment.id);
|
||||
if (notify && notify.activeQueue === queue) {
|
||||
if (notify && activeQueue === queue) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
notify
|
||||
&& (queue === 'all' || notify.anyQueue)
|
||||
&& queueHasComment(next, queue, comment.id)
|
||||
&& notify.activeQueue === queue
|
||||
&& activeQueue === queue
|
||||
) {
|
||||
showNotificationOnce(comment);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import {getModQueueConfigs} from 'coral-framework/helpers/plugins';
|
||||
|
||||
export default {
|
||||
premod: {
|
||||
statuses: ['PREMOD'],
|
||||
icon: 'access_time',
|
||||
name: t('modqueue.premod'),
|
||||
},
|
||||
new: {
|
||||
statuses: ['NONE', 'PREMOD'],
|
||||
icon: 'question_answer',
|
||||
name: t('modqueue.new'),
|
||||
},
|
||||
reported: {
|
||||
action_type: 'FLAG',
|
||||
statuses: ['NONE', 'PREMOD'],
|
||||
icon: 'flag',
|
||||
name: t('modqueue.reported'),
|
||||
},
|
||||
approved: {
|
||||
statuses: ['ACCEPTED'],
|
||||
icon: 'check',
|
||||
name: t('modqueue.approved'),
|
||||
},
|
||||
rejected: {
|
||||
statuses: ['REJECTED'],
|
||||
icon: 'close',
|
||||
name: t('modqueue.rejected'),
|
||||
},
|
||||
all: {
|
||||
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
|
||||
icon: 'question_answer',
|
||||
name: t('modqueue.all'),
|
||||
},
|
||||
...getModQueueConfigs(),
|
||||
};
|
||||
@@ -16,7 +16,7 @@ class ConfigureStreamContainer extends Component {
|
||||
this.state = {
|
||||
changed: false,
|
||||
dirtySettings: props.asset.settings,
|
||||
closedAt: (props.asset.closedAt === null ? 'open' : 'closed')
|
||||
closedAt: !props.asset.isClosed ? 'open' : 'closed'
|
||||
};
|
||||
|
||||
this.toggleStatus = this.toggleStatus.bind(this);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pym from 'coral-framework/services/pym';
|
||||
import * as actions from '../constants/stream';
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import {buildUrl} from 'coral-framework/utils/url';
|
||||
import queryString from 'query-string';
|
||||
|
||||
export const setActiveReplyBox = (id) => ({type: actions.SET_ACTIVE_REPLY_BOX, id});
|
||||
|
||||
@@ -93,6 +93,7 @@ class AllCommentsPane extends React.Component {
|
||||
|
||||
viewNewComments = () => {
|
||||
this.setState(resetCursors);
|
||||
this.props.emit('ui.AllCommentsPane.viewNewComments');
|
||||
};
|
||||
|
||||
// getVisibileComments returns a list containing comments
|
||||
@@ -142,6 +143,7 @@ class AllCommentsPane extends React.Component {
|
||||
charCountEnable,
|
||||
maxCharCount,
|
||||
editComment,
|
||||
emit,
|
||||
} = this.props;
|
||||
|
||||
const {loadingState} = this.state;
|
||||
@@ -181,6 +183,7 @@ class AllCommentsPane extends React.Component {
|
||||
charCountEnable={charCountEnable}
|
||||
maxCharCount={maxCharCount}
|
||||
editComment={editComment}
|
||||
emit={emit}
|
||||
/>;
|
||||
})}
|
||||
</TransitionGroup>
|
||||
|
||||
@@ -224,6 +224,7 @@ export default class Comment extends React.Component {
|
||||
return;
|
||||
}
|
||||
this.setState(resetCursors);
|
||||
this.props.emit('ui.Comment.showMoreReplies');
|
||||
};
|
||||
|
||||
showReplyBox = () => {
|
||||
@@ -400,13 +401,13 @@ export default class Comment extends React.Component {
|
||||
<div className={commentClassName}>
|
||||
|
||||
<Slot
|
||||
className={styles.commentAvatar}
|
||||
className={`${styles.commentAvatar} talk-stream-comment-avatar`}
|
||||
fill="commentAvatar"
|
||||
{...slotProps}
|
||||
inline
|
||||
/>
|
||||
|
||||
<div className={styles.commentContainer}>
|
||||
<div className={`${styles.commentContainer} talk-stream-comment-container`}>
|
||||
|
||||
<div className={styles.header}>
|
||||
<AuthorName author={comment.user} className={'talk-stream-comment-user-name'} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import cn from 'classnames';
|
||||
|
||||
import {getTopLevelParent, attachCommentToParent} from '../graphql/utils';
|
||||
import AllCommentsPane from './AllCommentsPane';
|
||||
import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
|
||||
|
||||
import styles from './Stream.css';
|
||||
|
||||
@@ -101,7 +102,7 @@ class Stream extends React.Component {
|
||||
editName
|
||||
} = this.props;
|
||||
const {keepCommentBox} = this.state;
|
||||
const open = asset.closedAt === null;
|
||||
const open = !asset.isClosed;
|
||||
|
||||
// even though the permalinked comment is the highlighted one, we're displaying its parent + replies
|
||||
let highlightedComment = comment && getTopLevelParent(comment);
|
||||
@@ -141,6 +142,7 @@ class Stream extends React.Component {
|
||||
|
||||
return (
|
||||
<div id="stream" className={styles.root}>
|
||||
<AutomaticAssetClosure assetId={asset.id} closedAt={asset.closedAt}/>
|
||||
{comment &&
|
||||
<Button
|
||||
cStyle="darkGrey"
|
||||
@@ -290,6 +292,7 @@ class Stream extends React.Component {
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={editComment}
|
||||
emit={this.props.emit}
|
||||
/>
|
||||
</TabPane>
|
||||
</TabContent>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import ClickOutside from 'coral-framework/components/ClickOutside';
|
||||
import styles from './Toggleable.css';
|
||||
import classnames from 'classnames';
|
||||
import cn from 'classnames';
|
||||
|
||||
const upArrow = <span className={classnames(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={classnames(styles.chevron, styles.down)}></span>;
|
||||
const upArrow = <span className={cn(styles.chevron, styles.up)}></span>;
|
||||
const downArrow = <span className={cn(styles.chevron, styles.down)}></span>;
|
||||
|
||||
export default class Toggleable extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -23,11 +23,11 @@ export default class Toggleable extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
const {children, className, ...rest} = this.props;
|
||||
const {isOpen} = this.state;
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.close}>
|
||||
<span className={styles.Toggleable}>
|
||||
<span {...rest} className={cn(className, styles.Toggleable)} >
|
||||
<button className={styles.toggler} onClick={this.toggle}>{isOpen ? upArrow : downArrow}</button>
|
||||
{isOpen ? children : null}
|
||||
</span>
|
||||
|
||||
@@ -45,7 +45,7 @@ export class TopRightMenu extends React.Component {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Toggleable key={this.state.timesReset}>
|
||||
<Toggleable key={this.state.timesReset} className="talk-stream-comment-chevron">
|
||||
<div style={{position: 'absolute', right: 0, zIndex: 1}}>
|
||||
<IgnoreUserWizard
|
||||
user={comment.user}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {gql} from 'react-apollo';
|
||||
|
||||
const FRAGMENT = gql`
|
||||
fragment CoralEmbedStream_AutomaticAssetClosure_Fragment on Asset {
|
||||
id
|
||||
isClosed
|
||||
}
|
||||
`;
|
||||
|
||||
function getFragmentId(assetId) {
|
||||
return `Asset_${assetId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* AutomaticAssetClosure updates the graphql state of the provide asset
|
||||
* to `isClosed=true` when passed `closedAt`.
|
||||
*/
|
||||
class AutomaticAssetClosure extends React.Component {
|
||||
static contextTypes = {
|
||||
client: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
timer = null;
|
||||
|
||||
componentWillMount() {
|
||||
this.setupTimer(this.props.assetId, this.props.closedAt);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(next) {
|
||||
if (
|
||||
this.props.assetId !== next.assetId ||
|
||||
this.props.closedAt !== next.closedAt
|
||||
) {
|
||||
this.setupTimer(next.assetId, next.closedAt);
|
||||
}
|
||||
}
|
||||
|
||||
closeAsset(assetId) {
|
||||
this.context.client.writeFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
data: {
|
||||
isClosed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setupTimer(assetId, closedAt) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
|
||||
if (assetId && closedAt) {
|
||||
const asset = this.context.client.readFragment({
|
||||
fragment: FRAGMENT,
|
||||
id: getFragmentId(assetId),
|
||||
});
|
||||
|
||||
if (!asset.isClosed && closedAt) {
|
||||
const diff = (new Date(closedAt) - new Date());
|
||||
if (diff >= 0) {
|
||||
this.timer = setTimeout(() => this.closeAsset(assetId), diff);
|
||||
} else {
|
||||
this.closeAsset(assetId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
AutomaticAssetClosure.PropTypes = {
|
||||
assetId: PropTypes.string,
|
||||
closedAt: PropTypes.string,
|
||||
};
|
||||
|
||||
export default AutomaticAssetClosure;
|
||||
@@ -14,7 +14,7 @@ import {editName} from 'coral-framework/actions/user';
|
||||
import {setActiveReplyBox, setActiveTab, viewAllComments} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {withFragments, withEmit} from 'coral-framework/hocs';
|
||||
import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {
|
||||
@@ -251,6 +251,7 @@ const fragments = {
|
||||
title
|
||||
url
|
||||
closedAt
|
||||
isClosed
|
||||
created_at
|
||||
settings {
|
||||
moderation
|
||||
@@ -325,6 +326,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
|
||||
export default compose(
|
||||
withFragments(fragments),
|
||||
withEmit,
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withPostComment,
|
||||
withPostFlag,
|
||||
|
||||
@@ -73,6 +73,11 @@ const extension = {
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
tags {
|
||||
tag {
|
||||
name
|
||||
@@ -190,6 +195,15 @@ const extension = {
|
||||
}
|
||||
return insertCommentIntoEmbedQuery(prev, comment);
|
||||
},
|
||||
CoralEmbedStream_Profile: (prev, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
return update(prev, {
|
||||
me: {
|
||||
comments: {
|
||||
nodes: {$unshift: [comment]},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
}),
|
||||
EditComment: () => ({
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const DEFAULT_STYLE = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
backgroundColor: '#323232',
|
||||
zIndex: 3,
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
|
||||
};
|
||||
|
||||
export default class Snackbar {
|
||||
constructor(customStyle = {}) {
|
||||
this.timeout = null;
|
||||
this.el = document.createElement('div');
|
||||
this.el.id = 'coral-notif';
|
||||
|
||||
// Apply custom styles to the snackbar.
|
||||
const style = Object.assign({}, DEFAULT_STYLE, customStyle);
|
||||
for (let key in style) {
|
||||
this.el.style[key] = style[key];
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.el.style.opacity = 0;
|
||||
}
|
||||
|
||||
alert(message) {
|
||||
const [type, text] = message.split('|');
|
||||
this.el.style.transform = 'translate(-50%, 20px)';
|
||||
this.el.style.opacity = 0;
|
||||
this.el.className = `coral-notif-${type}`;
|
||||
this.el.textContent = text;
|
||||
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.transform = 'translate(-50%, 0)';
|
||||
this.el.style.opacity = 1;
|
||||
|
||||
this.timeout = setTimeout(() => {
|
||||
this.el.style.opacity = 0;
|
||||
}, 7000);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
attach(el, pym) {
|
||||
el.appendChild(this.el);
|
||||
|
||||
// Attach the clear clear notification event to the clear method.
|
||||
pym.onMessage('coral-clear-notification', this.clear.bind(this));
|
||||
|
||||
// Attach the alert to the alert method.
|
||||
pym.onMessage('coral-alert', this.alert.bind(this));
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.el.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import queryString from 'query-string';
|
||||
import pym from 'pym.js';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
import {buildUrl} from 'coral-framework/utils/url';
|
||||
import Snackbar from './Snackbar';
|
||||
|
||||
const NOTIFICATION_OFFSET = 200;
|
||||
|
||||
// Build the URL to load in the pym iframe.
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?'
|
||||
].join('');
|
||||
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Get dimensions of viewport.
|
||||
function viewportDimensions() {
|
||||
let e = window, a = 'inner';
|
||||
if (!('innerWidth' in window)) {
|
||||
a = 'client';
|
||||
e = document.documentElement || document.body;
|
||||
}
|
||||
|
||||
return {
|
||||
width: e[`${a}Width`],
|
||||
height: e[`${a}Height`]
|
||||
};
|
||||
}
|
||||
|
||||
export default class Stream {
|
||||
constructor(el, talkBaseUrl, query, opts) {
|
||||
|
||||
// Create and save the options.
|
||||
|
||||
this.opts = opts;
|
||||
this.query = query;
|
||||
|
||||
this.emitter = new EventEmitter({wildcard: true});
|
||||
this.pym = new pym.Parent(el.id, buildStreamIframeUrl(talkBaseUrl, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
});
|
||||
this.snackBar = new Snackbar(opts.snackBarStyles || {});
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
this.pym.el.firstChild.style.width = '1px';
|
||||
this.pym.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
let cachedHeight;
|
||||
this.pym.onMessage('height', (height) => {
|
||||
if (height !== cachedHeight) {
|
||||
this.pym.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
// Attach to the events emitted by the pym parent.
|
||||
if (opts.events) {
|
||||
opts.events(this.emitter);
|
||||
}
|
||||
|
||||
this.pym.onMessage('getConfig', () => {
|
||||
this.pym.sendMessage('config', JSON.stringify(opts));
|
||||
});
|
||||
|
||||
// If the auth changes, and someone is listening for it, then re-emit it.
|
||||
if (opts.onAuthChanged) {
|
||||
this.pym.onMessage('coral-auth-changed', (message) => {
|
||||
opts.onAuthChanged(message ? JSON.parse(message) : null);
|
||||
});
|
||||
}
|
||||
|
||||
// Attach the snackbar to the pym parent and to the body of the page.
|
||||
this.snackBar.attach(window.document.body, this.pym);
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-all-comments', () => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Remove the permalink comment id from the hash.
|
||||
this.pym.onMessage('coral-view-comment', (id) => {
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// Remove the commentId url param.
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
// Change the url.
|
||||
window.history.replaceState({}, document.title, url);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop.
|
||||
this.pym.onMessage('getPosition', () => {
|
||||
const {height} = viewportDimensions();
|
||||
let position = height + document.body.scrollTop;
|
||||
|
||||
if (position > NOTIFICATION_OFFSET) {
|
||||
position = position - NOTIFICATION_OFFSET;
|
||||
}
|
||||
|
||||
this.pym.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
this.pym.onMessage('navigate', (url) => {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter.
|
||||
this.pym.onMessage('event', (raw) => {
|
||||
const {eventName, value} = JSON.parse(raw);
|
||||
this.emitter.emit(eventName, value);
|
||||
});
|
||||
|
||||
// If the user clicks outside the embed, then tell the embed.
|
||||
document.addEventListener('click', this.handleClick.bind(this), true);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
this.pym.sendMessage('login', token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pym.sendMessage('logout');
|
||||
}
|
||||
|
||||
remove() {
|
||||
|
||||
// Remove the event listeners.
|
||||
document.removeEventListener('click', this.handleClick.bind(this));
|
||||
this.emitter.removeAllListeners();
|
||||
|
||||
// Remove the snackbar.
|
||||
this.snackBar.remove();
|
||||
|
||||
// Remove the pym parent.
|
||||
this.pym.remove();
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.pym.sendMessage('click');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export default class StreamInterface {
|
||||
constructor(stream) {
|
||||
this._stream = stream;
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
return this._stream.emitter.on(eventName, callback);
|
||||
}
|
||||
|
||||
login(token) {
|
||||
return this._stream.login(token);
|
||||
}
|
||||
|
||||
logout() {
|
||||
return this._stream.logout();
|
||||
}
|
||||
|
||||
remove() {
|
||||
return this._stream.remove();
|
||||
}
|
||||
}
|
||||
+20
-224
@@ -1,196 +1,10 @@
|
||||
import pym from 'pym.js';
|
||||
import URLSearchParams from 'url-search-params';
|
||||
|
||||
import {buildUrl} from 'coral-framework/utils';
|
||||
import queryString from 'query-string';
|
||||
import EventEmitter from 'eventemitter2';
|
||||
|
||||
// TODO: Styles should live in a separate file
|
||||
const snackbarStyles = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
backgroundColor: '#323232',
|
||||
zIndex: 3,
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
maxWidth: '400px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'Helvetica, "Helvetica Neue", Verdana, sans-serif'
|
||||
};
|
||||
import Stream from './Stream';
|
||||
import StreamInterface from './StreamInterface';
|
||||
|
||||
// This function should return value of window.Coral
|
||||
const Coral = {};
|
||||
const Talk = (Coral.Talk = {});
|
||||
let notificationTimeout = null;
|
||||
|
||||
// build the URL to load in the pym iframe
|
||||
function buildStreamIframeUrl(talkBaseUrl, query) {
|
||||
let url = [
|
||||
talkBaseUrl,
|
||||
talkBaseUrl.match(/\/$/) ? '' : '/', // make sure no double-'/' if opts.talk already ends with '/'
|
||||
'embed/stream?'
|
||||
].join('');
|
||||
|
||||
url += queryString.stringify(query);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
// Set up postMessage listeners/handlers on the pymParent
|
||||
// e.g. to resize the iframe, and navigate the host page
|
||||
function configurePymParent(pymParent, eventEmitter, opts) {
|
||||
let notificationOffset = 200;
|
||||
let cachedHeight;
|
||||
const snackbar = document.createElement('div');
|
||||
|
||||
// Sends config to pymChild
|
||||
function sendConfig(config) {
|
||||
pymParent.sendMessage('config', JSON.stringify(config));
|
||||
}
|
||||
|
||||
if (opts.events) {
|
||||
opts.events(eventEmitter);
|
||||
}
|
||||
|
||||
pymParent.onMessage('coral-auth-changed', function(message) {
|
||||
if (opts.onAuthChanged) {
|
||||
opts.onAuthChanged(message ? JSON.parse(message) : null);
|
||||
}
|
||||
});
|
||||
|
||||
// Sends config to the child
|
||||
pymParent.onMessage('getConfig', function() {
|
||||
sendConfig(opts || {});
|
||||
});
|
||||
|
||||
snackbar.id = 'coral-notif';
|
||||
|
||||
for (let key in snackbarStyles) {
|
||||
snackbar.style[key] = snackbarStyles[key];
|
||||
}
|
||||
|
||||
window.document.body.appendChild(snackbar);
|
||||
|
||||
// Notify embed that there was a click outside.
|
||||
document.addEventListener('click', () => {
|
||||
pymParent.sendMessage('click');
|
||||
}, true);
|
||||
|
||||
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
|
||||
pymParent.el.firstChild.style.width = '1px';
|
||||
pymParent.el.firstChild.style.minWidth = '100%';
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
pymParent.onMessage('height', function(height) {
|
||||
if (height !== cachedHeight) {
|
||||
pymParent.el.firstChild.style.height = `${height}px`;
|
||||
cachedHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-clear-notification', function() {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-all-comments', function() {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: undefined,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
// remove the permalink comment id from the hash
|
||||
pymParent.onMessage('coral-view-comment', function(id) {
|
||||
|
||||
const search = queryString.stringify({
|
||||
...queryString.parse(location.search),
|
||||
commentId: id,
|
||||
});
|
||||
|
||||
// remove the commentId url param
|
||||
const url = buildUrl({...location, search});
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
url,
|
||||
);
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function(message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
snackbar.style.opacity = 0;
|
||||
snackbar.className = `coral-notif-${type}`;
|
||||
snackbar.textContent = text;
|
||||
|
||||
clearTimeout(notificationTimeout);
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.transform = 'translate(-50%, 0)';
|
||||
snackbar.style.opacity = 1;
|
||||
|
||||
notificationTimeout = setTimeout(() => {
|
||||
snackbar.style.opacity = 0;
|
||||
}, 7000);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop
|
||||
pymParent.onMessage('getPosition', function() {
|
||||
let position = viewport().height + document.body.scrollTop;
|
||||
|
||||
if (position > notificationOffset) {
|
||||
position = position - notificationOffset;
|
||||
}
|
||||
|
||||
pymParent.sendMessage('position', position);
|
||||
});
|
||||
|
||||
// When end-user clicks link in iframe, open it in parent context
|
||||
pymParent.onMessage('navigate', function(url) {
|
||||
window.open(url, '_blank').focus();
|
||||
});
|
||||
|
||||
// Pass events from iframe to the event emitter
|
||||
pymParent.onMessage('event', (raw) => {
|
||||
const {eventName, value} = JSON.parse(raw);
|
||||
eventEmitter.emit(eventName, value);
|
||||
});
|
||||
|
||||
// get dimensions of viewport
|
||||
const viewport = () => {
|
||||
let e = window, a = 'inner';
|
||||
if (!('innerWidth' in window)) {
|
||||
a = 'client';
|
||||
e = document.documentElement || document.body;
|
||||
}
|
||||
return {
|
||||
width: e[`${a}Width`],
|
||||
height: e[`${a}Height`]
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Talk stream
|
||||
@@ -219,17 +33,14 @@ function configurePymParent(pymParent, eventEmitter, opts) {
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
Talk.render = function(el, opts) {
|
||||
Talk.render = (el, opts) => {
|
||||
if (!el) {
|
||||
throw new Error(
|
||||
'Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.'
|
||||
);
|
||||
throw new Error('Please provide Coral.Talk.render() the HTMLElement you want to render Talk in.');
|
||||
}
|
||||
if (typeof el !== 'object') {
|
||||
throw new Error(
|
||||
`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`
|
||||
);
|
||||
throw new Error(`Coral.Talk.render() expected HTMLElement but got ${el} (${typeof el})`);
|
||||
}
|
||||
|
||||
opts = opts || {};
|
||||
|
||||
// TODO: infer this URL without explicit user input (if possible, may have to be added at build/render time of this script)
|
||||
@@ -245,22 +56,27 @@ Talk.render = function(el, opts) {
|
||||
}
|
||||
|
||||
// Compose the query to send down to the Talk API so it knows what to load.
|
||||
let query = {};
|
||||
|
||||
let urlParams = new URLSearchParams(window.location.search);
|
||||
const query = {};
|
||||
|
||||
// Parse the url parameters to extract some of the information.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('commentId')) {
|
||||
query.comment_id = urlParams.get('commentId');
|
||||
}
|
||||
|
||||
// Extract the asset id from the options.
|
||||
if (opts.asset_id) {
|
||||
query.asset_id = opts.asset_id;
|
||||
}
|
||||
|
||||
// Extract the asset url.
|
||||
if (opts.asset_url) {
|
||||
query.asset_url = opts.asset_url;
|
||||
}
|
||||
else {
|
||||
} else if (!opts.asset_id) {
|
||||
|
||||
// The asset url was not provided and the asset id was also not provided,
|
||||
// we need to infer the asset url from details on the page.
|
||||
|
||||
try {
|
||||
query.asset_url = document.querySelector('link[rel="canonical"]').href;
|
||||
} catch (e) {
|
||||
@@ -276,31 +92,11 @@ Talk.render = function(el, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
const pymParent = new pym.Parent(el.id, buildStreamIframeUrl(opts.talk, query), {
|
||||
title: opts.title,
|
||||
id: `${el.id}_iframe`,
|
||||
name: `${el.id}_iframe`
|
||||
});
|
||||
// Create the new Stream.
|
||||
const stream = new Stream(el, opts.talk, query, opts);
|
||||
|
||||
const eventEmitter = new EventEmitter({wildcard: true});
|
||||
|
||||
configurePymParent(
|
||||
pymParent,
|
||||
eventEmitter,
|
||||
opts
|
||||
);
|
||||
|
||||
return {
|
||||
on(eventName, callback) {
|
||||
eventEmitter.on(eventName, callback);
|
||||
},
|
||||
login(token) {
|
||||
pymParent.sendMessage('login', token);
|
||||
},
|
||||
logout() {
|
||||
pymParent.sendMessage('logout');
|
||||
}
|
||||
};
|
||||
// Return the public interface for the stream.
|
||||
return new StreamInterface(stream);
|
||||
};
|
||||
|
||||
export default Coral;
|
||||
|
||||
@@ -3,6 +3,7 @@ import uniq from 'lodash/uniq';
|
||||
import pick from 'lodash/pick';
|
||||
import merge from 'lodash/merge';
|
||||
import flattenDeep from 'lodash/flattenDeep';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import flatten from 'lodash/flatten';
|
||||
import {loadTranslations} from 'coral-framework/services/i18n';
|
||||
import {injectReducers} from 'coral-framework/services/store';
|
||||
@@ -10,7 +11,7 @@ import camelize from './camelize';
|
||||
import plugins from 'pluginsConfig';
|
||||
|
||||
export function getSlotComponents(slot, reduxState, props = {}) {
|
||||
const pluginConfig = reduxState.config.pluginConfig || {};
|
||||
const pluginConfig = reduxState.config.plugin_config || {};
|
||||
return flatten(plugins
|
||||
|
||||
// Filter out components that have slots and have been disabled in `plugin_config`
|
||||
@@ -39,7 +40,7 @@ export function isSlotEmpty(slot, reduxState, props) {
|
||||
* Returns React Elements for given slot.
|
||||
*/
|
||||
export function getSlotElements(slot, reduxState, props = {}) {
|
||||
const pluginConfig = reduxState.config.pluginConfig || {};
|
||||
const pluginConfig = reduxState.config.plugin_config || {};
|
||||
return getSlotComponents(slot, reduxState, props)
|
||||
.map((component, i) => React.createElement(component, {key: i, ...props, config: pluginConfig}));
|
||||
}
|
||||
@@ -64,7 +65,13 @@ export function getSlotFragments(slot, part) {
|
||||
export function getGraphQLExtensions() {
|
||||
return plugins
|
||||
.map((o) => pick(o.module, ['mutations', 'queries', 'fragments']))
|
||||
.filter((o) => o);
|
||||
.filter((o) => !isEmpty(o));
|
||||
}
|
||||
|
||||
export function getModQueueConfigs() {
|
||||
return merge(...plugins
|
||||
.map((o) => o.module.modQueues)
|
||||
.filter((o) => o));
|
||||
}
|
||||
|
||||
function getTranslations() {
|
||||
|
||||
@@ -169,14 +169,7 @@ export function insertCommentsSorted(nodes, comments, sortOrder = 'CHRONOLOGICAL
|
||||
|
||||
export const isTagged = (tags, which) => tags.some((t) => t.tag.name === which);
|
||||
|
||||
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
|
||||
if (search && search[0] !== '?') {
|
||||
search = `?${search}`;
|
||||
} else if (search === '?') {
|
||||
search = '';
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
|
||||
}
|
||||
export * from './url';
|
||||
|
||||
/**
|
||||
* getSlotFragmentSpreads will return a string in the
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function buildUrl({protocol, hostname, port, pathname, search, hash} = window.location) {
|
||||
if (search && search[0] !== '?') {
|
||||
search = `?${search}`;
|
||||
} else if (search === '?') {
|
||||
search = '';
|
||||
}
|
||||
return `${protocol}//${hostname}${port ? `:${port}` : ''}${pathname}${search}${hash}`;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import {connect} from 'react-redux';
|
||||
import {compose, graphql, gql} from 'react-apollo';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import React, {Component} from 'react';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {withQuery} from 'coral-framework/hocs';
|
||||
|
||||
import {withStopIgnoringUser} from 'coral-framework/graphql/mutations';
|
||||
|
||||
@@ -11,18 +12,12 @@ import IgnoredUsers from '../components/IgnoredUsers';
|
||||
import {Spinner} from 'coral-ui';
|
||||
import CommentHistory from 'talk-plugin-history/CommentHistory';
|
||||
import {showSignInDialog, checkLogin} from 'coral-framework/actions/auth';
|
||||
import {insertCommentsSorted} from 'plugin-api/beta/client/utils';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class ProfileContainer extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
activeTab: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!this.props.auth.loggedIn && nextProps.auth.loggedIn) {
|
||||
|
||||
@@ -31,21 +26,40 @@ class ProfileContainer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleTabChange = (tab) => {
|
||||
this.setState({
|
||||
activeTab: tab
|
||||
loadMore = () => {
|
||||
return this.props.data.fetchMore({
|
||||
query: LOAD_MORE_QUERY,
|
||||
variables: {
|
||||
limit: 5,
|
||||
cursor: this.props.root.me.comments.endCursor,
|
||||
},
|
||||
updateQuery: (previous, {fetchMoreResult:{comments}}) => {
|
||||
const updated = update(previous, {
|
||||
me: {
|
||||
comments: {
|
||||
nodes: {
|
||||
$apply: (nodes) => insertCommentsSorted(nodes, comments.nodes, 'REVERSE_CHRONOLOGICAL'),
|
||||
},
|
||||
hasNextPage: {$set: comments.hasNextPage},
|
||||
endCursor: {$set: comments.endCursor},
|
||||
},
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {auth, asset, data, showSignInDialog, stopIgnoringUser} = this.props;
|
||||
const {me} = this.props.data;
|
||||
const {auth, asset, showSignInDialog, stopIgnoringUser} = this.props;
|
||||
const {me} = this.props.root;
|
||||
const loading = [1, 2, 4].indexOf(this.props.data.networkStatus) >= 0;
|
||||
|
||||
if (!auth.loggedIn) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
if (!me || data.loading) {
|
||||
if (loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
@@ -73,14 +87,40 @@ class ProfileContainer extends Component {
|
||||
|
||||
<h3>{t('framework.my_comments')}</h3>
|
||||
{me.comments.nodes.length
|
||||
? <CommentHistory comments={me.comments.nodes} asset={asset} link={link} />
|
||||
? <CommentHistory comments={me.comments} asset={asset} link={link} loadMore={this.loadMore}/>
|
||||
: <p>{t('user_no_comment')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const withQuery = graphql(
|
||||
const CommentFragment = gql`
|
||||
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
`;
|
||||
|
||||
const LOAD_MORE_QUERY = gql`
|
||||
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Date) {
|
||||
comments(query: {limit: $limit, cursor: $cursor}) {
|
||||
...TalkSettings_CommentConnectionFragment
|
||||
}
|
||||
}
|
||||
${CommentFragment}
|
||||
`;
|
||||
|
||||
const withProfileQuery = withQuery(
|
||||
gql`
|
||||
query CoralEmbedStream_Profile {
|
||||
me {
|
||||
@@ -89,21 +129,13 @@ const withQuery = graphql(
|
||||
id,
|
||||
username,
|
||||
}
|
||||
comments {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
asset {
|
||||
id
|
||||
title
|
||||
url
|
||||
}
|
||||
created_at
|
||||
}
|
||||
comments(query: {limit: 10}) {
|
||||
...TalkSettings_CommentConnectionFragment
|
||||
}
|
||||
}
|
||||
}`
|
||||
);
|
||||
}
|
||||
${CommentFragment}
|
||||
`);
|
||||
|
||||
const mapStateToProps = (state) => ({
|
||||
user: state.user.toJS(),
|
||||
@@ -117,5 +149,5 @@ const mapDispatchToProps = (dispatch) =>
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withStopIgnoringUser,
|
||||
withQuery
|
||||
withProfileQuery
|
||||
)(ProfileContainer);
|
||||
|
||||
@@ -27,9 +27,10 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 13px;
|
||||
margin-right: 5px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
margin-top: -3px;
|
||||
}
|
||||
|
||||
.type--black {
|
||||
@@ -143,7 +144,7 @@
|
||||
border-radius: 3px;
|
||||
text-transform: capitalize;
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
|
||||
width: 128px;
|
||||
width: 129px;
|
||||
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
@@ -166,7 +167,7 @@
|
||||
border-radius: 3px;
|
||||
text-transform: capitalize;
|
||||
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.03), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.09);
|
||||
width: 128px;
|
||||
width: 129px;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
min-width: 550px;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -17px;
|
||||
right: 0px;
|
||||
bottom: 0;
|
||||
background-color: white;
|
||||
transition: transform 500ms ease-in-out;
|
||||
|
||||
@@ -1,25 +1,52 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import Comment from './Comment';
|
||||
import styles from './CommentHistory.css';
|
||||
import LoadMore from './LoadMore';
|
||||
import {forEachError} from 'plugin-api/beta/client/utils';
|
||||
|
||||
const CommentHistory = (props) => {
|
||||
return (
|
||||
<div className={`${styles.header} commentHistory`}>
|
||||
<div className="commentHistory__list">
|
||||
{props.comments.map((comment, i) => {
|
||||
return <Comment
|
||||
key={i}
|
||||
comment={comment}
|
||||
link={props.link}
|
||||
asset={comment.asset} />;
|
||||
})}
|
||||
class CommentHistory extends React.Component {
|
||||
state = {
|
||||
loadingState: '',
|
||||
};
|
||||
|
||||
loadMore = () => {
|
||||
this.setState({loadingState: 'loading'});
|
||||
this.props.loadMore()
|
||||
.then(() => {
|
||||
this.setState({loadingState: 'success'});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setState({loadingState: 'error'});
|
||||
forEachError(error, ({msg}) => {this.props.addNotification('error', msg);});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {link, comments} = this.props;
|
||||
return (
|
||||
<div className={`${styles.header} commentHistory`}>
|
||||
<div className="commentHistory__list">
|
||||
{comments.nodes.map((comment, i) => {
|
||||
return <Comment
|
||||
key={i}
|
||||
comment={comment}
|
||||
link={link}
|
||||
asset={comment.asset} />;
|
||||
})}
|
||||
</div>
|
||||
{comments.hasNextPage &&
|
||||
<LoadMore
|
||||
loadMore={this.loadMore}
|
||||
loadingState={this.state.loadingState}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CommentHistory.propTypes = {
|
||||
comments: PropTypes.array.isRequired
|
||||
comments: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default CommentHistory;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button} from 'coral-ui';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
import cn from 'classnames';
|
||||
|
||||
class LoadMore extends React.Component {
|
||||
render () {
|
||||
const {loadingState, loadMore} = this.props;
|
||||
const disabled = loadingState === 'loading';
|
||||
return (
|
||||
<div className='talk-load-more'>
|
||||
<Button
|
||||
onClick={loadMore}
|
||||
className={cn('talk-load-more-button', {[`talk-load-more-button-${loadingState}`]: loadingState})}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('framework.view_more_comments')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LoadMore.propTypes = {
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
loadingState: PropTypes.oneOf(['', 'loading', 'success', 'error']),
|
||||
};
|
||||
|
||||
export default LoadMore;
|
||||
@@ -7,6 +7,8 @@
|
||||
// entrypoint for the entire applications configuration.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
const uniq = require('lodash/uniq');
|
||||
|
||||
//==============================================================================
|
||||
// CONFIG INITIALIZATION
|
||||
//==============================================================================
|
||||
@@ -31,15 +33,33 @@ const CONFIG = {
|
||||
// token.
|
||||
JWT_COOKIE_NAME: process.env.TALK_JWT_COOKIE_NAME || 'authorization',
|
||||
|
||||
// JWT_SIGNING_COOKIE_NAME will be the cookie set when cookies are issued.
|
||||
// This defaults to the TALK_JWT_COOKIE_NAME value.
|
||||
JWT_SIGNING_COOKIE_NAME: process.env.TALK_JWT_SIGNING_COOKIE_NAME || process.env.TALK_JWT_COOKIE_NAME || 'authorization',
|
||||
|
||||
// JWT_COOKIE_NAMES declares the many cookie names used for verification.
|
||||
JWT_COOKIE_NAMES: process.env.TALK_JWT_COOKIE_NAMES || null,
|
||||
|
||||
// JWT_CLEAR_COOKIE_LOGOUT specifies whether the named cookie should be
|
||||
// cleared when the user is logged out.
|
||||
JWT_CLEAR_COOKIE_LOGOUT: process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT ? process.env.TALK_JWT_CLEAR_COOKIE_LOGOUT !== 'FALSE' : true,
|
||||
|
||||
// JWT_DISABLE_AUDIENCE when TRUE will disable the audience claim (aud) from tokens.
|
||||
JWT_DISABLE_AUDIENCE: process.env.TALK_JWT_DISABLE_AUDIENCE === 'TRUE',
|
||||
|
||||
// JWT_AUDIENCE is the value for the audience claim for the tokens that will be
|
||||
// verified when decoding. If `JWT_AUDIENCE` is not in the environment, then it
|
||||
// will default to `talk`.
|
||||
JWT_AUDIENCE: process.env.TALK_JWT_AUDIENCE || 'talk',
|
||||
|
||||
// JWT_DISABLE_ISSUER when TRUE will disable the issuer claim (iss) from tokens.
|
||||
JWT_DISABLE_ISSUER: process.env.TALK_JWT_DISABLE_ISSUER === 'TRUE',
|
||||
|
||||
// JWT_USER_ID_CLAIM is the claim which stores the user's id. This may be a deep
|
||||
// object delimited using dot notation. Example `user.id` would store it like:
|
||||
// {user: {id}} on the claims object. (Default `sub`)
|
||||
JWT_USER_ID_CLAIM: process.env.TALK_JWT_USER_ID_CLAIM || 'sub',
|
||||
|
||||
// JWT_ISSUER is the value for the issuer for the tokens that will be verified
|
||||
// when decoding. If `JWT_ISSUER` is not in the environment, then it will try
|
||||
// `TALK_ROOT_URL`, otherwise, it will be undefined.
|
||||
@@ -130,22 +150,40 @@ if (process.env.NODE_ENV === 'test' && !CONFIG.ROOT_URL) {
|
||||
|
||||
if (CONFIG.JWT_SECRETS) {
|
||||
CONFIG.JWT_SECRETS = JSON.parse(CONFIG.JWT_SECRETS);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) {
|
||||
CONFIG.JWT_SECRET = 'keyboard cat';
|
||||
} else if (!CONFIG.JWT_SECRET) {
|
||||
throw new Error(
|
||||
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
|
||||
);
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
if (!CONFIG.JWT_ALG.startsWith('HS')) {
|
||||
throw new Error('Providing a asymmetric signing/verfying algorithm without a corresponding secret is not permitted');
|
||||
}
|
||||
|
||||
CONFIG.JWT_SECRET = 'keyboard cat';
|
||||
} else {
|
||||
throw new Error(
|
||||
'TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is not employing a HMAC based signing method, then we need to turn
|
||||
// the secret into a buffer.
|
||||
if (!CONFIG.JWT_ALG.startsWith('HS')) {
|
||||
CONFIG.JWT_SECRET = Buffer.from(CONFIG.JWT_SECRET);
|
||||
// Disable the audience claim if requested.
|
||||
if (CONFIG.JWT_DISABLE_AUDIENCE) {
|
||||
CONFIG.JWT_AUDIENCE = undefined;
|
||||
}
|
||||
|
||||
// Disable the issuer claim if requested.
|
||||
if (CONFIG.JWT_DISABLE_ISSUER) {
|
||||
CONFIG.JWT_ISSUER = undefined;
|
||||
}
|
||||
|
||||
// Parse and handle cookie names.
|
||||
if (CONFIG.JWT_COOKIE_NAMES) {
|
||||
CONFIG.JWT_COOKIE_NAMES = CONFIG.JWT_COOKIE_NAMES.split(',');
|
||||
} else {
|
||||
CONFIG.JWT_COOKIE_NAMES = [];
|
||||
}
|
||||
|
||||
// Add in the default cookie names and strip duplicates.
|
||||
CONFIG.JWT_COOKIE_NAMES = uniq(CONFIG.JWT_COOKIE_NAMES.concat([CONFIG.JWT_COOKIE_NAME, CONFIG.JWT_SIGNING_COOKIE_NAME]));
|
||||
|
||||
//------------------------------------------------------------------------------
|
||||
// External database url's
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
@@ -77,16 +77,60 @@ The following are configuration shared with every type of secret used.
|
||||
tokens. (Default `process.env.TALK_ROOT_URL`)
|
||||
- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT
|
||||
tokens. (Default `talk`)
|
||||
- `TALK_JWT_COOKIE_NAME` (_optional_) - the name of the cookie to extract the
|
||||
JWT from (Default `authorization`)
|
||||
- `TALK_JWT_CLEAR_COOKIE_LOGOUT` (_optional_) - when `FALSE`, Talk will not
|
||||
clear the cookie with name `TALK_JWT_COOKIE_NAME` when logging out (Default
|
||||
`TRUE`)
|
||||
|
||||
**You must also specify secrets as either the `TALK_JWT_SECRET` or the `TALK_JWT_SECRETS`
|
||||
variable. Refer to the [Secrets Documentation]({{ "/docs/running/secrets/" | absolute_url }})
|
||||
on the contents of those variables.**
|
||||
|
||||
#### Advanced
|
||||
|
||||
These are advanced settings for fine tuning the auth integration, and
|
||||
is not needed in most situations.
|
||||
|
||||
- `TALK_JWT_COOKIE_NAME` (_optional_) - the default cookie name to check for a
|
||||
valid JWT token to use for verifying a user. (Default `authorization`)
|
||||
- `TALK_JWT_SIGNING_COOKIE_NAME` (_optional_) - the default cookie name that is
|
||||
use to set a cookie containing a JWT that was issued by Talk.
|
||||
(Default `process.env.TALK_JWT_COOKIE_NAME`)
|
||||
- `TALK_JWT_COOKIE_NAMES` (_optional_) - the different cookie names to check for
|
||||
a JWT token in, seperated by `,`. By default, we always use the
|
||||
`process.env.TALK_JWT_COOKIE_NAME` and `process.env.TALK_JWT_SIGNING_COOKIE_NAME`
|
||||
for this value. Any additional cookie names specified here will be appended to
|
||||
the list of cookie names to inspect.
|
||||
- `TALK_JWT_CLEAR_COOKIE_LOGOUT` (_optional_) - when `FALSE`, Talk will not
|
||||
clear the cookie with name `TALK_JWT_COOKIE_NAME` when logging out (Default
|
||||
`TRUE`)
|
||||
- `TALK_JWT_DISABLE_AUDIENCE` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
|
||||
with an audience (`aud`) claim, even if the `TALK_JWT_AUDIENCE` config is set. (Default `FALSE`)
|
||||
- `TALK_JWT_DISABLE_ISSUER` (_optional_) - when `TRUE`, Talk will not verify or sign JWT's
|
||||
with an issuer (`iss`) claim, even if the `TALK_JWT_ISSUER` config is set. (Default `FALSE`)
|
||||
- `TALK_JWT_USER_ID_CLAIM` (_optional_) - specify the claim using dot notation for where the
|
||||
user id should be stored/read to/from. Example `user.id` would store it like: `{user: {id}}`
|
||||
on the claims object. (Default `sub`)
|
||||
|
||||
When integrating with an external authentication system, the following JWT claims
|
||||
will be used:
|
||||
|
||||
```js
|
||||
{
|
||||
"jti": "<the unique token identifier>", // *required* unique id used for blacklisting
|
||||
"aud": TALK_JWT_AUDIENCE, // *optional* if TALK_JWT_DISABLE_AUDIENCE === 'TRUE', *required* otherwise
|
||||
"iss": TALK_JWT_ISSUER, // *optional* if TALK_JWT_DISABLE_ISSUER === 'TRUE', *required* otherwise
|
||||
|
||||
[TALK_JWT_USER_ID_CLAIM]: "<the user id>", // *required* the id of the user
|
||||
// Note, if TALK_JWT_USER_ID_CLAIM contains '.', it will be used to deliniate an object, for example
|
||||
// `user.id` would store it like: `{user: {id}}`
|
||||
}
|
||||
```
|
||||
|
||||
When our passport middleware checks for JWT tokens, it searches in the following
|
||||
order:
|
||||
|
||||
1. Custom cookies named from the list in `TALK_JWT_COOKIE_NAMES`.
|
||||
2. Default cookies named `TALK_JWT_COOKIE_NAME` then `TALK_JWT_SIGNING_COOKIE_NAME`.
|
||||
3. Query parameter `?access_token={TOKEN}`.
|
||||
4. Header: `Authorization: Bearer {TOKEN}`.
|
||||
|
||||
### Email
|
||||
|
||||
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from
|
||||
|
||||
@@ -60,6 +60,11 @@ must have their newlines replaced with `\\n`, this is to ensure that the
|
||||
newlines are preserved after JSON decoding. Not doing so will result in parsing
|
||||
errors.
|
||||
|
||||
To assist with this process, we have developed a tool that can generate new
|
||||
certificates that match our required format: [coralcert](https://github.com/coralproject/coralcert).
|
||||
This tool can generate RSA and ECDSA certificates, check it's [README](https://github.com/coralproject/coralcert)
|
||||
for more details.
|
||||
|
||||
## Authentication Types
|
||||
|
||||
Talk also supports two methods of providing authenticationd details.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Development Tooling
|
||||
permalink: /docs/development/tools
|
||||
permalink: /docs/development/tools/
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
@@ -2,18 +2,21 @@ const errors = require('../../errors');
|
||||
const {Error: {ValidationError}} = require('mongoose');
|
||||
|
||||
/**
|
||||
* Wraps up a promise to return an object with the resolution of the promise
|
||||
* Wraps up a promise or value to return an object with the resolution of the promise
|
||||
* keyed at `key` or an error caught at `errors`.
|
||||
*/
|
||||
|
||||
const wrapResponse = (key) => (promise) => {
|
||||
return promise.then((value) => {
|
||||
const wrapResponse = (key) => async (promise) => {
|
||||
try {
|
||||
let value = await promise;
|
||||
|
||||
let res = {};
|
||||
if (key) {
|
||||
res[key] = value;
|
||||
}
|
||||
|
||||
return res;
|
||||
}).catch((err) => {
|
||||
} catch (err) {
|
||||
if (err instanceof errors.APIError) {
|
||||
return {
|
||||
errors: [err]
|
||||
@@ -25,7 +28,7 @@ const wrapResponse = (key) => (promise) => {
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = wrapResponse;
|
||||
|
||||
@@ -29,12 +29,12 @@ const User = {
|
||||
|
||||
return null;
|
||||
},
|
||||
comments({id}, _, {loaders: {Comments}, user}) {
|
||||
comments({id}, {query}, {loaders: {Comments}, user}) {
|
||||
|
||||
// If the user is not an admin, only return comment list for the owner of
|
||||
// the comments.
|
||||
if (user && (user.can(SEARCH_OTHERS_COMMENTS) || user.id === id)) {
|
||||
return Comments.getByQuery({author_id: id, sort: 'REVERSE_CHRONOLOGICAL'});
|
||||
return Comments.getByQuery(Object.assign({}, query, {author_id: id}));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -596,6 +596,9 @@ type Asset {
|
||||
# The date that the asset was closed at.
|
||||
closedAt: Date
|
||||
|
||||
# True if asset is closed.
|
||||
isClosed: Boolean!
|
||||
|
||||
# Summary of all Actions against all entities associated with the Asset.
|
||||
# (likes, flags, etc.). Requires the `ADMIN` role.
|
||||
action_summaries: [AssetActionSummary!]
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ const AssetSchema = new Schema({
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
AssetSchema.index({
|
||||
@@ -79,7 +79,7 @@ AssetSchema.index({
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
AssetSchema.virtual('isClosed').get(function() {
|
||||
return this.closedAt && this.closedAt.getTime() <= new Date().getTime();
|
||||
return Boolean(this.closedAt && this.closedAt.getTime() <= new Date().getTime());
|
||||
});
|
||||
|
||||
const Asset = mongoose.model('Asset', AssetSchema);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "talk",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
@@ -93,7 +93,7 @@
|
||||
"graphql-tools": "^0.10.1",
|
||||
"helmet": "^3.5.0",
|
||||
"immutability-helper": "^2.2.0",
|
||||
"inquirer": "^3.0.6",
|
||||
"inquirer": "^3.2.1",
|
||||
"joi": "^10.4.1",
|
||||
"jsonwebtoken": "^7.3.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
|
||||
@@ -3,3 +3,4 @@ export {default as withTags} from './withTags';
|
||||
export {default as withFragments} from 'coral-framework/hocs/withFragments';
|
||||
export {default as excludeIf} from 'coral-framework/hocs/excludeIf';
|
||||
export {default as connect} from 'coral-framework/hocs/connect';
|
||||
export {default as withEmit} from 'coral-framework/hocs/withEmit';
|
||||
|
||||
@@ -271,6 +271,7 @@ export default (reaction) => (WrappedComponent) => {
|
||||
alreadyReacted={alreadyReacted}
|
||||
postReaction={this.postReaction}
|
||||
deleteReaction={this.deleteReaction}
|
||||
config={this.props.config}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,16 +68,17 @@ export default (tag) => (WrappedComponent) => {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {comment} = this.props;
|
||||
const {comment, user, config} = this.props;
|
||||
|
||||
const alreadyTagged = isTagged(comment.tags, TAG);
|
||||
|
||||
return <WrappedComponent
|
||||
user={this.props.user}
|
||||
user={user}
|
||||
comment={comment}
|
||||
alreadyTagged={alreadyTagged}
|
||||
postTag={this.postTag}
|
||||
deleteTag={this.deleteTag}
|
||||
config={config}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {showSignInDialog} from 'coral-framework/actions/auth';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const SignInButton = ({loggedIn, showSignInDialog}) => (
|
||||
<div>
|
||||
<div className="talk-stream-auth-sign-in-button">
|
||||
{!loggedIn
|
||||
? <Button id="coralSignInButton" onClick={showSignInDialog} full>
|
||||
{t('sign_in.sign_in_to_comment')}
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
color: #696969;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 8px;
|
||||
padding: 0px 5px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
height: 28px;
|
||||
height: 24px;
|
||||
transition: background-color .2s cubic-bezier(.4,0,.2,1), color .2s cubic-bezier(.4,0,.2,1), border-color .2s cubic-bezier(.4,0,.2,1);
|
||||
margin: 2px 0px;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
@@ -39,4 +40,3 @@
|
||||
font-size: 15px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class TabPane extends React.Component {
|
||||
{featuredComments.hasNextPage &&
|
||||
<LoadMore
|
||||
loadMore={this.loadMore}
|
||||
loadingState={this.loadingState}
|
||||
loadingState={this.state.loadingState}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import {gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import Comment from 'coral-admin/src/routes/Moderation/containers/Comment';
|
||||
import {handleCommentChange} from 'coral-admin/src/graphql/utils';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import truncate from 'lodash/truncate';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
@@ -22,20 +21,14 @@ class ModSubscription extends React.Component {
|
||||
assetId: this.props.data.variables.asset_id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentFeatured: {user, comment}}}}) => {
|
||||
const sort = this.props.data.variables.sort;
|
||||
const text = this.props.user.id === user.id
|
||||
? {}
|
||||
const notify = this.props.user.id === user.id
|
||||
? ''
|
||||
: t(
|
||||
'talk-plugin-featured-comments.notify_featured',
|
||||
user.username,
|
||||
prepareNotificationText(comment.body),
|
||||
);
|
||||
const notify = {
|
||||
activeQueue: this.props.activeTab,
|
||||
text,
|
||||
anyQueue: true,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
return this.props.handleCommentChange(prev, comment, notify);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -44,20 +37,14 @@ class ModSubscription extends React.Component {
|
||||
assetId: this.props.data.variables.asset_id,
|
||||
},
|
||||
updateQuery: (prev, {subscriptionData: {data: {commentUnfeatured: {user, comment}}}}) => {
|
||||
const sort = this.props.data.variables.sort;
|
||||
const text = this.props.user.id === user.id
|
||||
? {}
|
||||
const notify = this.props.user.id === user.id
|
||||
? ''
|
||||
: t(
|
||||
'talk-plugin-featured-comments.notify_unfeatured',
|
||||
user.username,
|
||||
prepareNotificationText(comment.body),
|
||||
);
|
||||
const notify = {
|
||||
activeQueue: this.props.activeTab,
|
||||
text,
|
||||
anyQueue: true,
|
||||
};
|
||||
return handleCommentChange(prev, comment, sort, notify);
|
||||
return this.props.handleCommentChange(prev, comment, notify);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
+5
-1
@@ -22,6 +22,10 @@ if (JWT_SECRETS) {
|
||||
throw new Error('when multiple keys are specified, kid\'s must be specified');
|
||||
}
|
||||
|
||||
if (typeof secret.kid !== 'string' || secret.kid.length === 0) {
|
||||
throw new Error('kid must be a unique string');
|
||||
}
|
||||
|
||||
// HMAC secrets do not have public/private keys.
|
||||
if (JWT_ALG.startsWith('HS')) {
|
||||
return new jwt.SharedSecret(secret, JWT_ALG);
|
||||
@@ -34,7 +38,7 @@ if (JWT_SECRETS) {
|
||||
return new jwt.AsymmetricSecret(secret, JWT_ALG);
|
||||
}));
|
||||
|
||||
debug(`loaded ${JWT_SECRET.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
|
||||
debug(`loaded ${JWT_SECRETS.length} ${JWT_ALG.startsWith('HS') ? 'shared' : 'asymmetric'} secrets`);
|
||||
} else if (JWT_SECRET) {
|
||||
if (JWT_ALG.startsWith('HS')) {
|
||||
module.exports.jwt = new jwt.SharedSecret({
|
||||
|
||||
+2
-1
@@ -173,7 +173,8 @@ module.exports = class ActionsService {
|
||||
return ActionModel.aggregate([
|
||||
{$match},
|
||||
{$group},
|
||||
{$project}
|
||||
{$project},
|
||||
{$sort: {action_type: 1, group_id: 1}},
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+13
-2
@@ -1,4 +1,5 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const uniq = require('lodash/uniq');
|
||||
|
||||
/**
|
||||
* MultiSecret will take many secrets and provide a unified interface for
|
||||
@@ -6,6 +7,12 @@ const jwt = require('jsonwebtoken');
|
||||
*/
|
||||
class MultiSecret {
|
||||
constructor(secrets) {
|
||||
this.kids = secrets.map(({kid}) => kid);
|
||||
|
||||
if (uniq(this.kids).length !== secrets.length) {
|
||||
throw new Error('Duplicate kid\'s cannot be used to construct a MultiSecret');
|
||||
}
|
||||
|
||||
this.secrets = secrets;
|
||||
}
|
||||
|
||||
@@ -86,7 +93,11 @@ class Secret {
|
||||
/**
|
||||
* SharedSecret is the HMAC based secret that's used for signing/verifying.
|
||||
*/
|
||||
function SharedSecret({kid = undefined, secret}, algorithm) {
|
||||
function SharedSecret({kid = undefined, secret = null}, algorithm) {
|
||||
if (secret === null || secret.length === 0) {
|
||||
throw new Error('Secret cannot have a zero length');
|
||||
}
|
||||
|
||||
return new Secret({
|
||||
kid,
|
||||
signingKey: secret,
|
||||
@@ -101,7 +112,7 @@ function SharedSecret({kid = undefined, secret}, algorithm) {
|
||||
*/
|
||||
function AsymmetricSecret({kid = undefined, private: privateKey, public: publicKey}, algorithm) {
|
||||
publicKey = Buffer.from(publicKey.replace(/\\n/g, '\n'));
|
||||
privateKey = privateKey ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
|
||||
privateKey = privateKey && privateKey.length > 0 ? Buffer.from(privateKey.replace(/\\n/g, '\n')) : null;
|
||||
|
||||
return new Secret({
|
||||
kid,
|
||||
|
||||
+38
-20
@@ -1,4 +1,5 @@
|
||||
const passport = require('passport');
|
||||
const {set, get} = require('lodash');
|
||||
const UsersService = require('./users');
|
||||
const SettingsService = require('./settings');
|
||||
const TokensService = require('./tokens');
|
||||
@@ -22,22 +23,28 @@ const {
|
||||
JWT_ALG,
|
||||
RECAPTCHA_SECRET,
|
||||
RECAPTCHA_ENABLED,
|
||||
JWT_COOKIE_NAME,
|
||||
JWT_CLEAR_COOKIE_LOGOUT
|
||||
JWT_SIGNING_COOKIE_NAME,
|
||||
JWT_COOKIE_NAMES,
|
||||
JWT_CLEAR_COOKIE_LOGOUT,
|
||||
JWT_USER_ID_CLAIM,
|
||||
} = require('../config');
|
||||
|
||||
const {
|
||||
jwt: JWT_SECRET
|
||||
jwt
|
||||
} = require('../secrets');
|
||||
|
||||
// GenerateToken will sign a token to include all the authorization information
|
||||
// needed for the front end.
|
||||
const GenerateToken = (user) => {
|
||||
return JWT_SECRET.sign({}, {
|
||||
const claims = {};
|
||||
|
||||
// Set the user id.
|
||||
set(claims, JWT_USER_ID_CLAIM, user.id);
|
||||
|
||||
return jwt.sign(claims, {
|
||||
jwtid: uuid.v4(),
|
||||
expiresIn: JWT_EXPIRY,
|
||||
issuer: JWT_ISSUER,
|
||||
subject: user.id,
|
||||
audience: JWT_AUDIENCE,
|
||||
algorithm: JWT_ALG
|
||||
});
|
||||
@@ -47,7 +54,7 @@ const GenerateToken = (user) => {
|
||||
const SetTokenForSafari = (req, res, token) => {
|
||||
const browser = bowser._detect(req.headers['user-agent']);
|
||||
if (browser.ios || browser.safari) {
|
||||
res.cookie(JWT_COOKIE_NAME, token, {
|
||||
res.cookie(JWT_SIGNING_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
expires: new Date(Date.now() + ms(JWT_EXPIRY))
|
||||
@@ -163,7 +170,7 @@ const HandleLogout = (req, res, next) => {
|
||||
|
||||
// Only clear the cookie on logout if enabled.
|
||||
if (JWT_CLEAR_COOKIE_LOGOUT) {
|
||||
res.clearCookie(JWT_COOKIE_NAME);
|
||||
res.clearCookie(JWT_SIGNING_COOKIE_NAME);
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
@@ -191,30 +198,38 @@ const CheckBlacklisted = async (jwt) => {
|
||||
|
||||
// Check to see if this is a PAT.
|
||||
if (jwt.pat) {
|
||||
return TokensService.validate(jwt.sub, jwt.jti);
|
||||
return TokensService.validate(get(jwt, JWT_USER_ID_CLAIM), jwt.jti);
|
||||
}
|
||||
|
||||
// It wasn't a PAT! Check to see if it is valid anyways.
|
||||
return checkGeneralTokenBlacklist(jwt);
|
||||
await checkGeneralTokenBlacklist(jwt);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const JwtStrategy = require('passport-jwt').Strategy;
|
||||
const ExtractJwt = require('passport-jwt').ExtractJwt;
|
||||
|
||||
let cookieExtractor = function(req) {
|
||||
let token = null;
|
||||
|
||||
let cookieExtractor = (req) => {
|
||||
if (req && req.cookies) {
|
||||
token = req.cookies[JWT_COOKIE_NAME];
|
||||
|
||||
// Walk over all the cookie names in JWT_COOKIE_NAMES.
|
||||
for (const cookieName of JWT_COOKIE_NAMES) {
|
||||
|
||||
// Check to see if that cookie is set.
|
||||
if (cookieName in req.cookies && req.cookies[cookieName] !== null && req.cookies[cookieName].length > 0) {
|
||||
return req.cookies[cookieName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return token;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Override the JwtVerifier method on the JwtStrategy so we can pack the
|
||||
// original token into the payload.
|
||||
JwtStrategy.JwtVerifier = (token, secretOrKey, options, callback) => {
|
||||
return JWT_SECRET.verify(token, options, (err, jwt) => {
|
||||
return jwt.verify(token, options, (err, jwt) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
@@ -236,7 +251,7 @@ passport.use(new JwtStrategy({
|
||||
|
||||
// Use the secret passed in which is loaded from the environment. This can be
|
||||
// a certificate (loaded) or a HMAC key.
|
||||
secretOrKey: JWT_SECRET,
|
||||
secretOrKey: jwt,
|
||||
|
||||
// Verify the issuer.
|
||||
issuer: JWT_ISSUER,
|
||||
@@ -257,11 +272,14 @@ passport.use(new JwtStrategy({
|
||||
try {
|
||||
|
||||
// Check to see if the token has been revoked
|
||||
await CheckBlacklisted(jwt);
|
||||
let user = await CheckBlacklisted(jwt);
|
||||
|
||||
// Try to get the user from the database or crack it from the token and
|
||||
// plugin integrations.
|
||||
let user = await UsersService.findOrCreateByIDToken(jwt.sub, {token, jwt});
|
||||
if (user === null) {
|
||||
|
||||
// Try to get the user from the database or crack it from the token and
|
||||
// plugin integrations.
|
||||
user = await UsersService.findOrCreateByIDToken(get(jwt, JWT_USER_ID_CLAIM), {token, jwt});
|
||||
}
|
||||
|
||||
// Attach the JWT to the request.
|
||||
req.jwt = jwt;
|
||||
|
||||
+2
-2
@@ -205,8 +205,8 @@ class TagsService {
|
||||
return updateModel(item_type, query, {
|
||||
$pull: {
|
||||
tags: {
|
||||
name: link.tag.name
|
||||
}
|
||||
'tag.name': link.tag.name,
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+8
-3
@@ -1,10 +1,12 @@
|
||||
const errors = require('../errors');
|
||||
const UserModel = require('../models/user');
|
||||
const uuid = require('uuid');
|
||||
const {set} = require('lodash');
|
||||
|
||||
const {
|
||||
JWT_ISSUER,
|
||||
JWT_AUDIENCE
|
||||
JWT_AUDIENCE,
|
||||
JWT_USER_ID_CLAIM,
|
||||
} = require('../config');
|
||||
|
||||
const {
|
||||
@@ -30,10 +32,11 @@ module.exports = class TokenService {
|
||||
jti: uuid.v4(),
|
||||
iss: JWT_ISSUER,
|
||||
aud: JWT_AUDIENCE,
|
||||
sub: userID,
|
||||
pat: true
|
||||
};
|
||||
|
||||
set(payload, JWT_USER_ID_CLAIM, userID);
|
||||
|
||||
// Sign the payload.
|
||||
const jwt = JWT_SECRET.sign(payload, {});
|
||||
|
||||
@@ -93,7 +96,7 @@ module.exports = class TokenService {
|
||||
// Find the user.
|
||||
let user = await UserModel.findOne({
|
||||
id: userID
|
||||
}).select('tokens');
|
||||
});
|
||||
if (!user || !user.tokens) {
|
||||
throw new errors.ErrAuthentication('user does not exist');
|
||||
}
|
||||
@@ -108,6 +111,8 @@ module.exports = class TokenService {
|
||||
if (!token.active) {
|
||||
throw new errors.ErrAuthentication('token is not active');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('/api/v1/assets', () => {
|
||||
.set(passport.inject({roles: ['ADMIN']}))
|
||||
.then((res) => {
|
||||
const body = res.body;
|
||||
|
||||
|
||||
expect(body).to.have.property('count', 2);
|
||||
expect(body).to.have.property('result');
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('/api/v1/assets', () => {
|
||||
|
||||
return AssetsService.findOrCreateByUrl('http://test.com')
|
||||
.then((asset) => {
|
||||
expect(asset).to.have.property('isClosed', null);
|
||||
expect(asset).to.have.property('isClosed', false);
|
||||
expect(asset).to.have.property('closedAt', null);
|
||||
|
||||
return chai.request(app)
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('services.TagsService', () => {
|
||||
const id = comment.id;
|
||||
const name = 'BEST';
|
||||
const assigned_by = user.id;
|
||||
|
||||
|
||||
await TagsService.add(id, 'COMMENTS', {
|
||||
tag: {
|
||||
name
|
||||
@@ -45,7 +45,7 @@ describe('services.TagsService', () => {
|
||||
const id = comment.id;
|
||||
const name = 'BEST';
|
||||
const assigned_by = user.id;
|
||||
|
||||
|
||||
await TagsService.add(id, 'COMMENTS', {
|
||||
tag: {
|
||||
name
|
||||
@@ -103,5 +103,43 @@ describe('services.TagsService', () => {
|
||||
expect(tags.length).to.equal(0);
|
||||
}
|
||||
});
|
||||
it('removes a tag out of 2', async () => {
|
||||
const id = comment.id;
|
||||
const name = 'BEST';
|
||||
const assigned_by = user.id;
|
||||
|
||||
await TagsService.add(id, 'COMMENTS', {
|
||||
tag: {
|
||||
name: 'ANOTHER'
|
||||
},
|
||||
assigned_by
|
||||
});
|
||||
|
||||
await TagsService.add(id, 'COMMENTS', {
|
||||
tag: {
|
||||
name
|
||||
},
|
||||
assigned_by
|
||||
});
|
||||
|
||||
{
|
||||
const {tags} = await CommentsService.findById(id);
|
||||
expect(tags.length).to.equal(2);
|
||||
}
|
||||
|
||||
// ok now to remove it
|
||||
await TagsService.remove(id, 'COMMENTS', {
|
||||
tag: {
|
||||
name
|
||||
},
|
||||
assigned_by
|
||||
});
|
||||
|
||||
{
|
||||
const {tags} = await CommentsService.findById(id);
|
||||
expect(tags.length).to.equal(1);
|
||||
expect(tags[0].tag.name).to.equal('ANOTHER');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -162,6 +162,10 @@ ansi-escapes@^1.1.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
|
||||
|
||||
ansi-escapes@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-2.0.0.tgz#5bae52be424878dd9783e8910e3fc2922e83c81b"
|
||||
|
||||
ansi-regex@^1.0.0, ansi-regex@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-1.1.1.tgz#41c847194646375e6a1a5d10c3ca054ef9fc980d"
|
||||
@@ -170,10 +174,20 @@ ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
|
||||
|
||||
ansi-regex@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
|
||||
|
||||
ansi-styles@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
|
||||
|
||||
ansi-styles@^3.1.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88"
|
||||
dependencies:
|
||||
color-convert "^1.9.0"
|
||||
|
||||
any-promise@^0.1.0, any-promise@~0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-0.1.0.tgz#830b680aa7e56f33451d4b049f3bd8044498ee27"
|
||||
@@ -1498,6 +1512,14 @@ chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
|
||||
strip-ansi "^3.0.0"
|
||||
supports-color "^2.0.0"
|
||||
|
||||
chalk@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.1.0.tgz#ac5becf14fa21b99c6c92ca7a7d7cfd5b17e743e"
|
||||
dependencies:
|
||||
ansi-styles "^3.1.0"
|
||||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^4.0.0"
|
||||
|
||||
change-emitter@^0.1.2:
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515"
|
||||
@@ -1727,7 +1749,7 @@ codemirror@*:
|
||||
version "5.25.2"
|
||||
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.25.2.tgz#8c77677ca9c9248d757d3a07ed1e89a8404850b7"
|
||||
|
||||
color-convert@^1.3.0:
|
||||
color-convert@^1.3.0, color-convert@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
|
||||
dependencies:
|
||||
@@ -3053,10 +3075,12 @@ extend@^1.2.1:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-1.3.0.tgz#d1516fb0ff5624d2ebf9123ea1dac5a1994004f8"
|
||||
|
||||
external-editor@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.1.tgz#4c597c6c88fa6410e41dbbaa7b1be2336aa31095"
|
||||
external-editor@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.0.4.tgz#1ed9199da9cbfe2ef2f7a31b2fde8b0d12368972"
|
||||
dependencies:
|
||||
iconv-lite "^0.4.17"
|
||||
jschardet "^1.4.2"
|
||||
tmp "^0.0.31"
|
||||
|
||||
extglob@^0.3.1:
|
||||
@@ -3757,6 +3781,10 @@ has-flag@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
|
||||
|
||||
has-flag@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
|
||||
|
||||
has-unicode@^2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
|
||||
@@ -3972,6 +4000,10 @@ iconv-lite@0.4.15, iconv-lite@^0.4.5, iconv-lite@~0.4.13:
|
||||
version "0.4.15"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb"
|
||||
|
||||
iconv-lite@^0.4.17:
|
||||
version "0.4.18"
|
||||
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
|
||||
|
||||
icss-replace-symbols@1.0.2, icss-replace-symbols@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5"
|
||||
@@ -4103,22 +4135,23 @@ inquirer@0.8.2:
|
||||
rx "^2.4.3"
|
||||
through "^2.3.6"
|
||||
|
||||
inquirer@^3.0.6:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.0.6.tgz#e04aaa9d05b7a3cb9b0f407d04375f0447190347"
|
||||
inquirer@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.2.1.tgz#06ceb0f540f45ca548c17d6840959878265fa175"
|
||||
dependencies:
|
||||
ansi-escapes "^1.1.0"
|
||||
chalk "^1.0.0"
|
||||
ansi-escapes "^2.0.0"
|
||||
chalk "^2.0.0"
|
||||
cli-cursor "^2.1.0"
|
||||
cli-width "^2.0.0"
|
||||
external-editor "^2.0.1"
|
||||
external-editor "^2.0.4"
|
||||
figures "^2.0.0"
|
||||
lodash "^4.3.0"
|
||||
mute-stream "0.0.7"
|
||||
run-async "^2.2.0"
|
||||
rx "^4.1.0"
|
||||
string-width "^2.0.0"
|
||||
strip-ansi "^3.0.0"
|
||||
rx-lite "^4.0.8"
|
||||
rx-lite-aggregates "^4.0.8"
|
||||
string-width "^2.1.0"
|
||||
strip-ansi "^4.0.0"
|
||||
through "^2.3.6"
|
||||
|
||||
interpret@^1.0.0:
|
||||
@@ -4560,6 +4593,10 @@ jsbn@~0.1.0:
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
|
||||
|
||||
jschardet@^1.4.2:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-1.5.1.tgz#c519f629f86b3a5bedba58a88d311309eec097f9"
|
||||
|
||||
jsdom@^7.0.2:
|
||||
version "7.2.2"
|
||||
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-7.2.2.tgz#40b402770c2bda23469096bee91ab675e3b1fc6e"
|
||||
@@ -7434,6 +7471,16 @@ run-async@^2.2.0:
|
||||
dependencies:
|
||||
is-promise "^2.1.0"
|
||||
|
||||
rx-lite-aggregates@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
|
||||
dependencies:
|
||||
rx-lite "*"
|
||||
|
||||
rx-lite@*, rx-lite@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
|
||||
|
||||
rx-lite@^3.1.2:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
|
||||
@@ -7442,10 +7489,6 @@ rx@^2.4.3:
|
||||
version "2.5.3"
|
||||
resolved "https://registry.yarnpkg.com/rx/-/rx-2.5.3.tgz#21adc7d80f02002af50dae97fd9dbf248755f566"
|
||||
|
||||
rx@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
|
||||
|
||||
safe-buffer@^5.0.1, safe-buffer@~5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7"
|
||||
@@ -7833,6 +7876,13 @@ string-width@^2.0.0:
|
||||
is-fullwidth-code-point "^2.0.0"
|
||||
strip-ansi "^3.0.0"
|
||||
|
||||
string-width@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
|
||||
dependencies:
|
||||
is-fullwidth-code-point "^2.0.0"
|
||||
strip-ansi "^4.0.0"
|
||||
|
||||
string.prototype.codepointat@^0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78"
|
||||
@@ -7871,6 +7921,12 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
|
||||
dependencies:
|
||||
ansi-regex "^2.0.0"
|
||||
|
||||
strip-ansi@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
|
||||
dependencies:
|
||||
ansi-regex "^3.0.0"
|
||||
|
||||
strip-bom@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
|
||||
@@ -7961,6 +8017,12 @@ supports-color@^3.1.2, supports-color@^3.2.3:
|
||||
dependencies:
|
||||
has-flag "^1.0.0"
|
||||
|
||||
supports-color@^4.0.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.1.tgz#65a4bb2631e90e02420dba5554c375a4754bb836"
|
||||
dependencies:
|
||||
has-flag "^2.0.0"
|
||||
|
||||
svgo@^0.7.0:
|
||||
version "0.7.2"
|
||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"
|
||||
|
||||
Reference in New Issue
Block a user