[CORL-236] Allow user to delete their account (#2495)

* Create preliminary account deletion program flow

Does not validate password as of yet.
Does not submit a mutation to begin deletion scheduling.

CORL-236

* Create preliminary account deletion confirmation form and schema mutation

Hooks up validation and error handling to confirm the user's
password during account deletion.

Scheduling the account deletion is not implemented yet.

CORL-236

* Update requestAccountDeletion to set the user's scheduled deletion date

CORL-236

* Create preliminary cron job for scheduled account deletions

CORL-236

* Show tombstoned comments when the user has deleted their account and comments

CORL-236

* Pull in client locales for date formatting on account deletion steps

CORL-236

* Update more areas due to comment revision being nullable

CORL-236

* Allow users to cancel their account deletion request

CORL-236

* Show account deletion callout at the top of the profile stream tab

CORL-236

* Further comments and edge case handling for deleted comments site wide

CORL-236

* Show account deletion callout in the comments stream tab

CORL-236

* Send out emails during relevant account deletion events

- Confirm deletion requested
- Cancellation of deletion request
- Account deletion completed

CORL-236

* Unset emails when deleting user accounts instead of setting to null

Fixes email index collision error from setting multiple emails to null.

CORL-236

* Show organization email in account deletion steps

CORL-236

* Mark comments as deleted so that we can properly tombstone them in the stream

CORL-236

* Fix improper templating for account deletion completed emails

Sub organization name in where previously we were using the account username.

CORL-236

* Disable user drawer and display deleted flag on users in community

CORL-236

* Check for author before passing inReplyTo in ModerateCardContainer

Sometimes null when user is deleted.

CORL-236

* Tombstone moderate cards when the underlying comment is deleted

CORL-236

* Re-add deleted field to CommentContainer fragment

CORL-236

* Disable commenting or replying when user is scheduled for deletion

CORL-236

* Add snapshot tests around deletion states for comment cards and forms

CORL-236

* Add tests around the account deletion steps

CORL-236

* Set account deletion to run every 30 minutes

CORL-236

* Rename DELETION authorization to PENDING_DELETION

CORL-236

* Change log message for when account deletion tasks finds no more users to delete

CORL-236

* Update snapshots to handle proper deleted flag for scheduled deleting users

CORL-236

* Fix import ordering on various files to match coding standards

CORL-236

* Specify tenantID when querying for user during deletion

CORL-236

* Add back missing clientMutationId on cancelAccountDeletion mutation

CORL-236

* Use $unset to clear the scheduledDeletionDate

CORL-236

* Simplify create of scheduledAccountDeletion CRON function

CORL-236

* Rename and organize scheduledTask creation logic

- Rename startCronJobs to startScheduledTasks
- Return an object of named ScheduledTask items instead of an array of tasks

CORL-236

* Add comment about returning updated user when clearing scheduledDeletionDate

CORL-236

* Remove unnecessary array around tenant.locale in dateTime formatter

CORL-236

* Remove unnecessary non-null enforcement on tenant.id

CORL-236

* Remove string templated React element content

CORL-236

* Move english translated validation error to correct language file

CORL-236

* Simplify rendering of DeletionRequestCallout when deletionDate isn't available

CORL-236

* Use ternary expression instead of multiple {bool && (...)} checks

CORL-236

* Use classes object with line child style to style the step bar

CORL-236

* Update StepBar so that children of Step nodes can be undefined

Allows us to not have to insert empty placeholder strings/elements when
we want to have an empty step node.

CORL-236

* Remove use of luxon from account deletion request callouts for now

We need to decide if the extra 15KB from luxon is worth it in our bundles
before making this change.

CORL-236

* Remove unnecessary embed/deleteAccount endpoint

CORL-236

* Move DeleteAccountModal contents into a separate component

Cleans up the state management when user closes the modal and we
need the modal steps to restart.

CORL-236

* Remove localization around delete account button icon

CORL-236

* Use inline `collection(mongo)` in clearing and setting deletion date

CORL-236

* Simplify form state validation on account deletion confirm page

CORL-236

* Add todo to iterate over tenants in account deletion

CORL-236

* Remove punctuation and add context to logger messages during account deletion

CORL-236

* Break out mongo collection initialization into a shared object

Also adds a try catch error handling around the scheduled deletion
operations. Prevents errors from taking the server down.

CORL-236

* Add TODO to extract out a common formatter for date times

CORL-236

* Update translations for account deletion details

CORL-236

* Update translations for account deletion completion page

CORL-236

* During scheduled deletion, when locking deletion date, return modified user

CORL-236

* Remove un-necessary fragment container around deletion steps component

CORL-236

* Move preventSubmit outside of render function on ConfirmPage

CORL-236

* Convert collections into object of Mongo db collection initializers

Allows us to only initialize the collections we need with our
Mongo instance.

CORL-236

* Remove un-necessary lambda wrapping around collections initializer functions

CORL-236
This commit is contained in:
Nick Funk
2019-09-03 16:38:43 -06:00
committed by Kim Gardner
parent fc039f8186
commit 46f0d08cf7
96 changed files with 3062 additions and 262 deletions
+17
View File
@@ -3618,6 +3618,15 @@
"@types/express": "*"
}
},
"@types/cron": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@types/cron/-/cron-1.7.1.tgz",
"integrity": "sha512-48brwgU18DqA0mQX1As5OcJEo1yNjaXMM6Mk4r8K1dOzLJRQ37FE/kCivKx7ClKEHfhX2FdcxKzJ1B744a+V3A==",
"requires": {
"@types/node": "*",
"moment": ">=2.14.0"
}
},
"@types/cross-spawn": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.0.tgz",
@@ -9153,6 +9162,14 @@
"gud": "^1.0.0"
}
},
"cron": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-1.7.1.tgz",
"integrity": "sha512-gmMB/pJcqUVs/NklR1sCGlNYM7TizEw+1gebz20BMc/8bTm/r7QUp3ZPSPlG8Z5XRlvb7qhjEjq/+bdIfUCL2A==",
"requires": {
"moment-timezone": "^0.5.x"
}
},
"cron-parser": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.11.0.tgz",
+2
View File
@@ -57,6 +57,7 @@
"dependencies": {
"@coralproject/bunyan-prettystream": "^0.1.4",
"@types/archiver": "^3.0.0",
"@types/cron": "^1.7.1",
"akismet-api": "^4.2.0",
"apollo-server-express": "^2.8.1",
"archiver": "^3.0.3",
@@ -72,6 +73,7 @@
"cookie": "^0.4.0",
"cookie-parser": "^1.4.4",
"cors": "^2.8.4",
"cron": "^1.7.1",
"csv-stringify": "^5.3.0",
"dataloader": "^1.4.0",
"dompurify": "^1.0.8",
@@ -14,7 +14,7 @@ const purify = createPurify(window, false);
interface Props {
className?: string;
children: string;
children: string | React.ReactElement;
suspectWords: ReadonlyArray<string>;
bannedWords: ReadonlyArray<string>;
}
@@ -117,25 +117,29 @@ const CommentContent: FunctionComponent<Props> = ({
className,
children,
}) => {
// 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 (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 (suspectWords.length || bannedWords.length) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
if (suspectWords.length || bannedWords.length) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
}
// Finally we render the content of the Shadow DOM Tree
return (
<Typography
className={cn(className, styles.root)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
container="div"
/>
);
}
// Finally we render the content of the Shadow DOM Tree
return (
<Typography
className={cn(className, styles.root)}
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
container="div"
/>
);
return <>{children}</>;
};
export default CommentContent;
@@ -9,10 +9,12 @@ import styles from "./FeatureButton.css";
interface Props extends PropTypesOf<typeof BaseButton> {
featured: boolean;
enabled?: boolean;
}
const FeatureButton: FunctionComponent<Props> = ({
featured,
enabled = true,
className,
...rest
}) => (
@@ -21,6 +23,7 @@ const FeatureButton: FunctionComponent<Props> = ({
className={cn(className, styles.root, {
[styles.invert]: featured,
})}
disabled={!enabled}
>
{featured ? (
<Localized id="moderate-comment-featuredText">
@@ -21,13 +21,13 @@ interface Props {
}
const FlagDetailsContainer: FunctionComponent<Props> = ({
comment: {
revision: { metadata },
flags: { nodes },
},
comment,
onUsernameClick,
settings,
}) => {
const metadata = comment.revision ? comment.revision.metadata : null;
const nodes = comment.flags.nodes;
const offensive = nodes.filter(
({ reason }) => reason === GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE
);
@@ -37,7 +37,7 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
return (
<HorizontalGutter size="oneAndAHalf">
{metadata.perspective && (
{metadata && metadata.perspective && (
<FlagDetailsCategory
category={
<Localized id="moderate-flagDetails-toxicityScore">
@@ -16,11 +16,11 @@ interface MarkersContainerProps {
}
function hasDetails(c: MarkersContainer_comment) {
return (
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
0 || c.revision.metadata.perspective
);
return c.revision
? c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
0 || c.revision.metadata.perspective
: false;
}
let keyCounter = 0;
@@ -35,72 +35,80 @@ const markers: Array<
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
<Localized id="moderate-marker-link" key={keyCounter++}>
<Marker color="primary">Link</Marker>
</Localized>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
<Localized id="moderate-marker-link" key={keyCounter++}>
<Marker color="primary">Link</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
</Localized>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
<Marker color="error">Banned Word</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
<Marker color="error" variant="filled">
Suspect Word
</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spam" key={keyCounter++}>
<Marker color="error">Spam</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
<Localized id="moderate-marker-toxic" key={keyCounter++}>
<Marker color="error">Toxic</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
</Localized>
)) ||
null,
c =>
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-offensive">
<span>Offensive</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
</MarkerCount>
</Marker>
</Localized>
)) ||
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
<Localized id="moderate-marker-spam" key={keyCounter++}>
<Marker color="error">Spam</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
<Localized id="moderate-marker-toxic" key={keyCounter++}>
<Marker color="error">Toxic</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
<Marker color="error">Recent History</Marker>
</Localized>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-offensive">
<span>Offensive</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
</MarkerCount>
</Marker>
)) ||
null,
c =>
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-spam">
<span>Spam</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
</MarkerCount>
</Marker>
)) ||
(c.revision &&
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
<Marker key={keyCounter++} color="error">
<Localized id="moderate-marker-spam">
<span>Spam</span>
</Localized>{" "}
<MarkerCount>
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
</MarkerCount>
</Marker>
)) ||
null,
];
@@ -58,8 +58,8 @@
}
.asideMiniWithReplyTo {
/*
We have reply to field, so we align below the fattest
/*
We have reply to field, so we align below the fattest
element plus the reply to field.
*/
padding-top: calc(2 * (14rem / var(--rem-base)) + 5px + var(--spacing-4));
@@ -151,4 +151,8 @@
.miniButton {
width: 40px;
height: 40px;
}
}
.deleted {
background: var(--palette-grey-lightest);
}
@@ -91,3 +91,13 @@ it("renders story info", () => {
renderer.render(<ModerateCardN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders tombstoned when comment is deleted", () => {
const props: PropTypesOf<typeof ModerateCardN> = {
...baseProps,
deleted: true,
};
const renderer = createRenderer();
renderer.render(<ModerateCardN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -9,6 +9,7 @@ import {
Flex,
HorizontalGutter,
TextLink,
Typography,
} from "coral-ui/components";
import ApproveButton from "./ApproveButton";
@@ -52,6 +53,7 @@ interface Props {
* reflect that
*/
dangling?: boolean;
deleted?: boolean;
}
const ModerateCard: FunctionComponent<Props> = ({
@@ -79,7 +81,18 @@ const ModerateCard: FunctionComponent<Props> = ({
moderatedBy,
mini = false,
hideUsername = false,
deleted = false,
}) => {
const commentBody = deleted ? (
<Localized id="moderate-comment-deleted-body">
<Typography>
This comment is no longer available. The commenter has deleted their
account.
</Typography>
</Localized>
) : (
body
);
const commentAuthorClick = useCallback(() => {
onUsernameClick();
}, [onUsernameClick]);
@@ -88,7 +101,8 @@ const ModerateCard: FunctionComponent<Props> = ({
className={cn(
styles.root,
{ [styles.borderless]: mini },
{ [styles.dangling]: dangling }
{ [styles.dangling]: dangling },
{ [styles.deleted]: deleted }
)}
data-testid={`moderate-comment-${id}`}
>
@@ -109,7 +123,11 @@ const ModerateCard: FunctionComponent<Props> = ({
</BaseButton>
)}
<Timestamp>{createdAt}</Timestamp>
<FeatureButton featured={featured} onClick={onFeature} />
<FeatureButton
featured={featured}
onClick={onFeature}
enabled={!deleted}
/>
</Flex>
{inReplyTo && (
<div>
@@ -122,7 +140,7 @@ const ModerateCard: FunctionComponent<Props> = ({
bannedWords={bannedWords}
className={styles.content}
>
{body}
{commentBody}
</CommentContent>
<div className={styles.footer}>
<HorizontalGutter>
@@ -185,13 +203,13 @@ const ModerateCard: FunctionComponent<Props> = ({
<RejectButton
onClick={onReject}
invert={status === "rejected"}
disabled={status === "rejected" || dangling}
disabled={status === "rejected" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
/>
<ApproveButton
onClick={onApprove}
invert={status === "approved"}
disabled={status === "approved" || dangling}
disabled={status === "approved" || dangling || deleted}
className={cn({ [styles.miniButton]: mini })}
/>
</Flex>
@@ -74,6 +74,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onUsernameClicked: usernameClicked,
}) => {
const handleApprove = useCallback(() => {
if (!comment.revision) {
return;
}
approveComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -82,6 +86,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}, [approveComment, comment, match]);
const handleReject = useCallback(() => {
if (!comment.revision) {
return;
}
rejectComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -90,6 +98,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}, [rejectComment, comment, match]);
const handleFeature = useCallback(() => {
if (!comment.revision) {
return;
}
featureComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
@@ -146,7 +158,11 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}
createdAt={comment.createdAt}
body={comment.body!}
inReplyTo={comment.parent && comment.parent.author!.username!}
inReplyTo={
comment.parent &&
comment.parent.author &&
comment.parent.author.username
}
comment={comment}
settings={settings}
dangling={danglingLogic(comment.status)}
@@ -172,6 +188,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
onModerateStory={handleModerateStory}
mini={mini}
hideUsername={hideUsername}
deleted={comment.deleted ? comment.deleted : false}
/>
</FadeInTransition>
</>
@@ -209,6 +226,7 @@ const enhanced = withFragmentContainer<Props>({
}
permalink
enteredLive
deleted
...MarkersContainer_comment
...ModeratedByContainer_comment
}
@@ -27,6 +27,7 @@ exports[`renders approved correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -95,6 +96,7 @@ exports[`renders approved correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -137,6 +139,7 @@ exports[`renders correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -205,11 +208,13 @@ exports[`renders correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -246,6 +251,7 @@ exports[`renders dangling correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -357,6 +363,7 @@ exports[`renders rejected correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -431,6 +438,7 @@ exports[`renders rejected correctly 1`] = `
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -467,6 +475,7 @@ exports[`renders reply correctly 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -540,11 +549,13 @@ exports[`renders reply correctly 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
@@ -581,6 +592,7 @@ exports[`renders story info 1`] = `
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={true}
featured={false}
onClick={[Function]}
/>
@@ -678,11 +690,131 @@ exports[`renders story info 1`] = `
>
<RejectButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={false}
invert={false}
onClick={[Function]}
/>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</ForwardRef(forwardRef)>
</withPropsOnChange(Card)>
`;
exports[`renders tombstoned when comment is deleted 1`] = `
<withPropsOnChange(Card)
className="ModerateCard-root ModerateCard-deleted"
data-testid="moderate-comment-comment-id"
>
<ForwardRef(forwardRef)>
<div
className="ModerateCard-mainContainer"
>
<div
className="ModerateCard-topBar"
>
<ForwardRef(forwardRef)
alignItems="center"
>
<ForwardRef(forwardRef)
className="ModerateCard-username"
onClick={[Function]}
>
<Username>
Theon
</Username>
</ForwardRef(forwardRef)>
<Timestamp>
2018-11-29T16:01:51.897Z
</Timestamp>
<FeatureButton
enabled={false}
featured={false}
onClick={[Function]}
/>
</ForwardRef(forwardRef)>
</div>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
}
>
<Localized
id="moderate-comment-deleted-body"
>
<ForwardRef(forwardRef)>
This comment is no longer available. The commenter has deleted their account.
</ForwardRef(forwardRef)>
</Localized>
</CommentContent>
<div
className="ModerateCard-footer"
>
<ForwardRef(forwardRef)>
<div>
<Localized
id="moderate-comment-viewContext"
>
<withPropsOnChange(TextLinkProps)
className="ModerateCard-link"
href="http://localhost/comment"
target="_blank"
>
View Context
</withPropsOnChange(TextLinkProps)>
</Localized>
</div>
<Relay(MarkersContainer)
comment={Object {}}
onUsernameClick={[Function]}
settings={Object {}}
/>
</ForwardRef(forwardRef)>
</div>
</div>
<div
className="ModerateCard-separator ModerateCard-ruledSeparator"
/>
<ForwardRef(forwardRef)
alignItems="center"
className="ModerateCard-aside ModerateCard-asideWithoutReplyTo"
direction="column"
itemGutter={true}
>
<Localized
id="moderate-comment-decision"
>
<div
className="ModerateCard-decision"
>
DECISION
</div>
</Localized>
<ForwardRef(forwardRef)
itemGutter={true}
>
<RejectButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
/>
<ApproveButton
className=""
disabled={true}
invert={false}
onClick={[Function]}
/>
@@ -29,3 +29,14 @@
font-size: calc(14rem / var(--rem-base));
line-height: calc(16em / 14);
}
.username {
display: inline-block;
}
.deleted {
display: inline-block;
padding-left: var(--spacing-2);
color: var(--palette-warning-main);
}
@@ -1,3 +1,4 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
@@ -18,6 +19,7 @@ interface Props {
viewer: PropTypesOf<typeof UserRole>["viewer"];
settings: PropTypesOf<typeof UserStatus>["settings"];
onUsernameClicked?: (userID: string) => void;
deletedAt?: string;
}
const UserRow: FunctionComponent<Props> = ({
@@ -29,6 +31,7 @@ const UserRow: FunctionComponent<Props> = ({
viewer,
onUsernameClicked,
settings,
deletedAt,
}) => {
const usernameClicked = useCallback(() => {
if (!onUsernameClicked) {
@@ -41,9 +44,23 @@ const UserRow: FunctionComponent<Props> = ({
return (
<TableRow>
<TableCell className={styles.usernameColumn}>
<Button onClick={usernameClicked} className={styles.usernameButton}>
{username || <NotAvailable />}
</Button>
{deletedAt && (
<>
<div className={styles.username}>
{username || <NotAvailable />}
</div>
<div className={styles.deleted}>
<Localized id="community-column-username-deleted">
Deleted
</Localized>
</div>
</>
)}
{!deletedAt && (
<Button onClick={usernameClicked} className={styles.usernameButton}>
{username || <NotAvailable />}
</Button>
)}
</TableCell>
<TableCell className={styles.emailColumn}>
{<TextLink href={`mailto:${email}`}>{email}</TextLink> || (
@@ -32,6 +32,7 @@ const UserRowContainer: FunctionComponent<Props> = props => {
year: "numeric",
}).format(new Date(props.user.createdAt))}
onUsernameClicked={props.onUsernameClicked}
deletedAt={props.user.deletedAt}
/>
);
};
@@ -55,6 +56,7 @@ const enhanced = withFragmentContainer<Props>({
username
email
createdAt
deletedAt
}
`,
})(UserRowContainer);
+1
View File
@@ -502,6 +502,7 @@ export const baseComment = createFixture<GQLComment>({
story: stories[0],
// TODO: Should be allowed to pass null here..
parent: undefined,
deleted: undefined,
});
export const unmoderatedComments = createFixtures<GQLComment>(
@@ -55,6 +55,7 @@ exports[`approves comment in reported queue: dangling 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -282,6 +283,7 @@ exports[`rejects comment in reported queue: dangling 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -506,6 +508,7 @@ exports[`renders reported queue with comments 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -717,6 +720,7 @@ exports[`renders reported queue with comments 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -944,6 +948,7 @@ exports[`renders reported queue with comments 2`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -1155,6 +1160,7 @@ exports[`renders reported queue with comments 2`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -1372,6 +1378,7 @@ exports[`renders reported queue with comments and load more 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -55,6 +55,7 @@ exports[`approves comment in rejected queue: dangling 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -279,6 +280,7 @@ exports[`renders rejected queue with comments 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -490,6 +492,7 @@ exports[`renders rejected queue with comments 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -707,6 +710,7 @@ exports[`renders rejected queue with comments and load more 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -42,6 +42,7 @@ exports[`approves single comment 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -235,6 +236,7 @@ exports[`rejects single comment 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -463,6 +465,7 @@ exports[`renders single comment view 1`] = `
</time>
<button
className="BaseButton-root FeatureButton-root"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
@@ -367,7 +367,7 @@ it("approves comment in reported queue", async () => {
>(({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: reportedComments[0].id,
commentRevisionID: reportedComments[0].revision.id,
commentRevisionID: reportedComments[0].revision!.id,
});
return {
comment: {
@@ -464,7 +464,7 @@ it("rejects comment in reported queue", async () => {
>(({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: reportedComments[0].id,
commentRevisionID: reportedComments[0].revision.id,
commentRevisionID: reportedComments[0].revision!.id,
});
return {
comment: {
@@ -229,7 +229,7 @@ it("approves comment in rejected queue", async () => {
>(({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: rejectedComments[0].id,
commentRevisionID: rejectedComments[0].revision.id,
commentRevisionID: rejectedComments[0].revision!.id,
});
return {
comment: {
@@ -91,7 +91,7 @@ it("approves single comment", async () => {
>(({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: comment.id,
commentRevisionID: comment.revision.id,
commentRevisionID: comment.revision!.id,
});
return {
comment: {
@@ -143,7 +143,7 @@ it("rejects single comment", async () => {
>(({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: comment.id,
commentRevisionID: comment.revision.id,
commentRevisionID: comment.revision!.id,
});
return {
comment: {
@@ -4,5 +4,6 @@ export default {
stream: "/embed/stream",
auth: "/embed/auth",
authCallback: "/embed/auth/callback",
deleteAccount: "/embed/deleteAccount",
},
};
@@ -105,3 +105,9 @@ export const NOT_A_WHOLE_NUMBER_BETWEEN = (min: number, max: number) => (
<span>Please enter a valid whole number between min and max</span>
</Localized>
);
export const DELETE_CONFIRMATION_INVALID = () => (
<Localized id="framework-validation-deleteConfirmationInvalid">
<span>Incorrect confirmation. Try again.</span>
</Localized>
);
@@ -10,6 +10,7 @@ import {
} from "coral-common/helpers/validate";
import {
DELETE_CONFIRMATION_INVALID,
EMAILS_DO_NOT_MATCH,
INVALID_CHARACTERS,
INVALID_EMAIL,
@@ -218,6 +219,9 @@ export const validatePercentage = (min: number, max: number) =>
NOT_A_WHOLE_NUMBER_BETWEEN(min * 100, max * 100)
);
export const validateDeleteConfirmation = (phrase: string) =>
createValidator(v => v === phrase, DELETE_CONFIRMATION_INVALID());
export const validateStrictURLList = createValidator(v => {
if (!Array.isArray(v)) {
return false;
@@ -0,0 +1,48 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CancelAccountDeletionMutation as MutationTypes } from "coral-stream/__generated__/CancelAccountDeletionMutation.graphql";
let clientMutationId = 0;
const CancelAccountDeletionMutation = createMutation(
"cancelAccountDeletionMutation",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CancelAccountDeletionMutation(
$input: CancelAccountDeletionInput!
) {
cancelAccountDeletion(input: $input) {
user {
scheduledDeletionDate
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(null, "scheduledDeletionDate");
}
},
})
);
export default CancelAccountDeletionMutation;
@@ -39,6 +39,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
pending: false,
tags: [],
lastViewerAction: null,
deleted: false,
},
settings: {
disableCommenting: {
@@ -131,3 +132,14 @@ it("renders disabled reply when commenting has been disabled", () => {
renderer.render(<CommentContainerN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
it("renders with tombstone when comment has been deleted", () => {
const props = createDefaultProps({
comment: {
deleted: true,
},
});
const renderer = createRenderer();
renderer.render(<CommentContainerN {...props} />);
expect(renderer.getRenderOutput()).toMatchSnapshot();
});
@@ -192,6 +192,9 @@ export class CommentContainer extends Component<Props, State> {
this.props.viewer &&
this.props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
);
const scheduledForDeletion = Boolean(
this.props.viewer && this.props.viewer.scheduledDeletionDate
);
const showCaret =
this.props.viewer && can(this.props.viewer, Ability.MODERATE);
if (showEditDialog) {
@@ -223,105 +226,111 @@ export class CommentContainer extends Component<Props, State> {
data-testid={`comment-${comment.id}`}
>
<HorizontalGutter>
<IndentedComment
indentLevel={indentLevel}
body={comment.body}
createdAt={comment.createdAt}
blur={comment.pending || false}
showEditedMarker={comment.editing.edited}
highlight={highlight}
parentAuthorName={
comment.parent &&
comment.parent.author &&
comment.parent.author.username
}
username={
comment.author && (
<>
<UsernameWithPopoverContainer
viewer={viewer}
user={comment.author}
/>
<UserTagsContainer comment={comment} />
<UserBadgesContainer comment={comment} />
</>
)
}
topBarRight={
<Flex alignItems="center" itemGutter>
{commentTags}
{editable && (
<Localized id="comments-commentContainer-editButton">
<Button
color="primary"
variant="underlined"
onClick={this.openEditDialog}
>
Edit
</Button>
</Localized>
)}
{showCaret && (
<CaretContainer
comment={comment}
story={story}
viewer={viewer!}
/>
)}
</Flex>
}
footer={
<>
<Flex justifyContent="space-between">
<ButtonsBar>
<ReactionButtonContainer
comment={comment}
settings={settings}
{!comment.deleted && (
<IndentedComment
indentLevel={indentLevel}
body={comment.body}
createdAt={comment.createdAt}
blur={comment.pending || false}
showEditedMarker={comment.editing.edited}
highlight={highlight}
parentAuthorName={
comment.parent &&
comment.parent.author &&
comment.parent.author.username
}
username={
comment.author && (
<>
<UsernameWithPopoverContainer
viewer={viewer}
readOnly={banned || suspended}
user={comment.author}
/>
{!disableReplies && !banned && !suspended && (
<ReplyButton
id={`comments-commentContainer-replyButton-${
comment.id
}`}
onClick={this.toggleReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled || story.isClosed
}
/>
)}
<PermalinkButtonContainer
<UserTagsContainer comment={comment} />
<UserBadgesContainer comment={comment} />
</>
)
}
topBarRight={
<Flex alignItems="center" itemGutter>
{commentTags}
{editable && (
<Localized id="comments-commentContainer-editButton">
<Button
color="primary"
variant="underlined"
onClick={this.openEditDialog}
>
Edit
</Button>
</Localized>
)}
{showCaret && (
<CaretContainer
comment={comment}
story={story}
commentID={comment.id}
viewer={viewer!}
/>
</ButtonsBar>
<ButtonsBar>
{!banned && !suspended && (
<ReportButtonContainer
comment={comment}
viewer={viewer}
/>
)}
</ButtonsBar>
)}
</Flex>
{showConversationLink && (
<ShowConversationLink
id={`comments-commentContainer-showConversation-${
comment.id
}`}
onClick={this.handleShowConversation}
href={getURLWithCommentID(
this.props.story.url,
this.props.comment.id
)}
/>
)}
</>
}
/>
{showReplyDialog && (
}
footer={
<>
<Flex justifyContent="space-between">
<ButtonsBar>
<ReactionButtonContainer
comment={comment}
settings={settings}
viewer={viewer}
readOnly={banned || suspended}
/>
{!disableReplies &&
!banned &&
!suspended &&
!scheduledForDeletion && (
<ReplyButton
id={`comments-commentContainer-replyButton-${
comment.id
}`}
onClick={this.toggleReplyDialog}
active={showReplyDialog}
disabled={
settings.disableCommenting.enabled ||
story.isClosed
}
/>
)}
<PermalinkButtonContainer
story={story}
commentID={comment.id}
/>
</ButtonsBar>
<ButtonsBar>
{!banned && !suspended && (
<ReportButtonContainer
comment={comment}
viewer={viewer}
/>
)}
</ButtonsBar>
</Flex>
{showConversationLink && (
<ShowConversationLink
id={`comments-commentContainer-showConversation-${
comment.id
}`}
onClick={this.handleShowConversation}
href={getURLWithCommentID(
this.props.story.url,
this.props.comment.id
)}
/>
)}
</>
}
/>
)}
{showReplyDialog && !comment.deleted && (
<ReplyCommentFormContainer
settings={settings}
comment={comment}
@@ -350,6 +359,7 @@ const enhanced = withSetCommentIDMutation(
}
badges
role
scheduledDeletionDate
...UsernameWithPopoverContainer_viewer
...ReactionButtonContainer_viewer
...ReportButtonContainer_viewer
@@ -391,6 +401,7 @@ const enhanced = withSetCommentIDMutation(
}
pending
lastViewerAction
deleted
...ReplyCommentFormContainer_comment
...EditCommentFormContainer_comment
...ReactionButtonContainer_comment
@@ -38,14 +38,21 @@ const ModerationActionsContainer: FunctionComponent<Props> = ({
const reject = useMutation(RejectCommentMutation);
const onApprove = useCallback(() => {
if (!comment.revision) {
return;
}
approve({ commentID: comment.id, commentRevisionID: comment.revision.id });
}, [approve, comment]);
const onReject = useCallback(
() =>
reject({ commentID: comment.id, commentRevisionID: comment.revision.id }),
[approve, comment]
);
const onReject = useCallback(() => {
if (!comment.revision) {
return;
}
reject({ commentID: comment.id, commentRevisionID: comment.revision.id });
}, [approve, comment]);
const onFeature = useCallback(() => {
if (!comment.revision) {
return;
}
feature({
storyID: story.id,
commentID: comment.id,
@@ -50,7 +50,10 @@ function commit(environment: Environment, input: CreateCommentReactionInput) {
reaction: true,
},
revision: {
id: lookup<GQLComment>(environment, input.commentID)!.revision.id,
// comment revision should not be null since we just
// reacted to it, revision can only be null when user
// deletes their account and thus all their comments
id: lookup<GQLComment>(environment, input.commentID)!.revision!.id,
},
actionCounts: {
reaction: {
@@ -40,7 +40,9 @@ class ReactionButtonContainer extends React.Component<Props> {
const input = {
commentID: this.props.comment.id,
commentRevisionID: this.props.comment.revision.id,
// can assume revision is not null as we
// tombstone when comment revisions don't exist
commentRevisionID: this.props.comment.revision!.id,
};
const { createCommentReaction, removeCommentReaction } = this.props;
@@ -49,7 +49,9 @@ function commit(environment: Environment, input: RemoveCommentReactionInput) {
reaction: false,
},
revision: {
id: lookup<GQLComment>(environment, input.commentID)!.revision.id,
// Can assume revision exists since we just selected
// to remove the reaction to it.
id: lookup<GQLComment>(environment, input.commentID)!.revision!.id,
},
actionCounts: {
reaction: {
@@ -220,6 +220,7 @@ function commit(
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
},
},
clientMutationId: (clientMutationId++).toString(),
@@ -107,7 +107,10 @@ export class ReplyCommentFormContainer extends Component<Props, State> {
await this.props.createCommentReply({
storyID: this.props.story.id,
parentID: this.props.comment.id,
parentRevisionID: this.props.comment.revision.id,
// Assuming comment revision exists otherwise we would
// not be seeing the reply form options as we we tombstone
// deleted comments without revision history
parentRevisionID: this.props.comment.revision!.id,
local: this.props.localReply,
nudge: this.state.nudge,
...input,
@@ -41,13 +41,15 @@ export class ReportCommentFormContainer extends Component<Props, State> {
if (input.reason === "DISAGREE") {
await this.props.createCommentDontAgree({
commentID: this.props.comment.id,
commentRevisionID: this.props.comment.revision.id,
// revision is guaranteed since we are able to interact with it
commentRevisionID: this.props.comment.revision!.id,
additionalDetails: input.additionalDetails,
});
} else {
await this.props.createCommentFlag({
commentID: this.props.comment.id,
commentRevisionID: this.props.comment.revision.id,
// revision is guaranteed since we are able to interact with it
commentRevisionID: this.props.comment.revision!.id,
reason: input.reason,
additionalDetails: input.additionalDetails,
});
@@ -38,7 +38,7 @@ const UserBanPopoverContainer: FunctionComponent<Props> = ({
{ username: user.username }
),
});
if (!rejected) {
if (!rejected && comment.revision) {
reject({ commentID: comment.id, commentRevisionID: comment.revision.id });
}
onDismiss();
@@ -26,6 +26,7 @@ exports[`hide reply button 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -69,6 +70,7 @@ exports[`hide reply button 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -120,6 +122,7 @@ exports[`hide reply button 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -143,6 +146,7 @@ exports[`hide reply button 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -189,6 +193,7 @@ exports[`renders body only 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -238,6 +243,7 @@ exports[`renders body only 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -289,6 +295,7 @@ exports[`renders body only 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -312,6 +319,7 @@ exports[`renders body only 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -358,6 +366,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -407,6 +416,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -458,6 +468,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -481,6 +492,7 @@ exports[`renders disabled reply when commenting has been disabled 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -527,6 +539,7 @@ exports[`renders disabled reply when story is closed 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -576,6 +589,7 @@ exports[`renders disabled reply when story is closed 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -627,6 +641,7 @@ exports[`renders disabled reply when story is closed 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -650,6 +665,7 @@ exports[`renders disabled reply when story is closed 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -696,6 +712,7 @@ exports[`renders in reply to 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -749,6 +766,7 @@ exports[`renders in reply to 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -804,6 +822,7 @@ exports[`renders in reply to 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -831,6 +850,7 @@ exports[`renders in reply to 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -881,6 +901,7 @@ exports[`renders username and body 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -930,6 +951,7 @@ exports[`renders username and body 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -981,6 +1003,7 @@ exports[`renders username and body 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -1004,6 +1027,7 @@ exports[`renders username and body 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -1024,6 +1048,15 @@ exports[`renders username and body 1`] = `
</div>
`;
exports[`renders with tombstone when comment has been deleted 1`] = `
<div
className="coral coral-comment"
data-testid="comment-comment-id"
>
<ForwardRef(forwardRef) />
</div>
`;
exports[`shows conversation link 1`] = `
<div
className="coral coral-comment"
@@ -1050,6 +1083,7 @@ exports[`shows conversation link 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -1099,6 +1133,7 @@ exports[`shows conversation link 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -1155,6 +1190,7 @@ exports[`shows conversation link 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -1178,6 +1214,7 @@ exports[`shows conversation link 1`] = `
},
"body": "Woof",
"createdAt": "1995-12-17T03:24:00.000Z",
"deleted": false,
"editing": Object {
"editableUntil": "1995-12-17T03:24:30.000Z",
"edited": false,
@@ -42,6 +42,22 @@ const IgnoredTombstoneOrHideContainer: FunctionComponent<Props> = ({
comment,
children,
}) => {
const deleted = Boolean(!comment.author);
if (deleted) {
return (
<>
<Localized id="comments-tombstone-deleted">
<CallOut fullWidth>
This comment is no longer available. The commenter has deleted their
account.
</CallOut>
</Localized>
{children}
</>
);
}
const hide = Boolean(
comment.author &&
viewer &&
@@ -0,0 +1,8 @@
.icon {
min-width: 18px;
margin-right: var(--spacing-2);
}
.gutter {
margin-bottom: var(--spacing-3);
}
@@ -0,0 +1,130 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { graphql } from "react-relay";
import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
CallOut,
Flex,
HorizontalGutter,
Icon,
Typography,
} from "coral-ui/components";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
import { StreamDeletionRequestCalloutContainer_viewer } from "coral-stream/__generated__/StreamDeletionRequestCalloutContainer_viewer.graphql";
import styles from "./StreamDeletionRequestCalloutContainer.css";
interface Props {
viewer: StreamDeletionRequestCalloutContainer_viewer;
}
const formatter = (locales: string[], date: Date) => {
return Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(date);
};
const subtractDays = (date: Date, days: number) => {
const millisecondsInADay = 86400000;
return new Date(date.getTime() - days * millisecondsInADay);
};
const StreamDeletionRequestCalloutContainer: FunctionComponent<Props> = ({
viewer,
}) => {
const { locales } = useCoralContext();
const cancelAccountDeletion = useMutation(CancelAccountDeletionMutation);
const cancelDeletion = useCallback(() => {
cancelAccountDeletion();
}, [cancelAccountDeletion]);
const deletionDate = viewer.scheduledDeletionDate
? formatter(locales, new Date(viewer.scheduledDeletionDate))
: null;
const requestDate = viewer.scheduledDeletionDate
? formatter(
locales,
subtractDays(
new Date(viewer.scheduledDeletionDate),
SCHEDULED_DELETION_TIMESPAN_DAYS
)
)
: null;
return (
<>
{deletionDate && (
<CallOut color="error">
<HorizontalGutter className={styles.gutter}>
<Flex>
<Icon size="md" className={styles.icon}>
report_problem
</Icon>
<Localized id="comments-stream-deleteAccount-callOut-title">
<Typography variant="bodyCopyBold">
Account deletion requested
</Typography>
</Localized>
</Flex>
</HorizontalGutter>
<HorizontalGutter className={styles.gutter}>
<Localized
id="comments-stream-deleteAccount-callOut-receivedDesc"
$date={requestDate}
>
<Typography variant="bodyCopy">
A request to delete your account was received on {requestDate}.
</Typography>
</Localized>
</HorizontalGutter>
<HorizontalGutter className={styles.gutter}>
<Localized
id="comments-stream-deleteAccount-callOut-cancelDesc"
$date={deletionDate}
>
<Typography variant="bodyCopy">
If you would like to continue leaving comments, replies or
reactions, you may cancel your request to delete your account
before {deletionDate}.
</Typography>
</Localized>
</HorizontalGutter>
<HorizontalGutter className={styles.gutter}>
<Localized id="comments-stream-deleteAccount-callOut-cancel">
<Button
variant="underlined"
color="primary"
onClick={cancelDeletion}
>
Cancel account deletion request
</Button>
</Localized>
</HorizontalGutter>
</CallOut>
)}
</>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment StreamDeletionRequestCalloutContainer_viewer on User {
scheduledDeletionDate
}
`,
})(StreamDeletionRequestCalloutContainer);
export default enhanced;
@@ -185,6 +185,7 @@ function commit(
edges: [],
pageInfo: { endCursor: null, hasNextPage: false },
},
deleted: false,
},
},
clientMutationId: (clientMutationId++).toString(),
@@ -50,6 +50,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
},
viewer: {
id: "viewer-id",
scheduledDeletionDate: undefined,
},
},
add
@@ -210,3 +211,14 @@ it("renders when commenting has been disabled (non-collapsing)", async () => {
wrapper.setProps(nextProps);
expect(wrapper).toMatchSnapshot();
});
it("renders when user is scheduled to be deleted", async () => {
const props = createDefaultProps({
viewer: {
scheduledDeletionDate: new Date("2019-01-01").toISOString(),
},
});
const wrapper = shallow(<PostCommentFormContainerN {...props} />);
await timeout();
expect(wrapper).toMatchSnapshot();
});
@@ -1,4 +1,5 @@
import { FORM_ERROR } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { Component } from "react";
import { withContext } from "coral-framework/lib/bootstrap";
@@ -211,6 +212,12 @@ export class PostCommentFormContainer extends Component<Props, State> {
/>
);
}
const scheduledForDeletion: boolean = Boolean(
this.props.viewer.scheduledDeletionDate !== undefined &&
this.props.viewer.scheduledDeletionDate !== null
);
return (
<PostCommentForm
story={this.props.story}
@@ -229,11 +236,18 @@ export class PostCommentFormContainer extends Component<Props, State> {
}
disabled={
this.props.settings.disableCommenting.enabled ||
this.props.story.isClosed
this.props.story.isClosed ||
scheduledForDeletion
}
disabledMessage={
(this.props.settings.disableCommenting.enabled &&
this.props.settings.disableCommenting.message) ||
(this.props.viewer.scheduledDeletionDate && (
<Localized id="comments-postCommentForm-userScheduledForDeletion-warning">
Commenting is disabled when your account is scheduled for
deletion.
</Localized>
)) ||
this.props.settings.closeCommenting.message
}
submitStatus={this.state.submitStatus}
@@ -281,6 +295,7 @@ const enhanced = withContext(({ sessionStorage }) => ({
viewer: graphql`
fragment PostCommentFormContainer_viewer on User {
id
scheduledDeletionDate
}
`,
})(PostCommentFormContainer)
@@ -123,6 +123,41 @@ exports[`renders when story has been closed (non-collapsing) 1`] = `
/>
`;
exports[`renders when user is scheduled to be deleted 1`] = `
<PostCommentForm
disabled={true}
disabledMessage={
<Localized
id="comments-postCommentForm-userScheduledForDeletion-warning"
>
Commenting is disabled when your account is scheduled for deletion.
</Localized>
}
initialValues={
Object {
"body": "",
}
}
max={100}
min={3}
onChange={[Function]}
onSubmit={[Function]}
showMessageBox={false}
story={
Object {
"id": "story-id",
"isClosed": false,
"settings": Object {
"messageBox": Object {
"enabled": false,
},
},
}
}
submitStatus={null}
/>
`;
exports[`renders with initialValues 1`] = `
<PostCommentForm
disabled={false}
@@ -28,6 +28,7 @@ import { PropTypesOf } from "coral-ui/types";
import AllCommentsTab from "./AllCommentsTab";
import BannedInfo from "./BannedInfo";
import { CommunityGuidelinesContainer } from "./CommunityGuidelines";
import StreamDeletionRequestCalloutContainer from "./DeleteAccount/StreamDeletionRequestCalloutContainer";
import FeaturedComments from "./FeaturedComments";
import FeaturedCommentTooltip from "./FeaturedCommentTooltip";
import { PostCommentFormContainer } from "./PostCommentForm";
@@ -115,6 +116,9 @@ export const StreamContainer: FunctionComponent<Props> = props => {
<StoryClosedTimeoutContainer story={props.story} />
<HorizontalGutter className={styles.root} size="double">
<UserBoxContainer viewer={props.viewer} settings={props.settings} />
{props.viewer && (
<StreamDeletionRequestCalloutContainer viewer={props.viewer} />
)}
<CommunityGuidelinesContainer settings={props.settings} />
{!banned && !suspended && (
<PostCommentFormContainer
@@ -220,6 +224,7 @@ const enhanced = withFragmentContainer<Props>({
...CreateCommentMutation_viewer
...PostCommentFormContainer_viewer
...SuspendedInfoContainer_viewer
...StreamDeletionRequestCalloutContainer_viewer
status {
current
}
@@ -0,0 +1,12 @@
.callout {
justify-content: left;
}
.icon {
min-width: 18px;
margin-right: var(--spacing-2);
}
.action {
padding-left: calc(var(--spacing-2) + 18px);
}
@@ -0,0 +1,90 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
Button,
CallOut,
Flex,
HorizontalGutter,
Icon,
Typography,
} from "coral-ui/components";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
import { DeletionRequestCalloutContainer_viewer } from "coral-stream/__generated__/DeletionRequestCalloutContainer_viewer.graphql";
import styles from "./DeletionRequestCalloutContainer.css";
interface Props {
viewer: DeletionRequestCalloutContainer_viewer;
}
const DeletionRequestCalloutContainer: FunctionComponent<Props> = ({
viewer,
}) => {
if (!viewer.scheduledDeletionDate) {
return null;
}
const cancelDeletionMutation = useMutation(CancelAccountDeletionMutation);
const cancelDeletion = useCallback(() => {
cancelDeletionMutation();
}, [cancelDeletionMutation]);
const { locales } = useCoralContext();
const deletionDate = viewer.scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(viewer.scheduledDeletionDate))
: null;
return (
<CallOut color="error" fullWidth className={styles.callout}>
<HorizontalGutter>
<Flex>
<Icon size="md" className={styles.icon}>
report_problem
</Icon>
<Localized
id="profile-accountDeletion-deletionDesc"
$date={deletionDate}
>
<Typography variant="bodyCopy">
Your account is scheduled to be deleted on {deletionDate}.
</Typography>
</Localized>
</Flex>
</HorizontalGutter>
<HorizontalGutter className={styles.action}>
<Localized id="profile-accountDeletion-cancelDeletion">
<Button variant="underlined" color="primary" onClick={cancelDeletion}>
Cancel account deletion request
</Button>
</Localized>
</HorizontalGutter>
</CallOut>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment DeletionRequestCalloutContainer_viewer on User {
scheduledDeletionDate
}
`,
})(DeletionRequestCalloutContainer);
export default enhanced;
@@ -17,6 +17,7 @@ import {
import ChangeEmailContainer from "./ChangeEmail";
import ChangeUsernameContainer from "./ChangeUsername";
import CommentHistoryContainer from "./CommentHistory";
import DeletionRequestCalloutContainer from "./DeletionRequest/DeletionRequestCalloutContainer";
import SettingsContainer from "./Settings";
export interface ProfileProps {
@@ -26,7 +27,8 @@ export interface ProfileProps {
PropTypesOf<typeof SettingsContainer>["viewer"] &
PropTypesOf<typeof ChangeUsernameContainer>["viewer"] &
PropTypesOf<typeof ChangeEmailContainer>["viewer"] &
PropTypesOf<typeof SettingsContainer>["viewer"];
PropTypesOf<typeof SettingsContainer>["viewer"] &
PropTypesOf<typeof DeletionRequestCalloutContainer>["viewer"];
settings: PropTypesOf<typeof UserBoxContainer>["settings"] &
PropTypesOf<typeof ChangeEmailContainer>["settings"] &
PropTypesOf<typeof SettingsContainer>["settings"] &
@@ -51,6 +53,7 @@ const Profile: FunctionComponent<ProfileProps> = props => {
viewer={props.viewer}
/>
<ChangeEmailContainer settings={props.settings} viewer={props.viewer} />
<DeletionRequestCalloutContainer viewer={props.viewer} />
</HorizontalGutter>
<TabBar
variant="secondary"
@@ -38,6 +38,7 @@ const enhanced = withFragmentContainer<ProfileContainerProps>({
...SettingsContainer_viewer
...ChangeUsernameContainer_viewer
...ChangeEmailContainer_viewer
...DeletionRequestCalloutContainer_viewer
}
`,
settings: graphql`
@@ -0,0 +1,17 @@
.root {
max-width: 500px;
margin-top: var(--spacing-2);
}
.icon {
margin-right: var(--spacing-2);
}
.title {
margin-bottom: var(--spacing-2);
}
.section {
margin-bottom: var(--spacing-3);
}
@@ -0,0 +1,132 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback, useState } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { Icon, Typography } from "coral-ui/components";
import { Button } from "coral-ui/components/Button";
import CancelAccountDeletionMutation from "coral-stream/mutations/CancelAccountDeletionMutation";
import { DeleteAccountContainer_settings } from "coral-stream/__generated__/DeleteAccountContainer_settings.graphql";
import { DeleteAccountContainer_viewer } from "coral-stream/__generated__/DeleteAccountContainer_viewer.graphql";
import DeleteAccountModal from "./DeleteAccountModal";
import styles from "./DeleteAccountContainer.css";
interface Props {
viewer: DeleteAccountContainer_viewer;
settings: DeleteAccountContainer_settings;
}
const DeleteAccountContainer: FunctionComponent<Props> = ({
viewer,
settings,
}) => {
const cancelAccountDeletion = useMutation(CancelAccountDeletionMutation);
const [deletePopoverVisible, setDeletePopoverVisible] = useState(false);
const showPopover = useCallback(() => {
setDeletePopoverVisible(true);
}, [setDeletePopoverVisible]);
const hidePopover = useCallback(() => {
setDeletePopoverVisible(false);
}, [setDeletePopoverVisible]);
const cancelDeletion = useCallback(() => {
cancelAccountDeletion();
}, [cancelAccountDeletion]);
const { locales } = useCoralContext();
const deletionDate = viewer.scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(viewer.scheduledDeletionDate))
: null;
return (
<div className={styles.root}>
<DeleteAccountModal
open={deletePopoverVisible}
onClose={hidePopover}
userID={viewer.id}
scheduledDeletionDate={viewer.scheduledDeletionDate}
organizationEmail={settings.organization.contactEmail}
/>
<Localized id="profile-settings-deleteAccount-title">
<Typography variant="heading3" className={styles.title}>
Delete My Account
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-description">
<Typography variant="bodyCopy" className={styles.section}>
Deleting your account will permanently erase your profile and remove
all your comments from this site.
</Typography>
</Localized>
{deletionDate ? (
<>
<Localized
id="profile-settings-deleteAccount-cancelDelete-description"
$date={deletionDate}
>
<Typography variant="bodyCopy" className={styles.section}>
You have already submitted a request to delete your account. Your
account will be deleted on {deletionDate}. You may cancel the
request until that time.
</Typography>
</Localized>
<Button variant="filled" size="small" onClick={cancelDeletion}>
<Icon size="sm" className={styles.icon}>
block
</Icon>
<Localized id="profile-settings-deleteAccount-cancelDelete">
<span>Cancel account deletion request</span>
</Localized>
</Button>
</>
) : (
<Button variant="outlined" size="small" onClick={showPopover}>
<Icon size="sm" className={styles.icon}>
cancel
</Icon>
<Localized id="profile-settings-deleteAccount-requestDelete">
<span>Request account deletion</span>
</Localized>
</Button>
)}
</div>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment DeleteAccountContainer_viewer on User {
id
scheduledDeletionDate
}
`,
settings: graphql`
fragment DeleteAccountContainer_settings on Settings {
organization {
contactEmail
}
}
`,
})(DeleteAccountContainer);
export default enhanced;
@@ -0,0 +1,4 @@
.root {
width: 328px;
max-width: 328px;
}
@@ -0,0 +1,44 @@
import React, { FunctionComponent, useCallback } from "react";
import { Modal } from "coral-ui/components";
import DeleteAccountModalContents from "./DeleteAccountModalContents";
interface Props {
userID: string;
scheduledDeletionDate?: string;
organizationEmail: string;
open: boolean;
onClose: () => void;
}
const DeleteAccountModal: FunctionComponent<Props> = ({
userID,
open = false,
onClose,
scheduledDeletionDate,
organizationEmail,
}) => {
const closeModal = useCallback(() => {
onClose();
}, [onClose]);
return (
<>
<Modal
open={open}
onClose={closeModal}
data-testid="delete-account-modal"
>
<DeleteAccountModalContents
userID={userID}
onClose={onClose}
scheduledDeletionDate={scheduledDeletionDate}
organizationEmail={organizationEmail}
/>
</Modal>
</>
);
};
export default DeleteAccountModal;
@@ -0,0 +1,69 @@
import React, { FunctionComponent, useCallback, useState } from "react";
import { Box } from "coral-ui/components";
import CompletionPage from "./Pages/CompletionPage";
import ConfirmPage from "./Pages/ConfirmPage";
import DescriptionPage from "./Pages/DescriptionPage";
import DownloadCommentsPage from "./Pages/DownloadCommentsPage";
import WhenPage from "./Pages/WhenPage";
import styles from "./DeleteAccountModal.css";
interface Props {
userID: string;
scheduledDeletionDate?: string;
organizationEmail: string;
onClose: () => void;
}
const DeleteAccountModalContents: FunctionComponent<Props> = ({
userID,
onClose,
scheduledDeletionDate,
organizationEmail,
}) => {
const [step, setStep] = useState(0);
const closeModal = useCallback(() => {
onClose();
}, [onClose, setStep]);
const incrementStep = useCallback(() => {
setStep(step + 1);
}, [step, setStep, closeModal]);
return (
<Box className={styles.root}>
{step === 0 && (
<DescriptionPage
step={0}
onProceed={incrementStep}
onCancel={closeModal}
/>
)}
{step === 1 && (
<WhenPage step={1} onProceed={incrementStep} onCancel={closeModal} />
)}
{step === 2 && (
<DownloadCommentsPage
step={2}
onProceed={incrementStep}
onCancel={closeModal}
/>
)}
{step === 3 && (
<ConfirmPage step={3} onProceed={incrementStep} onCancel={closeModal} />
)}
{step === 4 && (
<CompletionPage
step={4}
scheduledDeletionDate={scheduledDeletionDate}
organizationEmail={organizationEmail}
onClose={closeModal}
/>
)}
</Box>
);
};
export default DeleteAccountModalContents;
@@ -0,0 +1,83 @@
.header {
background: var(--palette-text-primary);
}
.headerText {
color: var(--palette-text-light);
min-height: calc(80rem / var(--rem-base));
line-height: calc(80rem / var(--rem-base));
}
.body {
background: var(--palette-common-white);
padding: var(--spacing-5) var(--spacing-5) var(--spacing-5) var(--spacing-5);
}
.sectionHeader {
margin-bottom: var(--spacing-2);
}
.sectionContent {
margin-bottom: var(--spacing-3);
}
.ul {
list-style: none;
margin-left: -2em;
}
.ul p {
display: inline;
}
.ul li {
padding-left: 1.3em;
margin-bottom: var(--spacing-2);
}
.ul li:before {
display: inline-block;
font-family: "Material Icons";
font-style: normal;
font-weight: bold;
font-variant: normal;
font-size: calc(18rem / var(--rem-base));
line-height: calc(20rem / 16);
vertical-align: top;
margin-left: -1.5em;
width: 1.5em;
content: 'check';
}
.controls {
width: 100%;
min-height: 36px;
}
.cancelButton {
margin-right: var(--spacing-2);
float: right;
}
.proceedButton {
border: 1px solid var(--palette-primary-main);
float: right;
}
.deleteButton {
border: 1px solid var(--palette-error-main);
float: right;
}
.deleteButton:hover {
border: 1px solid var(--palette-error-lighter);
}
@@ -0,0 +1,7 @@
.line {
width: 36px;
}
.stepBar {
margin-bottom: var(--spacing-3);
}
@@ -0,0 +1,24 @@
import React, { FunctionComponent } from "react";
import { Step, StepBar } from "coral-ui/components";
import styles from "./PageStepBar.css";
interface Props {
step: number;
}
const PageStepBar: FunctionComponent<Props> = ({ step }) => {
return (
<div>
<StepBar currentStep={step} className={styles.stepBar}>
<Step classes={{ line: styles.line }} />
<Step classes={{ line: styles.line }} />
<Step classes={{ line: styles.line }} />
<Step classes={{ line: styles.line }} />
</StepBar>
</div>
);
};
export default PageStepBar;
@@ -0,0 +1,120 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { Button, Flex, Typography } from "coral-ui/components";
import PageStepBar from "./Common/PageStepBar";
import styles from "./Common/Page.css";
interface Props {
scheduledDeletionDate?: string;
organizationEmail: string;
step: number;
onClose: () => void;
}
const CompletionPage: FunctionComponent<Props> = ({
scheduledDeletionDate,
organizationEmail,
step,
onClose,
}) => {
const onDoneClicked = useCallback(() => {
onClose();
}, [onClose]);
const { locales } = useCoralContext();
const formattedDate = scheduledDeletionDate
? Intl.DateTimeFormat(locales, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(new Date(scheduledDeletionDate))
: "";
return (
<>
<Flex
alignItems="center"
justifyContent="center"
className={styles.header}
>
<Localized id="profile-settings-deleteAccount-pages-completeHeader">
<Typography variant="header2" className={styles.headerText}>
Account deletion requested
</Typography>
</Localized>
</Flex>
<div className={styles.body}>
<PageStepBar step={step} />
<Localized id="profile-settings-deleteAccount-pages-completeDescript">
<Typography variant="bodyCopy" className={styles.sectionContent}>
Your request has been submitted and a confirmation has been sent to
the email address associated with your account.
</Typography>
</Localized>
<Localized
id="profile-settings-deleteAccount-pages-completeTimeHeader"
$date={formattedDate}
>
<Typography variant="bodyCopyBold" className={styles.sectionContent}>
Your account will be deleted on: {formattedDate}
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-completeChangeYourMindHeader">
<Typography variant="bodyCopyBold" className={styles.sectionHeader}>
Changed your mind?
</Typography>
</Localized>
<Localized
id="profile-settings-deleteAccount-pages-completeSignIntoYourAccount"
strong={<strong />}
>
<Typography variant="bodyCopy" className={styles.sectionContent}>
Simply sign in to your account again before this time and select
<strong>Cancel Account Deletion Request.</strong>
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-completeTellUsWhy">
<Typography variant="bodyCopyBold" className={styles.sectionHeader}>
Tell us why.
</Typography>
</Localized>
<Localized
id="profile-settings-deleteAccount-pages-completeWhyDeleteAccount"
$email={organizationEmail}
>
<Typography variant="bodyCopy" className={styles.sectionContent}>
We'd like to know why you chose to delete your account. Send us
feedback on our comment system by emailing {organizationEmail}.
</Typography>
</Localized>
<div className={styles.controls}>
<Button
variant="filled"
color="primary"
fullWidth
onClick={onDoneClicked}
>
<Localized id="profile-settings-deleteAccount-pages-done">
Done
</Localized>
</Button>
</div>
</div>
</>
);
};
export default CompletionPage;
@@ -0,0 +1,7 @@
.phrase {
margin-bottom: var(--spacing-2);
}
.controls {
margin-top: var(--spacing-5);
}
@@ -0,0 +1,232 @@
import { FORM_ERROR, FormApi, FormState } from "final-form";
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
composeValidators,
required,
validateDeleteConfirmation,
} from "coral-framework/lib/validation";
import {
Button,
CallOut,
Flex,
FormField,
HorizontalGutter,
InputLabel,
PasswordField,
TextField,
Typography,
} from "coral-ui/components";
import RequestAccountDeletionMutation from "./RequestAccountDeletionMutation";
import PageStepBar from "./Common/PageStepBar";
import sharedStyles from "./Common/Page.css";
import styles from "./ConfirmPage.css";
interface Props {
step: number;
onCancel: () => void;
onProceed: () => void;
}
interface FormProps {
confirmation: string;
password: string;
}
const preventSubmit = (
state: Pick<
FormState,
| "pristine"
| "hasSubmitErrors"
| "hasValidationErrors"
| "dirtySinceLastSubmit"
>
) =>
state.pristine ||
state.hasValidationErrors ||
(state.hasSubmitErrors && !state.dirtySinceLastSubmit);
const ConfirmPage: FunctionComponent<Props> = ({
step,
onCancel,
onProceed,
}) => {
const requestAccountDeletion = useMutation(RequestAccountDeletionMutation);
const onSubmit = useCallback(
async (input: FormProps, form: FormApi) => {
try {
await requestAccountDeletion({
password: input.password,
});
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return {
[FORM_ERROR]: err.message,
};
}
form.reset();
onProceed();
return;
},
[onProceed]
);
const onCancelClicked = useCallback(() => {
onCancel();
}, [onProceed]);
return (
<>
<Flex
alignItems="center"
justifyContent="center"
className={sharedStyles.header}
>
<Localized id="profile-settings-deleteAccount-pages-confirmHeader">
<Typography variant="header2" className={sharedStyles.headerText}>
Confirm account deletion?
</Typography>
</Localized>
</Flex>
<div className={sharedStyles.body}>
<PageStepBar step={step} />
<Localized id="profile-settings-deleteAccount-pages-confirmDescHeader">
<Typography
variant="bodyCopyBold"
className={sharedStyles.sectionHeader}
>
Are you sure you want to delete your account?
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-confirmDescContent">
<Typography
variant="bodyCopy"
className={sharedStyles.sectionContent}
>
To confirm you would like to delete your account please type in the
following phrase into the text box below:
</Typography>
</Localized>
<Form onSubmit={onSubmit}>
{({
handleSubmit,
submitError,
invalid,
submitting,
...formProps
}) => (
<form
autoComplete="off"
onSubmit={handleSubmit}
data-testid="confirm-page-form"
>
<HorizontalGutter>
<TextField fullWidth disabled readOnly value="delete" />
<FormField>
<Field
name="confirmation"
validate={composeValidators(
required,
validateDeleteConfirmation("delete")
)}
>
{({ input, meta }) => (
<FormField>
<Localized id="profile-settings-deleteAccount-pages-confirmPhraseLabel">
<InputLabel>
To confirm, type phrase below:
</InputLabel>
</Localized>
<TextField
fullWidth
id={input.name}
data-testid="confirm-page-confirmation"
disabled={submitting}
color={colorFromMeta(meta)}
autoComplete="confirmation"
{...input}
/>
<ValidationMessage fullWidth meta={meta} />
</FormField>
)}
</Field>
</FormField>
<FormField>
<Field name="password" validate={composeValidators(required)}>
{({ input, meta }) => (
<FormField>
<Localized id="profile-settings-deleteAccount-pages-confirmPasswordLabel">
<InputLabel>Enter your password:</InputLabel>
</Localized>
<PasswordField
fullWidth
id={input.name}
data-testid="confirm-page-password"
disabled={submitting}
color={colorFromMeta(meta)}
autoComplete="password"
{...input}
/>
<ValidationMessage fullWidth meta={meta} />
</FormField>
)}
</Field>
</FormField>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
</HorizontalGutter>
<div className={styles.controls}>
<HorizontalGutter>
<Flex justifyContent="flex-end">
<Localized id="profile-settings-deleteAccount-pages-cancel">
<Button
variant="outlined"
className={sharedStyles.cancelButton}
onClick={onCancelClicked}
>
Cancel
</Button>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-deleteButton">
<Button
color="error"
variant="filled"
type="submit"
disabled={preventSubmit(formProps)}
className={sharedStyles.deleteButton}
>
Delete my account
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</div>
</form>
)}
</Form>
</div>
</>
);
};
export default ConfirmPage;
@@ -0,0 +1,97 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Flex, Typography } from "coral-ui/components";
import PageStepBar from "./Common/PageStepBar";
import styles from "./Common/Page.css";
interface Props {
step: number;
onCancel: () => void;
onProceed: () => void;
}
const DescriptionPage: FunctionComponent<Props> = ({
step,
onCancel,
onProceed,
}) => {
const onProceedClicked = useCallback(() => {
onProceed();
}, [onProceed]);
const onCancelClicked = useCallback(() => {
onCancel();
}, [onProceed]);
return (
<>
<Flex
alignItems="center"
justifyContent="center"
className={styles.header}
>
<Localized id="profile-settings-deleteAccount-pages-descriptionHeader">
<Typography variant="header2" className={styles.headerText}>
Delete my account?
</Typography>
</Localized>
</Flex>
<div className={styles.body}>
<PageStepBar step={step} />
<Localized id="profile-settings-deleteAccount-pages-descriptionText">
<Typography variant="bodyCopy">
You are attempting to delete your account. This means:
</Typography>
</Localized>
<ul className={styles.ul}>
<li>
<Localized id="profile-settings-deleteAccount-pages-allCommentsRemoved">
<Typography variant="bodyCopy">
All of your comments are removed from this site
</Typography>
</Localized>
</li>
<li>
<Localized id="profile-settings-deleteAccount-pages-allCommentsDeleted">
<Typography variant="bodyCopy">
All of your comments are deleted from our database
</Typography>
</Localized>
</li>
<li>
<Localized id="profile-settings-deleteAccount-pages-emailRemoved">
<Typography variant="bodyCopy">
Your email address is removed from our system
</Typography>
</Localized>
</li>
</ul>
<div className={styles.controls}>
<Button
variant="filled"
color="primary"
className={styles.proceedButton}
onClick={onProceedClicked}
>
<Localized id="profile-settings-deleteAccount-pages-proceed">
Proceed
</Localized>
</Button>
<Button
variant="outlined"
className={styles.cancelButton}
onClick={onCancelClicked}
>
<Localized id="profile-settings-deleteAccount-pages-cancel">
Cancel
</Localized>
</Button>
</div>
</div>
</>
);
};
export default DescriptionPage;
@@ -0,0 +1,83 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Flex, Typography } from "coral-ui/components";
import PageStepBar from "./Common/PageStepBar";
import styles from "./Common/Page.css";
interface Props {
step: number;
onCancel: () => void;
onProceed: () => void;
}
const DownloadCommentsPage: FunctionComponent<Props> = ({
step,
onCancel,
onProceed,
}) => {
const onProceedClicked = useCallback(() => {
onProceed();
}, [onProceed]);
const onCancelClicked = useCallback(() => {
onCancel();
}, [onProceed]);
return (
<>
<Flex
alignItems="center"
justifyContent="center"
className={styles.header}
>
<Localized id="profile-settings-deleteAccount-pages-downloadCommentHeader">
<Typography variant="header2" className={styles.headerText}>
Download my comments?
</Typography>
</Localized>
</Flex>
<div className={styles.body}>
<PageStepBar step={step} />
<Localized id="profile-settings-deleteAccount-pages-downloadCommentsDesc">
<Typography variant="bodyCopy" className={styles.sectionContent}>
Before your account is deleted, we recommend you download your
comment history for your records. After your account is deleted, you
will be unable to request your comment history.
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-downloadCommentsPath">
<Typography variant="bodyCopyBold" className={styles.sectionContent}>
My Profile > Download My Comment History
</Typography>
</Localized>
<div className={styles.controls}>
<Button
variant="filled"
color="primary"
className={styles.proceedButton}
onClick={onProceedClicked}
>
<Localized id="profile-settings-deleteAccount-pages-proceed">
Proceed
</Localized>
</Button>
<Button
variant="outlined"
className={styles.cancelButton}
onClick={onCancelClicked}
>
<Localized id="profile-settings-deleteAccount-pages-cancel">
Cancel
</Localized>
</Button>
</div>
</div>
</>
);
};
export default DownloadCommentsPage;
@@ -0,0 +1,53 @@
import { DateTime } from "luxon";
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants";
import { getViewer } from "coral-framework/helpers";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RequestAccountDeletionMutation as MutationTypes } from "coral-stream/__generated__/RequestAccountDeletionMutation.graphql";
let clientMutationId = 0;
const RequestAccountDeletionMutation = createMutation(
"requestAccountDeletion",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RequestAccountDeletionMutation(
$input: RequestAccountDeletionInput!
) {
requestAccountDeletion(input: $input) {
user {
scheduledDeletionDate
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
optimisticUpdater: store => {
const viewer = getViewer(environment)!;
const deletionDate = DateTime.fromJSDate(new Date())
.plus({ days: SCHEDULED_DELETION_TIMESPAN_DAYS })
.toISO();
const viewerProxy = store.get(viewer.id);
if (viewerProxy !== null) {
viewerProxy.setValue(deletionDate, "scheduledDeletionDate");
}
},
})
);
export default RequestAccountDeletionMutation;
@@ -0,0 +1,89 @@
import { Localized } from "fluent-react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { Button, Flex, Typography } from "coral-ui/components";
import PageStepBar from "./Common/PageStepBar";
import styles from "./Common/Page.css";
interface Props {
step: number;
onCancel: () => void;
onProceed: () => void;
}
const WhenPage: FunctionComponent<Props> = ({ step, onCancel, onProceed }) => {
const onProceedClicked = useCallback(() => {
onProceed();
}, [onProceed]);
const onCancelClicked = useCallback(() => {
onCancel();
}, [onProceed]);
return (
<>
<Flex
alignItems="center"
justifyContent="center"
className={styles.header}
>
<Localized id="profile-settings-deleteAccount-pages-whenHeader">
<Typography variant="header2" className={styles.headerText}>
Delete my account: When?
</Typography>
</Localized>
</Flex>
<div className={styles.body}>
<PageStepBar step={step} />
<Localized id="profile-settings-deleteAccount-pages-whenSec1Header">
<Typography variant="bodyCopyBold" className={styles.sectionHeader}>
When will my account be deleted?
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-whenSec1Content">
<Typography variant="bodyCopy" className={styles.sectionContent}>
Your account will be deleted 24 hours after your request has been
submitted.
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-whenSec2Header">
<Typography variant="bodyCopyBold" className={styles.sectionHeader}>
Can I still write comments until my account is deleted?
</Typography>
</Localized>
<Localized id="profile-settings-deleteAccount-pages-whenSec2Content">
<Typography variant="bodyCopy" className={styles.sectionContent}>
No. Once you've requested account deletion, you can no longer write
comments, reply to comments, or select reactions.
</Typography>
</Localized>
<div className={styles.controls}>
<Button
variant="filled"
color="primary"
className={styles.proceedButton}
onClick={onProceedClicked}
>
<Localized id="profile-settings-deleteAccount-pages-proceed">
Proceed
</Localized>
</Button>
<Button
variant="outlined"
className={styles.cancelButton}
onClick={onCancelClicked}
>
<Localized id="profile-settings-deleteAccount-pages-cancel">
Cancel
</Localized>
</Button>
</div>
</div>
</>
);
};
export default WhenPage;
@@ -0,0 +1,4 @@
export {
default,
default as DeleteAccountContainer,
} from "./DeleteAccountContainer";
@@ -7,6 +7,7 @@ import { SettingsContainer_viewer } from "coral-stream/__generated__/SettingsCon
import { HorizontalGutter } from "coral-ui/components";
import ChangePasswordContainer from "./ChangePasswordContainer";
import DeleteAccountContainer from "./DeleteAccount/DeleteAccountContainer";
import DownloadCommentsContainer from "./DownloadCommentsContainer";
import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer";
@@ -22,6 +23,7 @@ const SettingsContainer: FunctionComponent<Props> = ({ viewer, settings }) => (
<IgnoreUserSettingsContainer viewer={viewer} />
<ChangePasswordContainer settings={settings} />
<DownloadCommentsContainer viewer={viewer} />
<DeleteAccountContainer viewer={viewer} settings={settings} />
</HorizontalGutter>
);
@@ -30,11 +32,13 @@ const enhanced = withFragmentContainer<Props>({
fragment SettingsContainer_viewer on User {
...IgnoreUserSettingsContainer_viewer
...DownloadCommentsContainer_viewer
...DeleteAccountContainer_viewer
}
`,
settings: graphql`
fragment SettingsContainer_settings on Settings {
...ChangePasswordContainer_settings
...DeleteAccountContainer_settings
}
`,
})(SettingsContainer);
@@ -104,7 +104,7 @@ it("post a reply", async () => {
expectAndFail(variables).toMatchObject({
storyID: storyFixture.id,
parentID: storyFixture.comments.edges[0].node.id,
parentRevisionID: storyFixture.comments.edges[0].node.revision.id,
parentRevisionID: storyFixture.comments.edges[0].node.revision!.id,
body: "<b>Hello world!</b>",
});
return {
@@ -52,7 +52,7 @@ function createTestRenderer(
edited: true,
},
revision: {
id: commentWithReplies.revision.id,
id: commentWithReplies.revision!.id,
},
},
clientMutationId: data.input.clientMutationId,
@@ -72,7 +72,7 @@ it("feature and unfeature comment", async () => {
featureComment: ({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: firstComment.id,
commentRevisionID: firstComment.revision.id,
commentRevisionID: firstComment.revision!.id,
});
return {
comment: pureMerge<typeof firstComment>(firstComment, {
@@ -140,7 +140,7 @@ it("approve comment", async () => {
approveComment: ({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: firstComment.id,
commentRevisionID: firstComment.revision.id,
commentRevisionID: firstComment.revision!.id,
});
return {
comment: pureMerge<typeof firstComment>(firstComment, {
@@ -172,7 +172,7 @@ it("reject comment", async () => {
rejectComment: ({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: firstComment.id,
commentRevisionID: firstComment.revision.id,
commentRevisionID: firstComment.revision!.id,
});
return {
comment: pureMerge<typeof firstComment>(firstComment, {
@@ -231,7 +231,7 @@ it("ban user", async () => {
rejectComment: ({ variables }) => {
expectAndFail(variables).toMatchObject({
commentID: firstComment.id,
commentRevisionID: firstComment.revision.id,
commentRevisionID: firstComment.revision!.id,
});
return {
comment: pureMerge<typeof firstComment>(firstComment, {
@@ -91,7 +91,7 @@ it("post a reply", async () => {
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID: stories[0].comments.edges[0].node.revision.id,
parentRevisionID: stories[0].comments.edges[0].node.revision!.id,
body: "<b>Hello world!</b>",
},
});
@@ -143,7 +143,7 @@ it("post a reply and handle non-visible comment state", async () => {
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID: stories[0].comments.edges[0].node.revision.id,
parentRevisionID: stories[0].comments.edges[0].node.revision!.id,
body: "<b>Hello world!</b>",
},
});
@@ -211,8 +211,8 @@ it("handle moderation nudge error", async () => {
input: {
storyID: stories[0].id,
parentID: stories[0].comments.edges[0].node.id,
parentRevisionID:
stories[0].comments.edges[0].node.revision.id,
parentRevisionID: stories[0].comments.edges[0].node.revision!
.id,
body: "<b>Hello world!</b>",
nudge: true,
},
@@ -22,12 +22,12 @@ function createTestRenderer() {
createCommentReaction: sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
commentID: stories[0].comments.edges[0].node.id,
commentRevisionID: stories[0].comments.edges[0].node.revision.id,
commentRevisionID: stories[0].comments.edges[0].node.revision!.id,
});
return {
comment: {
id: stories[0].comments.edges[0].node.id,
revision: { id: stories[0].comments.edges[0].node.revision.id },
revision: { id: stories[0].comments.edges[0].node.revision!.id },
viewerActionPresence: { reaction: true },
actionCounts: { reaction: { total: 1 } },
},
@@ -41,7 +41,7 @@ function createTestRenderer() {
return {
comment: {
id: stories[0].comments.edges[0].node.id,
revision: { id: stories[0].comments.edges[0].node.revision.id },
revision: { id: stories[0].comments.edges[0].node.revision!.id },
viewerActionPresence: { reaction: false },
actionCounts: { reaction: { total: 0 } },
},
@@ -29,7 +29,7 @@ function createTestRenderer(
createCommentFlag: sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
commentID: stories[0].comments.edges[0].node.id,
commentRevisionID: stories[0].comments.edges[0].node.revision.id,
commentRevisionID: stories[0].comments.edges[0].node.revision!.id,
reason: "COMMENT_REPORTED_OFFENSIVE",
additionalDetails: "More info",
});
@@ -44,7 +44,7 @@ function createTestRenderer(
createCommentDontAgree: sinon.stub().callsFake((_: any, data: any) => {
expectAndFail(data.input).toMatchObject({
commentID: stories[0].comments.edges[0].node.id,
commentRevisionID: stories[0].comments.edges[0].node.revision.id,
commentRevisionID: stories[0].comments.edges[0].node.revision!.id,
additionalDetails: "More info",
});
return {
+1
View File
@@ -254,6 +254,7 @@ export const baseComment = createFixture<GQLComment>({
parent: undefined,
viewerActionPresence: { reaction: false, dontAgree: false, flag: false },
tags: [],
deleted: undefined,
});
export const comments = denormalizeComments(
@@ -455,6 +455,43 @@ You can make
</span>
</button>
</div>
<div
className="DeleteAccountContainer-root"
>
<h1
className="Box-root Typography-root Typography-heading3 Typography-colorTextPrimary DeleteAccountContainer-title"
>
Delete My Account
</h1>
<p
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary DeleteAccountContainer-section"
>
Deleting your account will permanently erase your profile and remove
all your comments from this site.
</p>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorRegular Button-variantOutlined"
data-color="regular"
data-variant="outlined"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span
aria-hidden="true"
className="Icon-root Icon-sm DeleteAccountContainer-icon"
>
cancel
</span>
<span>
Request account deletion
</span>
</button>
</div>
</div>
</section>
</div>
@@ -0,0 +1,209 @@
import { ReactTestRenderer } from "react-test-renderer";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createResolversStub,
CreateTestRendererParams,
waitForElement,
within,
} from "coral-framework/testHelpers";
import { baseUser, settings, stories } from "../fixtures";
import create from "./create";
const story = stories[0];
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
viewer: () => baseUser,
story: () => story,
},
}),
params.resolvers
),
initLocalState: (localRecord, source, environment) => {
localRecord.setValue("SETTINGS", "profileTab");
if (params.initLocalState) {
params.initLocalState(localRecord, source, environment);
}
},
});
return {
testRenderer,
context,
};
}
describe("delete account steps", () => {
let testRenderer: ReactTestRenderer;
beforeEach(async () => {
const setup = await createTestRenderer({
resolvers: createResolversStub<GQLResolver>({
Query: {
viewer: () => baseUser,
},
Mutation: {
requestAccountDeletion: ({ variables }) => {
return {
user: {
...baseUser,
scheduledDeletionDate: new Date().toISOString(),
},
};
},
},
}),
});
testRenderer = setup.testRenderer;
const settingsButton = await waitForElement(() =>
within(testRenderer.root).getByText("Settings", {
selector: "button",
})
);
act(() => {
settingsButton.props.onClick();
});
});
it("request account deletion button shows deletion modal", async () => {
const requestDeletionButton = within(testRenderer.root).getByText(
"Request account deletion",
{
selector: "button",
}
);
act(() => {
requestDeletionButton.props.onClick();
});
const modal = await waitForElement(() =>
within(testRenderer.root).getByTestID("delete-account-modal")
);
expect(modal).toBeDefined();
});
it("schedules deletion if deletion steps are followed", async () => {
const requestDeletionButton = within(testRenderer.root).getByText(
"Request account deletion",
{
selector: "button",
}
);
act(() => {
requestDeletionButton.props.onClick();
});
const modal = await waitForElement(() =>
within(testRenderer.root).getByTestID("delete-account-modal")
);
// iterate through the delete account modal steps
for (let i = 0; i < 3; i++) {
const nextButton = await waitForElement(() =>
within(modal).getByText("Proceed", { selector: "button" })
);
act(() => {
nextButton.props.onClick();
});
}
const form = within(modal).getByType("form");
const confirm = within(modal).getByTestID("confirm-page-confirmation");
const password = within(modal).getByTestID("confirm-page-password");
await act(async () => {
confirm.props.onChange("delete");
password.props.onChange("password");
await form.props.onSubmit();
});
const successHeader = within(modal).getByText(
"Account deletion requested",
{ exact: false }
);
expect(successHeader).toBeDefined();
});
it("deletion confirmation is required during deletion steps", async () => {
const requestDeletionButton = within(testRenderer.root).getByText(
"Request account deletion",
{
selector: "button",
}
);
act(() => {
requestDeletionButton.props.onClick();
});
const modal = await waitForElement(() =>
within(testRenderer.root).getByTestID("delete-account-modal")
);
// iterate through the delete account modal steps
for (let i = 0; i < 3; i++) {
const nextButton = await waitForElement(() =>
within(modal).getByText("Proceed", { selector: "button" })
);
act(() => {
nextButton.props.onClick();
});
}
const form = within(modal).getByType("form");
const password = within(modal).getByTestID("confirm-page-password");
await act(async () => {
password.props.onChange("password");
await form.props.onSubmit();
});
const confirmRequiredWarning = within(modal).getByText(
"This field is required.",
{ exact: false }
);
expect(confirmRequiredWarning).toBeDefined();
});
it("password is required during deletion steps", async () => {
const requestDeletionButton = within(testRenderer.root).getByText(
"Request account deletion",
{
selector: "button",
}
);
act(() => {
requestDeletionButton.props.onClick();
});
const modal = await waitForElement(() =>
within(testRenderer.root).getByTestID("delete-account-modal")
);
// iterate through the delete account modal steps
for (let i = 0; i < 3; i++) {
const nextButton = await waitForElement(() =>
within(modal).getByText("Proceed", { selector: "button" })
);
act(() => {
nextButton.props.onClick();
});
}
const form = within(modal).getByType("form");
const confirm = within(modal).getByTestID("confirm-page-confirmation");
await act(async () => {
confirm.props.onChange("delete");
await form.props.onSubmit();
});
const passwordRequiredWarning = within(modal).getByText(
"This field is required.",
{ exact: false }
);
expect(passwordRequiredWarning).toBeDefined();
});
});
+10 -6
View File
@@ -3,15 +3,19 @@ import React, { FunctionComponent } from "react";
import styles from "./Line.css";
interface CircleProps {
active?: boolean;
interface LineProps {
completed?: boolean;
className?: string;
}
const Line: FunctionComponent<CircleProps> = ({ active, completed }) => {
const rootClassName = cn(styles.root, {
[styles.completed]: completed,
});
const Line: FunctionComponent<LineProps> = ({ completed, className }) => {
const rootClassName = cn(
styles.root,
{
[styles.completed]: completed,
},
className
);
return <span className={rootClassName} />;
};
+11 -3
View File
@@ -7,16 +7,19 @@ import Line from "./Line";
import styles from "./Step.css";
interface StepProps {
children: ReactText | ReactNode;
children?: ReactText | ReactNode;
active?: boolean;
completed?: boolean;
last?: boolean;
hidden?: boolean;
classes?: {
line?: string;
};
}
class Step extends Component<StepProps> {
public render() {
const { children, completed, active, last, hidden } = this.props;
const { children, completed, active, last, hidden, classes } = this.props;
if (hidden) {
return null;
}
@@ -29,7 +32,12 @@ class Step extends Component<StepProps> {
itemGutter
>
<Circle completed={completed} active={active} />
{!last && <Line completed={completed} />}
{!last && (
<Line
completed={completed}
className={classes ? classes.line : undefined}
/>
)}
</Flex>
<Typography className={styles.text}>{children}</Typography>
</div>
+7
View File
@@ -45,3 +45,10 @@ export const DOWNLOAD_LIMIT_TIMEFRAME = 14 * 86400;
* ALLOWED_USERNAME_CHANGE_FREQUENCY is the length of time in seconds a user must wait after changing their username to change it again.
*/
export const ALLOWED_USERNAME_CHANGE_FREQUENCY = 14 * 86400;
/**
* SCHEDULED_DELETION_TIMESPAN_DAYS is the length of time in days a user
* will have to wait for their account to be deleted after requesting a
* deletion.
*/
export const SCHEDULED_DELETION_TIMESPAN_DAYS = 14;
+243
View File
@@ -0,0 +1,243 @@
import { CronCommand, CronJob } from "cron";
import { DateTime } from "luxon";
import { Collection, Db } from "mongodb";
import logger from "coral-server/logger";
import { CommentAction } from "coral-server/models/action/comment";
import { createCollection } from "coral-server/models/helpers";
import { Story } from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
const BATCH_SIZE = 500;
// TODO: extract this out to a separate file so it
// can be re-used elsewhere
const collections = {
users: createCollection<User>("users"),
comments: createCollection<Comment>("comments"),
stories: createCollection<Story>("stories"),
tenants: createCollection<Tenant>("tenants"),
commentActions: createCollection<CommentAction>("commentActions"),
};
export function registerAccountDeletion(
mongo: Db,
mailer: MailerQueue
): CronJob {
const job = new CronJob({
cronTime: "0,30 * * * *",
timeZone: "America/New_York",
start: true,
runOnInit: false,
onTick: deleteScheduledAccounts(mongo, mailer),
});
if (job.running) {
logger.info("account deletion scheduler now running");
}
return job;
}
function deleteScheduledAccounts(mongo: Db, mailer: MailerQueue): CronCommand {
return async () => {
try {
logger.info("checking for accounts that require deletion");
// TODO: iterate over tenants in tenant cache
while (true) {
const now = new Date();
const rescheduledDeletionDate = DateTime.fromJSDate(now)
.plus({ hours: 1 })
.toJSDate();
const userResult = await collections.users(mongo).findOneAndUpdate(
{
scheduledDeletionDate: { $lte: now },
},
{
$set: {
scheduledDeletionDate: rescheduledDeletionDate,
},
},
{
// We want to get back the user with
// modified scheduledDeletionDate
returnOriginal: false,
}
);
if (!userResult.value) {
logger.info("no more users were scheduled for deletion");
break;
}
const userToDelete = userResult.value;
logger.info(
{ userID: userToDelete.id, tenantID: userToDelete.tenantID },
`deleting user`
);
deleteUser(mongo, mailer, userToDelete.id, userToDelete.tenantID, now);
}
} catch (error) {
logger.error(
{ error },
"an error occurred trying to perform scheduled account deletions"
);
}
};
}
async function executeBulkOperations<T>(
collection: Collection<T>,
operations: any[]
) {
const bulk: any = collection.initializeUnorderedBulkOp();
for (const operation of operations) {
bulk.raw(operation);
}
await bulk.execute();
}
interface Batch {
comments: any[];
stories: any[];
}
async function deleteUserActionCounts(db: Db, userID: string) {
const batch: Batch = {
comments: [],
stories: [],
};
async function processBatch() {
await executeBulkOperations<Comment>(
collections.comments(db),
batch.comments
);
batch.comments = [];
await executeBulkOperations<Story>(collections.stories(db), batch.stories);
batch.stories = [];
}
const cursor = db
.collection("commentActions")
.find({ userID, actionType: "REACTION" });
while (await cursor.hasNext()) {
const action = await cursor.next();
batch.comments.push({
updateOne: {
filter: { id: action.commentID },
update: {
$inc: {
"revisions.$[revisions].actionCounts.REACTION": -1,
"actionCounts.REACTION": -1,
},
},
arrayFilters: [{ "revisions.id": action.commentRevisionID }],
},
});
batch.stories.push({
updateOne: {
filter: { id: action.storyID },
update: {
$inc: {
"commentCounts.action.REACTION": -1,
},
},
},
});
if (
batch.comments.length >= BATCH_SIZE ||
batch.stories.length >= BATCH_SIZE
) {
await processBatch();
}
}
if (batch.comments.length > 0 || batch.stories.length > 0) {
await processBatch();
}
await collections.commentActions(db).deleteMany({
userID,
actionType: "REACTION",
});
}
async function deleteUserComments(db: Db, authorID: string) {
await collections.comments(db).updateMany(
{ authorID },
{
$set: {
authorID: null,
revisions: [],
tags: [],
deleted: true,
},
}
);
}
async function deleteUser(
db: Db,
mailer: MailerQueue,
userID: string,
tenantID: string,
now: Date
) {
const user = await collections.users(db).findOne({ id: userID, tenantID });
if (!user) {
logger.warn({ userID, tenantID }, `could not find user`);
return;
}
const tenant = await collections.tenants(db).findOne({ id: tenantID });
if (!tenant) {
logger.warn({ userID, tenantID }, `could not find tenant`);
return;
}
await deleteUserActionCounts(db, userID);
await deleteUserComments(db, userID);
collections.users(db).updateOne(
{ id: userID },
{
$set: {
profiles: [],
deletedAt: now,
},
$unset: {
email: "",
},
}
);
if (user.email) {
await mailer.add({
tenantID: tenant.id,
message: {
to: user.email,
},
template: {
name: "delete-request-completed",
context: {
organizationContactEmail: tenant.organization.contactEmail,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
},
},
});
}
}
+27
View File
@@ -0,0 +1,27 @@
import { CronJob } from "cron";
import { Db } from "mongodb";
import { MailerQueue } from "coral-server/queue/tasks/mailer";
import { registerAccountDeletion } from "./accountDeletion";
export interface ScheduledTasks {
accountDeletion: ScheduledTask;
}
export interface ScheduledTask {
name: string;
task: CronJob;
}
export default function startScheduledTasks(
mongo: Db,
mailer: MailerQueue
): ScheduledTasks {
return {
accountDeletion: {
name: "Account Deletion",
task: registerAccountDeletion(mongo, mailer),
},
};
}
@@ -51,6 +51,10 @@ function calculateAuthConditions(
conditions.push(GQLUSER_AUTH_CONDITIONS.SUSPENDED);
}
if (user.scheduledDeletionDate || user.deletedAt) {
conditions.push(GQLUSER_AUTH_CONDITIONS.PENDING_DELETION);
}
return conditions.sort();
}
@@ -4,12 +4,14 @@ import TenantContext from "coral-server/graph/tenant/context";
import { User } from "coral-server/models/user";
import {
ban,
cancelAccountDeletion,
createToken,
deactivateToken,
ignore,
removeBan,
removeIgnore,
removeSuspension,
requestAccountDeletion,
requestCommentsDownload,
requestUserCommentsDownload,
setEmail,
@@ -28,6 +30,7 @@ import { invite } from "coral-server/services/users/auth/invite";
import {
GQLBanUserInput,
GQLCancelAccountDeletionInput,
GQLCreateTokenInput,
GQLDeactivateTokenInput,
GQLIgnoreUserInput,
@@ -35,6 +38,7 @@ import {
GQLRemoveUserBanInput,
GQLRemoveUserIgnoreInput,
GQLRemoveUserSuspensionInput,
GQLRequestAccountDeletionInput,
GQLRequestCommentsDownloadInput,
GQLRequestUserCommentsDownloadInput,
GQLSetEmailInput,
@@ -114,6 +118,24 @@ export const Users = (ctx: TenantContext) => ({
),
{ "input.oldPassword": [ERROR_CODES.PASSWORD_INCORRECT] }
),
requestAccountDeletion: async (
input: GQLRequestAccountDeletionInput
): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
requestAccountDeletion(
ctx.mongo,
ctx.mailerQueue,
ctx.tenant,
ctx.user!,
input.password,
ctx.now
),
{ "input.password": [ERROR_CODES.PASSWORD_INCORRECT] }
),
cancelAccountDeletion: async (
input: GQLCancelAccountDeletionInput
): Promise<Readonly<User> | null> =>
cancelAccountDeletion(ctx.mongo, ctx.mailerQueue, ctx.tenant, ctx.user!),
createToken: async (input: GQLCreateTokenInput) =>
createToken(
ctx.mongo,
@@ -41,11 +41,14 @@ export const maybeLoadOnlyID = (
};
export const Comment: GQLCommentTypeResolver<comment.Comment> = {
body: c => getLatestRevision(c).body,
body: c => (c.revisions.length > 0 ? getLatestRevision(c).body : null),
// Send the whole comment back when you request revisions. This way, we get to
// know the comment ID. The field mapping is handled by the CommentRevision
// resolver.
revision: c => ({ revision: getLatestRevision(c), comment: c }),
revision: c =>
c.revisions.length > 0
? { revision: getLatestRevision(c), comment: c }
: null,
revisionHistory: c => c.revisions.map(revision => ({ revision, comment: c })),
editing: ({ revisions, createdAt }, input, ctx) => ({
// When there is more than one body history, then the comment has been
@@ -55,7 +58,8 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
// length added to the comment created date.
editableUntil: getCommentEditableUntilDate(ctx.tenant, createdAt),
}),
author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID),
author: (c, input, ctx) =>
c.authorID ? ctx.loaders.Users.user.load(c.authorID) : null,
statusHistory: ({ id }, input, ctx) =>
ctx.loaders.CommentModerationActions.forComment(input, id),
replies: (c, input, ctx) =>
@@ -185,12 +185,20 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.removeIgnore(input),
clientMutationId: input.clientMutationId,
}),
requestCommentsDownload: async (sourc, { input }, ctx) => ({
requestCommentsDownload: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.requestCommentsDownload(input),
clientMutationId: input.clientMutationId,
}),
requestUserCommentsDownload: async (sourc, { input }, ctx) => ({
requestUserCommentsDownload: async (source, { input }, ctx) => ({
archiveURL: await ctx.mutators.Users.requestUserCommentsDownload(input),
clientMutationId: input.clientMutationId,
}),
requestAccountDeletion: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.requestAccountDeletion(input),
clientMutationId: input.clientMutationId,
}),
cancelAccountDeletion: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.cancelAccountDeletion(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -28,6 +28,12 @@ enum USER_AUTH_CONDITIONS {
SUSPENDED is provided when the User is currently under an active suspension.
"""
SUSPENDED
"""
PENDING_DELETION is provided when the User is scheduled for deletion and will
remain after being deleted.
"""
PENDING_DELETION
}
"""
@@ -1321,7 +1327,7 @@ type BanStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1401,7 +1407,7 @@ type SuspensionStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1411,7 +1417,7 @@ type SuspensionStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1428,7 +1434,7 @@ type UsernameHistory {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1443,7 +1449,7 @@ type UsernameHistory {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
}
@@ -1465,7 +1471,7 @@ type UserStatus {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1536,7 +1542,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1547,7 +1553,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1557,7 +1563,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1567,7 +1573,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1588,7 +1594,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1626,7 +1632,7 @@ type User {
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]!
@auth(roles: [ADMIN], userIDField: "id", permit: [SUSPENDED, BANNED])
@auth(roles: [ADMIN], userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
ignoredUsers will return the list of ignored users.
@@ -1635,7 +1641,7 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -1648,6 +1654,17 @@ type User {
of their account data.
"""
lastDownloadedAt: Time
"""
scheduledDeletionDate is the time when the User is
scheduled to be deleted.
"""
scheduledDeletionDate: Time
"""
deletedAt is the time when the User was deleted.
"""
deletedAt: Time
}
"""
@@ -1900,7 +1917,7 @@ type Comment {
"""
revision is the current revision of the Comment's body.
"""
revision: CommentRevision!
revision: CommentRevision
"""
revisionHistory stores the previous CommentRevision's, with the most recent
@@ -1910,7 +1927,7 @@ type Comment {
@auth(
roles: [MODERATOR, ADMIN]
userIDField: "author_id"
permit: [SUSPENDED, BANNED]
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
@@ -2017,6 +2034,11 @@ type Comment {
tags are the list of tags assigned to the Comment.
"""
tags: [Tag!]!
"""
deleted is whether the comment has been deleted.
"""
deleted: Boolean
}
type PageInfo {
@@ -4140,6 +4162,57 @@ type UpdatePasswordPayload {
clientMutationId: String!
}
##################
# requestAccountDeletion
##################
input RequestAccountDeletionInput {
"""
password to verify for the current User.
"""
password: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RequestAccountDeletionPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# cancelAccountDeletion
##################
input CancelAccountDeletionInput {
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type CancelAccountDeletionPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# createToken
##################
@@ -4768,21 +4841,21 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED])
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION])
"""
updateUsername will set the username on the current User if they have not set one
before. This mutation will fail if the username is already set.
"""
updateUsername(input: UpdateUsernameInput!): UpdateUsernamePayload!
@auth(permit: [SUSPENDED, BANNED])
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
setEmail will set the email address on the current User if they have not set
one already. This mutation will fail if the email address is already set.
"""
setEmail(input: SetEmailInput!): SetEmailPayload!
@auth(permit: [MISSING_EMAIL, SUSPENDED, BANNED])
@auth(permit: [MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION])
"""
setPassword will set the password on the current User if they have not set
@@ -4796,6 +4869,22 @@ type Mutation {
"""
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload! @auth
"""
requestAccountDeletion allows the current logged in User to request to
delete their account.
"""
requestAccountDeletion(
input: RequestAccountDeletionInput!
): RequestAccountDeletionPayload! @auth
"""
cancelAccountDeletion allows the current logged in User to cancel the
request to delete their account
"""
cancelAccountDeletion(
input: CancelAccountDeletionInput!
): CancelAccountDeletionPayload! @auth(permit: [PENDING_DELETION])
"""
createToken allows an administrator to create a Token based on the current
logged in User.
@@ -4888,8 +4977,8 @@ type Mutation {
"""
requestCommentsDownload(
input: RequestCommentsDownloadInput!
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED])
): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
"""
requestUserCommentsDownload allows a user to request to download their comments.
"""
+5
View File
@@ -15,6 +15,7 @@ import { JSONErrorHandler } from "coral-server/app/middleware/error";
import { accessLogger, errorLogger } from "coral-server/app/middleware/logging";
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
import config, { Config } from "coral-server/config";
import startScheduledTasks, { ScheduledTasks } from "coral-server/cron";
import { createPubSubClient } from "coral-server/graph/common/subscriptions/pubsub";
import getTenantSchema from "coral-server/graph/tenant/schema";
import { createSubscriptionServer } from "coral-server/graph/tenant/subscriptions/server";
@@ -65,6 +66,8 @@ class Server {
// bind to the requested port to serve websocket traffic.
public subscriptionServer: SubscriptionServer;
public scheduledTasks: ScheduledTasks;
// tasks stores a reference to the queues that can process operations.
private tasks: TaskQueue;
@@ -307,6 +310,8 @@ class Server {
);
logger.info({ port }, "now listening");
this.scheduledTasks = startScheduledTasks(this.mongo, this.tasks.mailer);
}
}
+29
View File
@@ -57,3 +57,32 @@ email-subject-downloadComments = Your comments are ready for download
email-notification-template-downloadComments =
Your comments from { $organizationName } as of { $date } are now available for download.<br /><br />
<a data-l10n-name="downloadUrl">Download my comment archive</a>
email-subject-deleteRequestConfirmation =
Your commenter account is scheduled to be deleted
email-notification-template-deleteRequestConfirmation =
A request to delete your commenter account was received.
Your account is scheduled for deletion on { $requestDate }.<br /><br />
After that time all of your comments will be removed from the site,
all of your comments will be removed from our database, and your
username and email address will be removed from our system.<br /><br />
If you change your mind you can sign into your account and cancel the
request before your scheduled account deletion time.
email-subject-deleteRequestCancel =
Your account deletion request has been cancelled
email-notification-template-deleteRequestCancel =
You have cancelled your account deletion request for { $organizationName }.
Your account is now reactivated.
email-subject-deleteRequestCompleted =
Your account has been deleted
email-notification-template-deleteRequestCompleted =
Your commenter account for { $organizationName } is now deleted. We're sorry to
see you go!<br /><br />
If you'd like to re-join the discussion in the future, you can sign up for
a new account.<br /><br />
If you'd like to give us feedback on why you left and what we can do to make
the commenting experience better, please email us at
{ $organizationContactEmail }.
+69
View File
@@ -344,6 +344,17 @@ export interface User extends TenantResource {
* createdAt is the time that the User was created at.
*/
createdAt: Date;
/**
* scheduledDeletionDate is the time that a user is scheduled to be deleted.
* If this is null, the user has not requested for their account to be deleted.
*/
scheduledDeletionDate?: Date;
/**
* deletedAt is the time that this user was deleted from our system.
*/
deletedAt?: Date;
}
export async function createUserIndexes(mongo: Db) {
@@ -671,6 +682,64 @@ export async function updateUserPassword(
return result.value || null;
}
export async function scheduleDeletionDate(
mongo: Db,
tenantID: string,
userID: string,
deletionDate: Date
) {
const result = await collection(mongo).findOneAndUpdate(
{
id: userID,
tenantID,
},
{
$set: {
scheduledDeletionDate: deletionDate,
},
},
{
returnOriginal: false,
}
);
if (!result.value) {
throw new Error("Unable to update user deletion date.");
}
return result.value || null;
}
export async function clearDeletionDate(
mongo: Db,
tenantID: string,
userID: string
) {
const result = await collection(mongo).findOneAndUpdate(
{
id: userID,
tenantID,
},
{
$unset: {
scheduledDeletionDate: "",
},
},
{
// We want to return edited user so that
// we send back the cleared scheduledDeletionDate
// to the client
returnOriginal: false,
}
);
if (!result.value) {
throw new Error("Unable to update user deletion date.");
}
return result.value || null;
}
export interface UpdateUserInput {
email?: string;
username?: string;
@@ -0,0 +1,5 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
You have cancelled your account deletion request for {{ context.organizationName }}. Your account is now reactivated.
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
Your commenter account for {{ context.organizationName }} is now deleted. We're sorry to see you go!
If you'd like to re-join the discussion in the future, you can sign up for a new account.
If you'd like to give us feedback on why you left and what we can do to make the commenting experience better, please email us at {{ context.organizationContactEmail }}.
{% endblock %}
@@ -0,0 +1,9 @@
{% extends "layouts/user-notification.html" %}
{% block content %}
A request to delete your commenter account was received. Your account is scheduled for deletion on {{ context.requestDate }}.
After that time all of your comments will be removed from the site, all of your comments will be removed from our database, and your username and email address will be removed from our system.
If you change your mind you can sign into your account and cancel the request before your scheduled account deletion time.
{% endblock %}
@@ -79,6 +79,25 @@ export type UpdateUsernameTemplate = UserNotificationContext<
}
>;
export type AccountDeletionConfirmation = UserNotificationContext<
"delete-request-confirmation",
{
requestDate: string;
}
>;
export type AccountDeletionCancellation = UserNotificationContext<
"delete-request-cancel",
{}
>;
export type AccountDeletionCompleted = UserNotificationContext<
"delete-request-completed",
{
organizationContactEmail: string;
}
>;
type Templates =
| BanTemplate
| ConfirmEmailTemplate
@@ -87,6 +106,9 @@ type Templates =
| PasswordChangeTemplate
| SuspendTemplate
| DownloadCommentsTemplate
| UpdateUsernameTemplate;
| UpdateUsernameTemplate
| AccountDeletionConfirmation
| AccountDeletionCancellation
| AccountDeletionCompleted;
export { Templates as Template };
+91
View File
@@ -5,6 +5,7 @@ import {
ALLOWED_USERNAME_CHANGE_FREQUENCY,
DOWNLOAD_LIMIT_TIMEFRAME,
} from "coral-common/constants";
import { SCHEDULED_DELETION_TIMESPAN_DAYS } from "coral-common/constants";
import { Config } from "coral-server/config";
import {
DuplicateEmailError,
@@ -30,6 +31,7 @@ import logger from "coral-server/logger";
import { Tenant } from "coral-server/models/tenant";
import {
banUser,
clearDeletionDate,
consolidateUserBanStatus,
consolidateUserSuspensionStatus,
createUserToken,
@@ -42,6 +44,7 @@ import {
removeUserIgnore,
retrieveUser,
retrieveUserWithEmail,
scheduleDeletionDate,
setUserEmail,
setUserLastDownloadedAt,
setUserLocalProfile,
@@ -312,6 +315,94 @@ export async function updatePassword(
return user;
}
export async function requestAccountDeletion(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User,
password: string,
now: Date
) {
if (!user.email) {
throw new EmailNotSetError();
}
const passwordVerified = await verifyUserPassword(user, password, user.email);
if (!passwordVerified) {
// We throw a PasswordIncorrect error here instead of an
// InvalidCredentialsError because the current user is already signed in.
throw new PasswordIncorrect();
}
const deletionDate = DateTime.fromJSDate(now).plus({
days: SCHEDULED_DELETION_TIMESPAN_DAYS,
});
const updatedUser = await scheduleDeletionDate(
mongo,
tenant.id!,
user.id,
deletionDate.toJSDate()
);
// TODO: extract out into a common shared formatter
// this is being duplicated everywhere
const formattedDate = Intl.DateTimeFormat(tenant.locale, {
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).format(deletionDate.toJSDate());
await mailer.add({
tenantID: tenant.id,
message: {
to: user.email,
},
template: {
name: "delete-request-confirmation",
context: {
requestDate: formattedDate,
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
},
},
});
return updatedUser;
}
export async function cancelAccountDeletion(
mongo: Db,
mailer: MailerQueue,
tenant: Tenant,
user: User
) {
if (!user.email) {
throw new EmailNotSetError();
}
const updatedUser = await clearDeletionDate(mongo, tenant.id, user.id);
await mailer.add({
tenantID: tenant.id,
message: {
to: user.email,
},
template: {
name: "delete-request-cancel",
context: {
organizationName: tenant.organization.name,
organizationURL: tenant.organization.url,
},
},
});
return updatedUser;
}
/**
* createToken will create a Token for the User as well as return a signed Token
* that can be used to authenticate.
-1
View File
@@ -32,7 +32,6 @@ framework-validation-notAWholeNumberBetween = Indtast et helt tal mellem { $min
framework-validation-notAWholeNumberGreaterThan = Indtast et helt tal større end { $x }
framework-validation-notAWholeNumberGreaterThanOrEqual = Indtast et helt tal større end eller lig med { $x }
framework-timeago-just-now = Lige nu
framework-timeago-time =
+4
View File
@@ -426,6 +426,9 @@ moderate-queue-viewNew =
*[other] View {$count} new comments
}
moderate-comment-deleted-body =
This comment is no longer available. The commenter has deleted their account.
### Moderate Search Bar
moderate-searchBar-allStories = All stories
.title = All stories
@@ -591,6 +594,7 @@ community-filter-allRoles = All Roles
community-filter-allStatuses = All Statuses
community-column-username = Username
community-column-username-deleted = Deleted
community-column-email = Email
community-column-memberSince = Member Since
community-column-role = Role
+1
View File
@@ -32,6 +32,7 @@ framework-validation-notAWholeNumberBetween = Please enter a whole number betwee
framework-validation-notAWholeNumberGreaterThan = Please enter a whole number greater than { $x }
framework-validation-notAWholeNumberGreaterThanOrEqual = Please enter a whole number greater than or equal to { $x }
framework-validation-usernamesDoNotMatch = Usernames do not match. Try again.
framework-validation-deleteConfirmationInvalid = Incorrect confirmation. Try again.
framework-timeago-just-now = Just now
+96
View File
@@ -68,6 +68,9 @@ comments-postCommentForm-rte =
comments-postCommentFormFake-rte =
.placeholder = { comments-postCommentForm-rteLabel }
comments-postCommentForm-userScheduledForDeletion-warning =
Commenting is disabled when your account is scheduled for deletion.
comments-replyButton-reply = Reply
comments-permalinkViewQuery-storyNotFound = { comments-streamQuery-storyNotFound }
@@ -147,6 +150,18 @@ comments-rejectedTombstone =
comments-featuredTag = Featured
comments-staffTag = Staff
### Account Deletion Stream
comments-stream-deleteAccount-callOut-title =
Account deletion requested
comments-stream-deleteAccount-callOut-receivedDesc =
A request to delete your account was received on { $date }.
comments-stream-deleteAccount-callOut-cancelDesc =
If you would like to continue leaving comments, replies or reactions,
you may cancel your request to delete your account before { $date }.
comments-stream-deleteAccount-callOut-cancel =
Cancel account deletion request
### Featured Comments
comments-featured-gotoConversation = Go to Conversation
comments-featured-replies = Replies
@@ -156,6 +171,13 @@ comments-featured-replies = Replies
profile-myCommentsTab = My Comments
profile-settingsTab = Settings
### Account Deletion
profile-accountDeletion-deletionDesc =
Your account is scheduled to be deleted on { $date }.
profile-accountDeletion-cancelDeletion =
Cancel account deletion request
### Comment History
profile-historyComment-viewConversation = View Conversation
profile-historyComment-replies = Replies {$replyCount}
@@ -196,6 +218,78 @@ profile-settings-download-comments-recentRequest =
profile-settings-download-comments-timeOut =
You can submit another request in { framework-timeago-time }
profile-settings-deleteAccount-title = Delete My Account
profile-settings-deleteAccount-description =
Deleting your account will permanently erase your profile and remove
all your comments from this site.
profile-settings-deleteAccount-requestDelete = Request account deletion
profile-settings-deleteAccount-cancelDelete-description =
You have already submitted a request to delete your account.
Your account will be deleted on { $date }.
You may cancel the request until that time.
profile-settings-deleteAccount-cancelDelete = Cancel account deletion request
profile-settings-deleteAccount-pages-deleteButton = Delete my account
profile-settings-deleteAccount-pages-cancel = Cancel
profile-settings-deleteAccount-pages-proceed = Proceed
profile-settings-deleteAccount-pages-done = Done
profile-settings-deleteAccount-pages-descriptionHeader = Delete my account?
profile-settings-deleteAccount-pages-descriptionText =
You are attempting to delete your account. This means:
profile-settings-deleteAccount-pages-allCommentsRemoved =
All of your comments are removed from this site
profile-settings-deleteAccount-pages-allCommentsDeleted =
All of your comments are deleted from our database
profile-settings-deleteAccount-pages-emailRemoved =
Your email address is removed from our system
profile-settings-deleteAccount-pages-whenHeader = Delete my account: When?
profile-settings-deleteAccount-pages-whenSec1Header =
When will my account be deleted?
profile-settings-deleteAccount-pages-whenSec1Content =
Your account will be deleted 24 hours after your request has been submitted.
profile-settings-deleteAccount-pages-whenSec2Header =
Can I still write comments until my account is deleted?
profile-settings-deleteAccount-pages-whenSec2Content =
No. Once you've requested account deletion, you can no longer write comments,
reply to comments, or select reactions.
profile-settings-deleteAccount-pages-downloadCommentHeader = Download my comments?
profile-settings-deleteAccount-pages-downloadCommentsDesc =
Before your account is deleted, we recommend you download your comment
history for your records. After your account is deleted, you will be
unable to request your comment history.
profile-settings-deleteAccount-pages-downloadCommentsPath =
My Profile > Download My Comment History
profile-settings-deleteAccount-pages-confirmHeader = Confirm account deletion?
profile-settings-deleteAccount-pages-confirmDescHeader =
Are you sure you want to delete your account?
profile-settings-deleteAccount-confirmDescContent =
To confirm you would like to delete your account please type in the following
phrase into the text box below:
profile-settings-deleteAccount-pages-confirmPhraseLabel =
To confirm, type phrase below:
profile-settings-deleteAccount-pages-confirmPasswordLabel =
Enter your password:
profile-settings-deleteAccount-pages-completeHeader = Account deletion requested
profile-settings-deleteAccount-pages-completeDescript =
Your request has been submitted and a confirmation has been sent to the email
address associated with your account.
profile-settings-deleteAccount-pages-completeTimeHeader =
Your account will be deleted on: { $date }
profile-settings-deleteAccount-pages-completeChangeYourMindHeader = Changed your mind?
profile-settings-deleteAccount-pages-completeSignIntoYourAccount =
Simply sign in to your account again before this time and select
<strong>Cancel Account Deletion Request</strong>.
profile-settings-deleteAccount-pages-completeTellUsWhy = Tell us why.
profile-settings-deleteAccount-pages-completeWhyDeleteAccount =
We'd like to know why you chose to delete your account. Send us feedback on
our comment system by emailing { $email }.
## Report Comment Popover
comments-reportPopover =
.description = A dialog for reporting comments
@@ -281,6 +375,8 @@ configure-openStream-description =
configure-openStream-openStream = Open Stream
comments-tombstone-ignore = This comment is hidden because you ignored {$username}
comments-tombstone-deleted =
This comment is no longer available. The commenter has deleted their account.
suspendInfo-heading = Your account has been temporarily suspended from commenting.
suspendInfo-info =