mirror of
https://github.com/wassname/talk.git
synced 2026-08-11 11:27:10 +08:00
[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:
@@ -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;
|
||||
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }.
|
||||
@@ -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 %}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user