Compare commits

..
29 Commits
Author SHA1 Message Date
Wyatt Johnson e180cf87d7 fix: added more details to slack config (#2937) 2020-04-14 22:28:14 +00:00
Kim Gardner de976a5397 Additional Dutch translations (#2936)
* Fix typo

* Add new NL translations
2020-04-14 22:18:34 +00:00
Wyatt Johnson 257830a6c5 [CORL-1035] Moderated By Bug (#2935)
* fix: improved moderated by component

* fix: uncommented line
2020-04-14 21:55:40 +00:00
Tessa Thornton 518056f382 [CORL-1033] don't show view conversation link if callback is not defined (#2934)
* don't show view conversation link if callback is not defined

* remove unnecessary callback
2020-04-13 17:39:17 +00:00
Tessa Thornton 84936ca35c fix error thrown attempting to remove announcements (#2933) 2020-04-13 16:09:33 +00:00
Wyatt Johnson 4194b08a12 [CORL-1002] Queue Improvements (#2931)
* fix: improved count handler for stories not found

* feat: removed performance-now

* feat: cleaned up processors, exposed counts

* feat: increased verb of schd jobs

* fix: removed dead code
2020-04-10 16:15:09 +00:00
Wyatt JohnsonandKim Gardner 98e6a3ccc7 chore: bump version 6.1.0 (#2932)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-04-10 15:25:01 +00:00
Kim GardnerandGilles FRANCOIS 64f5171ff2 [CORL-1003] Add Markdown link to configure page & revert some French translations (#2928)
* french corrections

* Add translations

* Updated FR translations

* More FR fixes

Co-authored-by: Gilles FRANCOIS <gilles@sopress.net>
2020-04-10 10:37:11 -04:00
6711f09a79 [CORL-1001] Wordlist Fixes (#2920)
* fix: improve wordlist highlighting and perf

* fix: updated tests

* fix: implmeneted new regexp lib/patterns

* fix: improve comment body css

* fix: take into account the tree shaking is disabled

See: https://github.com/webpack/webpack/issues/7094

Co-authored-by: Chi Vinh Le <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-04-06 15:04:05 -04:00
Wyatt JohnsonandKim Gardner 8966a8201b fix: removed crossorigin attribute on custom css tags (#2919)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-04-03 17:58:52 -04:00
Tessa ThorntonandKim Gardner 7fc0e2e489 [CORL-974] Add more classnames (#2913)
* wired css

* add more classnames to hook into

* update snaps

* update more snaps

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-04-03 15:54:17 -04:00
Cristian DeanandWyatt Johnson 7c3c510bbc Add tenantDomain to StoryCreatedEventPayload (#2899)
* Add tenantID and siteID to StoryCreatedEventPayload

* fix: added tenantID to payload

* Remove tenantID from StoryCreatedCoralEvent

* Add tenantDomain to webhook schema

* fix: small comment fixes

Co-authored-by: Wyatt Johnson <me@wyattjoh.ca>
2020-04-02 21:39:58 +00:00
Wyatt Johnson 94ece10284 [CORL-996] Force SSL Configuration (#2917)
* fix: added new FORCE_SSL config, reverted production SSL handling

* fix: added another test to handle purify testing
2020-03-30 20:35:18 +00:00
ef01f427de Update PT-BR translations after release 6 (#2915)
Co-authored-by: hector.melo <hector.melo@corp.globo.com>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-30 16:12:34 -04:00
Tessa ThorntonandKim Gardner 5db096942a remove 'mailto:' prefix from links (#2914)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-27 14:09:42 -04:00
Tessa Thornton 4645ed4cd7 [CORL-943] Send test emails (#2882)
* add smtp test email

* disable button if email not enabled

* add error handling

* internationalize strings

* sort imports
2020-03-27 11:46:53 -04:00
7fd524cd6a [CORL-924/CORL-923] show all comments or staff comments in slack (#2872)
* add allcomments option to slack config

* create separate class for hadnling slack event publishing

* add strings

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 21:21:09 -04:00
Wyatt JohnsonandKim Gardner 3cb5d0ff9f [CORL-970] CircleCI Version Bump (#2893)
* feat: bump config version

* fix: removed old version tag

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 21:04:44 -04:00
Tessa ThorntonandKim Gardner 44216221a4 show badges for suspended and premod users (#2895)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 20:34:14 -04:00
Wyatt JohnsonandKim Gardner 0f9b2ba114 feat: added new DEFAULT_QA_STORY_MODE feature flag (#2905)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 18:31:48 -04:00
Nick FunkandKim Gardner 071666e8b8 Add more punctuation types to word list rules (#2907)
CORL-957

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 18:21:48 -04:00
Tessa ThorntonandKim Gardner 2a9a5099dc update click to copy link button styles (#2912)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 17:50:02 -04:00
Wyatt JohnsonandKim Gardner d0f51d8815 fix: added more guard clauses around the endpoint (#2910)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 17:29:51 -04:00
Andrew LosowskyandKim Gardner b228725370 Updated text (#2911)
removed mention of Talk, Coral Project, and links to v4 userguides

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-26 14:46:19 -04:00
Tessa Thornton 58f2f71743 [CORL-916] conversation view (#2850)
* show comment counts for stories in story table

* remove debug code

* add modal to view comment

* load converasation view into modal

* style modal header

* style conversation view modal

* style conversation thread in conversation modal

* styles conversation view comments

* show replies to replies on click

* update snaps

* update snap

* pass settings prop through to comment content, set default replies to 5
2020-03-26 13:47:16 -04:00
Wyatt Johnson 8fd266deeb Merge branch 'master' into release/6 2020-03-23 12:20:18 -06:00
Wyatt Johnson 1b73a5fcd7 [CORL-976] Story Closed Fix (#2903)
* fix: fixed issue where `isStoryClosed` can return null

* chore: version bump
2020-03-23 18:02:45 +00:00
Nick FunkandWyatt Johnson 9a58673545 [CORL-929] SSO Key Rotation (Front-end) (#2877)
* feat: initial impl

* Create preliminary SSO rotation components

CORL-929

* Create rotateSSOKey mutation

CORL-929

* Fix sorting by date for SSO keys

CORL-929

* Show tool tips beside expiring/expired statuses

CORL-929

* Hook up Deactivate and Delete SSO key mutations

CORL-929

* Tweak expired tooltip on SSO keys

CORL-929

* Replace old SSO key config with key rotation

CORL-929

* Fix copy button for SSO key's secret

CORL-929

* Refactor SSOKeyCard func's into components

All the func's building the sub-components
were prime targets for component's with props.

CORL-929

* Update tests to match SSO Key rotation

CORL-929

* Fix typo in translation id

CORL-929

* Test key rotation

CORL-929

* Plumb disabled through SSO key rotation

CORL-929

* Remove duplicate input/payload pairs from schema

For some reason it perfectly duplicated the
input/payload pairs for SSO key rotation on
rebase.

CORL-929

* Remove debug logging

CORL-929

* Use switch instead of if's to compute SSO date field

CORL-929

* Use switch to compute SSO action button state

CORL-929

* Use switch to compute SSO StatusField state

CORL-929

* Remove 10 second key rotation option

CORL-929

* Use memo in sorting SSO keys

CORL-929

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-03-20 20:59:59 +00:00
Wyatt Johnson 2dc63d3a27 [CORL-754] SSO Key Management (#2732)
* feat: initial impl

* feat: clean up redis when keys are deleted

* fix: remove test timeout value
2020-03-19 20:57:49 +00:00
214 changed files with 6829 additions and 3612 deletions
+1 -2
View File
@@ -13,7 +13,7 @@ job_defaults: &job_defaults
environment:
<<: *job_environment
version: 2
version: 2.1
jobs:
# npm_dependencies will install the dependencies used by all other steps.
npm_dependencies:
@@ -173,7 +173,6 @@ filter_develop: &filter_develop
- next
workflows:
version: 2
build-test:
jobs:
- docker_tests:
+4 -6
View File
@@ -5,9 +5,9 @@ how moderation, comment display, and conversation function, creating the
opportunity for safer, smarter discussions around your work.
[Read more about Coral here](https://coralproject.net/talk).
Built with <3 by The Coral Project, a part of [Vox Media](https://product.voxmedia.com/).
Built with <3 by the Coral team, a part of [Vox Media](https://product.voxmedia.com/).
Preview Coral easily by running Coral via a Heroku App:
Preview Coral easily by running it via a Heroku App:
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/coralproject/talk)
@@ -28,13 +28,11 @@ You can get started with Coral using our [Documentation](https://docs.coralproje
## Pre-Launch Guide
Youve installed Talk on your server, and youre preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Talk Community Guide](https://coralproject.net/blog/youve-installed-talk-now-what/).
Youve installed Coral on your server, and youre preparing to launch it on your site. The real community work starts now, before you go live. You have a unique opportunity pre-launch to set your community up for success. Read our [Community Guides](https://guides.coralproject.net/start-here/) to learn more.
## More Resources
- [Our Blog](https://coralproject.net/blog)
- [Community Guides for Journalism](https://guides.coralproject.net/)
- [More About Us](https://coralproject.net/)
- [Learn more about Coral and sign up for a webinar](https://coralproject.net/)
## License
+15
View File
@@ -174,6 +174,16 @@ function when comparing signatures.
* date when this event was created.
*/
createdAt: string;
/**
* tenantID is the ID of the Tenant that this event originated at.
*/
tenantID: string;
/**
* tenantDomain is the domain that is associated with this Tenant that this event originated at.
*/
tenantDomain: string;
}
```
@@ -199,6 +209,11 @@ function when comparing signatures.
* storyURL is the URL of the newly created Story.
*/
storyURL: string;
/**
* siteID is the Site that the newly created Story was created on.
*/
siteID: string;
}
createdAt: string;
}
+23 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "6.0.2",
"version": "6.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -3217,7 +3217,6 @@
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.8.3.tgz",
"integrity": "sha512-lrIU4aVbmlM/wQPzhEvzvNJskKyYptuXb0fGC0lTQTupTOYtR2Vqbu6/jf8vTr4M8Wt1nIzxVrSvPI5qESa/xA==",
"dev": true,
"requires": {
"core-js-pure": "^3.0.0",
"regenerator-runtime": "^0.13.2"
@@ -5760,6 +5759,12 @@
"@types/node": "*"
}
},
"@types/xregexp": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@types/xregexp/-/xregexp-4.3.0.tgz",
"integrity": "sha512-3gJTS9gt27pS7U9q5IVqo4YvKSlkf2ck8ish6etuDj6LIRxkL/2Y8RMUtK/QzvE1Yv2zwWV5yemI2BS0GGGFnA==",
"dev": true
},
"@types/yargs": {
"version": "12.0.12",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-12.0.12.tgz",
@@ -10721,8 +10726,7 @@
"core-js-pure": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz",
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw==",
"dev": true
"integrity": "sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw=="
},
"core-util-is": {
"version": "1.0.2",
@@ -32077,8 +32081,7 @@
"regenerator-runtime": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw==",
"dev": true
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
},
"regenerator-transform": {
"version": "0.13.4",
@@ -38138,6 +38141,14 @@
"dev": true,
"requires": {
"xregexp": "4.0.0"
},
"dependencies": {
"xregexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
"dev": true
}
}
},
"del": {
@@ -39365,10 +39376,12 @@
"integrity": "sha512-tGkGJkN8XqCod7OT+EvGYK5Z4SfDQGD30zAa58OcnAa0RRWgzUEK72tkXhsX1FZd+rgnhRxFtmO+ihkp8LHSkw=="
},
"xregexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
"integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
"dev": true
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.3.0.tgz",
"integrity": "sha512-7jXDIFXh5yJ/orPn4SXjuVrWWoi4Cr8jfV1eHv9CixKSbU+jY4mxfrBwAuDvupPNKpMUY+FeIqsVw/JLT9+B8g==",
"requires": {
"@babel/runtime-corejs3": "^7.8.3"
}
},
"xtend": {
"version": "4.0.1",
+5 -3
View File
@@ -1,9 +1,10 @@
{
"name": "@coralproject/talk",
"version": "6.0.2",
"version": "6.1.0",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
"*.css.ts",
"*.css"
],
"repository": {
@@ -127,7 +128,6 @@
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
"passport-strategy": "^1.0.0",
"performance-now": "^2.1.0",
"permit": "^0.2.4",
"prom-client": "^11.3.0",
"proxy-agent": "^3.1.1",
@@ -140,7 +140,8 @@
"tsscmp": "^1.0.6",
"url-regex": "^5.0.0",
"uuid": "^3.3.3",
"verror": "^1.10.0"
"verror": "^1.10.0",
"xregexp": "^4.3.0"
},
"devDependencies": {
"@babel/core": "^7.8.3",
@@ -240,6 +241,7 @@
"@types/webpack-bundle-analyzer": "^2.13.1",
"@types/webpack-dev-server": "^3.1.5",
"@types/ws": "^5.1.2",
"@types/xregexp": "^4.3.0",
"@typescript-eslint/eslint-plugin": "2.3.3",
"@typescript-eslint/eslint-plugin-tslint": "2.3.3",
"@typescript-eslint/parser": "2.3.3",
@@ -12,12 +12,12 @@ $comment-link-active: var(--v2-palette-primary-darkest);
color: $comment-content;
overflow-wrap: break-word;
& * bold,
& * strong {
b,
strong {
font-weight: var(--v2-font-weight-primary-bold);
}
& * italic,
& * em {
i,
em {
font-style: italic;
}
blockquote {
@@ -50,3 +50,7 @@ $comment-link-active: var(--v2-palette-primary-darkest);
}
}
}
.highlight {
white-space: pre-wrap;
}
@@ -16,6 +16,7 @@ it("renders correctly", () => {
},
className: "custom",
children: "Hello <b>Bob</b>, you bad guy",
highlight: true,
};
const renderer = createRenderer();
renderer.render(<CommentContent {...props} />);
@@ -33,6 +34,26 @@ it("renders empty words correctly", () => {
},
className: "custom",
children: "Hello <b>Bob</b>, you bad guy",
highlight: true,
};
const renderer = createRenderer();
renderer.render(<CommentContent {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders correctly even if it has consecutive banned words on comments", () => {
const props: PropTypesOf<typeof CommentContent> = {
phrases: {
locale: "en-US",
wordList: {
suspect: ["worse"],
banned: ["bad"],
},
},
className: "custom",
children:
"This is a very long comment with bad words. Let's try bad and bad. Now bad bad.\nBad BAD bad.\n",
highlight: true,
};
const renderer = createRenderer();
renderer.render(<CommentContent {...props} />);
@@ -0,0 +1,84 @@
import cn from "classnames";
import React, { FunctionComponent, useMemo } from "react";
import striptags from "striptags";
import {
getPhrasesRegExp,
GetPhrasesRegExpOptions,
markHTMLNode,
} from "coral-admin/helpers";
import { createPurify } from "coral-common/utils/purify";
import styles from "./CommentContent.css";
/**
* Create a purify instance that will be used to handle HTML content.
*/
const purify = createPurify(window, false);
interface Props {
className?: string;
children: string | React.ReactElement;
phrases: GetPhrasesRegExpOptions;
highlight?: boolean;
}
const CommentContent: FunctionComponent<Props> = ({
phrases,
className,
children,
highlight = false,
}) => {
// Cache the expression used via memo. This will reduce duplicate renders of
// this comment content when the children change but the phrase configuration
// does not change. The regExp is already cached on a deeper level
// automatically, this is just lessening that impact further.
const expression = useMemo(() => {
// If we aren't in highlight mode for this comment, don't even attempt to
// generate the expression.
if (!highlight) {
return null;
}
return getPhrasesRegExp(phrases);
}, [phrases, highlight]);
// Cache the parsed comment node. If the children cannot be parsed, this will
// be null.
const parsed = useMemo(() => {
if (typeof children !== "string") {
return null;
}
// Sanitize the input for display.
let html = purify.sanitize(children);
if (highlight) {
html = striptags(html, ["a"]);
}
// We create a Shadow DOM Tree with the HTML body content and use it as a
// parser.
const node = document.createElement("div");
node.innerHTML = html;
// If the expression is available, then mark the nodes.
if (expression) {
markHTMLNode(node, expression);
}
return node;
}, [children, expression, highlight]);
if (parsed) {
return (
<div
className={cn(className, styles.root, highlight && styles.highlight)}
dangerouslySetInnerHTML={{ __html: parsed.innerHTML }}
/>
);
}
return <>{children}</>;
};
export default CommentContent;
@@ -0,0 +1,21 @@
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
.root {
margin-right: var(--mini-unit);
padding: var(--spacing-1);
margin-left: calc(-1 * var(--v2-spacing-1));
line-height: var(--v2-line-height-reset);
&:hover {
background-color: $moderateCardUsernameBackgroundHover;
border-radius: var(--v2-round-corners);
border-style: none;
}
&:active {
background-color: $moderateCardUsernameBackgroundHover;
border-radius: var(--v2-round-corners);
border-style: none;
}
}
@@ -0,0 +1,27 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { BaseButton } from "coral-ui/components/v2";
import Username from "./Username";
import styles from "./UsernameButton.css";
interface Props {
username: string;
className?: string;
onClick: () => void;
}
const UsernameButton: FunctionComponent<Props> = ({
username,
onClick,
className,
}) => {
return (
<BaseButton onClick={onClick} className={cn(styles.root, className)}>
<Username>{username}</Username>
</BaseButton>
);
};
export default UsernameButton;
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="custom CommentContent-root CommentContent-highlight"
dangerouslySetInnerHTML={
Object {
"__html": "<span>Hello Bob, you <mark>bad</mark> guy</span>",
}
}
/>
`;
exports[`renders correctly even if it has consecutive banned words on comments 1`] = `
<div
className="custom CommentContent-root CommentContent-highlight"
dangerouslySetInnerHTML={
Object {
"__html": "<span>This is a very long comment with <mark>bad</mark> words. Let's try <mark>bad</mark> and <mark>bad</mark>. Now <mark>bad</mark> bad.
<mark>Bad</mark> BAD <mark>bad</mark>.
</span>",
}
}
/>
`;
exports[`renders empty words correctly 1`] = `
<div
className="custom CommentContent-root CommentContent-highlight"
dangerouslySetInnerHTML={
Object {
"__html": "Hello Bob, you bad guy",
}
}
/>
`;
@@ -0,0 +1,4 @@
export { default as CommentContent } from "./CommentContent";
export { default as InReplyTo } from "./InReplyTo";
export { default as Username } from "./Username";
export { default as UsernameButton } from "./UsernameButton";
@@ -0,0 +1,38 @@
import React, { FunctionComponent } from "react";
import { Modal } from "coral-ui/components";
import ConversationModalQuery from "./ConversationModalQuery";
interface ConversationModalProps {
open: boolean;
onClose: () => void;
commentID?: string;
onUsernameClicked: (userID: string) => void;
}
const ConversationModal: FunctionComponent<ConversationModalProps> = ({
open,
onClose,
commentID,
onUsernameClicked,
}) => {
if (!commentID) {
return null;
}
return (
<Modal open={open} onClose={onClose}>
{({ firstFocusableRef, lastFocusableRef }) => (
<ConversationModalQuery
commentID={commentID}
onClose={onClose}
firstFocusableRef={firstFocusableRef}
lastFocusableRef={lastFocusableRef}
onUsernameClicked={onUsernameClicked}
/>
)}
</Modal>
);
};
export default ConversationModal;
@@ -0,0 +1,37 @@
$conversationModalHighlightBackground: var(--v2-colors-teal-100);
$conversationModalCommentText: var(--v2-colors-mono-500);
.root {
flex: 1;
}
.line {
border-color: var(--v2-colors-grey-400);
}
.adornments {
margin-right: var(--v2-spacing-2);
}
.circle {
margin-top: var(--v2-spacing-2);
margin-bottom: var(--v2-spacing-2);
}
.highlightedCircle {
margin-top: var(--v2-spacing-1);
}
.highlighted {
background-color: $conversationModalHighlightBackground;
padding: var(--v2-spacing-2);
padding-top: var(--v2-spacing-1);
margin-bottom: var(--v2-spacing-2);
}
.commentText {
color: $conversationModalCommentText;
}
.showReplies {
padding-left: var(--v2-spacing-2);
}
@@ -0,0 +1,160 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
Flex,
HorizontalGutter,
Timestamp,
} from "coral-ui/components/v2";
import ConversationModalRepliesQuery from "./ConversationModalRepliesQuery";
import { ConversationModalCommentContainer_comment } from "coral-admin/__generated__/ConversationModalCommentContainer_comment.graphql";
import { ConversationModalCommentContainer_settings } from "coral-admin/__generated__/ConversationModalCommentContainer_settings.graphql";
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
import { Circle, Line } from "../Timeline";
import styles from "./ConversationModalCommentContainer.css";
interface Props {
comment: ConversationModalCommentContainer_comment;
isHighlighted: boolean;
onUsernameClick: (id?: string) => void;
isParent?: boolean;
isReply?: boolean;
settings: ConversationModalCommentContainer_settings;
}
const ConversationModalCommentContainer: FunctionComponent<Props> = ({
comment,
isHighlighted,
isParent,
isReply,
onUsernameClick,
settings,
}) => {
const commentAuthorClick = useCallback(() => {
if (comment.author) {
onUsernameClick(comment.author.id);
}
}, [onUsernameClick, comment.author]);
const commentParentAuthorClick = useCallback(() => {
if (comment.parent && comment.parent.author) {
onUsernameClick(comment.parent.author.id);
}
}, [onUsernameClick, comment.parent]);
const [showReplies, setShowReplies] = useState(false);
const onShowReplies = useCallback(() => {
setShowReplies(true);
}, []);
return (
<HorizontalGutter>
<Flex>
<Flex
direction="column"
alignItems="center"
className={cn(styles.adornments, {
[styles.highlightedCircle]: isHighlighted,
})}
>
{!isReply && <Circle className={styles.circle} />}
{(isParent || isReply) && <Line className={styles.line} />}
</Flex>
<HorizontalGutter
spacing={1}
className={cn(styles.root, {
[styles.highlighted]: isHighlighted,
})}
>
<div>
<Flex alignItems="center">
{comment.author && comment.author.username && (
<UsernameButton
onClick={commentAuthorClick}
username={comment.author.username}
/>
)}
<Timestamp>{comment.createdAt}</Timestamp>
</Flex>
{comment.parent &&
comment.parent.author &&
comment.parent.author.username && (
<InReplyTo onUsernameClick={commentParentAuthorClick}>
{comment.parent.author.username}
</InReplyTo>
)}
</div>
<div>
{comment.body && (
<CommentContent className={styles.commentText} phrases={settings}>
{comment.body}
</CommentContent>
)}
</div>
</HorizontalGutter>
</Flex>
{isReply && comment.replyCount > 0 && (
<div className={styles.showReplies}>
{!showReplies && (
<Localized id="conversation-modal-reply-show-replies">
<Button
color="mono"
variant="outline"
onClick={onShowReplies}
fullWidth
>
Show replies
</Button>
</Localized>
)}
{showReplies && (
<ConversationModalRepliesQuery
onUsernameClicked={onUsernameClick}
commentID={comment.id}
/>
)}
</div>
)}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment ConversationModalCommentContainer_settings on Settings {
locale
wordList {
banned
suspect
}
multisite
featureFlags
...MarkersContainer_settings
}
`,
comment: graphql`
fragment ConversationModalCommentContainer_comment on Comment {
id
body
createdAt
author {
username
id
}
replyCount
parent {
author {
username
id
}
}
}
`,
})(ConversationModalCommentContainer);
export default enhanced;
@@ -0,0 +1,10 @@
.root {
}
.topCircle {
margin-right: var(--v2-spacing-2);
}
.bottomCircleContainer {
width: 8px;
padding-top: var(--v2-spacing-1);
}
@@ -0,0 +1,159 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import {
Button,
Counter,
Flex,
HorizontalGutter,
} from "coral-ui/components/v2";
import { ConversationModalContainer_comment } from "coral-admin/__generated__/ConversationModalContainer_comment.graphql";
import { ConversationModalContainer_settings } from "coral-admin/__generated__/ConversationModalContainer_settings.graphql";
import { ConversationModalContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalContainerPaginationQuery.graphql";
import { Circle } from "../Timeline";
import ConversationModalComment from "./ConversationModalCommentContainer";
import styles from "./ConversationModalContainer.css";
interface Props {
relay: RelayPaginationProp;
comment: ConversationModalContainer_comment;
settings: ConversationModalContainer_settings;
onClose: () => void;
onUsernameClicked: (id?: string) => void;
}
const ConversationModalContainer: FunctionComponent<Props> = ({
comment,
relay,
settings,
onUsernameClicked,
}) => {
const [loadMore] = useLoadMore(relay, 5);
const parents = comment.parents.edges.map(edge => edge.node);
return (
<HorizontalGutter className={styles.root}>
{comment.parentCount > parents.length && (
<div>
<Flex alignItems="center">
<Circle hollow={true} className={styles.topCircle} />
<Button underline variant="text" onClick={loadMore}>
<Localized id="conversation-modal-show-more-parents">
<span>Show more of this conversation</span>
</Localized>
<Counter>{comment.parentCount}</Counter>
</Button>
</Flex>
<Flex
direction="column"
alignItems="center"
className={styles.bottomCircleContainer}
>
<Circle color="light" size="small" />
</Flex>
</div>
)}
{parents.map(parent => (
<ConversationModalComment
key={parent.id}
isParent={true}
comment={parent}
onUsernameClick={onUsernameClicked}
settings={settings}
isHighlighted={false}
/>
))}
<ConversationModalComment
comment={comment}
settings={settings}
onUsernameClick={onUsernameClicked}
isHighlighted={true}
/>
</HorizontalGutter>
);
};
// TODO: (cvle) If this could be autogenerated.
type FragmentVariables = ConversationModalContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
ConversationModalContainerPaginationQueryVariables,
FragmentVariables
>(
{
settings: graphql`
fragment ConversationModalContainer_settings on Settings {
...ConversationModalCommentContainer_settings
}
`,
comment: graphql`
fragment ConversationModalContainer_comment on Comment
@argumentDefinitions(
count: { type: "Int!", defaultValue: 1 }
cursor: { type: "Cursor" }
) {
id
...ConversationModalCommentContainer_comment
rootParent {
id
}
parents(last: $count, before: $cursor)
@connection(key: "ConversationModal_parents") {
edges {
node {
id
...ConversationModalCommentContainer_comment
}
}
}
parentCount
}
`,
},
{
direction: "backward",
getConnectionFromProps(props) {
return props.comment && props.comment.parents;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }) {
return {
count,
cursor,
// commentID isn't specified as an @argument for the fragment, but it should be a
// variable available for the fragment under the query root.
commentID: props.comment.id,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query ConversationModalContainerPaginationQuery(
$count: Int!
$cursor: Cursor
$commentID: ID!
) {
comment(id: $commentID) {
...ConversationModalContainer_comment
@arguments(count: $count, cursor: $cursor)
}
}
`,
}
)(ConversationModalContainer);
export default enhanced;
@@ -0,0 +1,17 @@
$modal-header-text: var(--v2-colors-mono-900);
.title {
font-size: var(--v2-font-size-4);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
line-height: var(--v2-line-height-title);
color: $modal-header-text;
margin: 0;
}
.conversationTitle {
font-weight: var(--v2-font-weight-primary-semi-bold);
text-transform: uppercase;
display: block;
margin-bottom: var(--v2-spacing-2);
}
@@ -0,0 +1,65 @@
import { Localized } from "@fluent/react/compat";
import { useRouter } from "found";
import React, { FunctionComponent, RefObject, useCallback } from "react";
import { getModerationLink } from "coral-framework/helpers";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button, HorizontalGutter, ModalHeader } from "coral-ui/components/v2";
import { ConversationModalHeaderContainer_comment } from "coral-admin/__generated__/ConversationModalHeaderContainer_comment.graphql";
import styles from "./ConversationModalHeaderContainer.css";
interface Props {
comment: ConversationModalHeaderContainer_comment;
onClose: () => void;
focusableRef: RefObject<any>;
}
const ConversationModalHeaderContainer: FunctionComponent<Props> = ({
comment,
onClose,
focusableRef,
}) => {
const { router } = useRouter();
const onModerate = useCallback(() => {
const link = getModerationLink({ storyID: comment.story.id });
router.push(link);
}, [comment.id]);
return (
<ModalHeader onClose={onClose} focusableRef={focusableRef}>
<HorizontalGutter spacing={3}>
<h1 className={styles.title}>
<Localized id="conversation-modal-header-title">
<span className={styles.conversationTitle}>Conversation on:</span>
</Localized>
{comment.story.metadata
? comment.story.metadata.title
: comment.story.url}
</h1>
<Localized id="conversation-modal-header-moderate-link">
<Button variant="outline" color="mono" uppercase onClick={onModerate}>
Moderate story
</Button>
</Localized>
</HorizontalGutter>
</ModalHeader>
);
};
const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment ConversationModalHeaderContainer_comment on Comment {
story {
id
metadata {
title
}
url
}
id
}
`,
})(ConversationModalHeaderContainer);
export default enhanced;
@@ -0,0 +1,8 @@
.root {
padding: 0;
width: 635px;
}
.body {
padding: var(--v2-spacing-4);
}
@@ -0,0 +1,99 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
} from "coral-framework/lib/relay";
import { CallOut, Card, Spinner } from "coral-ui/components/v2";
import { ConversationModalQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalQuery.graphql";
import ConversationModalContainer from "./ConversationModalContainer";
import ConversationModalHeaderContainer from "./ConversationModalHeaderContainer";
import ConversationModalRepliesContainer from "./ConversationModalRepliesContainer";
import styles from "./ConversationModalQuery.css";
interface Props {
commentID: string;
onClose: () => void;
firstFocusableRef: React.RefObject<any>;
onUsernameClicked: (userID: string) => void;
lastFocusableRef: React.RefObject<any>;
}
const ConversationModalQuery: FunctionComponent<Props> = ({
commentID,
onClose,
lastFocusableRef,
onUsernameClicked,
}) => {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query ConversationModalQuery($commentID: ID!) {
settings {
...ConversationModalContainer_settings
...ConversationModalRepliesContainer_settings
}
comment(id: $commentID) {
...ConversationModalContainer_comment
...ConversationModalRepliesContainer_comment
...ConversationModalHeaderContainer_comment
}
}
`}
variables={{ commentID }}
cacheConfig={{ force: true }}
render={({ props }: QueryRenderData<QueryTypes>) => {
if (!props) {
return (
<div>
<Spinner />
</div>
);
}
if (!props.comment) {
return (
<div>
<CallOut>
<Localized id="conversation-modal-comment-not-found">
Comment not found.
</Localized>
</CallOut>
</div>
);
}
return (
<Card className={styles.root}>
<ConversationModalHeaderContainer
onClose={onClose}
comment={props.comment}
focusableRef={lastFocusableRef}
/>
<div className={styles.body}>
<ConversationModalContainer
onClose={onClose}
settings={props.settings}
comment={props.comment}
onUsernameClicked={onUsernameClicked}
/>
<ConversationModalRepliesContainer
onClose={onClose}
comment={props.comment}
settings={props.settings}
onUsernameClicked={onUsernameClicked}
/>
</div>
</Card>
);
}}
/>
);
};
export default ConversationModalQuery;
@@ -0,0 +1,7 @@
.comment {
padding-left: calc(var(--v2-spacing-4));
}
.footer {
padding-left: calc(var(--v2-spacing-4));
}
@@ -0,0 +1,152 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import {
useLoadMore,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { Omit } from "coral-framework/types";
import { Button, HorizontalGutter } from "coral-ui/components/v2";
import { ConversationModalRepliesContainer_comment } from "coral-admin/__generated__/ConversationModalRepliesContainer_comment.graphql";
import { ConversationModalRepliesContainer_settings } from "coral-admin/__generated__/ConversationModalRepliesContainer_settings.graphql";
import { ConversationModalRepliesContainerPaginationQueryVariables } from "coral-admin/__generated__/ConversationModalRepliesContainerPaginationQuery.graphql";
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
import styles from "./ConversationModalRepliesContainer.css";
interface Props {
relay: RelayPaginationProp;
comment: ConversationModalRepliesContainer_comment;
settings: ConversationModalRepliesContainer_settings;
onClose: () => void;
onUsernameClicked: (id?: string) => void;
}
const ConversationModalRepliesContainer: FunctionComponent<Props> = ({
comment,
relay,
settings,
onUsernameClicked,
}) => {
const [loadMore] = useLoadMore(relay, 5);
const replies = comment.replies.edges.map(edge => edge.node);
const [showReplies, setShowReplies] = useState(false);
const onShowReplies = useCallback(() => {
setShowReplies(true);
}, []);
return (
<HorizontalGutter>
{showReplies &&
replies.map(reply => (
<div key={reply.id} className={styles.comment}>
<ConversationModalCommentContainer
key={reply.id}
settings={settings}
comment={reply}
isHighlighted={false}
isReply={true}
onUsernameClick={onUsernameClicked}
/>
</div>
))}
<div className={styles.footer}>
{!showReplies && comment.replyCount > 0 && (
<Localized id="conversation-modal-replies-show">
<Button variant="outline" fullWidth onClick={onShowReplies}>
Show replies
</Button>
</Localized>
)}
{showReplies &&
comment.replyCount > replies.length &&
replies.length > 0 && (
<Localized id="conversation-modal-replies-show-more">
<Button variant="outline" fullWidth onClick={loadMore}>
Show more replies
</Button>
</Localized>
)}
</div>
</HorizontalGutter>
);
};
// TODO: (cvle) If this could be autogenerated.
type FragmentVariables = Omit<
ConversationModalRepliesContainerPaginationQueryVariables,
"commentID"
>;
const enhanced = withPaginationContainer<
Props,
ConversationModalRepliesContainerPaginationQueryVariables,
FragmentVariables
>(
{
settings: graphql`
fragment ConversationModalRepliesContainer_settings on Settings {
...ConversationModalCommentContainer_settings
}
`,
comment: graphql`
fragment ConversationModalRepliesContainer_comment on Comment
@argumentDefinitions(
count: { type: "Int!", defaultValue: 5 }
cursor: { type: "Cursor" }
orderBy: { type: "COMMENT_SORT!", defaultValue: CREATED_AT_ASC }
) {
id
replies(first: $count, after: $cursor, orderBy: $orderBy)
@connection(key: "ConversationModalReplies_replies") {
edges {
node {
id
...ConversationModalCommentContainer_comment
}
}
}
replyCount
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.comment && props.comment.replies;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
orderBy: fragmentVariables.orderBy,
commentID: props.comment.id,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query ConversationModalRepliesContainerPaginationQuery(
$count: Int!
$cursor: Cursor
$orderBy: COMMENT_SORT!
$commentID: ID!
) {
comment(id: $commentID) {
...ConversationModalRepliesContainer_comment
@arguments(count: $count, cursor: $cursor, orderBy: $orderBy)
}
}
`,
}
)(ConversationModalRepliesContainer);
export default enhanced;
@@ -0,0 +1,87 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
} from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components/v2";
import { ConversationModalRepliesQuery as QueryTypes } from "coral-admin/__generated__/ConversationModalRepliesQuery.graphql";
import ConversationModalCommentContainer from "./ConversationModalCommentContainer";
interface Props {
commentID: string;
onUsernameClicked: (userID: string) => void;
}
const ConversationModalRepliesQuery: FunctionComponent<Props> = ({
commentID,
onUsernameClicked,
}) => {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query ConversationModalRepliesQuery($commentID: ID!) {
settings {
...ConversationModalCommentContainer_settings
}
comment(id: $commentID) {
replies {
edges {
node {
id
...ConversationModalCommentContainer_comment
}
}
}
}
}
`}
variables={{ commentID }}
cacheConfig={{ force: true }}
render={({ props }: QueryRenderData<QueryTypes>) => {
if (!props) {
return (
<div>
<Spinner />
</div>
);
}
if (!props.comment) {
return (
<div>
<CallOut>
<Localized id="conversation-modal-comment-not-found">
Comment not found.
</Localized>
</CallOut>
</div>
);
}
const replies = props.comment.replies.edges.map(edge => edge.node);
return (
<div>
{replies.map(reply => (
<div key={reply.id}>
<ConversationModalCommentContainer
key={reply.id}
comment={reply}
settings={props.settings}
isHighlighted={false}
isReply={true}
onUsernameClick={onUsernameClicked}
/>
</div>
))}
</div>
);
}}
/>
);
};
export default ConversationModalRepliesQuery;
@@ -0,0 +1 @@
export { default } from "./ConversationModal";
@@ -13,16 +13,39 @@ interface Props {
}
const CommentAuthorContainer: FunctionComponent<Props> = ({ comment }) => {
if (!comment.author || !comment.author.status.ban.active) {
if (
!comment.author ||
!(
comment.author.status.ban.active ||
comment.author.status.premod.active ||
comment.author.status.suspension.active
)
) {
return null;
}
return (
<>
<div className={styles.authorStatus}>
<Localized id="commentAuthor-status-banned">
<Tag color="error">BANNED</Tag>
</Localized>
</div>
{comment.author.status.ban.active && (
<div className={styles.authorStatus}>
<Localized id="commentAuthor-status-banned">
<Tag color="error">BANNED</Tag>
</Localized>
</div>
)}
{comment.author.status.suspension.active && (
<div className={styles.authorStatus}>
<Localized id="commentAuthor-status-suspended">
<Tag color="error">SUSPENDED</Tag>
</Localized>
</div>
)}
{comment.author.status.premod.active && (
<div className={styles.authorStatus}>
<Localized id="commentAuthor-status-premod">
<Tag color="grey">PRE-MOD</Tag>
</Localized>
</div>
)}
</>
);
};
@@ -34,6 +57,12 @@ const enhanced = withFragmentContainer<Props>({
id
username
status {
suspension {
active
}
premod {
active
}
ban {
active
}
@@ -1,100 +0,0 @@
import cn from "classnames";
import React, { FunctionComponent, useMemo } from "react";
import { getPhrasesRegExp, GetPhrasesRegExpOptions } from "coral-admin/helpers";
import { createPurify } from "coral-common/utils/purify";
import styles from "./CommentContent.css";
/**
* Create a purify instance that will be used to handle HTML content.
*/
const purify = createPurify(window, false);
interface Props {
className?: string;
children: string | React.ReactElement;
phrases: GetPhrasesRegExpOptions;
}
function escapeHTML(unsafe: string) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// markPhrasesHTML looks for `supsect` and `banned` words inside `text` given
// the settings applied for the locale and highlights them by returning an HTML
// string.
function markPhrasesHTML(text: string, expression: RegExp) {
const tokens = text.split(expression);
if (tokens.length === 1) {
return text;
}
return tokens
.map((token, i) =>
// Using our Regexp patterns it returns tokens arranged this way
// [STRING_WITH_NO_MATCH, NEW_WORD_DELIMITER, MATCHED_WORD, ...].
// This pattern repeats throughout. Next line will mark MATCHED_WORD
// and escape all tokens.
i % 3 === 2 ? `<mark>${escapeHTML(token)}</mark>` : escapeHTML(token)
)
.join("");
}
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
// for `supsectWords` and `bannedWords`.
function markHTMLNode(parentNode: Node, expression: RegExp) {
parentNode.childNodes.forEach(node => {
if (node.nodeName === "#text") {
const newContent = markPhrasesHTML(node.textContent!, expression);
if (newContent !== node.textContent) {
const newNode = document.createElement("span");
newNode.innerHTML = newContent;
parentNode.replaceChild(newNode, node);
}
} else {
markHTMLNode(node, expression);
}
});
}
const CommentContent: FunctionComponent<Props> = ({
phrases,
className,
children,
}) => {
// Cache the expression used via memo. This will reduce duplicate renders of
// this comment content when the children change but the phrase configuration
// does not change. The regExp is already cached on a deeper level
// automatically, this is just lessening that impact further.
const expression = useMemo(() => getPhrasesRegExp(phrases), [phrases]);
if (typeof children === "string") {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement("div");
node.innerHTML = purify.sanitize(children);
if (expression) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, expression);
}
// Finally we render the content of the Shadow DOM Tree
return (
<div
className={cn(className, styles.root)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
/>
);
}
return <>{children}</>;
};
export default CommentContent;
@@ -6,7 +6,7 @@ import { HorizontalGutter, Timestamp } from "coral-ui/components/v2";
import { CommentRevisionContainer_comment as CommentData } from "coral-admin/__generated__/CommentRevisionContainer_comment.graphql";
import { CommentRevisionContainer_settings as SettingsData } from "coral-admin/__generated__/CommentRevisionContainer_settings.graphql";
import CommentContent from "./CommentContent";
import { CommentContent } from "../Comment";
interface Props {
comment: CommentData;
@@ -9,8 +9,3 @@
display: inline-block;
padding-right: var(--spacing-2);
}
.button {
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}
@@ -5,7 +5,7 @@ import CopyToClipboard from "react-copy-to-clipboard";
import { getURLWithCommentID } from "coral-framework/helpers";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { getLocationOrigin } from "coral-framework/utils";
import { Button, Icon } from "coral-ui/components";
import { Button, Icon } from "coral-ui/components/v2";
import { LinkDetailsContainer_comment } from "coral-admin/__generated__/LinkDetailsContainer_comment.graphql";
import { LinkDetailsContainer_settings } from "coral-admin/__generated__/LinkDetailsContainer_settings.graphql";
@@ -32,12 +32,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
<CopyToClipboard
text={getURLWithCommentID(comment.story.url, comment.id)}
>
<Button
color="primary"
variant="filled"
size="small"
className={styles.button}
>
<Button>
<Icon size="md">link</Icon>
<Localized id="moderate-in-stream-link-copy">
<span>In Stream</span>
@@ -49,12 +44,7 @@ const LinkDetailsContainer: FunctionComponent<Props> = ({
<CopyToClipboard
text={`${getLocationOrigin()}/admin/moderate/comment/${comment.id}`}
>
<Button
color="primary"
variant="filled"
size="small"
className={styles.button}
>
<Button>
<Icon size="md">link</Icon>
<Localized id="moderate-in-moderation-link-copy">
<span>In Moderation</span>
@@ -15,26 +15,6 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
margin-bottom: var(--v2-spacing-4);
}
.usernameButton {
margin-right: var(--mini-unit);
padding: var(--spacing-1);
margin-left: calc(-1 * var(--v2-spacing-1));
line-height: var(--v2-line-height-reset);
&:hover {
background-color: $moderateCardUsernameBackgroundHover;
border-radius: var(--v2-round-corners);
border-style: none;
}
&:active {
background-color: $moderateCardUsernameBackgroundHover;
border-radius: var(--v2-round-corners);
border-style: none;
}
}
.inReplyTo {
}
@@ -35,6 +35,7 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
onBan: noop,
onUsernameClick: noop,
onFocusOrClick: noop,
onConversationClick: noop,
showStory: false,
moderatedBy: null,
};
@@ -13,7 +13,8 @@ import { HOTKEYS } from "coral-admin/constants";
import { GetPhrasesRegExpOptions } from "coral-admin/helpers";
import { PropTypesOf } from "coral-framework/types";
import {
BaseButton,
Button,
ButtonIcon,
Card,
Flex,
HorizontalGutter,
@@ -22,14 +23,12 @@ import {
Timestamp,
} from "coral-ui/components/v2";
import { CommentContent, InReplyTo, UsernameButton } from "../Comment";
import ApproveButton from "./ApproveButton";
import CommentAuthorContainer from "./CommentAuthorContainer";
import CommentContent from "./CommentContent";
import FeatureButton from "./FeatureButton";
import InReplyTo from "./InReplyTo";
import MarkersContainer from "./MarkersContainer";
import RejectButton from "./RejectButton";
import Username from "./Username";
import styles from "./ModerateCard.css";
@@ -38,6 +37,7 @@ interface Props {
username: string;
createdAt: string;
body: string;
highlight?: boolean;
inReplyTo?: {
id: string;
username: string | null;
@@ -59,6 +59,7 @@ interface Props {
onReject: () => void;
onFeature: () => void;
onUsernameClick: (id?: string) => void;
onConversationClick: (() => void) | null;
onFocusOrClick: () => void;
mini?: boolean;
hideUsername?: boolean;
@@ -82,6 +83,7 @@ const ModerateCard: FunctionComponent<Props> = ({
username,
createdAt,
body,
highlight = false,
inReplyTo,
comment,
settings,
@@ -102,6 +104,7 @@ const ModerateCard: FunctionComponent<Props> = ({
moderatedBy,
selected,
onFocusOrClick,
onConversationClick,
mini = false,
hideUsername = false,
deleted = false,
@@ -190,12 +193,10 @@ const ModerateCard: FunctionComponent<Props> = ({
<Flex alignItems="center">
{!hideUsername && username && (
<>
<BaseButton
<UsernameButton
username={username}
onClick={commentAuthorClick}
className={styles.usernameButton}
>
<Username>{username}</Username>
</BaseButton>
/>
<CommentAuthorContainer comment={comment} />
</>
)}
@@ -220,20 +221,23 @@ const ModerateCard: FunctionComponent<Props> = ({
)}
</div>
<div className={styles.contentArea}>
<CommentContent phrases={phrases} className={styles.content}>
<CommentContent
highlight={highlight}
phrases={phrases}
className={styles.content}
>
{commentBody}
</CommentContent>
<div className={styles.viewContext}>
<Localized id="moderate-comment-viewContext">
<TextLink
className={styles.link}
href={viewContextHref}
target="_blank"
>
View Context
</TextLink>
</Localized>
</div>
{onConversationClick && (
<div className={styles.viewContext}>
<Button iconLeft variant="text" onClick={onConversationClick}>
<ButtonIcon>question_answer</ButtonIcon>
<Localized id="moderate-comment-viewConversation">
<span>View conversation</span>
</Localized>
</Button>
</div>
)}
<div
className={cn(styles.separator, {
[styles.ruledSeparator]: !mini,
@@ -22,7 +22,6 @@ import {
ModerateCardContainer_comment,
} from "coral-admin/__generated__/ModerateCardContainer_comment.graphql";
import { ModerateCardContainer_settings } from "coral-admin/__generated__/ModerateCardContainer_settings.graphql";
import { ModerateCardContainer_viewer } from "coral-admin/__generated__/ModerateCardContainer_viewer.graphql";
import BanCommentUserMutation from "./BanCommentUserMutation";
import FeatureCommentMutation from "./FeatureCommentMutation";
@@ -32,7 +31,6 @@ import UnfeatureCommentMutation from "./UnfeatureCommentMutation";
interface Props {
comment: ModerateCardContainer_comment;
viewer: ModerateCardContainer_viewer;
settings: ModerateCardContainer_settings;
approveComment: MutationProp<typeof ApproveCommentMutation>;
rejectComment: MutationProp<typeof RejectCommentMutation>;
@@ -46,6 +44,7 @@ interface Props {
mini?: boolean;
hideUsername?: boolean;
onUsernameClicked?: (userID: string) => void;
onConversationClicked?: (commentID: string) => void;
onSetSelected?: () => void;
selected?: boolean;
selectPrev?: () => void;
@@ -73,7 +72,6 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
settings,
danglingLogic,
showStoryInfo,
viewer,
match,
router,
approveComment,
@@ -86,6 +84,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
selectPrev,
selectNext,
onUsernameClicked: usernameClicked,
onConversationClicked: conversationClicked,
onSetSelected: setSelected,
banUser,
loadNext,
@@ -160,6 +159,13 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
[usernameClicked, comment]
);
const onConversationClicked = useCallback(() => {
if (!conversationClicked) {
return;
}
conversationClicked(comment.id);
}, [conversationClicked, comment]);
const handleModerateStory = useCallback(
(e: React.MouseEvent) => {
router.push(getModerationLink({ storyID: comment.story.id }));
@@ -203,6 +209,16 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
},
[comment]
);
// Only highlight comments that have been flagged for containing a banned or
// suspect word.
const highlight = comment.revision
? comment.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD +
comment.revision.actionCounts.flag.reasons
.COMMENT_DETECTED_SUSPECT_WORD >
0
: false;
return (
<>
<FadeInTransition active={Boolean(comment.enteredLive)}>
@@ -215,6 +231,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}
createdAt={comment.createdAt}
body={comment.body!}
highlight={highlight}
inReplyTo={comment.parent && comment.parent.author}
comment={comment}
settings={settings}
@@ -227,6 +244,9 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onReject={handleReject}
onFeature={onFeature}
onUsernameClick={onUsernameClicked}
onConversationClick={
conversationClicked ? onConversationClicked : null
}
selected={selected}
selectPrev={selectPrev}
selectNext={selectNext}
@@ -235,7 +255,6 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
moderatedBy={
<ModeratedByContainer
onUsernameClicked={onUsernameClicked}
viewer={viewer}
comment={comment}
/>
}
@@ -283,6 +302,16 @@ const enhanced = withFragmentContainer<Props>({
statusLiveUpdated
createdAt
body
revision {
actionCounts {
flag {
reasons {
COMMENT_DETECTED_BANNED_WORD
COMMENT_DETECTED_SUSPECT_WORD
}
}
}
}
tags {
code
}
@@ -332,11 +361,6 @@ const enhanced = withFragmentContainer<Props>({
...MarkersContainer_settings
}
`,
viewer: graphql`
fragment ModerateCardContainer_viewer on User {
...ModeratedByContainer_viewer
}
`,
})(
withRouter(
withMutation(BanCommentUserMutation)(
@@ -24,10 +24,6 @@ it("viewer's username shows on moderation cards moderated by viewer", () => {
],
},
},
viewer: {
id: "viewer",
username: "viewer",
},
onUsernameClicked: (id?: string | null) => {
return;
},
@@ -1,55 +1,88 @@
import { Localized } from "@fluent/react/compat";
import React, { useCallback } from "react";
import React, { useCallback, useMemo } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { BaseButton } from "coral-ui/components/v2";
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
import styles from "./ModeratedByContainer.css";
interface Props {
viewer: ModeratedByContainer_viewer;
comment: ModeratedByContainer_comment;
onUsernameClicked: (id?: string | null) => void;
}
const MODERATION_STATUS = ["APPROVED", "REJECTED"];
interface ModeratedBy {
id?: string;
username?: string | null;
system?: boolean;
}
const system: ModeratedBy = { system: true };
const ModeratedByContainer: React.FunctionComponent<Props> = ({
comment,
viewer,
onUsernameClicked,
}) => {
let moderatedBy: React.ReactElement | null;
let id: string | null = null;
if (comment.statusHistory.edges.length === 0) {
moderatedBy = null;
} else if (comment.statusHistory.edges[0].node.moderator === null) {
moderatedBy = (
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
);
} else if (viewer.id === comment.statusHistory.edges[0].node.moderator.id) {
moderatedBy = viewer.username ? <>{viewer.username}</> : null;
} else {
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
id = comment.statusHistory.edges[0].node.moderator.id;
}
const moderatedBy: ModeratedBy | null = useMemo(() => {
// If the comment was just moderated by the viewer, don't display anything.
// This will update once the mutation returns.
if (comment.viewerDidModerate) {
return null;
}
// If the comment has not been approved or rejected, don't render anything.
if (!MODERATION_STATUS.includes(comment.status)) {
return null;
}
if (comment.statusHistory.edges.length === 0) {
return system;
}
// Get the node that was the last moderated by element.
const {
node: { status, moderator },
} = comment.statusHistory.edges[0];
if (!MODERATION_STATUS.includes(status)) {
return null;
}
if (!moderator) {
return system;
}
return moderator;
}, [comment]);
const onClick = useCallback(() => {
if (!moderatedBy || !moderatedBy.id) {
return;
}
onUsernameClicked(moderatedBy.id);
}, [onUsernameClicked, moderatedBy]);
if (!moderatedBy) {
return null;
}
const onClick = useCallback(() => {
onUsernameClicked(id);
}, [onUsernameClicked, comment]);
return (
<BaseButton onClick={onClick}>
<Localized id="moderate-comment-moderatedBy">
<div className={styles.moderatedBy}>Moderated By</div>
</Localized>
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
<div className={styles.moderatedByUsername}>
{moderatedBy.system ? (
<Localized id="moderate-comment-moderatedBySystem">System</Localized>
) : (
moderatedBy.username
)}
</div>
</BaseButton>
);
};
@@ -58,10 +91,12 @@ const enhanced = withFragmentContainer<Props>({
comment: graphql`
fragment ModeratedByContainer_comment on Comment {
id
statusLiveUpdated
status
viewerDidModerate
statusHistory(first: 1) {
edges {
node {
status
moderator {
id
username
@@ -71,12 +106,6 @@ const enhanced = withFragmentContainer<Props>({
}
}
`,
viewer: graphql`
fragment ModeratedByContainer_viewer on User {
id
username
}
`,
})(ModeratedByContainer);
export default enhanced;
@@ -1,23 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="custom CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Hello <b>Bob</b><span>, you <mark>bad</mark> guy</span>",
}
}
/>
`;
exports[`renders empty words correctly 1`] = `
<div
className="custom CommentContent-root"
dangerouslySetInnerHTML={
Object {
"__html": "Hello <b>Bob</b>, you bad guy",
}
}
/>
`;
@@ -19,14 +19,10 @@ exports[`renders approved correctly 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -48,6 +44,7 @@ exports[`renders approved correctly 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -67,17 +64,22 @@ exports[`renders approved correctly 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -152,14 +154,10 @@ exports[`renders correctly 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -181,6 +179,7 @@ exports[`renders correctly 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -200,17 +199,22 @@ exports[`renders correctly 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -285,14 +289,10 @@ exports[`renders dangling correctly 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -314,6 +314,7 @@ exports[`renders dangling correctly 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -333,17 +334,22 @@ exports[`renders dangling correctly 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -418,14 +424,10 @@ exports[`renders rejected correctly 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -447,6 +449,7 @@ exports[`renders rejected correctly 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -466,17 +469,22 @@ exports[`renders rejected correctly 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -551,14 +559,10 @@ exports[`renders reply correctly 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -589,6 +593,7 @@ exports[`renders reply correctly 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -608,17 +613,22 @@ exports[`renders reply correctly 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -693,14 +703,10 @@ exports[`renders story info 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -722,6 +728,7 @@ exports[`renders story info 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -741,17 +748,22 @@ exports[`renders story info 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -864,14 +876,10 @@ exports[`renders tombstoned when comment is deleted 1`] = `
alignItems="center"
>
<React.Fragment>
<ForwardRef(forwardRef)
className="ModerateCard-usernameButton"
<UsernameButton
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
username="Theon"
/>
<Relay(CommentAuthorContainer)
comment={Object {}}
/>
@@ -893,6 +901,7 @@ exports[`renders tombstoned when comment is deleted 1`] = `
>
<CommentContent
className="ModerateCard-content"
highlight={false}
phrases={
Object {
"locale": "en-US",
@@ -920,17 +929,22 @@ exports[`renders tombstoned when comment is deleted 1`] = `
<div
className="ModerateCard-viewContext"
>
<Localized
id="moderate-comment-viewContext"
<ForwardRef(forwardRef)
iconLeft={true}
onClick={[Function]}
variant="text"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
<ForwardRef(forwardRef)>
question_answer
</ForwardRef(forwardRef)>
<Localized
id="moderate-comment-viewConversation"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
<span>
View conversation
</span>
</Localized>
</ForwardRef(forwardRef)>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -19,11 +19,5 @@ exports[`viewer's username shows on moderation cards moderated by viewer 1`] = `
}
}
onUsernameClicked={[Function]}
viewer={
Object {
"id": "viewer",
"username": "viewer",
}
}
/>
`;
@@ -0,0 +1,29 @@
.sizeReg {
width: 8px;
height: 8px;
}
.sizeSmall {
width: 4px;
height: 4px;
}
.colorReg {
color: var(--v2-colors-grey-500);
}
.colorLight {
color: var(--v2-colors-grey-400);
}
.root {
position: relative;
}
.circle {
fill: currentColor;
stroke: currentColor;
position: absolute;
left: 0;
top: 0;
}
@@ -0,0 +1,42 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./Circle.css";
export interface CircleProps {
className?: string;
hollow?: boolean;
size?: "small" | "regular";
color?: "light" | "regular";
}
const Circle: FunctionComponent<CircleProps> = props => {
return (
<div
className={cn(styles.root, props.className, {
[styles.sizeReg]: props.size === "regular",
[styles.sizeSmall]: props.size === "small",
[styles.colorReg]: props.color === "regular",
[styles.colorLight]: props.color === "light",
})}
>
<svg
className={cn(styles.circle, {})}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
{props.hollow && (
<circle cx="50%" cy="50%" r="45" strokeWidth="10" fill="none" />
)}
{!props.hollow && <circle cx="50%" cy="50%" r="50" />}
</svg>
</div>
);
};
Circle.defaultProps = {
size: "regular",
color: "regular",
};
export default Circle;
@@ -0,0 +1,9 @@
.root {
border-left: 1px solid var(--palette-grey-main);
border-right: 1px solid var(--palette-grey-main);
flex: 1;
}
.dotted {
border-left-style: dotted;
}
@@ -0,0 +1,16 @@
import React from "react";
import { createRenderer } from "react-test-renderer/shallow";
import { PropTypesOf } from "coral-framework/types";
import Line from "./Line";
it("renders correctly", () => {
const props: PropTypesOf<typeof Line> = {
className: "root",
dotted: true,
};
const renderer = createRenderer();
renderer.render(<Line {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -0,0 +1,23 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./Line.css";
interface LineProps {
className?: string;
dotted?: boolean;
}
const Line: FunctionComponent<LineProps> = props => {
return (
<div
className={cn(styles.root, props.className, {
[styles.dotted]: props.dotted,
})}
>
{props.children}
</div>
);
};
export default Line;
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<div
className="Line-root root Line-dotted"
/>
`;
@@ -0,0 +1,2 @@
export { default as Circle } from "./Circle";
export { default as Line } from "./Line";
@@ -11,21 +11,18 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
import { UserHistoryDrawerAllComments_settings } from "coral-admin/__generated__/UserHistoryDrawerAllComments_settings.graphql";
import { UserHistoryDrawerAllComments_user } from "coral-admin/__generated__/UserHistoryDrawerAllComments_user.graphql";
import { UserHistoryDrawerAllComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerAllComments_viewer.graphql";
import { UserHistoryDrawerAllCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerAllCommentsPaginationQuery.graphql";
import styles from "./UserHistoryDrawerAllComments.css";
interface Props {
user: UserHistoryDrawerAllComments_user;
viewer: UserHistoryDrawerAllComments_viewer;
settings: UserHistoryDrawerAllComments_settings;
relay: RelayPaginationProp;
}
const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
user,
viewer,
settings,
relay,
}) => {
@@ -61,7 +58,6 @@ const UserHistoryDrawerAllComments: FunctionComponent<Props> = ({
<div key={c.id}>
<ModerateCardContainer
comment={c}
viewer={viewer}
settings={settings}
danglingLogic={status => false}
hideUsername
@@ -91,11 +87,6 @@ const enhanced = withPaginationContainer<
FragmentVariables
>(
{
viewer: graphql`
fragment UserHistoryDrawerAllComments_viewer on User {
...ModerateCardContainer_viewer
}
`,
settings: graphql`
fragment UserHistoryDrawerAllComments_settings on Settings {
...ModerateCardContainer_settings
@@ -24,9 +24,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
user(id: $userID) {
...UserHistoryDrawerAllComments_user
}
viewer {
...UserHistoryDrawerAllComments_viewer
}
settings {
...UserHistoryDrawerAllComments_settings
}
@@ -57,8 +54,6 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
return (
<UserHistoryDrawerAllComments
// We can never get to this part of the UI without being logged in.
viewer={props.viewer!}
settings={props.settings}
user={props.user}
/>
@@ -11,21 +11,18 @@ import { Button, CallOut, Divider } from "coral-ui/components/v2";
import { UserHistoryDrawerRejectedComments_settings } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_settings.graphql";
import { UserHistoryDrawerRejectedComments_user } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_user.graphql";
import { UserHistoryDrawerRejectedComments_viewer } from "coral-admin/__generated__/UserHistoryDrawerRejectedComments_viewer.graphql";
import { UserHistoryDrawerRejectedCommentsPaginationQueryVariables } from "coral-admin/__generated__/UserHistoryDrawerRejectedCommentsPaginationQuery.graphql";
import styles from "./UserHistoryDrawerRejectedComments.css";
interface Props {
user: UserHistoryDrawerRejectedComments_user;
viewer: UserHistoryDrawerRejectedComments_viewer;
settings: UserHistoryDrawerRejectedComments_settings;
relay: RelayPaginationProp;
}
const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
user,
viewer,
settings,
relay,
}) => {
@@ -63,7 +60,6 @@ const UserHistoryDrawerRejectedComments: FunctionComponent<Props> = ({
<div key={c.id}>
<ModerateCardContainer
comment={c}
viewer={viewer}
settings={settings}
danglingLogic={status => false}
hideUsername
@@ -93,11 +89,6 @@ const enhanced = withPaginationContainer<
FragmentVariables
>(
{
viewer: graphql`
fragment UserHistoryDrawerRejectedComments_viewer on User {
...ModerateCardContainer_viewer
}
`,
settings: graphql`
fragment UserHistoryDrawerRejectedComments_settings on Settings {
...ModerateCardContainer_settings
@@ -22,9 +22,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
user(id: $userID) {
...UserHistoryDrawerRejectedComments_user
}
viewer {
...UserHistoryDrawerRejectedComments_viewer
}
settings {
...UserHistoryDrawerRejectedComments_settings
}
@@ -55,8 +52,6 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
return (
<UserHistoryDrawerRejectedComments
// We can never get to this part of the UI without being logged in.
viewer={props.viewer!}
settings={props.settings}
user={props.user}
/>
@@ -1,5 +1,7 @@
import { lowerCase, uniqBy } from "lodash";
import { LanguageCode } from "coral-common/helpers";
import { createWordListRegExp } from "coral-common/utils";
import createWordListRegExp from "coral-common/utils/createWordListRegExp";
export interface GetPhrasesRegExpOptions {
locale: string;
@@ -17,20 +19,35 @@ export function getPhrasesRegExp({
return null;
}
return createWordListRegExp(locale as LanguageCode, [...banned, ...suspect]);
// Because the banned and suspect word lists may sometimes overlap, we should
// make this list as short as possible before compiling it into a RegExp.
const phrases = uniqBy<string>([...banned, ...suspect], lowerCase);
// The locale is passed down to us from the Graph, we can cast it to a
// LanguageCode.
return createWordListRegExp(locale as LanguageCode, phrases);
}
// cache is used as a global validator to the cached RegExp used by the
// Cache is used as a global validator to the cached RegExp used by the
// application. We expect that generally, there is only ever one word list used
// by the client at a time, so this ensures that we only re-create the word list
// if we must.
const cache = {
interface Cache {
keys: {
locale: string;
suspect: ReadonlyArray<string>;
banned: ReadonlyArray<string>;
};
value: RegExp | null;
}
const cache: Cache = {
keys: {
locale: "",
suspect: [] as ReadonlyArray<string>,
banned: [] as ReadonlyArray<string>,
suspect: [],
banned: [],
},
value: null as RegExp | null,
value: null,
};
export default function(options: GetPhrasesRegExpOptions) {
@@ -57,7 +74,12 @@ export default function(options: GetPhrasesRegExpOptions) {
// If the cache is expired, or the value doesn't exist, regenerate it.
if (expired) {
cache.value = getPhrasesRegExp(options);
try {
cache.value = getPhrasesRegExp(options);
} catch (err) {
window.console.error(err);
return null;
}
}
return cache.value;
+1
View File
@@ -3,3 +3,4 @@ export {
default as getPhrasesRegExp,
GetPhrasesRegExpOptions,
} from "./getPhrasesRegExp";
export { default as markHTMLNode } from "./markHTMLNode";
@@ -0,0 +1,58 @@
// markPhrasesHTML looks for `suspect` and `banned` words inside `text` given
// the settings applied for the locale and highlights them by returning an HTML
// string.
function markPhrasesHTML(text: string, expression: RegExp) {
const tokens = text.split(expression);
// If there were less than two matches, then there was no matched word
// associated with the passed in text.
if (tokens.length < 3) {
return null;
}
return tokens
.map((token, i) =>
// Using our Regexp patterns it returns tokens arranged this way:
//
// - STRING_WITH_NO_MATCH
// - NEW_WORD_DELIMITER
// - MATCHED_WORD
// - NEW_WORD_DELIMITER
// - ...
//
// This pattern repeats throughout. Next line will mark MATCHED_WORD.
i % 4 === 2 ? "<mark>" + token + "</mark>" : token
)
.join("");
}
// markHTMLNode manipulates the node by looking for #text nodes and adding
// markers.
export default function markHTMLNode(parentNode: Node, expression: RegExp) {
parentNode.childNodes.forEach(node => {
// Anchor links are already marked by default, skip them now.
if (node.nodeName === "A") {
return;
}
// If the node isn't of text type then we can't mark it directly.
if (node.nodeName !== "#text") {
return markHTMLNode(node, expression);
}
// If the node doesn't have any text content, then we can't mark it either.
if (!node.textContent) {
return;
}
// We've encountered a text node with text content that isn't in an anchor
// link. We should try to mark and replace it's content.
const replacement = markPhrasesHTML(node.textContent, expression);
if (replacement) {
// Create the new span node to replace the old node with.
const newNode = document.createElement("span");
newNode.innerHTML = replacement;
parentNode.replaceChild(newNode, node);
}
});
}
@@ -17,6 +17,9 @@ extend type Comment {
# If true then Comment came in live.
enteredLive: Boolean
# If true then the Comment status was updated by the viewer.
viewerDidModerate: Boolean
}
extend type CommentsConnection {
@@ -40,15 +40,7 @@ const ApproveCommentMutation = createMutation(
}
}
}
statusHistory(first: 1) {
edges {
node {
moderator {
username
}
}
}
}
...ModeratedByContainer_comment
}
moderationQueues(storyID: $storyID) {
unmoderated {
@@ -74,7 +66,9 @@ const ApproveCommentMutation = createMutation(
storyID: input.storyID,
},
optimisticUpdater: store => {
store.get(input.commentID)!.setValue("APPROVED", "status");
const proxy = store.get(input.commentID)!;
proxy.setValue("APPROVED", "status");
proxy.setValue(true, "viewerDidModerate");
},
updater: store => {
const connections = [
@@ -40,15 +40,7 @@ const RejectCommentMutation = createMutation(
}
}
}
statusHistory(first: 1) {
edges {
node {
moderator {
username
}
}
}
}
...ModeratedByContainer_comment
}
moderationQueues(storyID: $storyID) {
unmoderated {
@@ -74,7 +66,9 @@ const RejectCommentMutation = createMutation(
storyID: input.storyID,
},
optimisticUpdater: store => {
store.get(input.commentID)!.setValue("REJECTED", "status");
const proxy = store.get(input.commentID)!;
proxy.setValue("REJECTED", "status");
proxy.setValue(true, "viewerDidModerate");
},
updater: store => {
const connections = [
@@ -2,12 +2,13 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { PropTypesOf } from "coral-framework/types";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { FormFieldDescription } from "coral-ui/components/v2";
import Header from "../../Header";
import ConfigBoxWithToggleField from "./ConfigBoxWithToggleField";
import RegistrationField from "./RegistrationField";
import SSOKeyFieldContainer from "./SSOKeyFieldContainer";
import SSOKeyRotationQuery from "./SSOKeyRotation/SSOKeyRotationQuery";
import TargetFilterField from "./TargetFilterField";
// eslint-disable-next-line no-unused-expressions
@@ -28,10 +29,9 @@ graphql`
interface Props {
disabled?: boolean;
sso: PropTypesOf<typeof SSOKeyFieldContainer>["sso"];
}
const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
const SSOConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBoxWithToggleField
title={
<Localized id="configure-auth-sso-loginWith">
@@ -44,7 +44,23 @@ const SSOConfig: FunctionComponent<Props> = ({ disabled, sso }) => (
>
{disabledInside => (
<>
<SSOKeyFieldContainer sso={sso} disabled={disabledInside} />
<Localized
id="configure-auth-sso-description"
IntroLink={
<ExternalLink href="https://jwt.io/introduction/"></ExternalLink>
}
DocLink={
<ExternalLink href="https://docs.coralproject.net/coral/v5/integrating/sso/"></ExternalLink>
}
>
<FormFieldDescription>
To enable integration with your existing authentication system, you
will need to create a JWT Token to connect. You can learn more about
creating a JWT Token with this introduction. See our documentation
for additional information on single sign on.
</FormFieldDescription>
</Localized>
<SSOKeyRotationQuery disabled={disabledInside}></SSOKeyRotationQuery>
<TargetFilterField
label={
<Localized id="configure-auth-sso-useLoginOn">
@@ -16,17 +16,13 @@ const SSOConfigContainer: React.FunctionComponent<Props> = ({
disabled,
auth,
}) => {
return <SSOConfig disabled={disabled} sso={auth.integrations.sso} />;
return <SSOConfig disabled={disabled} />;
};
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment SSOConfigContainer_auth on Auth {
integrations {
sso {
...SSOKeyFieldContainer_sso
}
}
...SSOConfig_formValues
}
`,
})(SSOConfigContainer);
@@ -1,29 +0,0 @@
.root {
padding-bottom: var(--spacing-4);
}
.keyGenerated {
composes: button from "coral-ui/shared/typography.css";
color: var(--palette-text-secondary);
flex-shrink: 0;
}
.warnIcon {
color: var(--palette-text-secondary);
flex-shrink: 0;
padding-top: 3px;
padding-right: var(--spacing-1);
}
.warn {
color: var(--palette-text-secondary);
}
.warningSection {
padding-top: var(--spacing-1);
padding-bottom: var(--spacing-1);
}
.regenerateButton {
float: right;
}
@@ -1,79 +0,0 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
Button,
Flex,
FormField,
Icon,
Label,
PasswordField,
} from "coral-ui/components/v2";
import HelperText from "../../HelperText";
import styles from "./SSOKeyField.css";
interface Props {
disabled?: boolean;
generatedKey?: string;
keyGeneratedAt?: any;
onRegenerate?: () => void;
}
const SSOKeyField: FunctionComponent<Props> = ({
generatedKey,
keyGeneratedAt,
disabled,
onRegenerate,
}) => (
<FormField className={styles.root}>
<Localized id="configure-auth-sso-key">
<Label htmlFor="configure-auth-sso-key">Key</Label>
</Localized>
<PasswordField
id="configure-auth-sso-key"
name="key"
value={generatedKey}
readOnly
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show SSO Key"
showPasswordTitle="Hide SSO Key"
fullWidth
/>
{keyGeneratedAt && (
<Localized
id="configure-auth-sso-regenerateAt"
$date={new Date(keyGeneratedAt)}
>
<HelperText className={styles.keyGenerated}>
KEY GENERATED AT: {keyGeneratedAt}
</HelperText>
</Localized>
)}
<div className={styles.warningSection}>
<Flex direction="row" itemGutter="half">
<Icon className={styles.warnIcon}>warning</Icon>
<Localized id="configure-auth-sso-regenerateHonoredWarning">
<HelperText>
When regenerating a key, tokens signed with the previous key will be
honored for 30 days.
</HelperText>
</Localized>
</Flex>
</div>
<Localized id="configure-auth-sso-regenerate">
<Button
id="configure-auth-sso-regenerate"
disabled={disabled}
onClick={onRegenerate}
className={styles.regenerateButton}
>
Regenerate
</Button>
</Localized>
</FormField>
);
export default SSOKeyField;
@@ -1,60 +0,0 @@
import React from "react";
import { graphql } from "react-relay";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { SSOKeyFieldContainer_sso as SSOData } from "coral-admin/__generated__/SSOKeyFieldContainer_sso.graphql";
import RegenerateSSOKeyMutation from "./RegenerateSSOKeyMutation";
import SSOKeyField from "./SSOKeyField";
interface Props {
sso: SSOData;
disabled?: boolean;
regenerateSSOKey: MutationProp<typeof RegenerateSSOKeyMutation>;
}
interface State {
awaitingResponse: boolean;
}
class SSOKeyFieldContainer extends React.Component<Props, State> {
public state = {
awaitingResponse: false,
};
private handleRegenerate = async () => {
this.setState({ awaitingResponse: true });
await this.props.regenerateSSOKey();
this.setState({ awaitingResponse: false });
};
public render() {
const { disabled } = this.props;
return (
<SSOKeyField
disabled={disabled || this.state.awaitingResponse}
generatedKey={this.props.sso.key || undefined}
keyGeneratedAt={this.props.sso.keyGeneratedAt || undefined}
onRegenerate={this.handleRegenerate}
/>
);
}
}
const enhanced = withMutation(RegenerateSSOKeyMutation)(
withFragmentContainer<Props>({
sso: graphql`
fragment SSOKeyFieldContainer_sso on SSOAuthIntegration {
key
keyGeneratedAt
}
`,
})(SSOKeyFieldContainer)
);
export default enhanced;
@@ -0,0 +1,10 @@
.label {
padding-bottom: var(--v2-spacing-2);
}
.date {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
line-height: var(--v2-line-height-reset);
}
@@ -0,0 +1,97 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Flex, Label } from "coral-ui/components/v2";
import { SSOKeyStatus } from "./StatusField";
import styles from "./DateField.css";
export interface SSOKeyDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
interface Props {
status: SSOKeyStatus;
dates: SSOKeyDates;
}
const DateField: FunctionComponent<Props> = ({ status, dates }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-activeSince">
<Label>Active Since</Label>
</Localized>
</div>
<Localized
id="configure-auth-sso-rotate-date"
$date={new Date(dates.createdAt)}
>
<span className={styles.date}>{dates.createdAt}</span>
</Localized>
</>
);
case SSOKeyStatus.EXPIRING:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-inactiveAt">
<Label>Inactive At</Label>
</Localized>
</div>
<Flex
alignItems="center"
justifyContent="center"
className={styles.date}
>
<Localized
id="configure-auth-sso-rotate-date"
$date={
dates.inactiveAt
? new Date(dates.inactiveAt)
: new Date(dates.createdAt)
}
>
{dates.inactiveAt}
</Localized>
</Flex>
</>
);
case SSOKeyStatus.EXPIRED:
return (
<>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-inactiveSince">
<Label>Inactive Since</Label>
</Localized>
</div>
<Flex
alignItems="center"
justifyContent="center"
className={styles.date}
>
<Localized
id="configure-auth-sso-rotate-date"
$date={
dates.inactiveAt
? new Date(dates.inactiveAt)
: new Date(dates.createdAt)
}
>
{dates.inactiveAt}
</Localized>
</Flex>
</>
);
default:
return null;
}
};
export default DateField;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeactivateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeactivateSSOKeyMutation.graphql";
const clientMutationId = 0;
const DeactivateSSOKeyMutation = createMutation(
"deactivateSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeactivateSSOKeyMutation($input: DeactivateSSOKeyInput!) {
deactivateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default DeactivateSSOKeyMutation;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeleteSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/DeleteSSOKeyMutation.graphql";
const clientMutationId = 0;
const DeleteSSOKeyMutation = createMutation(
"deleteSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteSSOKeyMutation($input: DeleteSSOKeyInput!) {
deleteSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default DeleteSSOKeyMutation;
@@ -0,0 +1,52 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RotateSSOKeyMutation as MutationTypes } from "coral-admin/__generated__/RotateSSOKeyMutation.graphql";
const clientMutationId = 0;
const RotateSSOKeyMutation = createMutation(
"rotateSSOKey",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateSSOKeyMutation($input: RotateSSOKeyInput!) {
rotateSSOKey(input: $input) {
settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default RotateSSOKeyMutation;
@@ -0,0 +1,3 @@
.rotate {
margin-right: var(--v2-spacing-1)
}
@@ -0,0 +1,72 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import {
Button,
ClickOutside,
Dropdown,
DropdownButton,
Icon,
Popover,
} from "coral-ui/components/v2";
import RotateOption, { RotateOptions } from "./RotationOption";
import styles from "./RotationDropdown.css";
interface Props {
onRotateKey: (rotation: string) => void;
disabled?: boolean;
}
const RotationDropDown: FunctionComponent<Props> = ({
onRotateKey,
disabled,
}) => {
return (
<Localized
id="configure-auth-sso-rotate-dropdown-description"
attrs={{ description: true }}
>
<Popover
id="sso-key-rotate"
placement="bottom-start"
description="A dropdown to rotate the SSO key"
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<Dropdown>
{Object.keys(RotateOptions).map((opt: string) => (
<DropdownButton
key={opt}
onClick={() => {
onRotateKey(opt);
toggleVisibility();
}}
disabled={disabled}
>
<RotateOption value={opt}></RotateOption>
</DropdownButton>
))}
</Dropdown>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Button
onClick={toggleVisibility}
ref={ref}
color="regular"
disabled={disabled}
>
<Localized id="configure-auth-sso-rotate-rotate">
<span className={styles.rotate}>Rotate</span>
</Localized>
<Icon>arrow_drop_down</Icon>
</Button>
)}
</Popover>
</Localized>
);
};
export default RotationDropDown;
@@ -0,0 +1,46 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
export enum RotateOptions {
NOW = "NOW",
IN1DAY = "IN1DAY",
IN1WEEK = "IN1WEEK",
IN30DAYS = "IN30DAYS",
}
interface Props {
value: string;
}
const RotationOption: FunctionComponent<Props> = ({ value }) => {
switch (value) {
case RotateOptions.NOW: {
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
}
case RotateOptions.IN1DAY: {
return (
<Localized id="configure-auth-sso-rotate-1day">
1 day from now
</Localized>
);
}
case RotateOptions.IN1WEEK: {
return (
<Localized id="configure-auth-sso-rotate-1week">
1 week from now
</Localized>
);
}
case RotateOptions.IN30DAYS: {
return (
<Localized id="configure-auth-sso-rotate-30days">
30 days from now
</Localized>
);
}
default:
return <Localized id="configure-auth-sso-rotate-now">Now</Localized>;
}
};
export default RotationOption;
@@ -0,0 +1,23 @@
.label {
padding-bottom: var(--v2-spacing-2);
}
.keySection {
flex-grow: 1;
min-width: 50px;
padding-right: var(--v2-spacing-3);
}
.statusSection {
margin-right: var(--v2-spacing-3);
}
.secretSection {
flex-grow: 1;
min-width: 50px;
}
.action {
margin-right: var(--v2-spacing-1)
}
@@ -0,0 +1,187 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import CopyToClipboard from "react-copy-to-clipboard";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
Card,
Flex,
HorizontalGutter,
Icon,
Label,
PasswordField,
TextField,
} from "coral-ui/components/v2";
import DateField from "./DateField";
import DeactivateSSOKeyMutation from "./DeactivateSSOKeyMutation";
import DeleteSSOKeyMutation from "./DeleteSSOKeyMutation";
import RotateSSOKeyMutation from "./RotateSSOKeyMutation";
import RotationDropDown from "./RotationDropdown";
import { RotateOptions } from "./RotationOption";
import StatusField, { SSOKeyStatus } from "./StatusField";
import styles from "./SSOKeyCard.css";
export interface SSOKeyDates {
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
interface Props {
id: string;
secret: string;
status: SSOKeyStatus;
dates: SSOKeyDates;
disabled?: boolean;
}
function createActionButton(
status: SSOKeyStatus,
onRotateKey: (rotation: string) => void,
onDeactivateKey: () => void,
onDelete: () => void,
disabled?: boolean
) {
switch (status) {
case SSOKeyStatus.ACTIVE:
return <RotationDropDown onRotateKey={onRotateKey} disabled={disabled} />;
case SSOKeyStatus.EXPIRING:
return (
<Localized id="configure-auth-sso-rotate-deactivateNow">
<Button color="alert" onClick={onDeactivateKey} disabled={disabled}>
Deactivate Now
</Button>
</Localized>
);
case SSOKeyStatus.EXPIRED:
return (
<Localized id="configure-auth-sso-rotate-delete">
<Button color="alert" onClick={onDelete} disabled={disabled}>
Delete
</Button>
</Localized>
);
default:
return null;
}
}
const SSOKeyCard: FunctionComponent<Props> = ({
id,
secret,
status,
dates,
disabled,
}) => {
const rotateSSOKey = useMutation(RotateSSOKeyMutation);
const deactivateSSOKey = useMutation(DeactivateSSOKeyMutation);
const deleteSSOKey = useMutation(DeleteSSOKeyMutation);
const onRotate = useCallback(
(rotation: string) => {
switch (rotation) {
case RotateOptions.NOW:
rotateSSOKey({ inactiveIn: 0 });
break;
case RotateOptions.IN1DAY:
rotateSSOKey({ inactiveIn: 24 * 60 * 60 });
break;
case RotateOptions.IN1WEEK:
rotateSSOKey({ inactiveIn: 7 * 24 * 60 * 60 });
break;
case RotateOptions.IN30DAYS:
rotateSSOKey({ inactiveIn: 30 * 24 * 60 * 60 });
break;
default:
rotateSSOKey({ inactiveIn: 0 });
}
},
[rotateSSOKey]
);
const onDeactivate = useCallback(() => {
deactivateSSOKey({
kid: id,
});
}, [deactivateSSOKey, id]);
const onDelete = useCallback(() => {
deleteSSOKey({
kid: id,
});
}, [deleteSSOKey, id]);
return (
<Card>
<HorizontalGutter>
<Flex alignItems="center" justifyContent="space-between">
<div className={styles.keySection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-keyID">
<Label>Key ID</Label>
</Localized>
</div>
<TextField value={id} readOnly fullWidth data-testid="SSO-Key-ID" />
</div>
<div className={styles.secretSection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-secret">
<Label>Secret</Label>
</Localized>
</div>
<Flex alignItems="center" justifyContent="flex-start">
<PasswordField
id="configure-auth-sso-rotate-secretField"
name="key"
value={secret}
readOnly
// TODO: (nick-funk) figure out how to add translations to these props
hidePasswordTitle="Show Secret"
showPasswordTitle="Hide Secret"
fullWidth
/>
<CopyToClipboard text={secret}>
<Button color="mono" variant="flat">
<Localized
id="configure-auth-sso-rotate-copySecret"
attrs={{ "aria-label": true }}
>
<Icon size="md" aria-label="Copy Secret">
content_copy
</Icon>
</Localized>
</Button>
</CopyToClipboard>
</Flex>
</div>
</Flex>
<Flex alignItems="flex-end" justifyContent="space-between">
<Flex alignItems="center" justifyContent="flex-start">
<div className={styles.statusSection}>
<div className={styles.label}>
<Localized id="configure-auth-sso-rotate-status">
<Label>Status</Label>
</Localized>
</div>
<StatusField status={status}></StatusField>
</div>
<div>
<DateField status={status} dates={dates} />
</div>
</Flex>
{createActionButton(
status,
onRotate,
onDeactivate,
onDelete,
disabled
)}
</Flex>
</HorizontalGutter>
</Card>
);
};
export default SSOKeyCard;
@@ -0,0 +1,130 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useMemo } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Label } from "coral-ui/components/v2";
import { SSOKeyRotationContainer_settings } from "coral-admin/__generated__/SSOKeyRotationContainer_settings.graphql";
import SSOKeyCard, { SSOKeyDates } from "./SSOKeyCard";
import { SSOKeyStatus } from "./StatusField";
interface Props {
settings: SSOKeyRotationContainer_settings;
disabled?: boolean;
}
interface Key {
readonly kid: string;
readonly secret: string;
readonly createdAt: string;
readonly lastUsedAt: string | null;
readonly rotatedAt: string | null;
readonly inactiveAt: string | null;
}
function getStatus(dates: SSOKeyDates) {
if (
dates.inactiveAt &&
dates.rotatedAt &&
new Date(dates.inactiveAt) > new Date()
) {
return SSOKeyStatus.EXPIRING;
}
if (dates.inactiveAt && new Date(dates.inactiveAt) <= new Date()) {
return SSOKeyStatus.EXPIRED;
}
return SSOKeyStatus.ACTIVE;
}
const SSOKeyRotationContainer: FunctionComponent<Props> = ({
disabled,
settings,
}) => {
const {
auth: {
integrations: {
sso: { keys },
},
},
} = settings;
const sortedKeys = useMemo(
() =>
keys
// Copy this map because we don't want to modify the underlying copy.
.map(key => key)
.sort((a: Key, b: Key) => {
// Both active, sort on createdAt date.
if (!a.inactiveAt && !b.inactiveAt) {
return (
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}
// A is active, B is not, A comes before B.
if (!a.inactiveAt && b.inactiveAt) {
return -1;
}
// B is active, A is not, B comes before A.
if (a.inactiveAt && !b.inactiveAt) {
return 1;
}
// Sort primarily on inactiveAt, fall back to createdAt if
// for some reason it's not available.
const aDate = a.inactiveAt
? new Date(a.inactiveAt)
: new Date(a.createdAt);
const bDate = b.inactiveAt
? new Date(b.inactiveAt)
: new Date(b.createdAt);
return bDate.getTime() - aDate.getTime();
}),
[keys]
);
return (
<>
<Localized id="configure-auth-sso-rotate-keys">
<Label htmlFor="configure-auth-sso-rotate-keys">Keys</Label>
</Localized>
{sortedKeys.map(key => (
<SSOKeyCard
key={key.kid}
id={key.kid}
secret={key.secret}
status={getStatus(key)}
dates={key}
disabled={disabled}
/>
))}
</>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment SSOKeyRotationContainer_settings on Settings {
auth {
integrations {
sso {
enabled
keys {
kid
secret
createdAt
lastUsedAt
rotatedAt
inactiveAt
}
}
}
}
}
`,
})(SSOKeyRotationContainer);
export default enhanced;
@@ -0,0 +1,54 @@
import React, { FunctionComponent } from "react";
import {
graphql,
QueryRenderData,
QueryRenderer,
} from "coral-framework/lib/relay";
import { CallOut, Spinner } from "coral-ui/components/v2";
import { SSOKeyRotationQuery as QueryTypes } from "coral-admin/__generated__/SSOKeyRotationQuery.graphql";
import SSOKeyRotationContainer from "./SSOKeyRotationContainer";
interface Props {
disabled?: boolean;
}
const SSOKeyRotationQuery: FunctionComponent<Props> = ({ disabled }) => {
return (
<QueryRenderer<QueryTypes>
query={graphql`
query SSOKeyRotationQuery {
settings {
...SSOKeyRotationContainer_settings
}
}
`}
variables={{}}
cacheConfig={{ force: true }}
render={({ error, props }: QueryRenderData<QueryTypes>) => {
if (error) {
return <CallOut>{error.message}</CallOut>;
}
if (!props) {
return <Spinner />;
}
if (!props.settings) {
return <Spinner />;
}
return (
<SSOKeyRotationContainer
settings={props.settings}
disabled={disabled}
/>
);
}}
/>
);
};
export default SSOKeyRotationQuery;
@@ -0,0 +1,35 @@
.status {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
line-height: var(--v2-line-height-reset);
border-radius: 2px;
padding-left: var(--v2-spacing-1);
padding-right: var(--v2-spacing-1);
}
.active {
background-color: var(--v2-colors-green-500);
color: var(--v2-colors-pure-white);
}
.expiring {
background-color: var(--v2-colors-yellow-500);
color: var(--v2-colors-mono-500);
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
}
.expired {
background-color: var(--v2-colors-red-500);
color: var(--v2-colors-pure-white);
padding-top: var(--v2-spacing-1);
padding-bottom: var(--v2-spacing-1);
}
.icon {
padding-right: var(--v2-spacing-1);
}
@@ -0,0 +1,117 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import { Flex, Icon, Tooltip, TooltipButton } from "coral-ui/components/v2";
import styles from "./StatusField.css";
export enum SSOKeyStatus {
EXPIRED,
EXPIRING,
ACTIVE,
}
interface Props {
status: SSOKeyStatus;
}
const StatusField: FunctionComponent<Props> = ({ status }) => {
switch (status) {
case SSOKeyStatus.ACTIVE:
return (
<Localized id="configure-auth-sso-rotate-statusActive">
<span
className={cn(styles.status, styles.active)}
data-testid="SSO-Key-Status"
>
Active
</span>
</Localized>
);
case SSOKeyStatus.EXPIRING:
return (
<Flex alignItems="center" justifyContent="center">
<Flex
alignItems="center"
justifyContent="center"
className={cn(styles.status, styles.expiring)}
>
<Icon className={styles.icon}>alarm</Icon>
<Localized id="configure-auth-sso-rotate-statusExpiring">
<span data-testid="SSO-Key-Status">Expiring</span>
</Localized>
</Flex>
<Tooltip
id="configure-auth-sso-rotate-expiringTooltip"
title=""
body={
<Localized id="configure-auth-sso-rotate-expiringTooltip">
<span>
An SSO key is expiring when it is scheduled for rotation.
</span>
</Localized>
}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="configure-auth-sso-rotate-expiringTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<TooltipButton
active
aria-label="Toggle expiring tooltip visibility"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
);
case SSOKeyStatus.EXPIRED:
return (
<Flex alignItems="center" justifyContent="center">
<Localized id="configure-auth-sso-rotate-statusExpired">
<span
className={cn(styles.status, styles.expired)}
data-testid="SSO-Key-Status"
>
Expired
</span>
</Localized>
<Tooltip
id="configure-auth-sso-rotate-expiredTooltip"
title=""
body={
<Localized id="configure-auth-sso-rotate-expiredTooltip">
<span>
An SSO key is expired when it has been rotated out of use.
</span>
</Localized>
}
button={({ toggleVisibility, ref, visible }) => (
<Localized
id="configure-auth-sso-rotate-expiredTooltip-toggleButton"
attrs={{ "aria-label": true }}
>
<TooltipButton
active
aria-label="Toggle expired tooltip visibility"
toggleVisibility={toggleVisibility}
ref={ref}
/>
</Localized>
)}
/>
</Flex>
);
default:
return (
<Localized id="configure-auth-sso-rotate-statusUnknown">
<span data-testid="SSO-Key-Status">Unknown</span>
</Localized>
);
}
};
export default StatusField;
@@ -1,25 +1,31 @@
import { Localized } from "@fluent/react/compat";
import React, { useMemo } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useForm } from "react-final-form";
import { graphql } from "react-relay";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { DeepNullable } from "coral-common/types";
import {
purgeMetadata,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { GQLSettings } from "coral-framework/schema";
import { AppNotification, Button, CallOut, Flex } from "coral-ui/components/v2";
import { EmailConfigContainer_email } from "coral-admin/__generated__/EmailConfigContainer_email.graphql";
import { EmailConfigContainer_viewer } from "coral-admin/__generated__/EmailConfigContainer_viewer.graphql";
import Header from "../../Header";
import ConfigBoxWithToggleField from "../Auth/ConfigBoxWithToggleField";
import From from "./From";
import SMTP from "./SMTP";
import TestSMTPMutation from "./TestSMTPMutation";
interface Props {
submitting: boolean;
email: EmailConfigContainer_email;
viewer: EmailConfigContainer_viewer | null;
}
export type FormProps = DeepNullable<Pick<GQLSettings, "email">>;
@@ -27,8 +33,34 @@ export type FormProps = DeepNullable<Pick<GQLSettings, "email">>;
const EmailConfigContainer: React.FunctionComponent<Props> = ({
email,
submitting,
viewer,
}) => {
const form = useForm();
const [loading, setLoading] = useState(false);
const [submitError, setSubmitError] = useState<null | string>(null);
const { setMessage, clearMessage } = useNotification();
const sendTest = useMutation(TestSMTPMutation);
const sendTestEmail = useCallback(async () => {
if (!viewer) {
return;
}
setLoading(true);
setSubmitError(null);
try {
await sendTest();
setLoading(false);
setMessage(
<Localized id="configure-smtp-test-success" $email={viewer.email}>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Test email has been sent to {viewer.email}
</AppNotification>
</Localized>,
3000
);
} catch (error) {
setSubmitError(error.message);
}
}, []);
useMemo(() => {
let values: any = { email };
if (email && email.smtp && email.smtp.authentication === null) {
@@ -61,6 +93,21 @@ const EmailConfigContainer: React.FunctionComponent<Props> = ({
>
{disabledInside => (
<>
<Flex justifyContent="flex-end">
<Localized id="configure-email-send-test">
<Button
disabled={disabledInside || loading || !email.enabled}
onClick={sendTestEmail}
>
Send test email
</Button>
</Localized>
</Flex>
{submitError && (
<CallOut fullWidth color="error">
{submitError}
</CallOut>
)}
<From disabled={disabledInside} />
<SMTP disabled={disabledInside} />
</>
@@ -70,6 +117,11 @@ const EmailConfigContainer: React.FunctionComponent<Props> = ({
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment EmailConfigContainer_viewer on User {
email
}
`,
email: graphql`
fragment EmailConfigContainer_email on EmailConfiguration {
enabled
@@ -26,6 +26,7 @@ class EmailConfigRoute extends React.Component<Props> {
<EmailConfigContainer
email={this.props.data.settings.email}
submitting={this.props.submitting}
viewer={this.props.data.viewer}
/>
);
}
@@ -34,6 +35,9 @@ class EmailConfigRoute extends React.Component<Props> {
const enhanced = withRouteConfig<Props>({
query: graphql`
query EmailConfigRouteQuery {
viewer {
...EmailConfigContainer_viewer
}
settings {
email {
...EmailConfigContainer_email
@@ -0,0 +1,34 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { TestSMTPMutation as MutationTypes } from "coral-admin/__generated__/TestSMTPMutation.graphql";
const clientMutationId = 0;
const TestSMTPMutation = createMutation(
"testSMTP",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation TestSMTPMutation($input: TestSMTPInput!) {
testSMTP(input: $input) {
clientMutationId
}
}
`,
variables: {
input: {
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default TestSMTPMutation;
@@ -153,6 +153,42 @@ const SlackChannel: FunctionComponent<Props> = ({
</div>
<div className={styles.notificationToggles}>
<Field
name={`${channel}.triggers.allComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-allComments-${input.name}`}
disabled={disabled || !channelEnabled}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-allComments">
All Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.staffComments`}
type="checkbox"
parse={parseBool}
>
{({ input }) => (
<CheckBox
id={`configure-slack-channel-triggers-staffComments-${input.name}`}
disabled={disabled || !channelEnabled}
className={styles.trigger}
{...input}
>
<Localized id="configure-slack-channel-triggers-staffComments">
Staff Comments
</Localized>
</CheckBox>
)}
</Field>
<Field
name={`${channel}.triggers.reportedComments`}
type="checkbox"
@@ -104,7 +104,8 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
<FormFieldDescription>
Automatically send comments from Coral moderation queues to Slack
channels. You will need Slack admin access to set this up. For steps
on how to create a Slack App see our documentation.
on how to create a Slack App see our documentation. Not recommended
for sites with more than 10K comments per month.
</FormFieldDescription>
</Localized>
<Button iconLeft onClick={onAddChannel}>
@@ -144,6 +145,8 @@ const enhanced = withFragmentContainer<Props>({
reportedComments
pendingComments
featuredComments
allComments
staffComments
}
}
}
@@ -37,7 +37,6 @@ export class ApprovedQueueRoute extends React.Component<
return (
<IntersectionProvider>
<Queue
viewer={this.props.query.viewer!}
settings={this.props.query.settings}
comments={comments}
onLoadMore={this.loadMore}
@@ -107,9 +106,6 @@ const enhanced = (withPaginationContainer<
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
},
@@ -17,7 +17,6 @@ it("renders correctly with load more", () => {
hasLoadMore: true,
disableLoadMore: false,
danglingLogic: () => true,
viewer: { id: "me", username: "Mirai" },
onViewNew: noop,
};
const renderer = createRenderer();
@@ -33,7 +32,6 @@ it("renders correctly without load more", () => {
hasLoadMore: false,
disableLoadMore: false,
danglingLogic: () => true,
viewer: { id: "me", username: "Mirai" },
onViewNew: noop,
};
const renderer = createRenderer();
@@ -2,6 +2,7 @@ import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import ConversationModal from "coral-admin/components/ConversationModal";
import ModerateCardContainer from "coral-admin/components/ModerateCard";
import UserHistoryDrawer from "coral-admin/components/UserHistoryDrawer";
import { HOTKEYS } from "coral-admin/constants";
@@ -18,7 +19,6 @@ interface Props {
{ id: string } & PropTypesOf<typeof ModerateCardContainer>["comment"]
>;
settings: PropTypesOf<typeof ModerateCardContainer>["settings"];
viewer: PropTypesOf<typeof ModerateCardContainer>["viewer"];
onLoadMore: () => void;
onViewNew?: () => void;
hasLoadMore: boolean;
@@ -38,7 +38,6 @@ const Queue: FunctionComponent<Props> = ({
danglingLogic,
emptyElement,
allStories,
viewer,
viewNewCount,
onViewNew,
}) => {
@@ -46,6 +45,10 @@ const Queue: FunctionComponent<Props> = ({
const [userDrawerId, setUserDrawerID] = useState("");
const [selectedComment, setSelectedComment] = useState<number | null>(0);
const [singleView, setSingleView] = useState(false);
const [conversationModalVisible, setConversationModalVisible] = useState(
false
);
const [conversationCommentID, setConversationCommentID] = useState("");
const toggleView = useCallback(() => {
if (!singleView) {
@@ -94,6 +97,23 @@ const Queue: FunctionComponent<Props> = ({
setUserDrawerID("");
}, []);
const onShowConversationModal = useCallback((commentID: string) => {
setConversationCommentID(commentID);
setConversationModalVisible(true);
}, []);
const onHideConversationModal = useCallback(() => {
setConversationModalVisible(false);
setConversationCommentID("");
}, []);
const onUsernameClickedFromModal = useCallback((userID: string) => {
setUserDrawerID(userID);
setUserDrawerVisible(true);
setConversationModalVisible(false);
setConversationCommentID("");
}, []);
return (
<HorizontalGutter className={styles.root} size="double">
{Boolean(viewNewCount && viewNewCount > 0) && (
@@ -114,11 +134,11 @@ const Queue: FunctionComponent<Props> = ({
<ModerateCardContainer
key={comment.id}
settings={settings}
viewer={viewer}
comment={comment}
danglingLogic={danglingLogic}
showStoryInfo={Boolean(allStories)}
onUsernameClicked={onShowUserDrawer}
onConversationClicked={onShowConversationModal}
onSetSelected={() => setSelectedComment(i)}
selected={selectedComment === i}
selectPrev={selectPrev}
@@ -141,6 +161,12 @@ const Queue: FunctionComponent<Props> = ({
onClose={onHideUserDrawer}
userID={userDrawerId}
/>
<ConversationModal
onUsernameClicked={onUsernameClickedFromModal}
open={conversationModalVisible}
onClose={onHideConversationModal}
commentID={conversationCommentID}
/>
</HorizontalGutter>
);
};
@@ -15,7 +15,6 @@ import { GQLMODERATION_QUEUE } from "coral-framework/schema";
import { QueueRoute_queue } from "coral-admin/__generated__/QueueRoute_queue.graphql";
import { QueueRoute_settings } from "coral-admin/__generated__/QueueRoute_settings.graphql";
import { QueueRoute_viewer } from "coral-admin/__generated__/QueueRoute_viewer.graphql";
import { QueueRoutePaginationPendingQueryVariables } from "coral-admin/__generated__/QueueRoutePaginationPendingQuery.graphql";
import EmptyMessage from "./EmptyMessage";
@@ -30,7 +29,6 @@ interface Props {
queueName: GQLMODERATION_QUEUE;
queue: QueueRoute_queue | null;
settings: QueueRoute_settings | null;
viewer: QueueRoute_viewer | null;
relay: RelayPaginationProp;
emptyElement: React.ReactElement;
storyID?: string;
@@ -90,7 +88,6 @@ export const QueueRoute: FunctionComponent<Props> = props => {
<IntersectionProvider>
<Queue
comments={comments}
viewer={props.viewer!}
settings={props.settings!}
onLoadMore={loadMore}
hasLoadMore={props.relay.hasMore()}
@@ -128,7 +125,6 @@ const createQueueRoute = (
queueName={queueName}
queue={null}
settings={null}
viewer={null}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
@@ -143,7 +139,6 @@ const createQueueRoute = (
queueName={queueName}
queue={queue}
settings={data.settings}
viewer={data.viewer}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
@@ -183,11 +178,6 @@ const createQueueRoute = (
...ModerateCardContainer_settings
}
`,
viewer: graphql`
fragment QueueRoute_viewer on User {
...ModerateCardContainer_viewer
}
`,
},
{
direction: "forward",
@@ -228,9 +218,6 @@ export const PendingQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -269,9 +256,6 @@ export const ReportedQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -310,9 +294,6 @@ export const UnmoderatedQueueRoute = createQueueRoute(
settings {
...QueueRoute_settings
}
viewer {
...QueueRoute_viewer
}
}
`,
graphql`
@@ -37,7 +37,6 @@ export class RejectedQueueRoute extends React.Component<
return (
<IntersectionProvider>
<Queue
viewer={this.props.query.viewer!}
settings={this.props.query.settings}
comments={comments}
onLoadMore={this.loadMore}
@@ -107,9 +106,6 @@ const enhanced = (withPaginationContainer<
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
},
@@ -24,6 +24,12 @@ exports[`renders correctly with load more 1`] = `
open={false}
userID=""
/>
<ConversationModal
commentID=""
onClose={[Function]}
onUsernameClicked={[Function]}
open={false}
/>
</ForwardRef(forwardRef)>
`;
@@ -43,5 +49,11 @@ exports[`renders correctly without load more 1`] = `
open={false}
userID=""
/>
<ConversationModal
commentID=""
onClose={[Function]}
onUsernameClicked={[Function]}
open={false}
/>
</ForwardRef(forwardRef)>
`;
@@ -38,7 +38,6 @@ const SingleModerateRoute: FunctionComponent<Props> = props => {
<Queue
comments={[props.comment]}
settings={props.settings}
viewer={props.viewer!}
onLoadMore={noop}
hasLoadMore={false}
disableLoadMore={false}
@@ -58,9 +57,6 @@ const enhanced = withRouteConfig<Props, SingleModerateRouteQueryResponse>({
settings {
...ModerateCardContainer_settings
}
viewer {
...ModerateCardContainer_viewer
}
}
`,
cacheConfig: { force: true },
@@ -1242,86 +1242,233 @@ integration to register for a new account.
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<div
className="Box-root HorizontalGutter-root FormField-root SSOKeyField-root HorizontalGutter-spacing-2"
<p
className="FormFieldDescription-root"
>
<label
className="Label-root"
htmlFor="configure-auth-sso-key"
To enable integration with your existing authentication system,
you will need to create a JWT Token to connect. You can learn
more about creating a JWT Token with
<a
className="ExternalLink-root"
href="https://jwt.io/introduction/"
rel="noopener noreferrer"
target="_blank"
>
Key
</label>
this introduction
</a>
. See our
<a
className="ExternalLink-root"
href="https://docs.coralproject.net/coral/v5/integrating/sso/"
rel="noopener noreferrer"
target="_blank"
>
documentation
</a>
for additional information on single sign on.
</p>
<label
className="Label-root"
htmlFor="configure-auth-sso-rotate-keys"
>
Keys
</label>
<div
className="Card-root"
>
<div
className="PasswordField-fullWidth PasswordField-root"
className="Box-root HorizontalGutter-root HorizontalGutter-full"
>
<div
className="PasswordField-wrapper"
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignCenter"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
id="configure-auth-sso-key"
name="key"
placeholder=""
readOnly={true}
spellCheck={false}
type="password"
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide SSO Key"
className="SSOKeyCard-keySection"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
<div
className="SSOKeyCard-label"
>
visibility
</i>
<label
className="Label-root"
>
Key ID
</label>
</div>
<div
className="TextField-root TextField-fullWidth"
>
<input
className="TextField-input TextField-colorRegular"
data-testid="SSO-Key-ID"
placeholder=""
readOnly={true}
type="text"
value="kid-01"
/>
</div>
</div>
<div
className="SSOKeyCard-secretSection"
>
<div
className="SSOKeyCard-label"
>
<label
className="Label-root"
>
Secret
</label>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
>
<div
className="PasswordField-fullWidth PasswordField-root"
>
<div
className="PasswordField-wrapper"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
data-testid="password-field"
id="configure-auth-sso-rotate-secretField"
name="key"
placeholder=""
readOnly={true}
spellCheck={false}
type="password"
value="secret"
/>
<div
className="PasswordField-icon"
onClick={[Function]}
onKeyUp={[Function]}
role="button"
tabIndex={0}
title="Hide Secret"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
visibility
</i>
</div>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantFlat Button-uppercase"
data-color="mono"
data-variant="flat"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<i
aria-hidden="true"
aria-label="Copy Secret"
className="Icon-root Icon-md"
>
content_copy
</i>
</button>
</div>
</div>
</div>
<div
className="Box-root Flex-root Flex-flex Flex-justifySpaceBetween Flex-alignFlexEnd"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexStart Flex-alignCenter"
>
<div
className="SSOKeyCard-statusSection"
>
<div
className="SSOKeyCard-label"
>
<label
className="Label-root"
>
Status
</label>
</div>
<span
className="StatusField-status StatusField-active"
data-testid="SSO-Key-Status"
>
Active
</span>
</div>
<div>
<div
className="DateField-label"
>
<label
className="Label-root"
>
Active Since
</label>
</div>
<span
className="DateField-date"
>
1/1/2020, 1:00 AM
</span>
</div>
</div>
<div
className="Popover-root"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled"
data-color="regular"
data-variant="regular"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
className="RotationDropdown-rotate"
>
Rotate
</span>
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
arrow_drop_down
</i>
</button>
<div
aria-hidden={true}
aria-labelledby="sso-key-rotate-ariainfo"
id="sso-key-rotate"
role="dialog"
>
<div
className="AriaInfo-root"
id="sso-key-rotate-ariainfo"
>
A dropdown to rotate the SSO key
</div>
</div>
</div>
</div>
</div>
<div
className="SSOKeyField-warningSection"
>
<div
className="Box-root Flex-root Flex-flex Flex-halfItemGutter Flex-directionRow gutter"
>
<i
aria-hidden="true"
className="Icon-root Icon-sm SSOKeyField-warnIcon"
>
warning
</i>
<p
className="HelperText-root"
>
When regenerating a key, tokens signed with the previous key will be honored for 30 days.
</p>
</div>
</div>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantRegular Button-uppercase Button-disabled SSOKeyField-regenerateButton"
data-color="regular"
data-variant="regular"
disabled={true}
id="configure-auth-sso-regenerate"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Regenerate
</button>
</div>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
@@ -515,16 +515,15 @@ reactions, be reported, and be shared.
This will appear above the comments sitewide.
You can format the text using Markdown.
More information on how to use Markdown
here:
<a
className="ExternalLink-root"
href="#"
rel="noopener noreferrer"
target="_blank"
>
here
https://www.markdownguide.org/cheat-sheet/
</a>
.
</p>
</div>
<div
@@ -9,6 +9,7 @@ import {
CreateTestRendererParams,
findParentWithType,
replaceHistoryLocation,
toJSON,
wait,
waitForElement,
within,
@@ -57,11 +58,11 @@ it("renders configure auth", async () => {
expect(within(configureContainer).toJSON()).toMatchSnapshot();
});
it("regenerate sso key", async () => {
it("rotate sso key", async () => {
const { testRenderer } = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Mutation: {
regenerateSSOKey: () => {
rotateSSOKey: () => {
return {
settings: pureMerge<typeof settingsWithEmptyAuth>(
settingsWithEmptyAuth,
@@ -69,8 +70,22 @@ it("regenerate sso key", async () => {
auth: {
integrations: {
sso: {
key: "==GENERATED_KEY==",
keyGeneratedAt: "2018-11-12T23:26:06.239Z",
enabled: true,
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2015-01-01T00:00:00.000Z",
lastUsedAt: "2016-01-01T01:45:00.000Z",
rotatedAt: "2016-01-01T01:45:00.000Z",
inactiveAt: "2016-01-01T01:45:00.000Z",
},
{
kid: "kid-02",
secret: "new-secret",
createdAt: "2019-01-01T01:45:00.000Z",
},
],
},
},
},
@@ -90,15 +105,40 @@ it("regenerate sso key", async () => {
act(() => {
within(container)
.getByText("Regenerate", { selector: "button" })
.getByText("Rotate", { selector: "button" })
.props.onClick();
});
await wait(() =>
expect(within(container).getByLabelText("Key").props.value).toBe(
"==GENERATED_KEY=="
)
);
const rotateNow = await waitForElement(() => {
return within(container).getByText("Now", { selector: "button" });
});
act(() => {
rotateNow.props.onClick();
});
await wait(() => {
// Check that we have two SSO Keys that match
// our expected key IDs
const keyIDs = within(container).getAllByTestID("SSO-Key-ID");
const hasOldKey = keyIDs.some(k => k.props.value === "kid-01");
const hasNewKey = keyIDs.some(k => k.props.value === "kid-02");
expect(hasNewKey).toBe(true);
expect(hasOldKey).toBe(true);
const statuses = within(container).getAllByTestID("SSO-Key-Status");
expect(statuses.length).toBe(2);
const firstStatus: any = toJSON(statuses[0]);
const firstStatusIsActive = firstStatus.children.some(
(s: string) => s === "Active"
);
expect(firstStatusIsActive).toBe(true);
const secondStatus: any = toJSON(statuses[1]);
const secondStatusIsActive = secondStatus.children.some(
(s: string) => s === "Active"
);
expect(secondStatusIsActive).toBe(false);
});
});
it("prevents admin lock out", async () => {
+20
View File
@@ -114,6 +114,16 @@ export const settings = createFixture<GQLSettings>({
admin: true,
stream: true,
},
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
key: "",
keyGeneratedAt: null,
},
@@ -202,6 +212,16 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
stream: true,
},
key: "",
keys: [
{
kid: "kid-01",
secret: "secret",
createdAt: "2020-01-01T01:00:00.000Z",
lastUsedAt: undefined,
rotatedAt: undefined,
inactiveAt: undefined,
},
],
keyGeneratedAt: null,
},
google: {
@@ -34,7 +34,7 @@ exports[`approves comment in reported queue: dangling 1`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -98,19 +98,28 @@ exports[`approves comment in reported queue: dangling 1`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/0"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -291,7 +300,7 @@ exports[`rejects comment in reported queue: dangling 1`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -355,19 +364,28 @@ exports[`rejects comment in reported queue: dangling 1`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/0"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -545,7 +563,7 @@ exports[`renders reported queue with comments 1`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -609,19 +627,28 @@ exports[`renders reported queue with comments 1`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/0"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -786,7 +813,7 @@ exports[`renders reported queue with comments 1`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -850,19 +877,28 @@ exports[`renders reported queue with comments 1`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/1"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -1043,7 +1079,7 @@ exports[`renders reported queue with comments 2`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -1107,19 +1143,28 @@ exports[`renders reported queue with comments 2`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/0"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -1284,7 +1329,7 @@ exports[`renders reported queue with comments 2`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -1348,19 +1393,28 @@ exports[`renders reported queue with comments 2`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/1"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
@@ -1531,7 +1585,7 @@ exports[`renders reported queue with comments and load more 1`] = `
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<button
className="BaseButton-root ModerateCard-usernameButton"
className="BaseButton-root UsernameButton-root"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -1595,19 +1649,28 @@ exports[`renders reported queue with comments and load more 1`] = `
<div
className="ModerateCard-viewContext"
>
<a
className="TextLink-root ModerateCard-link"
href="http://localhost/comment/2"
target="_blank"
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="text"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
View Context
<i
aria-hidden="true"
className="Icon-root Icon-xs TextLink-icon"
className="Icon-root Icon-sm ButtonIcon-root"
>
open_in_new
question_answer
</i>
</a>
<span>
View Conversation
</span>
</button>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"

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