diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx index ddcaa6307f..43ffba5078 100644 --- a/client/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx +++ b/client/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx @@ -5,6 +5,9 @@ import ModMessageAction, { ModMessageActionProps } from "./ModMessageAction"; import PremodAction, { PremodActionProps } from "./PremodAction"; import SiteBanAction from "./SiteBanAction"; import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction"; +import UserDeletionAction, { + UserDeletionActionProps, +} from "./UserDeletionAction"; import UsernameChangeAction, { UsernameChangeActionProps, } from "./UsernameChangeAction"; @@ -18,14 +21,16 @@ export interface HistoryActionProps { | "site-ban" | "premod" | "warning" - | "modMessage"; + | "modMessage" + | "deletion"; action: | UsernameChangeActionProps | SuspensionActionProps | BanActionProps | PremodActionProps | WarningActionProps - | ModMessageActionProps; + | ModMessageActionProps + | UserDeletionActionProps; } const AccountHistoryAction: FunctionComponent = ({ @@ -49,6 +54,8 @@ const AccountHistoryAction: FunctionComponent = ({ return ; case "modMessage": return ; + case "deletion": + return ; default: return null; } diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/CancelScheduledAccountDeletionMutation.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/CancelScheduledAccountDeletionMutation.tsx new file mode 100644 index 0000000000..17ea643321 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/CancelScheduledAccountDeletionMutation.tsx @@ -0,0 +1,55 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { CancelScheduledAccountDeletionMutation as MutationTypes } from "coral-admin/__generated__/CancelScheduledAccountDeletionMutation.graphql"; + +let clientMutationId = 0; + +const CancelScheduledAccountDeletionMutation = createMutation( + "cancelScheduleAccountDeletion", + async (environment: Environment, input: MutationInput) => { + const result = await commitMutationPromiseNormalized( + environment, + { + mutation: graphql` + mutation CancelScheduledAccountDeletionMutation( + $input: CancelScheduledAccountDeletionInput! + ) { + cancelScheduledAccountDeletion(input: $input) { + user { + scheduledDeletionDate + status { + deletion { + history { + updateType + createdBy { + username + } + createdAt + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + } + ); + return result; + } +); + +export default CancelScheduledAccountDeletionMutation; diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.css b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.css new file mode 100644 index 0000000000..071451298e --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.css @@ -0,0 +1,95 @@ +.root { + width: 280px; + font-family: var(--font-family-primary); + max-width: 80vw; + text-align: left; +} + +.title { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-3); + line-height: 1; + + color: var(--palette-text-500); +} + +.header { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-bold); + font-size: var(--font-size-2); + color: var(--palette-text-500); + margin-bottom: var(--spacing-1); + margin-top: var(--spacing-3); +} + +.username { + font-size: var(--font-size-2); +} + +.orderedList { + margin: 0; + padding-left: var(--spacing-4); + + li { + font-family: var(--font-family-primary); + font-size: var(--font-size-2); + } +} + +.callOut { + padding: var(--spacing-1); + font-weight: var(--font-weight-primary-semi-bold); + margin-top: var(--spacing-3); + font-size: var(--font-size-1); +} + +.moreInfo { + font-size: var(--font-size-2); + margin-top: var(--spacing-2); +} + +.icon { + display: inline-block; + margin-right: var(--spacing-1); + position: relative; + top: 2px; +} + +.confirmationInput { + box-sizing: border-box; + font-family: var(--font-family-primary); + font-size: var(--font-size-2); + line-height: 2.25; + padding-left: var(--spacing-2); + width: 100%; +} + +.description { + font-family: var(--font-family-primary); + font-weight: var(--font-weight-primary-regular); + font-size: var(--font-size-3); + line-height: 1; + + color: var(--palette-text-500); +} + +.actions { + padding-top: var(--spacing-3); +} + +.link { + display: inline; + vertical-align: baseline; + white-space: break-spaces; +} + +.container { + margin-top: var(--spacing-2); +} + +.error { + color: var(--palette-error-500); + margin-top: var(--spacing-2); + font-weight: var(--font-weight-primary-semi-bold); +} diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.tsx new file mode 100644 index 0000000000..06eab24eb2 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopover.tsx @@ -0,0 +1,160 @@ +import { Localized } from "@fluent/react/compat"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; + +import { useMutation } from "coral-framework/lib/relay"; +import { + AlertCircleIcon, + AlertTriangleIcon, + SvgIcon, +} from "coral-ui/components/icons"; +import { Box, Button, CallOut, Flex } from "coral-ui/components/v2"; + +import ScheduleAccountDeletionMutation from "./ScheduleAccountDeletionMutation"; + +import styles from "./DeleteAccountPopover.css"; + +interface Props { + onDismiss: () => void; + userID: string; + username: string | null; +} + +const DeleteAccountPopover: FunctionComponent = ({ + onDismiss, + userID, + username, +}) => { + const scheduleAccountDeletion = useMutation(ScheduleAccountDeletionMutation); + const [requestDeletionError, setRequestDeletionError] = useState< + string | null + >(null); + + const onRequestDeletion = useCallback(async () => { + try { + await scheduleAccountDeletion({ userID }); + } catch (e) { + if (e.message) { + setRequestDeletionError(e.message as string); + } + } + }, [userID, scheduleAccountDeletion, setRequestDeletionError]); + + const deleteAccountConfirmationText = "delete"; + const [ + deleteAccountConfirmationTextInput, + setDeleteAccountConfirmationTextInput, + ] = useState(""); + + const onDeleteAccountConfirmationTextInputChange = useCallback( + (e: React.ChangeEvent) => { + setDeleteAccountConfirmationTextInput(e.target.value); + }, + [setDeleteAccountConfirmationTextInput] + ); + + const deleteAccountButtonDisabled = useMemo(() => { + return !( + deleteAccountConfirmationTextInput.toLowerCase() === + deleteAccountConfirmationText + ); + }, [deleteAccountConfirmationText, deleteAccountConfirmationTextInput]); + + return ( + + <> + +
Delete account
+
+ +
Username
+
+
{username ?? ""}
+ +
Delete account will
+
+
+
    + +
  1. + Remove all comments written by this user from the database. +
  2. +
    + +
  3. + Delete all record of this account. The user could then create a + new account using the same email address. If you want to Ban + this user instead and retain their history, press "CANCEL" and + use the Status dropdown below the username. +
  4. +
    +
