[CORL-219] Change username (#2452)

* add types for username updates

* add update username methods

* add types and resolvers for username history

* connect frontend to username history and change history mutation

* style update username box

* show username changes in user history drawer

* new users have 1 username entry

* add translations for username change

* allow new users to change username once

* add tests for change username ui

* send email to users after username change

* add types to tests

* treat status.username.history as mandatory

* remove hardcoded 14 day username update frequency

* use framework translation directives

* add account history action component

* clean up strings

* fix imports in change username container

* fix templates

* rename update username methods

* fix nunjucks formatting

* add warning if user missing email

* add fixme

* fix spacing
This commit is contained in:
Tessa Thornton
2019-08-12 10:45:49 -04:00
committed by GitHub
parent e3f24811fc
commit c245e9ba74
38 changed files with 1219 additions and 72 deletions
@@ -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<HistoryActionProps> = ({
kind,
action,
}) => {
switch (kind) {
case "username":
return <UsernameChangeAction {...action as UsernameChangeActionProps} />;
case "suspension":
return <SuspensionAction {...action as SuspensionActionProps} />;
case "ban":
return <BanAction {...action as BanActionProps} />;
default:
return null;
}
};
export default AccountHistoryAction;
@@ -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<Props> = ({ user }) => {
const system = (
@@ -117,6 +107,20 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ 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<Props> = ({ user }) => {
{formatter.format(history.date)}
</TableCell>
<TableCell className={styles.action}>
{history.kind === "suspension" ? (
<SuspensionAction {...history.action} />
) : (
<BanAction {...history.action} />
)}
<AccountHistoryAction {...history} />
</TableCell>
<TableCell className={styles.user}>{history.takenBy}</TableCell>
</TableRow>
@@ -172,6 +172,15 @@ const enhanced = withFragmentContainer<any>({
user: graphql`
fragment UserDrawerAccountHistory_user on User {
status {
username {
history {
username
createdAt
createdBy {
username
}
}
}
ban {
history {
active
@@ -0,0 +1,7 @@
.tableLight {
font-weight: var(--font-weight-regular);
}
.usernameCell {
line-height: calc(18em / 14);
}
@@ -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<UsernameChangeActionProps> = ({
username,
prevUsername,
}) => {
return (
<div className={styles.usernameCell}>
<Localized id="moderate-user-drawer-username-change">
<div>Username change</div>
</Localized>
<div>
<Localized id="moderate-user-drawer-username-change-new">
<span className={styles.tableLight}>New: </span>
</Localized>{" "}
{username}
</div>
{prevUsername && (
<div>
<Localized id="moderate-user-drawer-username-change-old">
<span className={styles.tableLight}>Old: </span>
</Localized>{" "}
{prevUsername}
</div>
)}
</div>
);
};
export default SuspensionAction;
+5 -1
View File
@@ -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";
@@ -60,6 +60,12 @@ export const PASSWORDS_DO_NOT_MATCH = () => (
</Localized>
);
export const USERNAMES_DO_NOT_MATCH = () => (
<Localized id="framework-validation-usernamesDoNotMatch">
<span>Usernames do not match. Try again.</span>
</Localized>
);
export const EMAILS_DO_NOT_MATCH = () => (
<Localized id="framework-validation-emailsDoNotMatch">
<span>Emails do not match. Try again.</span>
@@ -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.
*/
@@ -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);
}
@@ -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<Props> = ({ 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 (
<HorizontalGutter spacing={5} data-testid="profile-changeUsername">
{showSuccessMessage && (
<Box className={styles.successMessage}>
<Flex justifyContent="space-between" alignItems="center">
<Localized id="profile-changeUsername-success">
<Typography>
Your username has been successfully updated
</Typography>
</Localized>
<CardCloseButton
className={styles.closeButton}
onClick={closeSuccessMessage}
/>
</Flex>
</Box>
)}
{!showEditForm && (
<Flex alignItems="center">
<Typography variant="header2">{viewer.username}</Typography>
<Localized id="profile-changeUsername-edit">
<Button size="small" color="primary" onClick={toggleEditForm}>
Edit
</Button>
</Localized>
</Flex>
)}
{showEditForm && (
<CallOut className={styles.callOut} color="primary">
<HorizontalGutter spacing={4}>
<div>
<Localized id="profile-changeUsername-heading">
<Typography variant="heading2" gutterBottom>
Edit your username
</Typography>
</Localized>
<Localized
id="profile-changeUsername-desc"
$value={FREQUENCYSCALED.scaled}
$unit={FREQUENCYSCALED.unit}
>
<Typography>
Change the username that will appear on all of your past and
future comments. Usernames can be changed once every{" "}
{FREQUENCYSCALED.scaled} {FREQUENCYSCALED.unit}
</Typography>
</Localized>
</div>
<div>
<Localized id="profile-changeUsername-current">
<Typography
className={styles.currentUsername}
variant="bodyCopyBold"
>
Current username
</Typography>
</Localized>
<Typography variant="heading2">{viewer.username}</Typography>
</div>
{canChangeUsername && (
<Form onSubmit={onSubmit}>
{({ handleSubmit, submitError, pristine, invalid }) => (
<form
onSubmit={handleSubmit}
data-testid="profile-changeUsername-form"
>
<HorizontalGutter spacing={4}>
<FormField>
<HorizontalGutter>
<Localized id="profile-changeUsername-newUsername-label">
<InputLabel htmlFor="profile-changeUsername-username">
New username
</InputLabel>
</Localized>
<Field
name="username"
validate={composeValidators(
required,
validateUsername
)}
id="profile-changeUsername-username"
>
{({ input, meta }) => (
<>
<TextField
{...input}
id="profile-changeUsername-username"
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</HorizontalGutter>
</FormField>
<FormField>
<HorizontalGutter>
<Localized id="profile-changeUsername-confirmNewUsername-label">
<InputLabel htmlFor="profile-changeUsername-username-confirm">
Confirm new username
</InputLabel>
</Localized>
<Field
name="usernameConfirm"
validate={composeValidators(
required,
validateUsernameEquals
)}
>
{({ input, meta }) => (
<>
<TextField
{...input}
id="profile-changeUsername-username-confirm"
/>
<ValidationMessage meta={meta} />
</>
)}
</Field>
</HorizontalGutter>
</FormField>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
</HorizontalGutter>
<Flex justifyContent="flex-end" className={styles.footer}>
<Localized id="profile-changeUsername-cancel">
<Button type="button" onClick={toggleEditForm}>
Cancel
</Button>
</Localized>
<Localized id="profile-changeUsername-submit">
<Button
variant={pristine || invalid ? "outlined" : "filled"}
type="submit"
color={pristine || invalid ? "regular" : "primary"}
disabled={pristine || invalid}
>
<ButtonIcon>save</ButtonIcon>
<span>Save</span>
</Button>
</Localized>
</Flex>
</form>
)}
</Form>
)}
{!canChangeUsername && (
<div data-testid="profile-changeUsername-cantChange">
<Flex>
<Icon size="md" className={styles.errorIcon}>
error
</Icon>
<Localized
date={canChangeUsernameDate}
id="profile-changeUsername-recentChange"
$value={FREQUENCYSCALED.scaled}
$unit={FREQUENCYSCALED.unit}
$nextUpdate={
canChangeUsernameDate
? formatter.format(canChangeUsernameDate)
: null
}
>
<Typography className={styles.tooSoon}>
Your username has been changed in the last{" "}
{FREQUENCYSCALED.scaled} {FREQUENCYSCALED.unit}. You may
change your username again on{" "}
{canChangeUsernameDate
? formatter.format(canChangeUsernameDate)
: null}
</Typography>
</Localized>
</Flex>
<Flex justifyContent="flex-end">
<Localized id="profile-changeUsername-close">
<Button
color="primary"
variant="filled"
type="button"
onClick={toggleEditForm}
>
Close
</Button>
</Localized>
</Flex>
</div>
)}
</HorizontalGutter>
</CallOut>
)}
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
viewer: graphql`
fragment ChangeUsernameContainer_viewer on User {
username
status {
username {
history {
username
createdAt
}
}
}
}
`,
})(ChangeUsernameContainer);
export default enhanced;
@@ -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<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(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;
@@ -0,0 +1,4 @@
export {
default,
default as ChangeUsernameContainer,
} from "./ChangeUsernameContainer";
@@ -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<typeof CommentHistoryContainer>["story"];
viewer: PropTypesOf<typeof UserBoxContainer>["viewer"] &
PropTypesOf<typeof CommentHistoryContainer>["viewer"] &
PropTypesOf<typeof SettingsContainer>["viewer"];
PropTypesOf<typeof SettingsContainer>["viewer"] &
PropTypesOf<typeof ChangeUsernameContainer>["viewer"];
settings: PropTypesOf<typeof UserBoxContainer>["settings"] &
PropTypesOf<typeof SettingsContainer>["settings"];
}
@@ -37,7 +39,7 @@ const Profile: FunctionComponent<ProfileProps> = props => {
);
return (
<HorizontalGutter spacing={5}>
<UserBoxContainer viewer={props.viewer} settings={props.settings} />
<ChangeUsernameContainer viewer={props.viewer} />
<TabBar
variant="secondary"
activeTab={local.profileTab}
@@ -36,6 +36,7 @@ const enhanced = withFragmentContainer<ProfileContainerProps>({
...UserBoxContainer_viewer
...CommentHistoryContainer_viewer
...SettingsContainer_viewer
...ChangeUsernameContainer_viewer
}
`,
settings: graphql`
+55
View File
@@ -115,6 +115,9 @@ export const baseUser = createFixture<GQLUser>({
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<GQLUser>({
ignoreable: true,
});
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const weekago = new Date();
weekago.setDate(yesterday.getDate() - 7);
export const userWithNewUsername = createFixture<GQLUser>(
{
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<GQLUser>(
{
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<GQLUser>(
[
{
@@ -66,41 +66,29 @@ exports[`renders the empty settings pane 1`] = `
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
>
<div
className="Box-root Flex-root"
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
data-testid="profile-changeUsername"
>
<div
className="Flex-flex Flex-halfItemGutter Flex-wrap gutter"
className="Box-root Flex-root Flex-flex Flex-alignCenter"
>
<div
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
<h1
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
>
Signed in as
<span
className="Box-root Typography-root Typography-bodyCopyBold Typography-colorTextPrimary"
>
Passivo
</span>
.
</div>
<div
className="Box-root Flex-root Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary Flex-flex"
Passivo
</h1>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantRegular"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Not you? 
</span>
<button
className="BaseButton-root Button-root Button-sizeSmall Button-colorPrimary Button-variantUnderlined"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Sign Out
</button>
</div>
Edit
</button>
</div>
</div>
<ul
@@ -0,0 +1,211 @@
import { ReactTestRenderer } from "react-test-renderer";
import { pureMerge } from "coral-common/utils";
import { GQLResolver } from "coral-framework/schema";
import {
act,
createResolversStub,
CreateTestRendererParams,
waitForElement,
within,
} from "coral-framework/testHelpers";
import {
settings,
stories,
userWithChangedUsername,
userWithNewUsername,
viewerPassive,
} from "../fixtures";
import create from "./create";
const story = stories[0];
const viewer = viewerPassive;
async function createTestRenderer(
params: CreateTestRendererParams<GQLResolver> = {}
) {
const { testRenderer, context } = create({
...params,
resolvers: pureMerge(
createResolversStub<GQLResolver>({
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<GQLResolver>({
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<GQLResolver>({
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<GQLResolver>({
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"
);
});
});
@@ -10,4 +10,4 @@
.icon {
display: block;
}
}
+5
View File
@@ -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;
+6
View File
@@ -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.
+20
View File
@@ -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 });
+1
View File
@@ -50,4 +50,5 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
INVITE_REQUIRES_EMAIL_ADDRESSES: "error-inviteRequiresEmailAddresses",
LIVE_UPDATES_DISABLED: "error-liveUpdatesDisabled",
PASSWORD_INCORRECT: "error-passwordIncorrect",
USERNAME_UPDATED_WITHIN_WINDOW: "error-usernameAlreadyUpdated",
};
+17 -1
View File
@@ -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) =>
@@ -137,6 +137,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
...(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,
@@ -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,
@@ -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<user.UsernameHistory>
> = {
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
username: ({ username }) => username,
};
@@ -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<UsernameStatusInput>
> = {
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -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;
@@ -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.
+7
View File
@@ -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 },<br/><br/>
Thank you for updating your { $organizationName } commenter account information. The changes you made are effective immediately. <br /><br />
If you did not make this change please reach out to our community team at <a data-l10n-name="organizationContactEmail" >{ $organizationContactEmail }</a>.
email-notification-template-suspend =
{ $customMessage }<br/><br/>
If you think this has been done in error, please contact our community team
+1
View File
@@ -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 }.
+74 -9
View File
@@ -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<GQLBanStatus, "history"> &
Pick<BanStatus, "history">;
export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
Pick<UsernameStatus, "history">;
export function consolidateUsernameStatus(
username: User["status"]["username"]
): ConsolidatedUsernameStatus {
return username;
}
export function consolidateUserBanStatus(
ban: User["status"]["ban"]
): ConsolidatedBanStatus {
@@ -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 };
@@ -0,0 +1,7 @@
{% extends "layouts/user-notification.html" %}
{% block content %} Hello {{ context.username }},<br /><br />
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 <a data-l10n-name="organizationContactEmail" href="mailto:{{ context.organizationContactEmail }}">{{ context.organizationContactEmail }}</a>.
{% endblock %}
+78 -6
View File
@@ -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);
}
/**
+3
View File
@@ -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 ->
+1 -1
View File
@@ -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
+13
View File
@@ -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