From 46f0d08cf7431a02048f57e985dfc9e552782b65 Mon Sep 17 00:00:00 2001 From: Nick Funk Date: Tue, 3 Sep 2019 16:38:43 -0600 Subject: [PATCH] [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 --- package-lock.json | 17 ++ package.json | 2 + .../ModerateCard/CommentContent.tsx | 38 +-- .../components/ModerateCard/FeatureButton.tsx | 3 + .../ModerateCard/FlagDetailsContainer.tsx | 10 +- .../ModerateCard/MarkersContainer.tsx | 136 +++++----- .../components/ModerateCard/ModerateCard.css | 10 +- .../ModerateCard/ModerateCard.spec.tsx | 10 + .../components/ModerateCard/ModerateCard.tsx | 28 +- .../ModerateCard/ModerateCardContainer.tsx | 20 +- .../__snapshots__/ModerateCard.spec.tsx.snap | 132 ++++++++++ .../client/admin/routes/Community/UserRow.css | 11 + .../client/admin/routes/Community/UserRow.tsx | 23 +- .../routes/Community/UserRowContainer.tsx | 2 + src/core/client/admin/test/fixtures.ts | 1 + .../__snapshots__/regularQueue.spec.tsx.snap | 7 + .../__snapshots__/rejectedQueue.spec.tsx.snap | 4 + .../__snapshots__/singleComment.spec.tsx.snap | 3 + .../admin/test/moderate/regularQueue.spec.tsx | 4 +- .../test/moderate/rejectedQueue.spec.tsx | 2 +- .../test/moderate/singleComment.spec.tsx | 4 +- src/core/client/framework/helpers/urls.tsx | 1 + src/core/client/framework/lib/messages.tsx | 6 + src/core/client/framework/lib/validation.tsx | 4 + .../CancelAccountDeletionMutation.tsx | 48 ++++ .../Comment/CommentContainer.spec.tsx | 12 + .../Comments/Comment/CommentContainer.tsx | 199 +++++++------- .../ModerationActionsContainer.tsx | 17 +- .../CreateCommentReactionMutation.ts | 5 +- .../ReactionButtonContainer.tsx | 4 +- .../RemoveCommentReactionMutation.ts | 4 +- .../CreateCommentReplyMutation.ts | 1 + .../ReplyCommentFormContainer.tsx | 5 +- .../ReportCommentFormContainer.tsx | 6 +- .../UserBanPopoverContainer.tsx | 2 +- .../CommentContainer.spec.tsx.snap | 37 +++ .../IgnoredTombstoneOrHideContainer.tsx | 16 ++ .../StreamDeletionRequestCalloutContainer.css | 8 + .../StreamDeletionRequestCalloutContainer.tsx | 130 ++++++++++ .../PostCommentForm/CreateCommentMutation.ts | 1 + .../PostCommentFormContainer.spec.tsx | 12 + .../PostCommentFormContainer.tsx | 17 +- .../PostCommentFormContainer.spec.tsx.snap | 35 +++ .../tabs/Comments/Stream/StreamContainer.tsx | 5 + .../DeletionRequestCalloutContainer.css | 12 + .../DeletionRequestCalloutContainer.tsx | 90 +++++++ .../client/stream/tabs/Profile/Profile.tsx | 5 +- .../stream/tabs/Profile/ProfileContainer.tsx | 1 + .../DeleteAccount/DeleteAccountContainer.css | 17 ++ .../DeleteAccount/DeleteAccountContainer.tsx | 132 ++++++++++ .../DeleteAccount/DeleteAccountModal.css | 4 + .../DeleteAccount/DeleteAccountModal.tsx | 44 ++++ .../DeleteAccountModalContents.tsx | 69 +++++ .../DeleteAccount/Pages/Common/Page.css | 83 ++++++ .../Pages/Common/PageStepBar.css | 7 + .../Pages/Common/PageStepBar.tsx | 24 ++ .../DeleteAccount/Pages/CompletionPage.tsx | 120 +++++++++ .../DeleteAccount/Pages/ConfirmPage.css | 7 + .../DeleteAccount/Pages/ConfirmPage.tsx | 232 +++++++++++++++++ .../DeleteAccount/Pages/DescriptionPage.tsx | 97 +++++++ .../Pages/DownloadCommentsPage.tsx | 83 ++++++ .../Pages/RequestAccountDeletionMutation.tsx | 53 ++++ .../Settings/DeleteAccount/Pages/WhenPage.tsx | 89 +++++++ .../Profile/Settings/DeleteAccount/index.ts | 4 + .../Profile/Settings/SettingsContainer.tsx | 4 + .../permalink/permalinkViewPostReply.spec.tsx | 2 +- .../test/comments/stream/editComment.spec.tsx | 2 +- .../test/comments/stream/moderation.spec.tsx | 8 +- .../test/comments/stream/postReply.spec.tsx | 8 +- .../test/comments/stream/reaction.spec.tsx | 6 +- .../comments/stream/reportComment.spec.tsx | 4 +- src/core/client/stream/test/fixtures.ts | 1 + .../__snapshots__/settings.spec.tsx.snap | 37 +++ .../test/profile/deleteAccount.spec.tsx | 209 +++++++++++++++ src/core/client/ui/components/Steps/Line.tsx | 16 +- src/core/client/ui/components/Steps/Step.tsx | 14 +- src/core/common/constants.ts | 7 + src/core/server/cron/accountDeletion.ts | 243 ++++++++++++++++++ src/core/server/cron/index.ts | 27 ++ .../server/graph/common/directives/auth.ts | 4 + .../server/graph/tenant/mutators/Users.ts | 22 ++ .../server/graph/tenant/resolvers/Comment.ts | 10 +- .../server/graph/tenant/resolvers/Mutation.ts | 12 +- .../server/graph/tenant/schema/schema.graphql | 129 ++++++++-- src/core/server/index.ts | 5 + src/core/server/locales/en-US/email.ftl | 29 +++ src/core/server/models/user/user.ts | 69 +++++ .../templates/delete-request-cancel.html | 5 + .../templates/delete-request-completed.html | 10 + .../delete-request-confirmation.html | 9 + .../queue/tasks/mailer/templates/index.ts | 24 +- src/core/server/services/users/index.ts | 91 +++++++ src/locales/da/framework.ftl | 1 - src/locales/en-US/admin.ftl | 4 + src/locales/en-US/framework.ftl | 1 + src/locales/en-US/stream.ftl | 96 +++++++ 96 files changed, 3062 insertions(+), 262 deletions(-) create mode 100644 src/core/client/stream/mutations/CancelAccountDeletionMutation.tsx create mode 100644 src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.css create mode 100644 src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.css create mode 100644 src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModalContents.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/Page.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/CompletionPage.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DescriptionPage.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DownloadCommentsPage.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/WhenPage.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/DeleteAccount/index.ts create mode 100644 src/core/client/stream/test/profile/deleteAccount.spec.tsx create mode 100644 src/core/server/cron/accountDeletion.ts create mode 100644 src/core/server/cron/index.ts create mode 100644 src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html create mode 100644 src/core/server/queue/tasks/mailer/templates/delete-request-completed.html create mode 100644 src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html diff --git a/package-lock.json b/package-lock.json index dfa6c36ea..c815c0849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0278c4ac1..9475878e2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/client/admin/components/ModerateCard/CommentContent.tsx b/src/core/client/admin/components/ModerateCard/CommentContent.tsx index b78268668..0b878e747 100644 --- a/src/core/client/admin/components/ModerateCard/CommentContent.tsx +++ b/src/core/client/admin/components/ModerateCard/CommentContent.tsx @@ -14,7 +14,7 @@ const purify = createPurify(window, false); interface Props { className?: string; - children: string; + children: string | React.ReactElement; suspectWords: ReadonlyArray; bannedWords: ReadonlyArray; } @@ -117,25 +117,29 @@ const CommentContent: FunctionComponent = ({ 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 ( + + ); } - // Finally we render the content of the Shadow DOM Tree - return ( - - ); + return <>{children}; }; export default CommentContent; diff --git a/src/core/client/admin/components/ModerateCard/FeatureButton.tsx b/src/core/client/admin/components/ModerateCard/FeatureButton.tsx index a3c622b35..55f7472a4 100644 --- a/src/core/client/admin/components/ModerateCard/FeatureButton.tsx +++ b/src/core/client/admin/components/ModerateCard/FeatureButton.tsx @@ -9,10 +9,12 @@ import styles from "./FeatureButton.css"; interface Props extends PropTypesOf { featured: boolean; + enabled?: boolean; } const FeatureButton: FunctionComponent = ({ featured, + enabled = true, className, ...rest }) => ( @@ -21,6 +23,7 @@ const FeatureButton: FunctionComponent = ({ className={cn(className, styles.root, { [styles.invert]: featured, })} + disabled={!enabled} > {featured ? ( diff --git a/src/core/client/admin/components/ModerateCard/FlagDetailsContainer.tsx b/src/core/client/admin/components/ModerateCard/FlagDetailsContainer.tsx index a6ecdadf8..6147c6e7f 100644 --- a/src/core/client/admin/components/ModerateCard/FlagDetailsContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/FlagDetailsContainer.tsx @@ -21,13 +21,13 @@ interface Props { } const FlagDetailsContainer: FunctionComponent = ({ - 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 = ({ return ( - {metadata.perspective && ( + {metadata && metadata.perspective && ( diff --git a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx index df04a87ce..af0e8535f 100644 --- a/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/MarkersContainer.tsx @@ -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 && ( - - Link - - )) || + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && ( + + Link + + )) || null, c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && ( - - Banned Word - - )) || + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && ( + + Banned Word + + )) || null, c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && ( - - - Suspect Word + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && ( + + + Suspect Word + + + )) || + null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && ( + + Spam + + )) || + null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && ( + + Toxic + + )) || + null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && ( + + Recent History + + )) || + null, + c => + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && ( + + + Offensive + {" "} + + {c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE} + - - )) || + )) || null, c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && ( - - Spam - - )) || - null, - c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && ( - - Toxic - - )) || - null, - c => - (c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && ( - - Recent History - - )) || - null, - c => - (c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && ( - - - Offensive - {" "} - - {c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE} - - - )) || - null, - c => - (c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && ( - - - Spam - {" "} - - {c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM} - - - )) || + (c.revision && + c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && ( + + + Spam + {" "} + + {c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM} + + + )) || null, ]; diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.css b/src/core/client/admin/components/ModerateCard/ModerateCard.css index 76ae37bde..b334acde3 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -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; -} \ No newline at end of file +} + +.deleted { + background: var(--palette-grey-lightest); +} diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx index 49b172885..a519c7565 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx @@ -91,3 +91,13 @@ it("renders story info", () => { renderer.render(); expect(renderer.getRenderOutput()).toMatchSnapshot(); }); + +it("renders tombstoned when comment is deleted", () => { + const props: PropTypesOf = { + ...baseProps, + deleted: true, + }; + const renderer = createRenderer(); + renderer.render(); + expect(renderer.getRenderOutput()).toMatchSnapshot(); +}); diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index 32263be14..860a43897 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -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 = ({ @@ -79,7 +81,18 @@ const ModerateCard: FunctionComponent = ({ moderatedBy, mini = false, hideUsername = false, + deleted = false, }) => { + const commentBody = deleted ? ( + + + This comment is no longer available. The commenter has deleted their + account. + + + ) : ( + body + ); const commentAuthorClick = useCallback(() => { onUsernameClick(); }, [onUsernameClick]); @@ -88,7 +101,8 @@ const ModerateCard: FunctionComponent = ({ 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 = ({ )} {createdAt} - + {inReplyTo && (
@@ -122,7 +140,7 @@ const ModerateCard: FunctionComponent = ({ bannedWords={bannedWords} className={styles.content} > - {body} + {commentBody}
@@ -185,13 +203,13 @@ const ModerateCard: FunctionComponent = ({ diff --git a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 25c4af8f4..baaad4ce6 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -74,6 +74,10 @@ const ModerateCardContainer: FunctionComponent = ({ onUsernameClicked: usernameClicked, }) => { const handleApprove = useCallback(() => { + if (!comment.revision) { + return; + } + approveComment({ commentID: comment.id, commentRevisionID: comment.revision.id, @@ -82,6 +86,10 @@ const ModerateCardContainer: FunctionComponent = ({ }, [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 = ({ }, [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 = ({ } 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 = ({ onModerateStory={handleModerateStory} mini={mini} hideUsername={hideUsername} + deleted={comment.deleted ? comment.deleted : false} /> @@ -209,6 +226,7 @@ const enhanced = withFragmentContainer({ } permalink enteredLive + deleted ...MarkersContainer_comment ...ModeratedByContainer_comment } diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap index 02494acc8..dc05c612c 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap @@ -27,6 +27,7 @@ exports[`renders approved correctly 1`] = ` 2018-11-29T16:01:51.897Z @@ -95,6 +96,7 @@ exports[`renders approved correctly 1`] = ` > @@ -137,6 +139,7 @@ exports[`renders correctly 1`] = ` 2018-11-29T16:01:51.897Z @@ -205,11 +208,13 @@ exports[`renders correctly 1`] = ` > @@ -246,6 +251,7 @@ exports[`renders dangling correctly 1`] = ` 2018-11-29T16:01:51.897Z @@ -357,6 +363,7 @@ exports[`renders rejected correctly 1`] = ` 2018-11-29T16:01:51.897Z @@ -431,6 +438,7 @@ exports[`renders rejected correctly 1`] = ` /> @@ -467,6 +475,7 @@ exports[`renders reply correctly 1`] = ` 2018-11-29T16:01:51.897Z @@ -540,11 +549,13 @@ exports[`renders reply correctly 1`] = ` > @@ -581,6 +592,7 @@ exports[`renders story info 1`] = ` 2018-11-29T16:01:51.897Z @@ -678,11 +690,131 @@ exports[`renders story info 1`] = ` > + + + + +`; + +exports[`renders tombstoned when comment is deleted 1`] = ` + + +
+
+ + + + Theon + + + + 2018-11-29T16:01:51.897Z + + + +
+ + + + This comment is no longer available. The commenter has deleted their account. + + + +
+ +
+ + + View Context + + +
+ +
+
+
+
+ + +
+ DECISION +
+
+ + + diff --git a/src/core/client/admin/routes/Community/UserRow.css b/src/core/client/admin/routes/Community/UserRow.css index 2ee8f0eb1..52109dd33 100644 --- a/src/core/client/admin/routes/Community/UserRow.css +++ b/src/core/client/admin/routes/Community/UserRow.css @@ -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); +} diff --git a/src/core/client/admin/routes/Community/UserRow.tsx b/src/core/client/admin/routes/Community/UserRow.tsx index 5a1da791e..6357d09ac 100644 --- a/src/core/client/admin/routes/Community/UserRow.tsx +++ b/src/core/client/admin/routes/Community/UserRow.tsx @@ -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["viewer"]; settings: PropTypesOf["settings"]; onUsernameClicked?: (userID: string) => void; + deletedAt?: string; } const UserRow: FunctionComponent = ({ @@ -29,6 +31,7 @@ const UserRow: FunctionComponent = ({ viewer, onUsernameClicked, settings, + deletedAt, }) => { const usernameClicked = useCallback(() => { if (!onUsernameClicked) { @@ -41,9 +44,23 @@ const UserRow: FunctionComponent = ({ return ( - + {deletedAt && ( + <> +
+ {username || } +
+
+ + Deleted + +
+ + )} + {!deletedAt && ( + + )}
{{email} || ( diff --git a/src/core/client/admin/routes/Community/UserRowContainer.tsx b/src/core/client/admin/routes/Community/UserRowContainer.tsx index 6dfcff7b5..97d068f6b 100644 --- a/src/core/client/admin/routes/Community/UserRowContainer.tsx +++ b/src/core/client/admin/routes/Community/UserRowContainer.tsx @@ -32,6 +32,7 @@ const UserRowContainer: FunctionComponent = props => { year: "numeric", }).format(new Date(props.user.createdAt))} onUsernameClicked={props.onUsernameClicked} + deletedAt={props.user.deletedAt} /> ); }; @@ -55,6 +56,7 @@ const enhanced = withFragmentContainer({ username email createdAt + deletedAt } `, })(UserRowContainer); diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index 02613af82..2819dc774 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -502,6 +502,7 @@ export const baseComment = createFixture({ story: stories[0], // TODO: Should be allowed to pass null here.. parent: undefined, + deleted: undefined, }); export const unmoderatedComments = createFixtures( diff --git a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap index 3e6d2f5f2..124bdecc6 100644 --- a/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap +++ b/src/core/client/admin/test/moderate/__snapshots__/regularQueue.spec.tsx.snap @@ -55,6 +55,7 @@ exports[`approves comment in reported queue: dangling 1`] = ` - - )} - {showCaret && ( - - )} - - } - footer={ - <> - - - + - {!disableReplies && !banned && !suspended && ( - - )} - + + + ) + } + topBarRight={ + + {commentTags} + {editable && ( + + + + )} + {showCaret && ( + - - - {!banned && !suspended && ( - - )} - + )} - {showConversationLink && ( - - )} - - } - /> - {showReplyDialog && ( + } + footer={ + <> + + + + {!disableReplies && + !banned && + !suspended && + !scheduledForDeletion && ( + + )} + + + + {!banned && !suspended && ( + + )} + + + {showConversationLink && ( + + )} + + } + /> + )} + {showReplyDialog && !comment.deleted && ( = ({ 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, diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts index 482a028ea..577351c07 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/CreateCommentReactionMutation.ts @@ -50,7 +50,10 @@ function commit(environment: Environment, input: CreateCommentReactionInput) { reaction: true, }, revision: { - id: lookup(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(environment, input.commentID)!.revision!.id, }, actionCounts: { reaction: { diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx index db06cc041..35513f72f 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/ReactionButtonContainer.tsx @@ -40,7 +40,9 @@ class ReactionButtonContainer extends React.Component { 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; diff --git a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts index 0957f63da..178233b07 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReactionButton/RemoveCommentReactionMutation.ts @@ -49,7 +49,9 @@ function commit(environment: Environment, input: RemoveCommentReactionInput) { reaction: false, }, revision: { - id: lookup(environment, input.commentID)!.revision.id, + // Can assume revision exists since we just selected + // to remove the reaction to it. + id: lookup(environment, input.commentID)!.revision!.id, }, actionCounts: { reaction: { diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts index 341eff9a9..28dcfe273 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/CreateCommentReplyMutation.ts @@ -220,6 +220,7 @@ function commit( edges: [], pageInfo: { endCursor: null, hasNextPage: false }, }, + deleted: false, }, }, clientMutationId: (clientMutationId++).toString(), diff --git a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentFormContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentFormContainer.tsx index 885d3c629..451deb985 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReplyCommentForm/ReplyCommentFormContainer.tsx @@ -107,7 +107,10 @@ export class ReplyCommentFormContainer extends Component { 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, diff --git a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx index 312c2a920..273739b8e 100644 --- a/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/ReportPopover/ReportCommentFormContainer.tsx @@ -41,13 +41,15 @@ export class ReportCommentFormContainer extends Component { 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, }); diff --git a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx index e8fbcd7e1..450c506ea 100644 --- a/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Comment/UserBanPopover/UserBanPopoverContainer.tsx @@ -38,7 +38,7 @@ const UserBanPopoverContainer: FunctionComponent = ({ { username: user.username } ), }); - if (!rejected) { + if (!rejected && comment.revision) { reject({ commentID: comment.id, commentRevisionID: comment.revision.id }); } onDismiss(); diff --git a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap index 61c3c15a4..77a767059 100644 --- a/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Comment/__snapshots__/CommentContainer.spec.tsx.snap @@ -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`] = `
`; +exports[`renders with tombstone when comment has been deleted 1`] = ` +
+ +
+`; + exports[`shows conversation link 1`] = `
= ({ comment, children, }) => { + const deleted = Boolean(!comment.author); + + if (deleted) { + return ( + <> + + + This comment is no longer available. The commenter has deleted their + account. + + + {children} + + ); + } + const hide = Boolean( comment.author && viewer && diff --git a/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.css b/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.css new file mode 100644 index 000000000..49b029a4c --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.css @@ -0,0 +1,8 @@ +.icon { + min-width: 18px; + margin-right: var(--spacing-2); +} + +.gutter { + margin-bottom: var(--spacing-3); +} diff --git a/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.tsx new file mode 100644 index 000000000..a18ca09d4 --- /dev/null +++ b/src/core/client/stream/tabs/Comments/Stream/DeleteAccount/StreamDeletionRequestCalloutContainer.tsx @@ -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 = ({ + 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 && ( + + + + + report_problem + + + + Account deletion requested + + + + + + + + A request to delete your account was received on {requestDate}. + + + + + + + If you would like to continue leaving comments, replies or + reactions, you may cancel your request to delete your account + before {deletionDate}. + + + + + + + + + + )} + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment StreamDeletionRequestCalloutContainer_viewer on User { + scheduledDeletionDate + } + `, +})(StreamDeletionRequestCalloutContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts index 19cf518e2..784960bdf 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/CreateCommentMutation.ts @@ -185,6 +185,7 @@ function commit( edges: [], pageInfo: { endCursor: null, hasNextPage: false }, }, + deleted: false, }, }, clientMutationId: (clientMutationId++).toString(), diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.spec.tsx index ef5d44bc6..4e29c0535 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.spec.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.spec.tsx @@ -50,6 +50,7 @@ function createDefaultProps(add: DeepPartial = {}): 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(); + await timeout(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx index 75676b2ec..480474af1 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/PostCommentFormContainer.tsx @@ -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 { /> ); } + + const scheduledForDeletion: boolean = Boolean( + this.props.viewer.scheduledDeletionDate !== undefined && + this.props.viewer.scheduledDeletionDate !== null + ); + return ( { } 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 && ( + + Commenting is disabled when your account is scheduled for + deletion. + + )) || 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) diff --git a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormContainer.spec.tsx.snap b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormContainer.spec.tsx.snap index 1a27b4b06..a5e0a0f6a 100644 --- a/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/Comments/Stream/PostCommentForm/__snapshots__/PostCommentFormContainer.spec.tsx.snap @@ -123,6 +123,41 @@ exports[`renders when story has been closed (non-collapsing) 1`] = ` /> `; +exports[`renders when user is scheduled to be deleted 1`] = ` + + Commenting is disabled when your account is scheduled for deletion. + + } + 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`] = ` = props => { + {props.viewer && ( + + )} {!banned && !suspended && ( ({ ...CreateCommentMutation_viewer ...PostCommentFormContainer_viewer ...SuspendedInfoContainer_viewer + ...StreamDeletionRequestCalloutContainer_viewer status { current } diff --git a/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.css b/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.css new file mode 100644 index 000000000..de1c9b3c2 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.css @@ -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); +} diff --git a/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.tsx b/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.tsx new file mode 100644 index 000000000..4295d44ea --- /dev/null +++ b/src/core/client/stream/tabs/Profile/DeletionRequest/DeletionRequestCalloutContainer.tsx @@ -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 = ({ + 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 ( + + + + + report_problem + + + + Your account is scheduled to be deleted on {deletionDate}. + + + + + + + + + + + ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment DeletionRequestCalloutContainer_viewer on User { + scheduledDeletionDate + } + `, +})(DeletionRequestCalloutContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Profile.tsx b/src/core/client/stream/tabs/Profile/Profile.tsx index 74d75268f..ff74ba642 100644 --- a/src/core/client/stream/tabs/Profile/Profile.tsx +++ b/src/core/client/stream/tabs/Profile/Profile.tsx @@ -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["viewer"] & PropTypesOf["viewer"] & PropTypesOf["viewer"] & - PropTypesOf["viewer"]; + PropTypesOf["viewer"] & + PropTypesOf["viewer"]; settings: PropTypesOf["settings"] & PropTypesOf["settings"] & PropTypesOf["settings"] & @@ -51,6 +53,7 @@ const Profile: FunctionComponent = props => { viewer={props.viewer} /> + ({ ...SettingsContainer_viewer ...ChangeUsernameContainer_viewer ...ChangeEmailContainer_viewer + ...DeletionRequestCalloutContainer_viewer } `, settings: graphql` diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.css b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.css new file mode 100644 index 000000000..d4b153d6b --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.css @@ -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); +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.tsx new file mode 100644 index 000000000..3f0a27311 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountContainer.tsx @@ -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 = ({ + 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 ( +
+ + + + + Delete My Account + + + + + Deleting your account will permanently erase your profile and remove + all your comments from this site. + + + + {deletionDate ? ( + <> + + + 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. + + + + + ) : ( + + )} +
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment DeleteAccountContainer_viewer on User { + id + scheduledDeletionDate + } + `, + settings: graphql` + fragment DeleteAccountContainer_settings on Settings { + organization { + contactEmail + } + } + `, +})(DeleteAccountContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.css b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.css new file mode 100644 index 000000000..b791b7fb5 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.css @@ -0,0 +1,4 @@ +.root { + width: 328px; + max-width: 328px; +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.tsx new file mode 100644 index 000000000..ba0472596 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModal.tsx @@ -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 = ({ + userID, + open = false, + onClose, + scheduledDeletionDate, + organizationEmail, +}) => { + const closeModal = useCallback(() => { + onClose(); + }, [onClose]); + + return ( + <> + + + + + ); +}; + +export default DeleteAccountModal; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModalContents.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModalContents.tsx new file mode 100644 index 000000000..1aaea3b05 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/DeleteAccountModalContents.tsx @@ -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 = ({ + 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 ( + + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + {step === 4 && ( + + )} + + ); +}; + +export default DeleteAccountModalContents; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/Page.css b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/Page.css new file mode 100644 index 000000000..ef32d42f1 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/Page.css @@ -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); +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.css b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.css new file mode 100644 index 000000000..276e84601 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.css @@ -0,0 +1,7 @@ +.line { + width: 36px; +} + +.stepBar { + margin-bottom: var(--spacing-3); +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.tsx new file mode 100644 index 000000000..b9318a3a8 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/Common/PageStepBar.tsx @@ -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 = ({ step }) => { + return ( +
+ + + + + + +
+ ); +}; + +export default PageStepBar; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/CompletionPage.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/CompletionPage.tsx new file mode 100644 index 000000000..c905c7dda --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/CompletionPage.tsx @@ -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 = ({ + 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 ( + <> + + + + Account deletion requested + + + +
+ + + + + Your request has been submitted and a confirmation has been sent to + the email address associated with your account. + + + + + + Your account will be deleted on: {formattedDate} + + + + + + Changed your mind? + + + } + > + + Simply sign in to your account again before this time and select + “Cancel Account Deletion Request.” + + + + + + Tell us why. + + + + + We'd like to know why you chose to delete your account. Send us + feedback on our comment system by emailing {organizationEmail}. + + + +
+ +
+
+ + ); +}; + +export default CompletionPage; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.css b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.css new file mode 100644 index 000000000..f97e22ea8 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.css @@ -0,0 +1,7 @@ +.phrase { + margin-bottom: var(--spacing-2); +} + +.controls { + margin-top: var(--spacing-5); +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.tsx new file mode 100644 index 000000000..07132041a --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/ConfirmPage.tsx @@ -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 = ({ + 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 ( + <> + + + + Confirm account deletion? + + + +
+ + + + + Are you sure you want to delete your account? + + + + + To confirm you would like to delete your account please type in the + following phrase into the text box below: + + + +
+ {({ + handleSubmit, + submitError, + invalid, + submitting, + ...formProps + }) => ( + + + + + + {({ input, meta }) => ( + + + + To confirm, type phrase below: + + + + + + )} + + + + + {({ input, meta }) => ( + + + Enter your password: + + + + + )} + + + + {submitError && ( + + {submitError} + + )} + +
+ + + + + + + + + + +
+
+ )} + +
+ + ); +}; + +export default ConfirmPage; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DescriptionPage.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DescriptionPage.tsx new file mode 100644 index 000000000..f29a2e7f0 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DescriptionPage.tsx @@ -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 = ({ + step, + onCancel, + onProceed, +}) => { + const onProceedClicked = useCallback(() => { + onProceed(); + }, [onProceed]); + const onCancelClicked = useCallback(() => { + onCancel(); + }, [onProceed]); + + return ( + <> + + + + Delete my account? + + + +
+ + + + You are attempting to delete your account. This means: + + +
    +
  • + + + All of your comments are removed from this site + + +
  • +
  • + + + All of your comments are deleted from our database + + +
  • +
  • + + + Your email address is removed from our system + + +
  • +
+
+ + +
+
+ + ); +}; + +export default DescriptionPage; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DownloadCommentsPage.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DownloadCommentsPage.tsx new file mode 100644 index 000000000..628116498 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/DownloadCommentsPage.tsx @@ -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 = ({ + step, + onCancel, + onProceed, +}) => { + const onProceedClicked = useCallback(() => { + onProceed(); + }, [onProceed]); + const onCancelClicked = useCallback(() => { + onCancel(); + }, [onProceed]); + + return ( + <> + + + + Download my comments? + + + +
+ + + + + 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. + + + + + My Profile > Download My Comment History + + + +
+ + +
+
+ + ); +}; + +export default DownloadCommentsPage; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx new file mode 100644 index 000000000..97363a0cc --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/RequestAccountDeletionMutation.tsx @@ -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) => + commitMutationPromiseNormalized(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; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/WhenPage.tsx b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/WhenPage.tsx new file mode 100644 index 000000000..d77eacaf8 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/Pages/WhenPage.tsx @@ -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 = ({ step, onCancel, onProceed }) => { + const onProceedClicked = useCallback(() => { + onProceed(); + }, [onProceed]); + const onCancelClicked = useCallback(() => { + onCancel(); + }, [onProceed]); + + return ( + <> + + + + Delete my account: When? + + + +
+ + + + + When will my account be deleted? + + + + + Your account will be deleted 24 hours after your request has been + submitted. + + + + + Can I still write comments until my account is deleted? + + + + + No. Once you've requested account deletion, you can no longer write + comments, reply to comments, or select reactions. + + + +
+ + +
+
+ + ); +}; + +export default WhenPage; diff --git a/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/index.ts b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/index.ts new file mode 100644 index 000000000..ed7153247 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DeleteAccount/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as DeleteAccountContainer, +} from "./DeleteAccountContainer"; diff --git a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx index c5d3917a9..347a6d81b 100644 --- a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx @@ -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 = ({ viewer, settings }) => ( + ); @@ -30,11 +32,13 @@ const enhanced = withFragmentContainer({ fragment SettingsContainer_viewer on User { ...IgnoreUserSettingsContainer_viewer ...DownloadCommentsContainer_viewer + ...DeleteAccountContainer_viewer } `, settings: graphql` fragment SettingsContainer_settings on Settings { ...ChangePasswordContainer_settings + ...DeleteAccountContainer_settings } `, })(SettingsContainer); diff --git a/src/core/client/stream/test/comments/permalink/permalinkViewPostReply.spec.tsx b/src/core/client/stream/test/comments/permalink/permalinkViewPostReply.spec.tsx index 1deda89cc..5830d34aa 100644 --- a/src/core/client/stream/test/comments/permalink/permalinkViewPostReply.spec.tsx +++ b/src/core/client/stream/test/comments/permalink/permalinkViewPostReply.spec.tsx @@ -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: "Hello world!", }); return { diff --git a/src/core/client/stream/test/comments/stream/editComment.spec.tsx b/src/core/client/stream/test/comments/stream/editComment.spec.tsx index c8acc98ee..5f7965417 100644 --- a/src/core/client/stream/test/comments/stream/editComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/editComment.spec.tsx @@ -52,7 +52,7 @@ function createTestRenderer( edited: true, }, revision: { - id: commentWithReplies.revision.id, + id: commentWithReplies.revision!.id, }, }, clientMutationId: data.input.clientMutationId, diff --git a/src/core/client/stream/test/comments/stream/moderation.spec.tsx b/src/core/client/stream/test/comments/stream/moderation.spec.tsx index ad20bec6a..c7db3184f 100644 --- a/src/core/client/stream/test/comments/stream/moderation.spec.tsx +++ b/src/core/client/stream/test/comments/stream/moderation.spec.tsx @@ -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(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(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(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(firstComment, { diff --git a/src/core/client/stream/test/comments/stream/postReply.spec.tsx b/src/core/client/stream/test/comments/stream/postReply.spec.tsx index d3773ef17..643fc7b0c 100644 --- a/src/core/client/stream/test/comments/stream/postReply.spec.tsx +++ b/src/core/client/stream/test/comments/stream/postReply.spec.tsx @@ -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: "Hello world!", }, }); @@ -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: "Hello world!", }, }); @@ -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: "Hello world!", nudge: true, }, diff --git a/src/core/client/stream/test/comments/stream/reaction.spec.tsx b/src/core/client/stream/test/comments/stream/reaction.spec.tsx index e40d3a1b0..df40956e6 100644 --- a/src/core/client/stream/test/comments/stream/reaction.spec.tsx +++ b/src/core/client/stream/test/comments/stream/reaction.spec.tsx @@ -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 } }, }, diff --git a/src/core/client/stream/test/comments/stream/reportComment.spec.tsx b/src/core/client/stream/test/comments/stream/reportComment.spec.tsx index 59f8b0a60..d4cac6d41 100644 --- a/src/core/client/stream/test/comments/stream/reportComment.spec.tsx +++ b/src/core/client/stream/test/comments/stream/reportComment.spec.tsx @@ -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 { diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 93bf939ea..c59edde08 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -254,6 +254,7 @@ export const baseComment = createFixture({ parent: undefined, viewerActionPresence: { reaction: false, dontAgree: false, flag: false }, tags: [], + deleted: undefined, }); export const comments = denormalizeComments( diff --git a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap index f292c3dfd..3fd646347 100644 --- a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -455,6 +455,43 @@ You can make
+
+

+ Delete My Account +

+

+ Deleting your account will permanently erase your profile and remove +all your comments from this site. +

+ +
diff --git a/src/core/client/stream/test/profile/deleteAccount.spec.tsx b/src/core/client/stream/test/profile/deleteAccount.spec.tsx new file mode 100644 index 000000000..ad4763693 --- /dev/null +++ b/src/core/client/stream/test/profile/deleteAccount.spec.tsx @@ -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 = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + 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({ + 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(); + }); +}); diff --git a/src/core/client/ui/components/Steps/Line.tsx b/src/core/client/ui/components/Steps/Line.tsx index bc8e237f0..6748419e1 100644 --- a/src/core/client/ui/components/Steps/Line.tsx +++ b/src/core/client/ui/components/Steps/Line.tsx @@ -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 = ({ active, completed }) => { - const rootClassName = cn(styles.root, { - [styles.completed]: completed, - }); +const Line: FunctionComponent = ({ completed, className }) => { + const rootClassName = cn( + styles.root, + { + [styles.completed]: completed, + }, + className + ); return ; }; diff --git a/src/core/client/ui/components/Steps/Step.tsx b/src/core/client/ui/components/Steps/Step.tsx index bb5355355..0b6922218 100644 --- a/src/core/client/ui/components/Steps/Step.tsx +++ b/src/core/client/ui/components/Steps/Step.tsx @@ -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 { 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 { itemGutter > - {!last && } + {!last && ( + + )} {children} diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index 30b066fa3..12a3bda8b 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -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; diff --git a/src/core/server/cron/accountDeletion.ts b/src/core/server/cron/accountDeletion.ts new file mode 100644 index 000000000..517a9453f --- /dev/null +++ b/src/core/server/cron/accountDeletion.ts @@ -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("users"), + comments: createCollection("comments"), + stories: createCollection("stories"), + tenants: createCollection("tenants"), + commentActions: createCollection("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( + collection: Collection, + 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( + collections.comments(db), + batch.comments + ); + batch.comments = []; + + await executeBulkOperations(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, + }, + }, + }); + } +} diff --git a/src/core/server/cron/index.ts b/src/core/server/cron/index.ts new file mode 100644 index 000000000..e5dd16ab5 --- /dev/null +++ b/src/core/server/cron/index.ts @@ -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), + }, + }; +} diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 003d7c1d0..1876827ec 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -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(); } diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index a2c1805d2..71c0cf081 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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 | 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 | null> => + cancelAccountDeletion(ctx.mongo, ctx.mailerQueue, ctx.tenant, ctx.user!), createToken: async (input: GQLCreateTokenInput) => createToken( ctx.mongo, diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index 8320738d6..60da0d28a 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -41,11 +41,14 @@ export const maybeLoadOnlyID = ( }; export const Comment: GQLCommentTypeResolver = { - 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 = { // 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) => diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 143ea9603..863dd7003 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -185,12 +185,20 @@ export const Mutation: Required> = { 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, + }), }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 08b5509d4..5b0e87ad1 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. """ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 50e0a41ba..34f41a34a 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -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); } } diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index 5e45e1c32..c44df23fc 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -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.

Download my comment archive + +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 }.

+ 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. + +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!

+ 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 + { $organizationContactEmail }. + diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 43b67abad..014172373 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -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; diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html b/src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html new file mode 100644 index 000000000..5b41a7744 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/delete-request-cancel.html @@ -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 %} diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-completed.html b/src/core/server/queue/tasks/mailer/templates/delete-request-completed.html new file mode 100644 index 000000000..a2a6ca104 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/delete-request-completed.html @@ -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 %} diff --git a/src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html b/src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html new file mode 100644 index 000000000..34c97589d --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/delete-request-confirmation.html @@ -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 %} diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index 73075fe6b..d772bb478 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -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 }; diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 6c4506b71..f8cdd2717 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -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. diff --git a/src/locales/da/framework.ftl b/src/locales/da/framework.ftl index 0f85c5a35..d527ac8b2 100644 --- a/src/locales/da/framework.ftl +++ b/src/locales/da/framework.ftl @@ -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 = diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 8f4c619cb..a76fa8f76 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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 diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index 6e3c44382..9b88ede20 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -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 diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index ea551b972..88253348b 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -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 + Cancel Account Deletion Request. +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 =