[CORL-845] Account Linking (#2818)

* feat: added new linking backend

* feat: added duplicateEmail to hash

* fix: stored the duplicate email on the user

* feat: initial implmentation of account linking in auth

* test: fix unit tests

* fix+test: translations and tests added

* chore+test: rename view to LINK_ACCOUNT + more tests

* feat+test: account linking admin + more tests

* feat: Handle incomplete accounts

* chore: add some comments

* feat: expose duplicateEmail through graphql and impl for stream

* feat: admin to use duplicateEmail from graphql

* fix: no need to validate password for account linking

* fix: dont validate password

* fix: no need to render error message when account was incomplete

* chore: log to console when encountering incomplete account

* chore: adjust comment

* chore: simplify + add comments

* chore: wording

* chore: comments

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
This commit is contained in:
Wyatt Johnson
2020-02-25 15:46:32 -05:00
committed by GitHub
co-authored by Vinh Kim Gardner
parent 7497e33046
commit dcb2a10e72
80 changed files with 2414 additions and 710 deletions
+16
View File
@@ -4,6 +4,7 @@ import crypto from "crypto";
import { translate } from "coral-server/services/i18n";
import {
GQLAuthIntegrations,
GQLFEATURE_FLAG,
GQLReactionConfiguration,
GQLStaffConfiguration,
@@ -68,6 +69,21 @@ export function hasFeatureFlag(
return false;
}
export function hasEnabledAuthIntegration(
tenant: Pick<Tenant, "auth">,
integration: keyof GQLAuthIntegrations
) {
return tenant.auth.integrations[integration].enabled;
}
export function linkUsersAvailable(tenant: Pick<Tenant, "auth">) {
return (
hasEnabledAuthIntegration(tenant, "local") &&
(hasEnabledAuthIntegration(tenant, "facebook") ||
hasEnabledAuthIntegration(tenant, "google"))
);
}
export function getWebhookEndpoint(
tenant: Pick<Tenant, "webhooks">,
endpointID: string
+14 -15
View File
@@ -1,10 +1,11 @@
import { isEqual } from "lodash";
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/verifiers/sso";
import { STAFF_ROLES } from "./constants";
import { LocalProfile, SSOProfile, User } from "./user";
import { LocalProfile, Profile, SSOProfile, User } from "./user";
export function roleIsStaff(role: GQLUSER_ROLE) {
if (STAFF_ROLES.includes(role)) {
@@ -18,14 +19,19 @@ export function hasStaffRole(user: Pick<User, "role">) {
return roleIsStaff(user.role);
}
export function getSSOProfile(user: Pick<User, "profiles">) {
export function getUserProfile(
user: Pick<User, "profiles">,
type: Profile["type"]
) {
if (!user.profiles) {
return;
return null;
}
return user.profiles.find(profile => profile.type === "sso") as
| SSOProfile
| undefined;
return user.profiles.find(p => p.type === type) || null;
}
export function getSSOProfile(user: Pick<User, "profiles">) {
return getUserProfile(user, "sso") as SSOProfile | null;
}
export function needsSSOUpdate(
@@ -50,14 +56,7 @@ export function getLocalProfile(
user: Pick<User, "profiles">,
withEmail?: string
): LocalProfile | undefined {
if (!user.profiles) {
return;
}
const profile = user.profiles.find(({ type }) => type === "local") as
| LocalProfile
| undefined;
const profile = getUserProfile(user, "local") as LocalProfile | null;
if (!profile) {
return;
}
+57 -1
View File
@@ -9,6 +9,7 @@ import {
ConfirmEmailTokenExpired,
DuplicateEmailError,
DuplicateUserError,
EmailAlreadySetError,
LocalProfileAlreadySetError,
LocalProfileNotSetError,
PasswordResetTokenExpired,
@@ -408,6 +409,13 @@ export interface User extends TenantResource {
*/
emailVerified?: boolean;
/**
* duplicateEmail is used to store the email address that was associated with
* the user account on creation that at the time was previously associated
* with another local account.
*/
duplicateEmail?: string;
/**
* profiles is the array of profiles assigned to the user. When a user deletes
* their account, this is unset.
@@ -492,6 +500,7 @@ export interface FindOrCreateUserInput {
badges?: string[];
ssoURL?: string;
emailVerified?: boolean;
duplicateEmail?: string;
role: GQLUSER_ROLE;
profile: Profile;
}
@@ -1059,6 +1068,9 @@ export async function setUserEmail(
$set: {
email,
},
$unset: {
duplicateEmail: "",
},
},
{
// False to return the updated document instead of the original
@@ -1074,7 +1086,7 @@ export async function setUserEmail(
}
if (user.email) {
throw new UsernameAlreadySetError();
throw new EmailAlreadySetError();
}
throw new Error("an unexpected error occurred");
@@ -2472,6 +2484,50 @@ export async function deleteModeratorNote(
return result.value;
}
export async function linkUsers(
mongo: Db,
tenantID: string,
sourceUserID: string,
destinationUserID: string
) {
// Delete the old user from the database.
const source = await collection(mongo).findOneAndDelete({
id: sourceUserID,
tenantID,
});
if (!source.value) {
throw new UserNotFoundError(sourceUserID);
}
// If the source user doesn't have any profiles, we have nothing to do. We
// should abort.
if (!source.value.profiles) {
throw new Error(
"cannot link a user with no profiles, failed source authentication precondition"
);
}
// Add the new profile to the destination user.
const dest = await collection(mongo).findOneAndUpdate(
{
id: destinationUserID,
tenantID,
},
{
$push: {
profiles: {
$each: source.value.profiles,
},
},
}
);
if (!dest.value) {
throw new UserNotFoundError(destinationUserID);
}
return dest.value;
}
export const updateUserCommentCounts = (
mongo: Db,
tenantID: string,