[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:
Tessa Thornton
2019-08-20 12:28:14 -04:00
committed by GitHub
parent 0f02b05689
commit ee93267d97
18 changed files with 1016 additions and 69 deletions
+14 -1
View File
@@ -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.
+35 -35
View File
@@ -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;
}
/**
+103 -4
View File
@@ -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);
}
/**