mirror of
https://github.com/wassname/talk.git
synced 2026-07-23 13:10:20 +08:00
[CORL-220] Change email address (#2461)
* change email form * add update email mutation * validate new email addresses * add email verification callout * add translation strings * fix submit button logic * rename model methods * style email verifcation box * resend email verificatoin button * show success message on email resend * update user profile email * fix duplicate import * add change email spec * fix profile spacing * update snapshots * update snaps * add preventSubmit function to email change component * update business logic for profile updating * update logic for when users can update email or username * check for less specific duplicate email error * prevent sso-only users from editing email * only allow email and username edit if enabeld in local profile * use generic server error for cannot update profile * remove merge conflict * fix tests * extract logic to get auth integrations * fix merge error
This commit is contained in:
Generated
+1
-1
@@ -24217,7 +24217,7 @@
|
||||
"dependencies": {
|
||||
"async": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz",
|
||||
"integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=",
|
||||
"dev": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { GQLAuthIntegrations } from "../schema/__generated__/types";
|
||||
|
||||
interface Integration {
|
||||
enabled: boolean;
|
||||
targetFilter: {
|
||||
admin?: boolean;
|
||||
stream?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthSettings {
|
||||
integrations: {
|
||||
[index: string]: Integration;
|
||||
};
|
||||
}
|
||||
|
||||
export default function enabledAuthenticationIntegrations(
|
||||
auth: AuthSettings,
|
||||
target?: "stream" | "admin"
|
||||
): string[] {
|
||||
if (auth.integrations) {
|
||||
return Object.keys(auth.integrations).filter(
|
||||
(key: keyof GQLAuthIntegrations) => {
|
||||
const { targetFilter, enabled } = auth.integrations[key];
|
||||
if (target) {
|
||||
return enabled && targetFilter[target];
|
||||
}
|
||||
return enabled && targetFilter.admin && targetFilter.stream;
|
||||
}
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.callOut {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
padding-right: var(--spacing-2);
|
||||
padding-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.currentEmail {
|
||||
color: var(--palette-grey-dark);
|
||||
}
|
||||
|
||||
.resendButton {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { FORM_ERROR, FormApi, FormState } 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 { Environment } from "relay-runtime";
|
||||
|
||||
import { PasswordField } from "coral-framework/components";
|
||||
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
|
||||
import { createFetch, useFetch } from "coral-framework/lib/relay";
|
||||
import {
|
||||
graphql,
|
||||
useMutation,
|
||||
withFragmentContainer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import {
|
||||
composeValidators,
|
||||
required,
|
||||
validateEmail,
|
||||
} from "coral-framework/lib/validation";
|
||||
import {
|
||||
Button,
|
||||
ButtonIcon,
|
||||
CallOut,
|
||||
Flex,
|
||||
FormField,
|
||||
HorizontalGutter,
|
||||
Icon,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import { ChangeEmailContainer_settings as SettingsData } from "coral-stream/__generated__/ChangeEmailContainer_settings.graphql";
|
||||
import { ChangeEmailContainer_viewer as ViewerData } from "coral-stream/__generated__/ChangeEmailContainer_viewer.graphql";
|
||||
|
||||
import UpdateEmailMutation from "./UpdateEmailMutation";
|
||||
|
||||
import styles from "./ChangeEmailContainer.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"resendConfirmation",
|
||||
(environment: Environment, variables, context) => {
|
||||
return context.rest.fetch<void>("/account/confirm", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
interface Props {
|
||||
viewer: ViewerData;
|
||||
settings: SettingsData;
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const changeEmailContainer: FunctionComponent<Props> = ({
|
||||
viewer,
|
||||
settings,
|
||||
}) => {
|
||||
const updateEmail = useMutation(UpdateEmailMutation);
|
||||
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [emailUpdated, setEmailUpdated] = useState(false);
|
||||
const [confirmationResent, setConfirmationResent] = useState(false);
|
||||
const makeFetchCall = useFetch(fetcher);
|
||||
const resend = useCallback(async () => {
|
||||
await makeFetchCall();
|
||||
setConfirmationResent(true);
|
||||
}, [fetcher]);
|
||||
|
||||
const toggleEditForm = useCallback(() => {
|
||||
setShowEditForm(!showEditForm);
|
||||
}, [setShowEditForm, showEditForm]);
|
||||
const onSubmit = useCallback(
|
||||
async (input: FormProps, form: FormApi) => {
|
||||
try {
|
||||
await updateEmail({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidRequestError) {
|
||||
return err.invalidArgs;
|
||||
}
|
||||
|
||||
return {
|
||||
[FORM_ERROR]: err.message,
|
||||
};
|
||||
}
|
||||
|
||||
form.reset();
|
||||
setShowEditForm(false);
|
||||
setEmailUpdated(true);
|
||||
|
||||
return;
|
||||
},
|
||||
[updateEmail]
|
||||
);
|
||||
|
||||
const canChangeEmail = useMemo(() => {
|
||||
if (
|
||||
!viewer.profiles.find(profile => profile.__typename === "LocalProfile")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const enabled = getAuthenticationIntegrations(settings.auth, "stream");
|
||||
|
||||
return (
|
||||
enabled.includes("local") ||
|
||||
!(enabled.length === 1 && enabled[0] === "sso")
|
||||
);
|
||||
}, [viewer, settings]);
|
||||
|
||||
const preventSubmit = (
|
||||
formState: Pick<
|
||||
FormState,
|
||||
| "pristine"
|
||||
| "hasSubmitErrors"
|
||||
| "hasValidationErrors"
|
||||
| "dirtySinceLastSubmit"
|
||||
>
|
||||
) => {
|
||||
const {
|
||||
pristine,
|
||||
hasValidationErrors,
|
||||
hasSubmitErrors,
|
||||
dirtySinceLastSubmit,
|
||||
} = formState;
|
||||
return (
|
||||
pristine ||
|
||||
hasValidationErrors ||
|
||||
(hasSubmitErrors && !dirtySinceLastSubmit)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<HorizontalGutter spacing={5} data-testid="profile-changeEmail">
|
||||
{!showEditForm && (
|
||||
<Flex alignItems="center">
|
||||
<Typography>{viewer.email}</Typography>{" "}
|
||||
{!viewer.emailVerified && (
|
||||
<Localized id="profile-changeEmail-unverified">
|
||||
<Typography color="textSecondary">(Unverified)</Typography>
|
||||
</Localized>
|
||||
)}
|
||||
{canChangeEmail && (
|
||||
<Localized id="profile-changeEmail-edit">
|
||||
<Button size="small" color="primary" onClick={toggleEditForm}>
|
||||
Edit
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{!viewer.emailVerified && emailUpdated && !showEditForm && (
|
||||
<CallOut>
|
||||
<Flex itemGutter>
|
||||
<div>
|
||||
<Icon size="lg">email</Icon>
|
||||
</div>
|
||||
<div>
|
||||
<Localized id="profile-changeEmail-please-verify">
|
||||
<Typography variant="heading3" gutterBottom>
|
||||
Verify your email address
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="profile-changeEmail-please-verify-details"
|
||||
$email={viewer.email}
|
||||
>
|
||||
<Typography>
|
||||
An email has been sent to {viewer.email} to verify your
|
||||
account. You must verify your new email address before it can
|
||||
be used for signing into your account or for email
|
||||
notifications.
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<Localized id="profile-changeEmail-resend">
|
||||
<Button
|
||||
onClick={resend}
|
||||
className={styles.resendButton}
|
||||
color="primary"
|
||||
>
|
||||
Resend verification
|
||||
</Button>
|
||||
</Localized>
|
||||
</div>
|
||||
</Flex>
|
||||
</CallOut>
|
||||
)}
|
||||
{confirmationResent && (
|
||||
<CallOut fullWidth color="primary">
|
||||
<Localized id="profile-changeEmail-resent">
|
||||
<Typography>Your confirmation email has been re-sent.</Typography>
|
||||
</Localized>
|
||||
</CallOut>
|
||||
)}
|
||||
{showEditForm && (
|
||||
<CallOut className={styles.callOut} color="primary">
|
||||
<HorizontalGutter spacing={4}>
|
||||
<div>
|
||||
<Localized id="profile-changeEmail-heading">
|
||||
<Typography variant="heading2" gutterBottom>
|
||||
Edit your email address
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="profile-changeEmail-desc">
|
||||
<Typography>
|
||||
Change the email address used for signing in and for receiving
|
||||
communication about your account.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</div>
|
||||
<div>
|
||||
<Localized id="profile-changeEmail-current">
|
||||
<Typography
|
||||
className={styles.currentEmail}
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
Current email
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Typography variant="heading2">{viewer.email}</Typography>
|
||||
</div>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({
|
||||
handleSubmit,
|
||||
submitError,
|
||||
invalid,
|
||||
submitting,
|
||||
...formProps
|
||||
}) => (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
data-testid="profile-changeEmail-form"
|
||||
>
|
||||
<HorizontalGutter spacing={4}>
|
||||
<FormField>
|
||||
<HorizontalGutter>
|
||||
<Localized id="profile-changeEmail-newEmail-label">
|
||||
<InputLabel htmlFor="profile-changeEmail-Email">
|
||||
New email address
|
||||
</InputLabel>
|
||||
</Localized>
|
||||
<Field
|
||||
name="email"
|
||||
validate={composeValidators(required, validateEmail)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<>
|
||||
<TextField
|
||||
{...input}
|
||||
fullWidth
|
||||
id="profile-changeEmail-Email"
|
||||
/>
|
||||
<ValidationMessage meta={meta} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</HorizontalGutter>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<HorizontalGutter>
|
||||
<Field
|
||||
name="password"
|
||||
validate={composeValidators(required)}
|
||||
>
|
||||
{({ input, meta }) => (
|
||||
<FormField>
|
||||
<Localized id="profile-changeEmail-password">
|
||||
<InputLabel htmlFor={input.name}>
|
||||
Password
|
||||
</InputLabel>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="profile-changeEmail-password-input"
|
||||
attrs={{ placeholder: true }}
|
||||
>
|
||||
<PasswordField
|
||||
id={input.name}
|
||||
placeholder="Password"
|
||||
color={colorFromMeta(meta)}
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
{...input}
|
||||
/>
|
||||
</Localized>
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
</HorizontalGutter>
|
||||
</FormField>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
<Flex justifyContent="flex-end" className={styles.footer}>
|
||||
<Localized id="profile-changeEmail-cancel">
|
||||
<Button type="button" onClick={toggleEditForm}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="profile-changeEmail-submit">
|
||||
<Button
|
||||
variant={
|
||||
preventSubmit(formProps) ? "outlined" : "filled"
|
||||
}
|
||||
type="submit"
|
||||
color={preventSubmit(formProps) ? "regular" : "primary"}
|
||||
disabled={preventSubmit(formProps)}
|
||||
>
|
||||
<ButtonIcon>save</ButtonIcon>
|
||||
<span>Save</span>
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</HorizontalGutter>
|
||||
</CallOut>
|
||||
)}
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment ChangeEmailContainer_viewer on User {
|
||||
email
|
||||
emailVerified
|
||||
profiles {
|
||||
__typename
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment ChangeEmailContainer_settings on Settings {
|
||||
auth {
|
||||
integrations {
|
||||
local {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
google {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
oidc {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
sso {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
facebook {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(changeEmailContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { UpdateEmailMutation as MutationTypes } from "coral-stream/__generated__/UpdateEmailMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const UpdateEmailMutation = createMutation(
|
||||
"updateEmail",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation UpdateEmailMutation($input: UpdateEmailInput!) {
|
||||
updateEmail(input: $input) {
|
||||
clientMutationId
|
||||
user {
|
||||
email
|
||||
emailVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
updateEmail: {
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
user: {
|
||||
email: input.email,
|
||||
emailVerified: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
export default UpdateEmailMutation;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
default,
|
||||
default as ChangeEmailContainer,
|
||||
} from "./ChangeEmailContainer";
|
||||
@@ -11,6 +11,7 @@ import { Field, Form } from "react-final-form";
|
||||
|
||||
import { ALLOWED_USERNAME_CHANGE_FREQUENCY } from "coral-common/constants";
|
||||
import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
|
||||
import getAuthenticationIntegrations from "coral-framework/helpers/getAuthenticationIntegrations";
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
import { ValidationMessage } from "coral-framework/lib/form";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
validateUsername,
|
||||
validateUsernameEquals,
|
||||
} from "coral-framework/lib/validation";
|
||||
import { ChangeUsernameContainer_settings as SettingsData } from "coral-stream/__generated__/ChangeUsernameContainer_settings.graphql";
|
||||
import { ChangeUsernameContainer_viewer as ViewerData } from "coral-stream/__generated__/ChangeUsernameContainer_viewer.graphql";
|
||||
import {
|
||||
Box,
|
||||
@@ -50,6 +52,7 @@ const FREQUENCYSCALED = reduceSeconds(ALLOWED_USERNAME_CHANGE_FREQUENCY, [
|
||||
|
||||
interface Props {
|
||||
viewer: ViewerData;
|
||||
settings: SettingsData;
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
@@ -57,7 +60,10 @@ interface FormProps {
|
||||
usernameConfirm: string;
|
||||
}
|
||||
|
||||
const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
|
||||
const ChangeUsernameContainer: FunctionComponent<Props> = ({
|
||||
viewer,
|
||||
settings,
|
||||
}) => {
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
|
||||
const toggleEditForm = useCallback(() => {
|
||||
@@ -69,6 +75,20 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
|
||||
setShowEditForm,
|
||||
]);
|
||||
|
||||
const canChangeLocalAuth = useMemo(() => {
|
||||
if (
|
||||
!viewer.profiles.find(profile => profile.__typename === "LocalProfile")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const enabled = getAuthenticationIntegrations(settings.auth, "stream");
|
||||
|
||||
return (
|
||||
enabled.includes("local") ||
|
||||
!(enabled.length === 1 && enabled[0] === "sso")
|
||||
);
|
||||
}, [viewer, settings]);
|
||||
|
||||
const canChangeUsername = useMemo(() => {
|
||||
const { username } = viewer.status;
|
||||
if (username && username.history.length > 1) {
|
||||
@@ -148,11 +168,13 @@ const ChangeUsernameContainer: FunctionComponent<Props> = ({ viewer }) => {
|
||||
{!showEditForm && (
|
||||
<Flex alignItems="baseline">
|
||||
<Typography variant="header2">{viewer.username}</Typography>
|
||||
<Localized id="profile-changeUsername-edit">
|
||||
<Button size="small" color="primary" onClick={toggleEditForm}>
|
||||
edit
|
||||
</Button>
|
||||
</Localized>
|
||||
{canChangeLocalAuth && (
|
||||
<Localized id="profile-changeUsername-edit">
|
||||
<Button size="small" color="primary" onClick={toggleEditForm}>
|
||||
edit
|
||||
</Button>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
{showEditForm && (
|
||||
@@ -337,6 +359,9 @@ const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment ChangeUsernameContainer_viewer on User {
|
||||
username
|
||||
profiles {
|
||||
__typename
|
||||
}
|
||||
status {
|
||||
username {
|
||||
history {
|
||||
@@ -347,6 +372,44 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment ChangeUsernameContainer_settings on Settings {
|
||||
auth {
|
||||
integrations {
|
||||
local {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
google {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
oidc {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
sso {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
facebook {
|
||||
enabled
|
||||
targetFilter {
|
||||
stream
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ChangeUsernameContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
|
||||
import { graphql, useLocal } from "coral-framework/lib/relay";
|
||||
@@ -11,8 +12,8 @@ import {
|
||||
TabContent,
|
||||
TabPane,
|
||||
} from "coral-ui/components";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
|
||||
import ChangeEmailContainer from "./ChangeEmail";
|
||||
import ChangeUsernameContainer from "./ChangeUsername";
|
||||
import CommentHistoryContainer from "./CommentHistory";
|
||||
import SettingsContainer from "./Settings";
|
||||
@@ -22,9 +23,13 @@ export interface ProfileProps {
|
||||
viewer: PropTypesOf<typeof UserBoxContainer>["viewer"] &
|
||||
PropTypesOf<typeof CommentHistoryContainer>["viewer"] &
|
||||
PropTypesOf<typeof SettingsContainer>["viewer"] &
|
||||
PropTypesOf<typeof ChangeUsernameContainer>["viewer"];
|
||||
PropTypesOf<typeof ChangeUsernameContainer>["viewer"] &
|
||||
PropTypesOf<typeof ChangeEmailContainer>["viewer"] &
|
||||
PropTypesOf<typeof SettingsContainer>["viewer"];
|
||||
settings: PropTypesOf<typeof UserBoxContainer>["settings"] &
|
||||
PropTypesOf<typeof SettingsContainer>["settings"];
|
||||
PropTypesOf<typeof ChangeEmailContainer>["settings"] &
|
||||
PropTypesOf<typeof SettingsContainer>["settings"] &
|
||||
PropTypesOf<typeof ChangeUsernameContainer>["settings"];
|
||||
}
|
||||
|
||||
const Profile: FunctionComponent<ProfileProps> = props => {
|
||||
@@ -39,7 +44,13 @@ const Profile: FunctionComponent<ProfileProps> = props => {
|
||||
);
|
||||
return (
|
||||
<HorizontalGutter spacing={5}>
|
||||
<ChangeUsernameContainer viewer={props.viewer} />
|
||||
<HorizontalGutter spacing={2}>
|
||||
<ChangeUsernameContainer
|
||||
settings={props.settings}
|
||||
viewer={props.viewer}
|
||||
/>
|
||||
<ChangeEmailContainer settings={props.settings} viewer={props.viewer} />
|
||||
</HorizontalGutter>
|
||||
<TabBar
|
||||
variant="secondary"
|
||||
activeTab={local.profileTab}
|
||||
|
||||
@@ -37,11 +37,14 @@ const enhanced = withFragmentContainer<ProfileContainerProps>({
|
||||
...CommentHistoryContainer_viewer
|
||||
...SettingsContainer_viewer
|
||||
...ChangeUsernameContainer_viewer
|
||||
...ChangeEmailContainer_viewer
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment ProfileContainer_settings on Settings {
|
||||
...UserBoxContainer_settings
|
||||
...ChangeEmailContainer_settings
|
||||
...ChangeUsernameContainer_settings
|
||||
...SettingsContainer_settings
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -113,6 +113,8 @@ export const settingsWithoutLocalAuth = createFixture<GQLSettings>(
|
||||
|
||||
export const baseUser = createFixture<GQLUser>({
|
||||
createdAt: "2018-02-06T18:24:00.000Z",
|
||||
id: "base-user",
|
||||
role: GQLUSER_ROLE.COMMENTER,
|
||||
status: {
|
||||
current: [GQLUSER_STATUS.ACTIVE],
|
||||
username: {
|
||||
@@ -130,6 +132,11 @@ export const baseUser = createFixture<GQLUser>({
|
||||
},
|
||||
},
|
||||
ignoreable: true,
|
||||
profiles: [
|
||||
{
|
||||
__typename: "LocalProfile",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const yesterday = new Date();
|
||||
@@ -184,6 +191,14 @@ export const userWithChangedUsername = createFixture<GQLUser>(
|
||||
baseUser
|
||||
);
|
||||
|
||||
export const userWithEmail = createFixture<GQLUser>(
|
||||
{
|
||||
id: "email-user",
|
||||
email: "used-email@email.com",
|
||||
},
|
||||
baseUser
|
||||
);
|
||||
|
||||
export const commenters = createFixtures<GQLUser>(
|
||||
[
|
||||
{
|
||||
|
||||
@@ -66,29 +66,65 @@ exports[`renders the empty settings pane 1`] = `
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
|
||||
data-testid="profile-changeUsername"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-2"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-alignBaseline"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
|
||||
data-testid="profile-changeUsername"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-alignBaseline"
|
||||
>
|
||||
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"
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-header2 Typography-colorTextPrimary"
|
||||
>
|
||||
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"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-5"
|
||||
data-testid="profile-changeEmail"
|
||||
>
|
||||
<div
|
||||
className="Box-root Flex-root Flex-flex Flex-alignCenter"
|
||||
>
|
||||
edit
|
||||
</button>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
/>
|
||||
<span>
|
||||
|
||||
</span>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextSecondary"
|
||||
>
|
||||
(Unverified)
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import { pureMerge } from "coral-common/utils";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createResolversStub,
|
||||
CreateTestRendererParams,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import { baseUser, settings, stories } from "../fixtures";
|
||||
import create from "./create";
|
||||
|
||||
const story = stories[0];
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context } = create({
|
||||
...params,
|
||||
resolvers: pureMerge(
|
||||
createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => baseUser,
|
||||
story: () => story,
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
),
|
||||
initLocalState: (localRecord, source, environment) => {
|
||||
localRecord.setValue("SETTINGS", "profileTab");
|
||||
if (params.initLocalState) {
|
||||
params.initLocalState(localRecord, source, environment);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
testRenderer,
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
describe("change email form", () => {
|
||||
let testRenderer: ReactTestRenderer;
|
||||
beforeEach(async () => {
|
||||
const setup = await createTestRenderer({
|
||||
resolvers: createResolversStub<GQLResolver>({
|
||||
Query: {
|
||||
viewer: () => baseUser,
|
||||
},
|
||||
Mutation: {
|
||||
updateEmail: ({ variables }) => {
|
||||
expectAndFail(variables).toMatchObject({
|
||||
email: "updated_email@test.com",
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
...baseUser,
|
||||
email: "updated_email@test.com",
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
testRenderer = setup.testRenderer;
|
||||
});
|
||||
|
||||
it("ensures email field is required", async () => {
|
||||
const changeEmail = within(testRenderer.root).getByTestID(
|
||||
"profile-changeEmail"
|
||||
);
|
||||
const editButton = within(changeEmail).getByText("Edit");
|
||||
act(() => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
const form = within(changeEmail).getByType("form");
|
||||
act(() => {
|
||||
form.props.onSubmit();
|
||||
});
|
||||
within(changeEmail).getAllByText("This field is required", {
|
||||
exact: false,
|
||||
});
|
||||
const button = within(changeEmail).getByText("Save");
|
||||
expect(button.props.disabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ensures password field is required", async () => {
|
||||
const changeEmail = within(testRenderer.root).getByTestID(
|
||||
"profile-changeEmail"
|
||||
);
|
||||
const editButton = within(changeEmail).getByText("Edit");
|
||||
act(() => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
const form = within(changeEmail).getByType("form");
|
||||
const emailInput = within(changeEmail).getByLabelText("New email address", {
|
||||
exact: false,
|
||||
});
|
||||
act(() => {
|
||||
emailInput.props.onChange("test@test.com");
|
||||
form.props.onSubmit();
|
||||
});
|
||||
within(changeEmail).getByText("This field is required", {
|
||||
exact: false,
|
||||
});
|
||||
const button = within(changeEmail).getByText("Save");
|
||||
expect(button.props.disabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it("updates email if fields are valid", async () => {
|
||||
const changeEmail = within(testRenderer.root).getByTestID(
|
||||
"profile-changeEmail"
|
||||
);
|
||||
const editButton = within(changeEmail).getByText("Edit");
|
||||
act(() => {
|
||||
editButton.props.onClick();
|
||||
});
|
||||
const form = within(changeEmail).getByType("form");
|
||||
const emailInput = within(changeEmail).getByLabelText("New email address", {
|
||||
exact: false,
|
||||
});
|
||||
const password = within(changeEmail).getByLabelText("Password");
|
||||
await act(async () => {
|
||||
emailInput.props.onChange("updated_email@test.com");
|
||||
password.props.onChange("test");
|
||||
await form.props.onSubmit();
|
||||
});
|
||||
|
||||
within(changeEmail).getAllByText("updated_email@test.com", {
|
||||
exact: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
suspend,
|
||||
updateAvatar,
|
||||
updateEmail,
|
||||
updateEmailByID,
|
||||
updatePassword,
|
||||
updateRole,
|
||||
updateUsername,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
GQLSetPasswordInput,
|
||||
GQLSetUsernameInput,
|
||||
GQLSuspendUserInput,
|
||||
GQLUpdateEmailInput,
|
||||
GQLUpdatePasswordInput,
|
||||
GQLUpdateUserAvatarInput,
|
||||
GQLUpdateUserEmailInput,
|
||||
@@ -140,7 +142,18 @@ export const Users = (ctx: TenantContext) => ({
|
||||
ctx.user!
|
||||
),
|
||||
updateUserEmail: async (input: GQLUpdateUserEmailInput) =>
|
||||
updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email),
|
||||
updateEmailByID(ctx.mongo, ctx.tenant, input.userID, input.email),
|
||||
updateEmail: async (input: GQLUpdateEmailInput) =>
|
||||
updateEmail(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.mailerQueue,
|
||||
ctx.config,
|
||||
ctx.signingConfig!,
|
||||
ctx.user!,
|
||||
input.email,
|
||||
input.password
|
||||
),
|
||||
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
|
||||
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
|
||||
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
|
||||
|
||||
@@ -145,6 +145,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.updateUserUsername(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
updateEmail: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.updateEmail(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
updateUserEmail: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.updateUserEmail(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
|
||||
@@ -4189,6 +4189,38 @@ type UpdateUserUsernamePayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateEmail
|
||||
##################
|
||||
|
||||
input UpdateEmailInput {
|
||||
"""
|
||||
email is the email address to set for the User.
|
||||
"""
|
||||
email: String!
|
||||
"""
|
||||
password is the users password.
|
||||
"""
|
||||
password: String!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type UpdateEmailPayload {
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateUserEmail
|
||||
##################
|
||||
@@ -4704,6 +4736,12 @@ type Mutation {
|
||||
input: UpdateUserUsernameInput!
|
||||
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateEmail allows administrators to update a given User's email address
|
||||
to the one provided.
|
||||
"""
|
||||
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload! @auth
|
||||
|
||||
"""
|
||||
updateUserEmail allows administrators to update a given User's email address
|
||||
to the one provided.
|
||||
|
||||
@@ -889,53 +889,53 @@ export async function setUserEmail(
|
||||
* @param tenantID the Tenant ID of the Tenant where the User exists
|
||||
* @param id the User ID that we are updating
|
||||
* @param emailAddress email address that we are setting on the User
|
||||
* @param emailVerified whether email is verified
|
||||
*/
|
||||
export async function updateUserEmail(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
emailAddress: string
|
||||
emailAddress: string,
|
||||
emailVerified = false
|
||||
) {
|
||||
// Lowercase the email address.
|
||||
const email = emailAddress.toLowerCase();
|
||||
|
||||
// Search to see if this email has been used before.
|
||||
let user = await collection(mongo).findOne({
|
||||
tenantID,
|
||||
email,
|
||||
});
|
||||
if (user) {
|
||||
throw new DuplicateEmailError(email);
|
||||
}
|
||||
|
||||
// The email wasn't found, so try to update the User.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
email,
|
||||
try {
|
||||
// The email wasn't found, so try to update the User.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
{
|
||||
$set: {
|
||||
email,
|
||||
emailVerified,
|
||||
"profiles.$[profiles].id": email,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [{ "profiles.type": "local" }],
|
||||
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);
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
return result.value;
|
||||
} catch (err) {
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
throw new DuplicateEmailError(email!);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
UsernameUpdatedWithinWindowError,
|
||||
UserNotFoundError,
|
||||
} from "coral-server/errors";
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/tenant/schema/__generated__/types";
|
||||
import logger from "coral-server/logger";
|
||||
import { Tenant } from "coral-server/models/tenant";
|
||||
import {
|
||||
@@ -58,6 +61,8 @@ import {
|
||||
} from "coral-server/models/user/helpers";
|
||||
import { userIsStaff } from "coral-server/models/user/helpers";
|
||||
import { MailerQueue } from "coral-server/queue/tasks/mailer";
|
||||
import { sendConfirmationEmail } from "coral-server/services/users/auth";
|
||||
|
||||
import { JWTSigningConfig, signPATString } from "coral-server/services/jwt";
|
||||
|
||||
import { generateDownloadLink } from "./download/download";
|
||||
@@ -388,6 +393,11 @@ export async function updateUsername(
|
||||
// Validate the username.
|
||||
validateUsername(username);
|
||||
|
||||
const canUpdate = canUpdateLocalProfile(tenant, user);
|
||||
if (!canUpdate) {
|
||||
throw new Error("Cannot update profile due to tenant settings");
|
||||
}
|
||||
|
||||
// Get the earliest date that the username could have been edited before to/
|
||||
// allow it now.
|
||||
const lastUsernameEditAllowed = DateTime.fromJSDate(now)
|
||||
@@ -483,7 +493,96 @@ export async function updateRole(
|
||||
}
|
||||
|
||||
/**
|
||||
* updateEmail will update the given User's email address. This should not
|
||||
* enabledAuthenticationIntegrations returns enabled auth integrations for a tenant
|
||||
* @param tenant Tenant where the User will be interacted with
|
||||
* @param target whether to filter by stream or admin enabled. defaults to requiring both.
|
||||
*/
|
||||
function enabledAuthenticationIntegrations(
|
||||
tenant: Tenant,
|
||||
target?: "stream" | "admin"
|
||||
): string[] {
|
||||
return Object.keys(tenant.auth.integrations).filter((key: string) => {
|
||||
const { enabled, targetFilter } = tenant.auth.integrations[
|
||||
key as keyof GQLAuthIntegrations
|
||||
];
|
||||
if (target) {
|
||||
return enabled && targetFilter[target];
|
||||
}
|
||||
return enabled && targetFilter.admin && targetFilter.stream;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* canUpdateLocalProfile will determine if a user is permitted to update their email address.
|
||||
* @param tenant Tenant where the User will be interacted with
|
||||
* @param user the User that we are updating
|
||||
*/
|
||||
function canUpdateLocalProfile(tenant: Tenant, user: User): boolean {
|
||||
if (!hasLocalProfile(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const streamAuthTypes = enabledAuthenticationIntegrations(tenant, "stream");
|
||||
|
||||
// user can update email if local auth is enabled or any integration other than sso is enabled
|
||||
return (
|
||||
streamAuthTypes.includes("local") ||
|
||||
!(streamAuthTypes.length === 1 && streamAuthTypes[0] === "sso")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* updateEmail will update the current User's email address.
|
||||
* @param mongo mongo database to interact with
|
||||
* @param tenant Tenant where the User will be interacted with
|
||||
* @param mailer The mailer queue
|
||||
* @param config Convict config
|
||||
* @param user the User that we are updating
|
||||
* @param email the email address that we are setting on the User
|
||||
* @param password the users password for confirmation
|
||||
*/
|
||||
export async function updateEmail(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
mailer: MailerQueue,
|
||||
config: Config,
|
||||
signingConfig: JWTSigningConfig,
|
||||
user: User,
|
||||
emailAddress: string,
|
||||
password: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const email = emailAddress.toLowerCase();
|
||||
validateEmail(email);
|
||||
|
||||
const canUpdate = canUpdateLocalProfile(tenant, user);
|
||||
if (!canUpdate) {
|
||||
throw new Error("Cannot update profile due to tenant settings");
|
||||
}
|
||||
|
||||
const passwordVerified = await verifyUserPassword(user, password);
|
||||
if (!passwordVerified) {
|
||||
// We throw a PasswordIncorrect error here instead of an
|
||||
// InvalidCredentialsError because the current user is already signed in.
|
||||
throw new PasswordIncorrect();
|
||||
}
|
||||
|
||||
const updated = await updateUserEmail(mongo, tenant.id, user.id, email);
|
||||
|
||||
await sendConfirmationEmail(
|
||||
mongo,
|
||||
mailer,
|
||||
tenant,
|
||||
config,
|
||||
signingConfig,
|
||||
updated as Required<User>,
|
||||
now
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* updateUserEmail will update the given User's email address. This should not
|
||||
* trigger and email notifications as it's designed to be used by administrators
|
||||
* to update a user's email address.
|
||||
*
|
||||
@@ -492,7 +591,7 @@ export async function updateRole(
|
||||
* @param userID the User's ID that we are updating
|
||||
* @param email the email address that we are setting on the User
|
||||
*/
|
||||
export async function updateEmail(
|
||||
export async function updateEmailByID(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
userID: string,
|
||||
@@ -501,7 +600,7 @@ export async function updateEmail(
|
||||
// Validate the email address.
|
||||
validateEmail(email);
|
||||
|
||||
return updateUserEmail(mongo, tenant.id, userID, email);
|
||||
return updateUserEmail(mongo, tenant.id, userID, email, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -278,3 +278,21 @@ suspendInfo-info =
|
||||
account has been temporarily suspended. While suspended you will not
|
||||
be able to comment, respect or report comments. Please rejoin the
|
||||
conversation on { $until }
|
||||
|
||||
profile-changeEmail-unverified = (Unverified)
|
||||
profile-changeEmail-edit = Edit
|
||||
profile-changeEmail-please-verify = Verify your email address
|
||||
profile-changeEmail-please-verify-details =
|
||||
An email has been sent to { $email } to verify your account.
|
||||
You must verify your new email address before it can be used for
|
||||
signing into your account or for email notifications.
|
||||
profile-changeEmail-resend = Resend verification
|
||||
profile-changeEmail-heading = Edit your email address
|
||||
profile-changeEmail-desc = Change the email address used for signing in and for receiving communication about your account.
|
||||
profile-changeEmail-current = Current email
|
||||
profile-changeEmail-newEmail-label = New email address
|
||||
profile-changeEmail-password = Password
|
||||
profile-changeEmail-password-input =
|
||||
.placeholder = Password
|
||||
profile-changeEmail-cancel = Cancel
|
||||
profile-changeEmail-submit = Save
|
||||
|
||||
Reference in New Issue
Block a user