+
+ + + + This removes all records of this user + + + +
+ This will go into effect in 24 hours. +
+
+ +
+ Type in "{deleteAccountConfirmationText}" to confirm +
+
+ + {requestDeletionError && ( +
+ + {requestDeletionError} +
+ )} + + + + + + + + + +
+ ); +}; + +export default DeleteAccountPopover; diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.css b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.css new file mode 100644 index 0000000000..783cb33ad2 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.css @@ -0,0 +1,26 @@ +.icon { + display: inline-block; + margin-right: var(--spacing-1); + position: relative; + top: 2px; +} + +.error { + color: var(--palette-error-500); + margin-top: var(--spacing-2); + font-weight: var(--font-weight-primary-semi-bold); +} + +.deletionCalloutTitle { + font-weight: var(--font-weight-primary-semi-bold); + font-size: var(--font-size-3); +} + +.deletionCalloutInfo { + margin-top: var(--spacing-2); + margin-bottom: var(--spacing-1); +} + +.cancelDeletionButton { + margin-top: var(--spacing-2); +} diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.tsx new file mode 100644 index 0000000000..df50e585d1 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/DeleteAccountPopoverContainer.tsx @@ -0,0 +1,139 @@ +import { Localized } from "@fluent/react/compat"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; +import { graphql } from "react-relay"; + +import { useDateTimeFormatter } from "coral-framework/hooks"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { AlertCircleIcon, SvgIcon } from "coral-ui/components/icons"; +import { Button, CallOut, ClickOutside, Popover } from "coral-ui/components/v2"; + +import { DeleteAccountPopoverContainer_user as UserData } from "coral-admin/__generated__/DeleteAccountPopoverContainer_user.graphql"; + +import CancelScheduledAccountDeletionMutation from "./CancelScheduledAccountDeletionMutation"; +import DeleteAccountPopover from "./DeleteAccountPopover"; + +import styles from "./DeleteAccountPopoverContainer.css"; + +interface Props { + user: UserData; +} + +const DeleteAccountPopoverContainer: FunctionComponent = ({ user }) => { + const cancelScheduledAccountDeletion = useMutation( + CancelScheduledAccountDeletionMutation + ); + const [cancelDeletionError, setCancelDeletionError] = useState( + null + ); + + const formatter = useDateTimeFormatter({ + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + + const onCancelScheduledDeletion = useCallback(async () => { + try { + await cancelScheduledAccountDeletion({ userID: user.id }); + } catch (e) { + if (e.message) { + setCancelDeletionError(e.message as string); + } + } + }, [user.id, cancelScheduledAccountDeletion, setCancelDeletionError]); + + const deletionDate = useMemo( + () => + user.scheduledDeletionDate ? formatter(user.scheduledDeletionDate) : null, + [user, formatter] + ); + + if (deletionDate) { + return ( + + +
+ User deletion activated +
+
+ +
+ This will occur at {deletionDate}. +
+
+ + + + {cancelDeletionError && ( +
+ {" "} + + {cancelDeletionError} +
+ )} +
+ ); + } + + return ( + + ( + + + + )} + > + {({ toggleVisibility, ref }) => ( + + + + )} + + + ); +}; + +const enhanced = withFragmentContainer({ + user: graphql` + fragment DeleteAccountPopoverContainer_user on User { + id + username + scheduledDeletionDate + } + `, +})(DeleteAccountPopoverContainer); + +export default enhanced; diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/ScheduleAccountDeletionMutation.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/ScheduleAccountDeletionMutation.tsx new file mode 100644 index 0000000000..3979dd1428 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/ScheduleAccountDeletionMutation.tsx @@ -0,0 +1,55 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { ScheduleAccountDeletionMutation as MutationTypes } from "coral-admin/__generated__/ScheduleAccountDeletionMutation.graphql"; + +let clientMutationId = 0; + +const ScheduleAccountDeletionMutation = createMutation( + "scheduleAccountDeletion", + async (environment: Environment, input: MutationInput) => { + const result = await commitMutationPromiseNormalized( + environment, + { + mutation: graphql` + mutation ScheduleAccountDeletionMutation( + $input: ScheduleAccountDeletionInput! + ) { + scheduleAccountDeletion(input: $input) { + user { + scheduledDeletionDate + status { + deletion { + history { + updateType + createdBy { + username + } + createdAt + } + } + } + } + clientMutationId + } + } + `, + variables: { + input: { + userID: input.userID, + clientMutationId: (clientMutationId++).toString(), + }, + }, + } + ); + return result; + } +); + +export default ScheduleAccountDeletionMutation; diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/UserDeletionAction.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/UserDeletionAction.tsx new file mode 100644 index 0000000000..976c0c8bc5 --- /dev/null +++ b/client/src/core/client/admin/components/UserHistoryDrawer/UserDeletionAction.tsx @@ -0,0 +1,26 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +export interface UserDeletionActionProps { + action: "CANCELED" | "REQUESTED" | "%future added value"; +} + +const UserDeletionAction: FunctionComponent = ({ + action, +}) => { + if (action === "REQUESTED") { + return ( + + User scheduled for deletion + + ); + } else if (action === "CANCELED") { + return ( + + User deletion request canceled + + ); + } + return null; +}; +export default UserDeletionAction; diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.css b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.css index a8abf8974f..567aeb9202 100644 --- a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.css +++ b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.css @@ -59,3 +59,7 @@ padding-right: var(--spacing-1); vertical-align: middle; } + +.deleteButtonWrapper { + margin-bottom: var(--spacing-2); +} diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx index 4446d57386..2fd93557c4 100644 --- a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx +++ b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx @@ -1,9 +1,13 @@ import { Localized } from "@fluent/react/compat"; -import React, { FunctionComponent, useMemo } from "react"; +import React, { FunctionComponent, useCallback, useMemo } from "react"; import { graphql } from "react-relay"; +import { SCHEDULED_DELETION_WINDOW_DURATION } from "coral-common/common/lib/constants"; import { useDateTimeFormatter } from "coral-framework/hooks"; +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { getMessage } from "coral-framework/lib/i18n"; import { withFragmentContainer } from "coral-framework/lib/relay"; +import { GQLUSER_ROLE } from "coral-framework/schema"; import { CoralMarkIcon, SvgIcon } from "coral-ui/components/icons"; import { CallOut, @@ -16,16 +20,19 @@ import { } from "coral-ui/components/v2"; import { UserDrawerAccountHistory_user } from "coral-admin/__generated__/UserDrawerAccountHistory_user.graphql"; +import { UserDrawerAccountHistory_viewer } from "coral-admin/__generated__/UserDrawerAccountHistory_viewer.graphql"; import AccountHistoryAction, { HistoryActionProps, } from "./AccountHistoryAction"; import { BanActionProps } from "./BanAction"; +import DeleteAccountPopoverContainer from "./DeleteAccountPopoverContainer"; import styles from "./UserDrawerAccountHistory.css"; interface Props { user: UserDrawerAccountHistory_user; + viewer: UserDrawerAccountHistory_viewer; } interface From { @@ -39,7 +46,10 @@ type HistoryRecord = HistoryActionProps & { description?: string | null; }; -const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { +const UserDrawerAccountHistory: FunctionComponent = ({ + user, + viewer, +}) => { const system = ( = ({ user }) => { ); + + const { localeBundles } = useCoralContext(); + + const deletionFormatter = useDateTimeFormatter({ + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + const addSeconds = (date: Date, seconds: number) => { + return new Date(date.getTime() + seconds * 1000); + }; + + const deletionDescriptionMapping = useCallback( + (updateType: string, createdAt: string) => { + const mapping: { [key: string]: string } = { + REQUESTED: getMessage( + localeBundles, + "moderate-user-drawer-account-history-deletion-scheduled", + `Deletion scheduled for ${deletionFormatter( + addSeconds(new Date(createdAt), SCHEDULED_DELETION_WINDOW_DURATION) + )}`, + { + createdAt: deletionFormatter( + addSeconds( + new Date(createdAt), + SCHEDULED_DELETION_WINDOW_DURATION + ) + ), + } + ), + CANCELED: getMessage( + localeBundles, + "moderate-user-drawer-account-history-canceled-at", + `Canceled at ${deletionFormatter(new Date(createdAt))}`, + { createdAt: deletionFormatter(new Date(createdAt)) } + ), + "%future added value": getMessage( + localeBundles, + "moderate-user-drawer-account-history-updated-at", + `Updated at ${deletionFormatter(new Date(createdAt))}`, + { createdAt: deletionFormatter(new Date(createdAt)) } + ), + }; + return mapping[updateType]; + }, + [getMessage, localeBundles, addSeconds, deletionFormatter] + ); + const combinedHistory = useMemo(() => { // Collect all the different types of history items. const history: HistoryRecord[] = []; @@ -217,6 +278,19 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { }); }); + user.status.deletion.history.forEach((record) => { + history.push({ + kind: "deletion", + date: new Date(record.createdAt), + takenBy: record.createdBy ? record.createdBy.username : system, + action: { action: record.updateType }, + description: deletionDescriptionMapping( + record.updateType, + record.createdAt + ), + }); + }); + // Sort the history so that it's in the right order. const dateSortedHistory = history.sort( (a, b) => b.date.getTime() - a.date.getTime() @@ -227,7 +301,7 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { } return dateSortedHistory; - }, [system, user.status]); + }, [system, user.status, deletionDescriptionMapping]); const formatter = useDateTimeFormatter({ year: "numeric", month: "long", @@ -236,11 +310,18 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { if (combinedHistory.length === 0) { return ( - - - No actions have been taken on this account - - + <> + {viewer.role === GQLUSER_ROLE.ADMIN && ( +
+ +
+ )} + + + No actions have been taken on this account + + + ); } @@ -263,6 +344,9 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { return ( + {viewer.role === GQLUSER_ROLE.ADMIN && ( + + )} @@ -294,8 +378,15 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { }; const enhanced = withFragmentContainer({ + viewer: graphql` + fragment UserDrawerAccountHistory_viewer on User { + id + role + } + `, user: graphql` fragment UserDrawerAccountHistory_user on User { + ...DeleteAccountPopoverContainer_user status { username { history { @@ -306,6 +397,15 @@ const enhanced = withFragmentContainer({ } } } + deletion { + history { + updateType + createdAt + createdBy { + username + } + } + } warning { history { active diff --git a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistoryQuery.tsx b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistoryQuery.tsx index a980d7669e..38bbf68b8f 100644 --- a/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistoryQuery.tsx +++ b/client/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistoryQuery.tsx @@ -26,6 +26,9 @@ const UserDrawerAccountHistoryQuery: FunctionComponent = ({ user(id: $userID) { ...UserDrawerAccountHistory_user } + viewer { + ...UserDrawerAccountHistory_viewer + } } `} variables={{ userID }} @@ -55,7 +58,9 @@ const UserDrawerAccountHistoryQuery: FunctionComponent = ({ ); } - return ; + return ( + + ); }} /> ); diff --git a/client/src/core/client/admin/test/community/userHistoryDrawer.spec.tsx b/client/src/core/client/admin/test/community/userHistoryDrawer.spec.tsx index 8df58c5cea..d50082ab9e 100644 --- a/client/src/core/client/admin/test/community/userHistoryDrawer.spec.tsx +++ b/client/src/core/client/admin/test/community/userHistoryDrawer.spec.tsx @@ -1,8 +1,18 @@ -import { act, screen, waitFor, within } from "@testing-library/react"; +import { + act, + fireEvent, + screen, + waitFor, + within, +} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { pureMerge } from "coral-common/common/lib/utils"; -import { GQLFEATURE_FLAG, GQLResolver } from "coral-framework/schema"; +import { + GQLFEATURE_FLAG, + GQLResolver, + GQLUserDeletionUpdateType, +} from "coral-framework/schema"; import { createResolversStub, CreateTestRendererParams, @@ -77,6 +87,106 @@ it("user drawer is open and both user name and user id are visible", async () => ).toBeInTheDocument(); }); +it("user drawer is open and user can be scheduled for deletion and have deletion canceled", async () => { + const user = users.commenters[0]; + const resolvers = createResolversStub({ + Mutation: { + scheduleAccountDeletion: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + }); + const userRecord = pureMerge(user, { + scheduledDeletionDate: "2024-01-11T20:48:20.317+00:00", + status: { + deletion: { + history: [ + { + updateType: GQLUserDeletionUpdateType.REQUESTED, + createdAt: "2018-11-29T16:01:51.897Z", + createdBy: viewer, + }, + ], + }, + }, + }); + return { + user: userRecord, + }; + }, + cancelScheduledAccountDeletion: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + userID: user.id, + }); + const userRecord = pureMerge(user, { + scheduledDeletionDate: null, + status: { + deletion: { + history: [ + { + updateType: GQLUserDeletionUpdateType.REQUESTED, + createdAt: "2024-01-10T16:01:51.897Z", + createdBy: viewer, + }, + { + updateType: GQLUserDeletionUpdateType.CANCELED, + createdAt: "2024-01-10T16:25:51.897Z", + createdBy: viewer, + }, + ], + }, + }, + }); + return { + user: userRecord, + }; + }, + }, + }); + await createTestRenderer({ resolvers }); + await screen.findByTestId("community-container"); + const isabelle = await screen.findByRole("button", { name: "Isabelle" }); + await act(async () => { + userEvent.click(isabelle); + }); + const isabelleUserHistory = await screen.findByTestId( + "userHistoryDrawer-modal" + ); + const historyTab = await within(isabelleUserHistory).findByRole("tab", { + name: "Tab: time-reverse Account History", + }); + await act(async () => { + userEvent.click(historyTab); + }); + const tabRegion = screen.getByRole("region", { + name: "Tab: time-reverse Account History", + }); + const deleteAccountButton = within(tabRegion).getByRole("button", { + name: "Delete account", + }); + expect(deleteAccountButton).toBeVisible(); + + await act(async () => { + userEvent.click(deleteAccountButton); + }); + const popover = screen.getByRole("dialog", { + name: "A popover menu to delete a user's account", + }); + const deleteButton = within(popover).getByRole("button", { name: "Delete" }); + expect(deleteButton).toBeDisabled(); + const input = within(popover).getByRole("textbox"); + fireEvent.change(input, { target: { value: "delete" } }); + + expect(deleteButton).toBeEnabled(); + fireEvent.click(deleteButton); + expect(resolvers.Mutation!.scheduleAccountDeletion!.called).toBe(true); + await screen.findByText("User deletion activated"); + const cancelDeletionButton = screen.getByRole("button", { + name: "Cancel user deletion", + }); + userEvent.click(cancelDeletionButton); + expect(resolvers.Mutation!.cancelScheduledAccountDeletion!.called).toBe(true); +}); + it("opens user drawer and shows external profile url link when has feature flag and configured", async () => { const settingsOverride = settings; settingsOverride.featureFlags = [ diff --git a/client/src/core/client/admin/test/fixtures.ts b/client/src/core/client/admin/test/fixtures.ts index 0d06200721..1fb1a1db2d 100644 --- a/client/src/core/client/admin/test/fixtures.ts +++ b/client/src/core/client/admin/test/fixtures.ts @@ -442,6 +442,12 @@ export const baseUser = createFixture({ active: false, history: [], }, + deletion: { + history: [], + }, + username: { + history: [], + }, }, }); diff --git a/client/src/core/client/test/helpers/fixture.ts b/client/src/core/client/test/helpers/fixture.ts index 789770b1a6..7e04d122fa 100644 --- a/client/src/core/client/test/helpers/fixture.ts +++ b/client/src/core/client/test/helpers/fixture.ts @@ -50,6 +50,9 @@ export function createUserStatus(banned = false): GQLUserStatus { username: { history: [], }, + deletion: { + history: [], + }, premod: { active: false, history: [], diff --git a/locales/en-US/admin.ftl b/locales/en-US/admin.ftl index 0a5517277b..eff09ec811 100644 --- a/locales/en-US/admin.ftl +++ b/locales/en-US/admin.ftl @@ -1232,6 +1232,33 @@ moderate-user-drawer-suspension = *[other] unknown unit } +moderate-user-drawer-deleteAccount-popover = + .description = A popover menu to delete a user's account +moderate-user-drawer-deleteAccount-button = + .aria-label = Delete account +moderate-user-drawer-deleteAccount-popover-confirm = Type in "{ $text }" to confirm +moderate-user-drawer-deleteAccount-popover-title = Delete account +moderate-user-drawer-deleteAccount-popover-username = Username +moderate-user-drawer-deleteAccount-popover-header-description = Delete account will +moderate-user-drawer-deleteAccount-popover-description-list-removeComments = Remove all comments written by this user from the database. +moderate-user-drawer-deleteAccount-popover-description-list-deleteAll = Delete all record of this account. The + user could then create a new account using the same email address. If you want to Ban this user instead and + retain their history, press "CANCEL" and use the Status dropdown below the username. +moderate-user-drawer-deleteAccount-popover-callout = This removes all records of this user +moderate-user-drawer-deleteAccount-popover-timeframe = This will go into effect in 24 hours. +moderate-user-drawer-deleteAccount-popover-cancelButton = Cancel +moderate-user-drawer-deleteAccount-popover-deleteButton = Delete + +moderate-user-drawer-deleteAccount-scheduled-callout = User deletion activated +moderate-user-drawer-deleteAccount-scheduled-timeframe = This will occur at { $deletionDate }. +moderate-user-drawer-deleteAccount-scheduled-cancelDeletion = Cancel user deletion + +moderate-user-drawer-user-scheduled-deletion = User scheduled for deletion +moderate-user-drawer-user-deletion-canceled = User deletion request canceled + +moderate-user-drawer-account-history-deletion-scheduled = Deletion scheduled for { $createdAt } +moderate-user-drawer-account-history-canceled-at = Canceled at { $createdAt } +moderate-user-drawer-account-history-updated-at = Updated at { $createdAt } moderate-user-drawer-recent-history-title = Recent comment history moderate-user-drawer-recent-history-calculated = diff --git a/server/src/core/server/graph/mutators/Users.ts b/server/src/core/server/graph/mutators/Users.ts index 141b938909..59cdf6771f 100644 --- a/server/src/core/server/graph/mutators/Users.ts +++ b/server/src/core/server/graph/mutators/Users.ts @@ -8,6 +8,7 @@ import { addModeratorNote, ban, cancelAccountDeletion, + cancelScheduledAccountDeletion, createToken, deactivateToken, demoteMember, @@ -25,6 +26,7 @@ import { requestAccountDeletion, requestCommentsDownload, requestUserCommentsDownload, + scheduleAccountDeletion, sendModMessage, setEmail, setPassword, @@ -52,6 +54,7 @@ import { deleteUser } from "coral-server/services/users/delete"; import { GQLBanUserInput, GQLCancelAccountDeletionInput, + GQLCancelScheduledAccountDeletionInput, GQLCreateModeratorNoteInput, GQLCreateTokenInput, GQLDeactivateTokenInput, @@ -72,6 +75,7 @@ import { GQLRequestAccountDeletionInput, GQLRequestCommentsDownloadInput, GQLRequestUserCommentsDownloadInput, + GQLScheduleAccountDeletionInput, GQLSendModMessageInput, GQLSetEmailInput, GQLSetPasswordInput, @@ -174,6 +178,17 @@ export const Users = (ctx: GraphContext) => ({ ), { "input.password": [ERROR_CODES.PASSWORD_INCORRECT] } ), + scheduleAccountDeletion: async ( + input: GQLScheduleAccountDeletionInput + ): Promise | null> => + scheduleAccountDeletion( + ctx.mongo, + ctx.mailerQueue, + ctx.tenant, + ctx.user!, + input.userID, + ctx.now + ), deleteAccount: async ( input: GQLDeleteUserAccountInput ): Promise | null> => { @@ -189,9 +204,20 @@ export const Users = (ctx: GraphContext) => ({ input.userID, ctx.tenant.id, ctx.now, - ctx.tenant.dsa?.enabled + ctx.tenant.dsa?.enabled, + ctx.user!.id ); }, + cancelScheduledAccountDeletion: async ( + input: GQLCancelScheduledAccountDeletionInput + ): Promise | null> => + cancelScheduledAccountDeletion( + ctx.mongo, + ctx.mailerQueue, + ctx.tenant, + ctx.user!, + input.userID + ), cancelAccountDeletion: async ( input: GQLCancelAccountDeletionInput ): Promise | null> => diff --git a/server/src/core/server/graph/resolvers/Mutation.ts b/server/src/core/server/graph/resolvers/Mutation.ts index 26e23ffb2d..e5ac47184d 100644 --- a/server/src/core/server/graph/resolvers/Mutation.ts +++ b/server/src/core/server/graph/resolvers/Mutation.ts @@ -307,6 +307,14 @@ export const Mutation: Required> = { user: await ctx.mutators.Users.requestAccountDeletion(input), clientMutationId: input.clientMutationId, }), + scheduleAccountDeletion: async (source, { input }, ctx) => ({ + user: await ctx.mutators.Users.scheduleAccountDeletion(input), + clientMutationId: input.clientMutationId, + }), + cancelScheduledAccountDeletion: async (source, { input }, ctx) => ({ + user: await ctx.mutators.Users.cancelScheduledAccountDeletion(input), + clientMutationId: input.clientMutationId, + }), cancelAccountDeletion: async (source, { input }, ctx) => ({ user: await ctx.mutators.Users.cancelAccountDeletion(input), clientMutationId: input.clientMutationId, diff --git a/server/src/core/server/graph/resolvers/UserDeletionHistory.ts b/server/src/core/server/graph/resolvers/UserDeletionHistory.ts new file mode 100644 index 0000000000..c14d53cc94 --- /dev/null +++ b/server/src/core/server/graph/resolvers/UserDeletionHistory.ts @@ -0,0 +1,17 @@ +import * as user from "coral-server/models/user"; + +import { GQLUserDeletionHistoryTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export const UserDeletionHistory: Required< + GQLUserDeletionHistoryTypeResolver +> = { + createdBy: ({ createdBy }, input, ctx) => { + if (createdBy) { + return ctx.loaders.Users.user.load(createdBy); + } + + return null; + }, + createdAt: ({ createdAt }) => createdAt, + updateType: ({ updateType }) => updateType, +}; diff --git a/server/src/core/server/graph/resolvers/UserDeletionStatus.ts b/server/src/core/server/graph/resolvers/UserDeletionStatus.ts new file mode 100644 index 0000000000..ac90b84531 --- /dev/null +++ b/server/src/core/server/graph/resolvers/UserDeletionStatus.ts @@ -0,0 +1,14 @@ +import * as user from "coral-server/models/user"; + +import { GQLUserDeletionStatusTypeResolver } from "coral-server/graph/schema/__generated__/types"; + +export type UserDeletionStatusInput = user.ConsolidatedUserDeletionStatus & { + userID: string; +}; + +export const UserDeletionStatus: Required< + GQLUserDeletionStatusTypeResolver +> = { + history: ({ history, userID }) => + history.map((status) => ({ ...status, userID })), +}; diff --git a/server/src/core/server/graph/resolvers/UserStatus.ts b/server/src/core/server/graph/resolvers/UserStatus.ts index 85a8201492..8daeeac520 100644 --- a/server/src/core/server/graph/resolvers/UserStatus.ts +++ b/server/src/core/server/graph/resolvers/UserStatus.ts @@ -9,6 +9,7 @@ import { BanStatusInput } from "./BanStatus"; import { ModMessageStatusInput } from "./ModMessageStatus"; import { PremodStatusInput } from "./PremodStatus"; import { SuspensionStatusInput } from "./SuspensionStatus"; +import { UserDeletionStatusInput } from "./UserDeletionStatus"; import { UsernameStatusInput } from "./UsernameStatus"; import { WarningStatusInput } from "./WarningStatus"; @@ -57,6 +58,10 @@ export const UserStatus: Required> = ...user.consolidateUsernameStatus(username), userID, }), + deletion: ({ userID, deletion }): UserDeletionStatusInput => ({ + ...user.consolidateUserDeletionStatus(deletion), + userID, + }), ban: async ({ ban, userID }, args, ctx): Promise => ({ ...user.consolidateUserBanStatus(ban, ctx.site?.id), userID, diff --git a/server/src/core/server/graph/resolvers/index.ts b/server/src/core/server/graph/resolvers/index.ts index 5206a6d196..9e927b4f4a 100644 --- a/server/src/core/server/graph/resolvers/index.ts +++ b/server/src/core/server/graph/resolvers/index.ts @@ -78,6 +78,8 @@ import { SuspensionStatusHistory } from "./SuspensionStatusHistory"; import { Tag } from "./Tag"; import { TwitterMediaConfiguration } from "./TwitterMediaConfiguration"; import { User } from "./User"; +import { UserDeletionHistory } from "./UserDeletionHistory"; +import { UserDeletionStatus } from "./UserDeletionStatus"; import { UserMediaSettings } from "./UserMediaSettings"; import { UserMembershipScopes } from "./UserMembershipScopes"; import { UserModerationScopes } from "./UserModerationScopes"; @@ -160,6 +162,8 @@ const Resolvers: GQLResolver = { Time, TwitterMediaConfiguration, User, + UserDeletionHistory, + UserDeletionStatus, UserMediaSettings, UserMembershipScopes, UserModerationScopes, diff --git a/server/src/core/server/graph/schema/schema.graphql b/server/src/core/server/graph/schema/schema.graphql index 3b9129a7cb..7b42ef6e73 100644 --- a/server/src/core/server/graph/schema/schema.graphql +++ b/server/src/core/server/graph/schema/schema.graphql @@ -2710,6 +2710,33 @@ type UsernameStatus { history: [UsernameHistory!]! } +enum UserDeletionUpdateType { + REQUESTED + CANCELED +} + +type UserDeletionHistory { + """ + updateType is the type of deletion status update that was made. For example, + user deletion was requested or canceled. + """ + updateType: UserDeletionUpdateType! + + """ + createdBy is the user that made the deletion status update. + """ + createdBy: User + + """ + createdAt is the time the user had a deletion status update. + """ + createdAt: Time! +} + +type UserDeletionStatus { + history: [UserDeletionHistory!]! @auth(roles: [ADMIN, MODERATOR]) +} + """ UserStatus stores the user status information regarding moderation state. """ @@ -2734,6 +2761,16 @@ type UserStatus { permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] ) + """ + deletion stores the history of deletion requests and cancellations for the user + """ + deletion: UserDeletionStatus! + @auth( + userIDField: "userID" + roles: [ADMIN, MODERATOR] + permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED] + ) + """ banned stores the user banned status as well as the history of changes. """ @@ -8297,6 +8334,62 @@ type RequestAccountDeletionPayload { clientMutationId: String! } +################## +# scheduleAccountDeletion +################## + +input ScheduleAccountDeletionInput { + """ + userID is the ID of the User being deleted. + """ + userID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type ScheduleAccountDeletionPayload { + """ + user is the User that was deleted. + """ + user: User + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +################## +# cancelScheduledAccountDeletion +################## + +input CancelScheduledAccountDeletionInput { + """ + userID is the ID of the User whose scheduled deletion is being canceled. + """ + userID: ID! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type CancelScheduledAccountDeletionPayload { + """ + user is the User whose scheduled deletion was canceled. + """ + user: User + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## # deleteUserAccount ################## @@ -10278,6 +10371,22 @@ type Mutation { @auth(permit: [SUSPENDED, BANNED, WARNED]) @rate(seconds: 10) + """ + scheduleAccountDeletion allows an admin to schedule an account deletion + for a target user + """ + scheduleAccountDeletion( + input: ScheduleAccountDeletionInput! + ): ScheduleAccountDeletionPayload! @auth(roles: [ADMIN]) + + """ + cancelScheduledAccountDeletion allows an admin to cancel a scheduled account deletion + for a target user + """ + cancelScheduledAccountDeletion( + input: CancelScheduledAccountDeletionInput! + ): CancelScheduledAccountDeletionPayload! @auth(roles: [ADMIN]) + """ deleteUserAccount will delete the target user now. """ diff --git a/server/src/core/server/models/user/user.ts b/server/src/core/server/models/user/user.ts index 519dc6478b..ade988ef19 100644 --- a/server/src/core/server/models/user/user.ts +++ b/server/src/core/server/models/user/user.ts @@ -39,6 +39,8 @@ import { GQLSuspensionStatus, GQLTimeRange, GQLUSER_ROLE, + GQLUserDeletionStatus, + GQLUserDeletionUpdateType, GQLUserMediaSettings, GQLUsernameStatus, GQLUserNotificationSettings, @@ -273,6 +275,37 @@ export interface UsernameStatus { history: UsernameHistory[]; } +export interface UserDeletionHistory { + /** + * id is a specific reference for a particular user deletion history that will be + * used internally to update user deletion records. + */ + id: string; + + /** + * updateType is the kind of update to a user's deletion status that was made, + * whether it was requested or canceled. + */ + updateType: GQLUserDeletionUpdateType; + + /** + * createdBy is the user that made this deletion status update + */ + createdBy: string; + + /** + * createdAt is when the deletion status update was made + */ + createdAt: string; +} + +export interface UserDeletionStatus { + /** + * history is the list of all user deletion status updates for this user + */ + history: UserDeletionHistory[]; +} + /** * PremodStatusHistory is the history of premod status changes * against a specific User. @@ -406,6 +439,11 @@ export interface UserStatus { * a history of moderation messages */ modMessage?: ModMessageStatus; + + /** + * deletion stores the history of deletion status updates for a user. + */ + deletion: UserDeletionStatus; } /** @@ -675,6 +713,9 @@ export async function findOrCreateUserInput( premod: { active: false, history: [] }, warning: { active: false, history: [] }, modMessage: { active: false, history: [] }, + deletion: { + history: [], + }, }, notifications: { onReply: false, @@ -1158,9 +1199,18 @@ export async function updateUserPassword( export async function scheduleDeletionDate( mongo: MongoContext, tenantID: string, + requestingUserID: string, userID: string, - deletionDate: Date + deletionDate: Date, + now = new Date() ) { + const scheduleDeletionHistory = { + id: uuid(), + createdBy: requestingUserID, + createdAt: now, + updateType: GQLUserDeletionUpdateType.REQUESTED, + }; + const result = await mongo.users().findOneAndUpdate( { id: userID, @@ -1170,6 +1220,9 @@ export async function scheduleDeletionDate( $set: { scheduledDeletionDate: deletionDate, }, + $push: { + "status.deletion.history": scheduleDeletionHistory, + }, }, { returnOriginal: false, @@ -1185,8 +1238,16 @@ export async function scheduleDeletionDate( export async function clearDeletionDate( mongo: MongoContext, tenantID: string, - userID: string + userID: string, + requestingUserID: string, + now = new Date() ) { + const cancelDeletionHistory = { + id: uuid(), + createdBy: requestingUserID, + createdAt: now, + updateType: GQLUserDeletionUpdateType.CANCELED, + }; const result = await mongo.users().findOneAndUpdate( { id: userID, @@ -1196,6 +1257,9 @@ export async function clearDeletionDate( $unset: { scheduledDeletionDate: "", }, + $push: { + "status.deletion.history": cancelDeletionHistory, + }, }, { // We want to return edited user so that @@ -2643,6 +2707,12 @@ export type ConsolidatedBanStatus = Omit & export type ConsolidatedUsernameStatus = Omit & Pick; +export type ConsolidatedUserDeletionStatus = Omit< + GQLUserDeletionStatus, + "history" +> & + Pick; + export type ConsolidatedPremodStatus = Omit & Pick; @@ -2661,6 +2731,17 @@ export function consolidateUsernameStatus( return username; } +export function consolidateUserDeletionStatus( + deletion: User["status"]["deletion"] +) { + if (!deletion) { + return { + history: [], + }; + } + return deletion; +} + const computeBanActive = (ban: BanStatus, siteID?: string) => { if (ban.active) { return true; diff --git a/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html b/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html index 99b4f900d5..d548f04f76 100644 --- a/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html +++ b/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-cancel.html @@ -1,5 +1,5 @@ {% extends "layouts/account-notification.html" %} {% block content %} - You have cancelled your account deletion request for {{ context.organizationName }}. Your account is now reactivated. + The account deletion request for {{ context.organizationName }} has been cancelled. Your account is now reactivated. {% endblock %} diff --git a/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html b/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html index ea86a3fce2..6e089f6165 100644 --- a/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html +++ b/server/src/core/server/queue/tasks/mailer/templates/account-notification/delete-request-confirmation.html @@ -1,9 +1,9 @@ {% extends "layouts/account-notification.html" %} {% block content %} - A request to delete your commenter account was received. Your account is scheduled for deletion on {{ context.requestDate }}. + We have received a request to delete your commenter account. 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. + If you would like to cancel the request, you can sign into your account and cancel the request before your scheduled account deletion time. {% endblock %} diff --git a/server/src/core/server/services/users/delete.ts b/server/src/core/server/services/users/delete.ts index 0284eeaf2c..822317ffa7 100644 --- a/server/src/core/server/services/users/delete.ts +++ b/server/src/core/server/services/users/delete.ts @@ -365,7 +365,8 @@ export async function deleteUser( userID: string, tenantID: string, now: Date, - dsaEnabled: boolean + dsaEnabled: boolean, + requestingUser: string | null = null ) { const user = await mongo.users().findOne({ id: userID, tenantID }); if (!user) { diff --git a/server/src/core/server/services/users/users.ts b/server/src/core/server/services/users/users.ts index a14c69524f..81cc078c6b 100644 --- a/server/src/core/server/services/users/users.ts +++ b/server/src/core/server/services/users/users.ts @@ -567,6 +567,7 @@ export async function requestAccountDeletion( mongo, tenant.id, user.id, + user.id, deletionDate.toJSDate() ); @@ -590,6 +591,82 @@ export async function requestAccountDeletion( return updatedUser; } +export async function scheduleAccountDeletion( + mongo: MongoContext, + mailer: MailerQueue, + tenant: Tenant, + requestingUser: User, + userID: string, + now: Date +) { + const deletionDate = DateTime.fromJSDate(now).plus({ + seconds: SCHEDULED_DELETION_WINDOW_DURATION, + }); + + const updatedUser = await scheduleDeletionDate( + mongo, + tenant.id, + requestingUser.id, + userID, + deletionDate.toJSDate(), + now + ); + + const formattedDate = formatDate(deletionDate.toJSDate(), tenant.locale); + + if (updatedUser.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: updatedUser.email, + }, + template: { + name: "account-notification/delete-request-confirmation", + context: { + requestDate: formattedDate, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + } + + return updatedUser; +} + +export async function cancelScheduledAccountDeletion( + mongo: MongoContext, + mailer: MailerQueue, + tenant: Tenant, + requestingUser: User, + userID: string +) { + const updatedUser = await clearDeletionDate( + mongo, + tenant.id, + userID, + requestingUser.id + ); + + if (updatedUser.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: updatedUser.email, + }, + template: { + name: "account-notification/delete-request-cancel", + context: { + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + } + + return updatedUser; +} + export async function cancelAccountDeletion( mongo: MongoContext, mailer: MailerQueue, @@ -600,7 +677,12 @@ export async function cancelAccountDeletion( throw new EmailNotSetError(); } - const updatedUser = await clearDeletionDate(mongo, tenant.id, user.id); + const updatedUser = await clearDeletionDate( + mongo, + tenant.id, + user.id, + user.id + ); await mailer.add({ tenantID: tenant.id, diff --git a/server/src/core/server/test/fixtures.ts b/server/src/core/server/test/fixtures.ts index c47e3b54aa..7773df0de5 100644 --- a/server/src/core/server/test/fixtures.ts +++ b/server/src/core/server/test/fixtures.ts @@ -248,6 +248,9 @@ export const createUserFixture = (defaults: Defaults = {}): User => { siteIDs: [], history: [], }, + deletion: { + history: [], + }, username: { history: [ {