diff --git a/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx new file mode 100644 index 000000000..3479c0fa6 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/AccountHistoryAction.tsx @@ -0,0 +1,30 @@ +import React, { FunctionComponent } from "react"; + +import BanAction, { BanActionProps } from "./BanAction"; +import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction"; +import UsernameChangeAction, { + UsernameChangeActionProps, +} from "./UsernameChangeAction"; + +export interface HistoryActionProps { + kind: "username" | "suspension" | "ban"; + action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps; +} + +const AccountHistoryAction: FunctionComponent = ({ + kind, + action, +}) => { + switch (kind) { + case "username": + return ; + case "suspension": + return ; + case "ban": + return ; + default: + return null; + } +}; + +export default AccountHistoryAction; diff --git a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx index f53a382d5..93d7d1ba6 100644 --- a/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx +++ b/src/core/client/admin/components/UserHistoryDrawer/UserDrawerAccountHistory.tsx @@ -16,8 +16,9 @@ import { TableRow, } from "coral-ui/components"; -import BanAction, { BanActionProps } from "./BanAction"; -import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction"; +import AccountHistoryAction, { + HistoryActionProps, +} from "./AccountHistoryAction"; import styles from "./UserDrawerAccountHistory.css"; @@ -30,21 +31,10 @@ interface From { finish: any; } -interface SuspensionHistoryRecord { - kind: "suspension"; - action: SuspensionActionProps; +type HistoryRecord = HistoryActionProps & { date: Date; takenBy: React.ReactNode; -} - -interface BanHistoryRecord { - kind: "ban"; - action: BanActionProps; - date: Date; - takenBy: React.ReactNode; -} - -type HistoryRecord = SuspensionHistoryRecord | BanHistoryRecord; +}; const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { const system = ( @@ -117,6 +107,20 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { }); }); + user.status.username.history.forEach((record, i) => { + history.push({ + kind: "username", + action: { + username: record.username, + // grab username at previous index to show what username was changed from + prevUsername: + i >= 1 ? user.status.username.history[i - 1].username : null, + }, + date: new Date(record.createdAt), + takenBy: record.createdBy ? record.createdBy.username : system, + }); + }); + // Sort the history so that it's in the right order. return history.sort((a, b) => b.date.getTime() - a.date.getTime()); }, [user]); @@ -153,11 +157,7 @@ const UserDrawerAccountHistory: FunctionComponent = ({ user }) => { {formatter.format(history.date)} - {history.kind === "suspension" ? ( - - ) : ( - - )} + {history.takenBy} @@ -172,6 +172,15 @@ const enhanced = withFragmentContainer({ user: graphql` fragment UserDrawerAccountHistory_user on User { status { + username { + history { + username + createdAt + createdBy { + username + } + } + } ban { history { active diff --git a/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.css b/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.css new file mode 100644 index 000000000..d9018a722 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.css @@ -0,0 +1,7 @@ +.tableLight { + font-weight: var(--font-weight-regular); +} + +.usernameCell { + line-height: calc(18em / 14); +} \ No newline at end of file diff --git a/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.tsx b/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.tsx new file mode 100644 index 000000000..3a4b179e0 --- /dev/null +++ b/src/core/client/admin/components/UserHistoryDrawer/UsernameChangeAction.tsx @@ -0,0 +1,38 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import styles from "./UsernameChangeAction.css"; + +export interface UsernameChangeActionProps { + username: string; + prevUsername: string | null; +} + +const SuspensionAction: FunctionComponent = ({ + username, + prevUsername, +}) => { + return ( +
+ +
Username change
+
+
+ + New: + {" "} + {username} +
+ {prevUsername && ( +
+ + Old: + {" "} + {prevUsername} +
+ )} +
+ ); +}; + +export default SuspensionAction; diff --git a/src/core/client/framework/lib/i18n/index.ts b/src/core/client/framework/lib/i18n/index.ts index ccc154ea6..a5dab4f05 100644 --- a/src/core/client/framework/lib/i18n/index.ts +++ b/src/core/client/framework/lib/i18n/index.ts @@ -3,4 +3,8 @@ export { default as negotiateLanguages } from "./negotiateLanguages"; export { BundledLocales, LoadableLocales, LocalesData } from "./locales"; export { default as getMessage } from "./getMessage"; export { default as withGetMessage, GetMessage } from "./withGetMessage"; -export { default as reduceSeconds, UNIT, ScaledUnit } from "./reduceSeconds"; +export { + default as reduceSeconds, + UNIT, + ScaledUnit, +} from "../../../../common/helpers/i18n/reduceSeconds"; diff --git a/src/core/client/framework/lib/messages.tsx b/src/core/client/framework/lib/messages.tsx index c6ccaef2c..fc8ab9cb4 100644 --- a/src/core/client/framework/lib/messages.tsx +++ b/src/core/client/framework/lib/messages.tsx @@ -60,6 +60,12 @@ export const PASSWORDS_DO_NOT_MATCH = () => ( ); +export const USERNAMES_DO_NOT_MATCH = () => ( + + Usernames do not match. Try again. + +); + export const EMAILS_DO_NOT_MATCH = () => ( Emails do not match. Try again. diff --git a/src/core/client/framework/lib/validation.tsx b/src/core/client/framework/lib/validation.tsx index 1761e5cc9..744d69c77 100644 --- a/src/core/client/framework/lib/validation.tsx +++ b/src/core/client/framework/lib/validation.tsx @@ -22,6 +22,7 @@ import { PASSWORDS_DO_NOT_MATCH, USERNAME_TOO_LONG, USERNAME_TOO_SHORT, + USERNAMES_DO_NOT_MATCH, VALIDATION_REQUIRED, VALIDATION_TOO_LONG, VALIDATION_TOO_SHORT, @@ -158,6 +159,14 @@ export const validateEqualEmails = createValidator( EMAILS_DO_NOT_MATCH() ); +/** + * validateUsernameEquals is a Validator that checks for correct username confirmation. + */ +export const validateUsernameEquals = createValidator( + (v, values) => v === values.username, + USERNAMES_DO_NOT_MATCH() +); + /** * validateWholeNumber is a Validator that checks for a valid whole number. */ diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.css b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.css new file mode 100644 index 000000000..dd840b28d --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.css @@ -0,0 +1,35 @@ +.callOut { + max-width: 500px; +} + +.footer { + margin-top: var(--spacing-4); +} + +.errorIcon { + padding-right: var(--spacing-2); + padding-top: var(--spacing-1); +} + +.tooSoon { + margin-left: var(--spacing-2); +} + +.successMessage { + background-color: var(--palette-primary-lightest); + border-color: var(--palette-primary-light); + color: var(--palette-text-primary); + padding: var(--spacing-2); + box-sizing: border-box; + border-width: 1px; + border-style: solid; +} + +.closeButton { + float: none; + position: static; +} + +.currentUsername { + color: var(--palette-grey-dark); +} \ No newline at end of file diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx new file mode 100644 index 000000000..e3d4667f4 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/ChangeUsernameContainer.tsx @@ -0,0 +1,343 @@ +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { FORM_ERROR, FormApi } from "final-form"; +import { Localized } from "fluent-react/compat"; +import React, { + FunctionComponent, + useCallback, + useMemo, + useState, +} from "react"; +import { Field, Form } from "react-final-form"; + +import { ALLOWED_USERNAME_CHANGE_FREQUENCY } from "coral-common/constants"; +import reduceSeconds, { UNIT } from "coral-common/helpers/i18n/reduceSeconds"; +import { InvalidRequestError } from "coral-framework/lib/errors"; +import { ValidationMessage } from "coral-framework/lib/form"; +import { + graphql, + useMutation, + withFragmentContainer, +} from "coral-framework/lib/relay"; +import { + composeValidators, + required, + validateUsername, + validateUsernameEquals, +} from "coral-framework/lib/validation"; +import { ChangeUsernameContainer_viewer as ViewerData } from "coral-stream/__generated__/ChangeUsernameContainer_viewer.graphql"; +import { + Box, + Button, + ButtonIcon, + CallOut, + CardCloseButton, + Flex, + FormField, + HorizontalGutter, + Icon, + InputLabel, + TextField, + Typography, +} from "coral-ui/components"; + +import UpdateUsernameMutation from "./UpdateUsernameMutation"; + +import styles from "./ChangeUsernameContainer.css"; + +const FREQUENCYSCALED = reduceSeconds(ALLOWED_USERNAME_CHANGE_FREQUENCY, [ + UNIT.DAYS, +]); + +interface Props { + viewer: ViewerData; +} + +interface FormProps { + username: string; + usernameConfirm: string; +} + +const ChangeUsernameContainer: FunctionComponent = ({ viewer }) => { + const [showEditForm, setShowEditForm] = useState(false); + const [showSuccessMessage, setShowSuccessMessage] = useState(false); + const toggleEditForm = useCallback(() => { + setShowEditForm(!showEditForm); + }, [setShowEditForm, showEditForm]); + const updateUsername = useMutation(UpdateUsernameMutation); + + const closeSuccessMessage = useCallback(() => setShowSuccessMessage(false), [ + setShowEditForm, + ]); + + const canChangeUsername = useMemo(() => { + const { username } = viewer.status; + if (username && username.history.length > 1) { + const lastUsernameEditAllowed = new Date(); + lastUsernameEditAllowed.setSeconds( + lastUsernameEditAllowed.getSeconds() - ALLOWED_USERNAME_CHANGE_FREQUENCY + ); + const lastUsernameEdit = + username.history[username.history.length - 1].createdAt; + return lastUsernameEdit > lastUsernameEditAllowed; + } + return true; + }, [viewer]); + + const canChangeUsernameDate = useMemo(() => { + const { username } = viewer.status; + if (username && username.history.length > 1) { + const date = new Date( + username.history[username.history.length - 1].createdAt + ); + date.setSeconds(date.getSeconds() + ALLOWED_USERNAME_CHANGE_FREQUENCY); + return date; + } + return null; + }, [viewer]); + + const onSubmit = useCallback( + async (input: FormProps, form: FormApi) => { + try { + await updateUsername({ + username: input.username, + }); + } catch (err) { + if (err instanceof InvalidRequestError) { + return err.invalidArgs; + } + + return { + [FORM_ERROR]: err.message, + }; + } + + form.reset(); + setShowEditForm(false); + setShowSuccessMessage(true); + + return; + }, + [updateUsername] + ); + + const { locales } = useCoralContext(); + + const formatter = new Intl.DateTimeFormat(locales, { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + + return ( + + {showSuccessMessage && ( + + + + + Your username has been successfully updated + + + + + + )} + {!showEditForm && ( + + {viewer.username} + + + + + )} + {showEditForm && ( + + +
+ + + Edit your username + + + + + Change the username that will appear on all of your past and + future comments. Usernames can be changed once every{" "} + {FREQUENCYSCALED.scaled} {FREQUENCYSCALED.unit} + + +
+
+ + + Current username + + + {viewer.username} +
+ {canChangeUsername && ( +
+ {({ handleSubmit, submitError, pristine, invalid }) => ( + + + + + + + New username + + + + {({ input, meta }) => ( + <> + + + + )} + + + + + + + + Confirm new username + + + + {({ input, meta }) => ( + <> + + + + )} + + + + {submitError && ( + + {submitError} + + )} + + + + + + + + + +
+ )} + + )} + {!canChangeUsername && ( +
+ + + error + + + + Your username has been changed in the last{" "} + {FREQUENCYSCALED.scaled} {FREQUENCYSCALED.unit}. You may + change your username again on{" "} + {canChangeUsernameDate + ? formatter.format(canChangeUsernameDate) + : null} + + + + + + + + +
+ )} +
+
+ )} +
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment ChangeUsernameContainer_viewer on User { + username + status { + username { + history { + username + createdAt + } + } + } + } + `, +})(ChangeUsernameContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx b/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx new file mode 100644 index 000000000..3538b1241 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/UpdateUsernameMutation.tsx @@ -0,0 +1,63 @@ +import { graphql } from "react-relay"; +import { Environment } from "relay-runtime"; + +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; +import { UpdateUsernameMutation as MutationTypes } from "coral-stream/__generated__/UpdateUsernameMutation.graphql"; + +let clientMutationId = 0; + +const UpdateUsernameMutation = createMutation( + "updateUsername", + (environment: Environment, input: MutationInput) => + commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation UpdateUsernameMutation($input: UpdateUsernameInput!) { + updateUsername(input: $input) { + clientMutationId + user { + username + status { + username { + history { + username + createdAt + } + } + } + } + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + optimisticResponse: { + updateUsername: { + clientMutationId: (clientMutationId++).toString(), + user: { + username: input.username, + status: { + username: { + // FIXME: (tessalt) merge in existing history + history: [ + { + username: input.username, + createdAt: Date.now(), + }, + ], + }, + }, + }, + }, + }, + }) +); + +export default UpdateUsernameMutation; diff --git a/src/core/client/stream/tabs/Profile/ChangeUsername/index.ts b/src/core/client/stream/tabs/Profile/ChangeUsername/index.ts new file mode 100644 index 000000000..2952746d2 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/ChangeUsername/index.ts @@ -0,0 +1,4 @@ +export { + default, + default as ChangeUsernameContainer, +} from "./ChangeUsernameContainer"; diff --git a/src/core/client/stream/tabs/Profile/Profile.tsx b/src/core/client/stream/tabs/Profile/Profile.tsx index 8d041a302..6b4999e50 100644 --- a/src/core/client/stream/tabs/Profile/Profile.tsx +++ b/src/core/client/stream/tabs/Profile/Profile.tsx @@ -13,6 +13,7 @@ import { } from "coral-ui/components"; import { Localized } from "fluent-react/compat"; +import ChangeUsernameContainer from "./ChangeUsername"; import CommentHistoryContainer from "./CommentHistory"; import SettingsContainer from "./Settings"; @@ -20,7 +21,8 @@ export interface ProfileProps { story: PropTypesOf["story"]; viewer: PropTypesOf["viewer"] & PropTypesOf["viewer"] & - PropTypesOf["viewer"]; + PropTypesOf["viewer"] & + PropTypesOf["viewer"]; settings: PropTypesOf["settings"] & PropTypesOf["settings"]; } @@ -37,7 +39,7 @@ const Profile: FunctionComponent = props => { ); return ( - + ({ ...UserBoxContainer_viewer ...CommentHistoryContainer_viewer ...SettingsContainer_viewer + ...ChangeUsernameContainer_viewer } `, settings: graphql` diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index deca9bf4a..b2916ee41 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -115,6 +115,9 @@ export const baseUser = createFixture({ createdAt: "2018-02-06T18:24:00.000Z", status: { current: [GQLUSER_STATUS.ACTIVE], + username: { + history: [], + }, suspension: { active: false, }, @@ -129,6 +132,58 @@ export const baseUser = createFixture({ ignoreable: true, }); +const yesterday = new Date(); +yesterday.setDate(yesterday.getDate() - 1); +const weekago = new Date(); +weekago.setDate(yesterday.getDate() - 7); + +export const userWithNewUsername = createFixture( + { + id: "new-user", + username: "u_original", + role: GQLUSER_ROLE.COMMENTER, + status: { + current: [GQLUSER_STATUS.ACTIVE], + username: { + history: [ + { + username: "u_original", + createdAt: `${yesterday.toISOString()}`, + createdBy: { id: "new-user" }, + }, + ], + }, + }, + }, + baseUser +); + +export const userWithChangedUsername = createFixture( + { + id: "changed-user", + username: "u_changed", + role: GQLUSER_ROLE.COMMENTER, + status: { + current: [GQLUSER_STATUS.ACTIVE], + username: { + history: [ + { + username: "original", + createdAt: weekago.toISOString(), + createdBy: { id: "changed-user" }, + }, + { + username: "u_changed", + createdAt: yesterday.toISOString(), + createdBy: { id: "changed-user" }, + }, + ], + }, + }, + }, + baseUser +); + export const commenters = createFixtures( [ { diff --git a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap index 095c9d045..fee8d5765 100644 --- a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -66,41 +66,29 @@ exports[`renders the empty settings pane 1`] = ` className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5" >
-
- Signed in as - - Passivo - - . -
-
+ -
+ Edit +
    = {} +) { + const { testRenderer, context } = create({ + ...params, + resolvers: pureMerge( + createResolversStub({ + Query: { + settings: () => settings, + viewer: () => viewer, + story: () => story, + }, + }), + params.resolvers + ), + initLocalState: (localRecord, source, environment) => { + localRecord.setValue("SETTINGS", "profileTab"); + if (params.initLocalState) { + params.initLocalState(localRecord, source, environment); + } + }, + }); + + return { + testRenderer, + context, + }; +} + +describe("with recently changed username", () => { + let testRenderer: ReactTestRenderer; + beforeEach(async () => { + const setup = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => userWithChangedUsername, + }, + }), + }); + testRenderer = setup.testRenderer; + }); + + it("does not allow editing", async () => { + const changeUsername = await waitForElement(() => + within(testRenderer.root).queryByTestID("profile-changeUsername") + ); + within(changeUsername).getByText("u_changed"); + const editButton = within(changeUsername).getByText("Edit"); + act(() => { + editButton.props.onClick(); + }); + const form = within(changeUsername).queryByType("form"); + const message = within(changeUsername).queryByText( + "Your username has been changed in the last 14 days", + { exact: false } + ); + expect(form).toBeNull(); + expect(message).toBeTruthy(); + }); +}); + +describe("with new username", () => { + let testRenderer: ReactTestRenderer; + beforeEach(async () => { + const setup = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => userWithNewUsername, + }, + }), + }); + testRenderer = setup.testRenderer; + }); + + it("shows username change form", async () => { + const changeUsername = await waitForElement(() => + within(testRenderer.root).queryByTestID("profile-changeUsername") + ); + + within(changeUsername).getByText("u_original"); + const editButton = within(changeUsername).getByText("Edit"); + act(() => { + editButton.props.onClick(); + }); + within(changeUsername).getByType("form"); + const message = within(changeUsername).queryByText( + "Your username has been changed in the last 14 days", + { exact: false } + ); + expect(message).toBeNull(); + }); +}); + +describe("change username form", () => { + let testRenderer: ReactTestRenderer; + beforeEach(async () => { + const setup = await createTestRenderer({ + resolvers: createResolversStub({ + Query: { + viewer: () => userWithNewUsername, + }, + Mutation: { + updateUsername: ({ variables }) => { + expectAndFail(variables).toMatchObject({ + username: "updated_username", + }); + return { + user: { + ...userWithNewUsername, + username: "updated_username", + }, + }; + }, + }, + }), + }); + testRenderer = setup.testRenderer; + }); + + it("ensures username field is required", async () => { + const changeUsername = within(testRenderer.root).getByTestID( + "profile-changeUsername" + ); + const editButton = within(changeUsername).getByText("Edit"); + act(() => { + editButton.props.onClick(); + }); + const form = within(changeUsername).getByType("form"); + act(() => { + form.props.onSubmit(); + }); + within(changeUsername).getAllByText("This field is required", { + exact: false, + }); + const button = within(changeUsername).getByText("Save"); + expect(button.props.disabled).toBeTruthy(); + }); + + it("ensures username confirmation matches", async () => { + const changeUsername = within(testRenderer.root).getByTestID( + "profile-changeUsername" + ); + const editButton = within(changeUsername).getByText("Edit"); + act(() => { + editButton.props.onClick(); + }); + const form = within(changeUsername).getByType("form"); + const username = within(changeUsername).getByLabelText("New username"); + const usernameConfirm = within(changeUsername).getByLabelText( + "Confirm new username" + ); + act(() => { + username.props.onChange("testusername"); + usernameConfirm.props.onChange("test"); + form.props.onSubmit(); + }); + within(changeUsername).getByText("Usernames do not match. Try again.", { + exact: false, + }); + const button = within(changeUsername).getByText("Save"); + expect(button.props.disabled).toBeTruthy(); + }); + + it("updates username if fields are valid", async () => { + const changeUsername = within(testRenderer.root).getByTestID( + "profile-changeUsername" + ); + const editButton = within(changeUsername).getByText("Edit"); + act(() => { + editButton.props.onClick(); + }); + const form = within(changeUsername).getByType("form"); + const username = within(changeUsername).getByLabelText("New username"); + const usernameConfirm = within(changeUsername).getByLabelText( + "Confirm new username" + ); + await act(async () => { + username.props.onChange("updated_username"); + usernameConfirm.props.onChange("updated_username"); + await form.props.onSubmit(); + }); + + within(changeUsername).getByText( + "Your username has been successfully updated" + ); + }); +}); diff --git a/src/core/client/ui/components/Card/CloseButton.css b/src/core/client/ui/components/Card/CloseButton.css index 8b1b081ff..f38be18dc 100644 --- a/src/core/client/ui/components/Card/CloseButton.css +++ b/src/core/client/ui/components/Card/CloseButton.css @@ -10,4 +10,4 @@ .icon { display: block; -} +} \ No newline at end of file diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index 00ba17de1..30b066fa3 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -40,3 +40,8 @@ export const TOXICITY_ENDPOINT_DEFAULT = * be made within. */ 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; diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts index b6523424c..60aa6b69f 100644 --- a/src/core/common/errors.ts +++ b/src/core/common/errors.ts @@ -124,6 +124,12 @@ export enum ERROR_CODES { */ USERNAME_EXCEEDS_MAX_LENGTH = "USERNAME_EXCEEDS_MAX_LENGTH", + /** + * USERNAME_UPDATED_WITHIN_WINDOW is returned when the user attempts to associate + * a new username when they have previously changed their username within ALLOWED_USERNAME_CHANGE_FREQUENCY + */ + USERNAME_UPDATED_WITHIN_WINDOW = "USERNAME_UPDATED_WITHIN_WINDOW", + /** * USERNAME_TOO_SHORT is returned when the user attempts to associate a new * username that is too short. diff --git a/src/core/client/framework/lib/i18n/reduceSeconds.ts b/src/core/common/helpers/i18n/reduceSeconds.ts similarity index 100% rename from src/core/client/framework/lib/i18n/reduceSeconds.ts rename to src/core/common/helpers/i18n/reduceSeconds.ts diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 0e626d7e6..8007eb2ad 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -4,7 +4,9 @@ import { FluentBundle } from "fluent/compat"; import uuid from "uuid"; import { VError } from "verror"; +import { ALLOWED_USERNAME_CHANGE_FREQUENCY } from "coral-common/constants"; import { ERROR_CODES, ERROR_TYPES } from "coral-common/errors"; +import reduceSeconds, { UNIT } from "coral-common/helpers/i18n/reduceSeconds"; import { translate } from "coral-server/services/i18n"; import { Writeable } from "coral-common/types"; @@ -291,6 +293,24 @@ export class UsernameAlreadySetError extends CoralError { } } +export class UsernameUpdatedWithinWindowError extends CoralError { + constructor(lastUpdate: Date) { + const { scaled, unit } = reduceSeconds(ALLOWED_USERNAME_CHANGE_FREQUENCY, [ + UNIT.DAYS, + ]); + super({ + code: ERROR_CODES.USERNAME_UPDATED_WITHIN_WINDOW, + context: { + pub: { + lastUpdate, + unit, + value: scaled, + }, + }, + }); + } +} + export class EmailAlreadySetError extends CoralError { constructor() { super({ code: ERROR_CODES.EMAIL_ALREADY_SET }); diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index 5b4318371..2780d36ec 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -50,4 +50,5 @@ export const ERROR_TRANSLATIONS: Record = { INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses", LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled", PASSWORD_INCORRECT: "error-passwordIncorrect", + USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated", }; diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 835955919..4cff2f07f 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -20,6 +20,7 @@ import { updatePassword, updateRole, updateUsername, + updateUsernameByID, } from "coral-server/services/users"; import { invite } from "coral-server/services/users/auth/invite"; @@ -40,6 +41,7 @@ import { GQLUpdatePasswordInput, GQLUpdateUserAvatarInput, GQLUpdateUserEmailInput, + GQLUpdateUsernameInput, GQLUpdateUserRoleInput, GQLUpdateUserUsernameInput, } from "../schema/__generated__/types"; @@ -120,8 +122,22 @@ export const Users = (ctx: TenantContext) => ({ ), deactivateToken: async (input: GQLDeactivateTokenInput) => deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id), + updateUsername: async (input: GQLUpdateUsernameInput) => + updateUsername( + ctx.mongo, + ctx.mailerQueue, + ctx.tenant, + ctx.user!, + input.username + ), updateUserUsername: async (input: GQLUpdateUserUsernameInput) => - updateUsername(ctx.mongo, ctx.tenant, input.userID, input.username), + updateUsernameByID( + ctx.mongo, + ctx.tenant, + input.userID, + input.username, + ctx.user! + ), updateUserEmail: async (input: GQLUpdateUserEmailInput) => updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email), updateUserAvatar: async (input: GQLUpdateUserAvatarInput) => diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 2126cdea5..b01dba0b5 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -137,6 +137,10 @@ export const Mutation: Required> = { ...(await ctx.mutators.Users.deactivateToken(input)), clientMutationId: input.clientMutationId, }), + updateUsername: async (source, { input }, ctx) => ({ + user: await ctx.mutators.Users.updateUsername(input), + clientMutationId: input.clientMutationId, + }), updateUserUsername: async (source, { input }, ctx) => ({ user: await ctx.mutators.Users.updateUserUsername(input), clientMutationId: input.clientMutationId, diff --git a/src/core/server/graph/tenant/resolvers/UserStatus.ts b/src/core/server/graph/tenant/resolvers/UserStatus.ts index 61d824f07..f72a76abd 100644 --- a/src/core/server/graph/tenant/resolvers/UserStatus.ts +++ b/src/core/server/graph/tenant/resolvers/UserStatus.ts @@ -6,6 +6,7 @@ import * as user from "coral-server/models/user"; import { BanStatusInput } from "./BanStatus"; import { SuspensionStatusInput } from "./SuspensionStatus"; +import { UsernameStatusInput } from "./UsernameStatus"; export type UserStatusInput = user.UserStatus & { userID: string; @@ -35,6 +36,10 @@ export const UserStatus: Required< return statuses; }, + username: ({ userID, username }): UsernameStatusInput => ({ + ...user.consolidateUsernameStatus(username), + userID, + }), ban: ({ ban, userID }): BanStatusInput => ({ ...user.consolidateUserBanStatus(ban), userID, diff --git a/src/core/server/graph/tenant/resolvers/UsernameHistory.ts b/src/core/server/graph/tenant/resolvers/UsernameHistory.ts new file mode 100644 index 000000000..46622531d --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/UsernameHistory.ts @@ -0,0 +1,16 @@ +import { GQLUsernameHistoryTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as user from "coral-server/models/user"; + +export const UsernameHistory: Required< + GQLUsernameHistoryTypeResolver +> = { + createdBy: ({ createdBy }, input, ctx) => { + if (createdBy) { + return ctx.loaders.Users.user.load(createdBy); + } + + return null; + }, + createdAt: ({ createdAt }) => createdAt, + username: ({ username }) => username, +}; diff --git a/src/core/server/graph/tenant/resolvers/UsernameStatus.ts b/src/core/server/graph/tenant/resolvers/UsernameStatus.ts new file mode 100644 index 000000000..e5150b2a5 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/UsernameStatus.ts @@ -0,0 +1,13 @@ +import { GQLUsernameStatusTypeResolver } from "coral-server/graph/tenant/schema/__generated__/types"; +import * as user from "coral-server/models/user"; + +export type UsernameStatusInput = user.ConsolidatedUsernameStatus & { + userID: string; +}; + +export const UsernameStatus: Required< + GQLUsernameStatusTypeResolver +> = { + history: ({ history, userID }) => + history.map(status => ({ ...status, userID })), +}; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index 3d398e200..d6d47de1c 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -38,6 +38,8 @@ import { SuspensionStatus } from "./SuspensionStatus"; import { SuspensionStatusHistory } from "./SuspensionStatusHistory"; import { Tag } from "./Tag"; import { User } from "./User"; +import { UsernameHistory } from "./UsernameHistory"; +import { UsernameStatus } from "./UsernameStatus"; import { UserStatus } from "./UserStatus"; const Resolvers: GQLResolver = { @@ -76,10 +78,12 @@ const Resolvers: GQLResolver = { Subscription, SuspensionStatus, SuspensionStatusHistory, + UsernameHistory, Tag, Time, User, UserStatus, + UsernameStatus, }; export default Resolvers; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 2f93fa7dc..6e097acb9 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1420,6 +1420,40 @@ type SuspensionStatus { history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR]) } +type UsernameHistory { + """ + username is the username that was assigned + """ + username: String! + @auth( + roles: [ADMIN, MODERATOR] + userIDField: "userID" + permit: [SUSPENDED, BANNED] + ) + + """ + createdBy is the user that created this username + """ + createdBy: User! @auth(roles: [ADMIN, MODERATOR]) + + """ + createdAt is the time the username was created + """ + createdAt: Time! + @auth( + roles: [ADMIN, MODERATOR] + userIDField: "userID" + permit: [SUSPENDED, BANNED] + ) +} + +type UsernameStatus { + """ + history is the list of all usernames for this user + """ + history: [UsernameHistory!]! +} + """ UserStatus stores the user status information regarding moderation state. """ @@ -1434,6 +1468,11 @@ type UserStatus { permit: [SUSPENDED, BANNED] ) + """ + username stores the history of username changes for this user + """ + username: UsernameStatus! + """ banned stores the user banned status as well as the history of changes. """ @@ -3894,6 +3933,35 @@ type SetUsernamePayload { """ clientMutationId: String! } + +################## +# updateUsername +################## + +input UpdateUsernameInput { + """ + username is the desired username that should be set to the current User. + """ + username: String! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type UpdateUsernamePayload { + """ + user is the possibly modified User. + """ + user: User! + + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## # inviteUser ################## @@ -4588,6 +4656,13 @@ type Mutation { setUsername(input: SetUsernameInput!): SetUsernamePayload! @auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]) + """ + 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]) + """ 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. diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index 570465e27..bb7b9db24 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -24,6 +24,13 @@ email-notification-template-passwordChange = email-subject-passwordChange = Your password has been changed +email-subject-updateUsername = Your username has been changed + +email-notification-template-updateUsername = + Hello { $username },

    + Thank you for updating your { $organizationName } commenter account information. The changes you made are effective immediately.

    + If you did not make this change please reach out to our community team at { $organizationContactEmail }. + email-notification-template-suspend = { $customMessage }

    If you think this has been done in error, please contact our community team diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index 94ab279a8..ec27f26c0 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -54,3 +54,4 @@ error-rateLimitExceeded = Rate limit exceeded. error-inviteTokenExpired = Invite link has expired. error-inviteRequiresEmailAddresses = Please add an email address to send invitations. error-passwordIncorrect = Password provided was incorrect. +error-usernameAlreadyUpdated = You may only change your username once every { framework-timeago-time }. \ No newline at end of file diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 7e7ad1979..bdcff08df 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -22,6 +22,7 @@ import { GQLSuspensionStatus, GQLTimeRange, GQLUSER_ROLE, + GQLUsernameStatus, } from "coral-server/graph/tenant/schema/__generated__/types"; import logger from "coral-server/logger"; import { @@ -201,6 +202,36 @@ export interface BanStatus { history: BanStatusHistory[]; } +export interface UsernameHistory { + /** + * id is a specific reference for a particular username history that will be + * used internally to update username records. + */ + id: string; + + /** + * username is the username that was assigned + */ + username: string; + + /** + * createdBy is the user that created this username + */ + createdBy: string; + + /** + * createdAt is the time the username was created + */ + createdAt: Date; +} + +export interface UsernameStatus { + /** + * history is the list of all usernames for this user + */ + history: UsernameHistory[]; +} + /** * UserStatus stores the user status information regarding moderation state. */ @@ -215,6 +246,11 @@ export interface UserStatus { * ban stores the user ban status as well as the history of changes. */ ban: BanStatus; + + /** + * username stores the history of username changes for this user. + */ + username: UsernameStatus; } /** @@ -407,12 +443,24 @@ export async function insertUser( tokens: [], ignoredUsers: [], status: { + username: { + history: [], + }, suspension: { history: [] }, ban: { active: false, history: [] }, }, createdAt: now, }; + if (input.username) { + defaults.status.username.history.push({ + id: uuid.v4(), + username: input.username, + createdBy: id, + createdAt: now, + }); + } + // Guard against empty login profiles (they need some way to login). if (input.profiles.length === 0) { throw new Error("users require at least one profile"); @@ -719,31 +767,39 @@ export async function setUserUsername( } /** - * updateUserUsername will set the username of the User. + * updateUsername will set the username of the User. * * @param mongo the database handle * @param tenantID the ID to the Tenant * @param id the ID of the User where we are setting the username on * @param username the username that we want to set + * @param createdBy the user making the change */ + export async function updateUserUsername( mongo: Db, tenantID: string, id: string, - username: string + username: string, + createdBy: string, + now = new Date() ) { - // TODO: (wyattjoh) investigate adding the username previously used to an array. + const usernameHistory: UsernameHistory = { + id: uuid(), + username, + createdBy, + createdAt: now, + }; - // The username wasn't found, so add it to the user. const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - }, + { id, tenantID }, { $set: { username, }, + $push: { + "status.username.history": usernameHistory, + }, }, { // False to return the updated document instead of the original @@ -751,8 +807,8 @@ export async function updateUserUsername( returnOriginal: false, } ); + if (!result.value) { - // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); @@ -1421,6 +1477,15 @@ export async function removeActiveUserSuspensions( export type ConsolidatedBanStatus = Omit & Pick; +export type ConsolidatedUsernameStatus = Omit & + Pick; + +export function consolidateUsernameStatus( + username: User["status"]["username"] +): ConsolidatedUsernameStatus { + return username; +} + export function consolidateUserBanStatus( ban: User["status"]["ban"] ): ConsolidatedBanStatus { diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index f1c57a339..73075fe6b 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -71,6 +71,14 @@ export type DownloadCommentsTemplate = UserNotificationContext< } >; +export type UpdateUsernameTemplate = UserNotificationContext< + "update-username", + { + username: string; + organizationContactEmail: string; + } +>; + type Templates = | BanTemplate | ConfirmEmailTemplate @@ -78,6 +86,7 @@ type Templates = | InviteEmailTemplate | PasswordChangeTemplate | SuspendTemplate - | DownloadCommentsTemplate; + | DownloadCommentsTemplate + | UpdateUsernameTemplate; export { Templates as Template }; diff --git a/src/core/server/queue/tasks/mailer/templates/update-username.html b/src/core/server/queue/tasks/mailer/templates/update-username.html new file mode 100644 index 000000000..eef3bab15 --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/update-username.html @@ -0,0 +1,7 @@ +{% extends "layouts/user-notification.html" %} +{% block content %} Hello {{ context.username }},

    + +Thank you for updating your {{ context.organizationName }} commenter account +information. The changes you made are effective immediately. If you did not make +this change please reach out to {{ context.organizationContactEmail }}. +{% endblock %} \ No newline at end of file diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index baf0624de..adcdc8c50 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,7 +1,10 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; -import { DOWNLOAD_LIMIT_TIMEFRAME } from "coral-common/constants"; +import { + ALLOWED_USERNAME_CHANGE_FREQUENCY, + DOWNLOAD_LIMIT_TIMEFRAME, +} from "coral-common/constants"; import { Config } from "coral-server/config"; import { DuplicateEmailError, @@ -16,9 +19,11 @@ import { UserAlreadySuspendedError, UserCannotBeIgnoredError, UsernameAlreadySetError, + UsernameUpdatedWithinWindowError, UserNotFoundError, } from "coral-server/errors"; import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types"; +import logger from "coral-server/logger"; import { Tenant } from "coral-server/models/tenant"; import { banUser, @@ -55,7 +60,6 @@ import { userIsStaff } from "coral-server/models/user/helpers"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; -import logger from "coral-server/logger"; import { generateDownloadLink } from "./download/download"; import { validateEmail, validatePassword, validateUsername } from "./helpers"; @@ -365,23 +369,91 @@ export async function deactivateToken( } /** - * updateUsername will update a given User's username. + * updateUsername will update the current users username. + * + * @param mongo mongo database to interact with + * @param mailer mailer queue instance + * @param tenant Tenant where the User will be interacted with + * @param user the User we are updating + * @param username the username that we are setting on the User + */ +export async function updateUsername( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + user: User, + username: string +) { + // Validate the username. + validateUsername(username); + + const lastUsernameEditAllowed = new Date(); + const dateDiff = + lastUsernameEditAllowed.getSeconds() - ALLOWED_USERNAME_CHANGE_FREQUENCY; + lastUsernameEditAllowed.setDate(dateDiff); + + const { history } = user.status.username; + if (history.length > 1) { + const lastUpdate = history[history.length - 1]; + + if (lastUpdate.createdAt > lastUsernameEditAllowed) { + throw new UsernameUpdatedWithinWindowError(lastUpdate.createdAt); + } + } + + const updated = await updateUserUsername( + mongo, + tenant.id, + user.id, + username, + user.id + ); + + if (user.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "update-username", + context: { + username: user.username!, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + organizationContactEmail: tenant.organization.contactEmail, + }, + }, + }); + } else { + logger.warn( + { id: user.id }, + "Failed to send email: user does not have email address" + ); + } + + return updated; +} + +/** + * updateUsernameByID will update a given User's username. * * @param mongo mongo database to interact with * @param tenant Tenant where the User will be interacted with * @param userID the User's ID that we are updating * @param username the username that we are setting on the User */ -export async function updateUsername( +export async function updateUsernameByID( mongo: Db, tenant: Tenant, userID: string, - username: string + username: string, + createdBy: User ) { // Validate the username. validateUsername(username); - return updateUserUsername(mongo, tenant.id, userID, username); + return updateUserUsername(mongo, tenant.id, userID, username, createdBy.id); } /** diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 3c5b0f464..30d5bb31b 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -471,6 +471,9 @@ moderate-user-drawer-account-history-suspension-removed = Suspension removed moderate-user-drawer-account-history-banned = Banned moderate-user-drawer-account-history-ban-removed = Ban removed moderate-user-drawer-account-history-no-history = No actions have been taken on this account +moderate-user-drawer-username-change = Username change +moderate-user-drawer-username-change-new = New: +moderate-user-drawer-username-change-old = Old: moderate-user-drawer-suspension = Suspension, { $value } { $unit -> diff --git a/src/locales/en-US/framework.ftl b/src/locales/en-US/framework.ftl index 38c7bd98f..6e3c44382 100644 --- a/src/locales/en-US/framework.ftl +++ b/src/locales/en-US/framework.ftl @@ -31,7 +31,7 @@ framework-validation-emailsDoNotMatch = Emails do not match. Try again. framework-validation-notAWholeNumberBetween = Please enter a whole number between { $min } and { $max }. 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-timeago-just-now = Just now diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index f0225f4b5..e704ba5e1 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -219,6 +219,19 @@ comments-submitStatus-submittedAndWillBeReviewed = configure-configureQuery-errorLoadingProfile = Error loading configure configure-configureQuery-storyNotFound = Story not found +## Change username +profile-changeUsername-success = Your username has been successfully updated +profile-changeUsername-edit = Edit +profile-changeUsername-heading = Edit your username +profile-changeUsername-desc = Change the username that will appear on all of your past and future comments. Usernames can be changed once every { framework-timeago-time }. +profile-changeUsername-current = Current username +profile-changeUsername-newUsername-label = New username +profile-changeUsername-confirmNewUsername-label = Confirm new username +profile-changeUsername-cancel = Cancel +profile-changeUsername-submit = Save +profile-changeUsername-recentChange = Your username has been changed in the last { framework-timeago-time }. You may change your username again on { $nextUpdate } +profile-changeUsername-close = Close + ## Comment Stream configure-stream-title = Configure this Comment Stream configure-stream-apply = Apply