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