mirror of
https://github.com/wassname/talk.git
synced 2026-07-20 12:40:47 +08:00
[next] Auth Popup v2 (#2101)
* feat: Implement new Sign In view * feat: Move forgot + resetPassword to new design * feat: Implement sign up with new design * fix: narrow gutter * test: add unit tests * test: integration tests * feat: support show / hide password * feat: support oauth2 flow * feat: add views for user completion * feat: implement oauth2 sign up * test: fix snapshots * fix: lint * fix: get more complete mutation response * fix: removed array of OIDC integrations * fix: renamed resolver function * fix: adapt oidc client implementation * fix: targetFilter should be stream on signup * fix: removed unneeded message * fix: moved password into local profile * fix: made username optional, removed valid null value * fix: linting * fix: respect targetFilter * feat: support user registration mutations - Added `setUsername` - Added `setEmail` - Added `setPassword` - Added `permit` to `@auth` - Added `email` to `User` * fix: fixed issue with query * feat: added user password update * feat: complete sign in mutation * fix: adapt some rebasing gitches * test: improve tests * test: unittest for setting auth token * fix: failing tests * test: move most tests from enzyme to react-test-renderer * fix: remove schema warnings in tests * test: improve window mock * test: test different social login configurations * test: test social logins for sign up * fix: use htmlFor instead of for * test: more feature tests * feat: always go through account completion * test: feature test account completion * feat: addtional account completion test * Update start.ts * chore: refactor auth token retrieval logic
This commit is contained in:
@@ -1,55 +1,20 @@
|
||||
import { Omit } from "talk-common/types";
|
||||
import {
|
||||
GQLAuth,
|
||||
GQLAuthDisplayNameConfiguration,
|
||||
GQLCharCount,
|
||||
GQLEmail,
|
||||
GQLExternalIntegrations,
|
||||
GQLFacebookAuthIntegration,
|
||||
GQLGoogleAuthIntegration,
|
||||
GQLKarma,
|
||||
GQLLocalAuthIntegration,
|
||||
GQLMODERATION_MODE,
|
||||
GQLOIDCAuthIntegration,
|
||||
GQLReactionConfiguration,
|
||||
GQLSSOAuthIntegration,
|
||||
GQLWordList,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
// export interface EmailDomainRuleCondition {
|
||||
// /**
|
||||
// * emailDomain is the domain name component of the email addresses that should
|
||||
// * match for this condition.
|
||||
// */
|
||||
// emailDomain: string;
|
||||
// /**
|
||||
// * emailVerifiedRequired stipulates that this rule only applies when the user
|
||||
// * account has been marked as having their email address already verified.
|
||||
// */
|
||||
// emailVerifiedRequired: boolean;
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * RoleRule describes the role assignment for when a user logs into Talk, how
|
||||
// * they can have their account automatically upgraded to a specific role when
|
||||
// * the domain for their email matches the one provided.
|
||||
// */
|
||||
// export interface RoleRule extends Partial<EmailDomainRuleCondition> {
|
||||
// /**
|
||||
// * role is the specific GQLUSER_ROLE that should be assigned to the newly
|
||||
// * created user depending on their email address.
|
||||
// */
|
||||
// role: GQLUSER_ROLE;
|
||||
// }
|
||||
|
||||
// export interface AuthRules {
|
||||
// /**
|
||||
// * roles allow the configuration of automatic role assignment based on the
|
||||
// * user's email address.
|
||||
// */
|
||||
// roles?: RoleRule[];
|
||||
|
||||
// /**
|
||||
// * restrictTo when populated, will restrict which users can login using this
|
||||
// * integration. If a user successfully logs in using the OIDCStrategy, but
|
||||
// * does not match the following rules, the user will not be created.
|
||||
// */
|
||||
// restrictTo?: EmailDomainRuleCondition[];
|
||||
// }
|
||||
|
||||
export interface ModerationSettings {
|
||||
moderation: GQLMODERATION_MODE;
|
||||
requireEmailConfirmation: boolean;
|
||||
@@ -67,6 +32,46 @@ export interface ModerationSettings {
|
||||
charCount: GQLCharCount;
|
||||
}
|
||||
|
||||
export type LocalAuthIntegration = GQLLocalAuthIntegration;
|
||||
export type SSOAuthIntegration = GQLSSOAuthIntegration;
|
||||
|
||||
export type OIDCAuthIntegration = Omit<
|
||||
GQLOIDCAuthIntegration,
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export type GoogleAuthIntegration = Omit<
|
||||
GQLGoogleAuthIntegration,
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export type FacebookAuthIntegration = Omit<
|
||||
GQLFacebookAuthIntegration,
|
||||
"callbackURL" | "redirectURL"
|
||||
>;
|
||||
|
||||
export interface AuthIntegrations {
|
||||
local: LocalAuthIntegration;
|
||||
sso: SSOAuthIntegration;
|
||||
oidc: OIDCAuthIntegration;
|
||||
google: GoogleAuthIntegration;
|
||||
facebook: FacebookAuthIntegration;
|
||||
}
|
||||
|
||||
export interface Auth {
|
||||
/**
|
||||
* integrations are the set of configurations for the variations of
|
||||
* authentication solutions.
|
||||
*/
|
||||
integrations: AuthIntegrations;
|
||||
|
||||
/**
|
||||
* displayName contains configuration related to the use of Display Names
|
||||
* across AuthIntegrations.
|
||||
*/
|
||||
displayName: GQLAuthDisplayNameConfiguration;
|
||||
}
|
||||
|
||||
export interface Settings extends ModerationSettings {
|
||||
customCssUrl?: string;
|
||||
|
||||
@@ -96,7 +101,7 @@ export interface Settings extends ModerationSettings {
|
||||
/**
|
||||
* Set of configured authentication integrations.
|
||||
*/
|
||||
auth: GQLAuth;
|
||||
auth: Auth;
|
||||
|
||||
/**
|
||||
* Various integrations with external services.
|
||||
|
||||
@@ -5,10 +5,7 @@ import uuid from "uuid";
|
||||
|
||||
import { DeepPartial, Omit, Sub } from "talk-common/types";
|
||||
import { dotize, DotizeOptions } from "talk-common/utils/dotize";
|
||||
import {
|
||||
GQLMODERATION_MODE,
|
||||
GQLOIDCAuthIntegration,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Settings } from "talk-server/models/settings";
|
||||
|
||||
function collection(db: Db) {
|
||||
@@ -114,11 +111,17 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
|
||||
key: generateSSOKey(),
|
||||
keyGeneratedAt: new Date(),
|
||||
},
|
||||
oidc: [],
|
||||
oidc: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
google: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
callbackURL: "" as any, // FIXME: this should not be required
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
@@ -127,7 +130,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) {
|
||||
facebook: {
|
||||
enabled: false,
|
||||
allowRegistration: false,
|
||||
callbackURL: "", // FIXME: this should not be required
|
||||
targetFilter: {
|
||||
admin: true,
|
||||
stream: true,
|
||||
@@ -282,162 +284,3 @@ export async function regenerateTenantSSOKey(db: Db, id: string) {
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export type CreateTenantOIDCAuthIntegrationInput = Omit<
|
||||
GQLOIDCAuthIntegration,
|
||||
"id" | "callbackURL"
|
||||
>;
|
||||
|
||||
export interface CreateTenantOIDCAuthIntegrationResultObject {
|
||||
tenant?: Tenant;
|
||||
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
wasCreated: boolean;
|
||||
}
|
||||
|
||||
export async function createTenantOIDCAuthIntegration(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
input: CreateTenantOIDCAuthIntegrationInput
|
||||
): Promise<CreateTenantOIDCAuthIntegrationResultObject> {
|
||||
// Add the ID to the integration.
|
||||
const integration = {
|
||||
id: uuid.v4(),
|
||||
...input,
|
||||
};
|
||||
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
// Serialize the deep update into the Tenant.
|
||||
{
|
||||
$push: { "auth.integrations.oidc": integration },
|
||||
},
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
{ returnOriginal: false }
|
||||
);
|
||||
if (!result.value) {
|
||||
return {
|
||||
wasCreated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const wasCreated =
|
||||
result.value.auth.integrations.oidc.findIndex(
|
||||
({ id: integrationID }) => integrationID === integration.id
|
||||
) !== -1;
|
||||
|
||||
return {
|
||||
tenant: result.value,
|
||||
integration,
|
||||
wasCreated,
|
||||
};
|
||||
}
|
||||
|
||||
export type UpdateTenantOIDCAuthIntegrationInput = Partial<
|
||||
Omit<GQLOIDCAuthIntegration, "id">
|
||||
>;
|
||||
|
||||
export interface UpdateTenantOIDCAuthIntegrationResultObject {
|
||||
tenant?: Tenant;
|
||||
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
wasUpdated: boolean;
|
||||
}
|
||||
|
||||
export async function updateTenantOIDCAuthIntegration(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
oidcID: string,
|
||||
input: UpdateTenantOIDCAuthIntegrationInput
|
||||
): Promise<UpdateTenantOIDCAuthIntegrationResultObject> {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: dotizeDropNull({
|
||||
"auth.integrations.oidc.$[oidc]": input,
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Add an ArrayFilter to only update one of the OpenID Connect
|
||||
// integrations.
|
||||
arrayFilters: [{ "oidc.id": oidcID }],
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return {
|
||||
wasUpdated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const integration = result.value.auth.integrations.oidc.find(
|
||||
({ id: integrationID }) => integrationID === oidcID
|
||||
);
|
||||
|
||||
const wasUpdated = Boolean(integration);
|
||||
|
||||
return {
|
||||
tenant: result.value,
|
||||
integration,
|
||||
wasUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RemoveTenantOIDCAuthIntegrationResultObject {
|
||||
tenant?: Tenant;
|
||||
integration?: Omit<GQLOIDCAuthIntegration, "callbackURL">;
|
||||
wasRemoved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeTenantOIDCAuthIntegration will delete the specific OpenID Connect Auth
|
||||
* Integration on the Tenant.
|
||||
*
|
||||
* @param mongo MongoDB Database handle
|
||||
* @param id the id of the Tenant
|
||||
* @param oidcID the id of the OpenID Connect Auth Integration we're deleting
|
||||
*/
|
||||
export async function removeTenantOIDCAuthIntegration(
|
||||
mongo: Db,
|
||||
id: string,
|
||||
oidcID: string
|
||||
): Promise<RemoveTenantOIDCAuthIntegrationResultObject> {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$pull: { "auth.integrations.oidc": { id: oidcID } },
|
||||
},
|
||||
{
|
||||
// True to return the document before we modified it. This gives us the
|
||||
// opportunity to return the original document and asertain if the
|
||||
// integration was/could be removed.
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
return { wasRemoved: false };
|
||||
}
|
||||
|
||||
// Find the integration that we wanted to delete.
|
||||
const integration = result.value.auth.integrations.oidc.find(
|
||||
({ id: integrationID }) => integrationID === oidcID
|
||||
);
|
||||
if (!integration) {
|
||||
// The integration was not in the original document, so we could not have
|
||||
// possibly deleted it!
|
||||
return { wasRemoved: false };
|
||||
}
|
||||
|
||||
// The integration was found, we should pull that integration out of the
|
||||
// resulting Tenant.
|
||||
result.value.auth.integrations.oidc.filter(
|
||||
({ id: integrationID }) => integrationID !== integration.id
|
||||
);
|
||||
|
||||
return {
|
||||
tenant: result.value,
|
||||
integration,
|
||||
wasRemoved: true,
|
||||
};
|
||||
}
|
||||
|
||||
+330
-16
@@ -17,6 +17,7 @@ function collection(db: Db) {
|
||||
export interface LocalProfile {
|
||||
type: "local";
|
||||
id: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface OIDCProfile {
|
||||
@@ -74,9 +75,9 @@ export interface UserStatus {
|
||||
|
||||
export interface User extends TenantResource {
|
||||
readonly id: string;
|
||||
username: string | null;
|
||||
username?: string;
|
||||
lowercaseUsername?: string;
|
||||
displayName?: string;
|
||||
password?: string;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
@@ -88,9 +89,19 @@ export interface User extends TenantResource {
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export type UpsertUserInput = Omit<
|
||||
User,
|
||||
"id" | "tenantID" | "tokens" | "status" | "ignoredUserIDs" | "createdAt"
|
||||
| "id"
|
||||
| "tenantID"
|
||||
| "tokens"
|
||||
| "status"
|
||||
| "ignoredUserIDs"
|
||||
| "createdAt"
|
||||
| "lowercaseUsername"
|
||||
>;
|
||||
|
||||
export async function upsertUser(
|
||||
@@ -129,20 +140,39 @@ export async function upsertUser(
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
let hashedPassword;
|
||||
if (input.password) {
|
||||
// Hash the user's password with bcrypt.
|
||||
hashedPassword = await bcrypt.hash(input.password, 10);
|
||||
// Mutate the profiles to ensure we mask handle any secrets.
|
||||
const profiles: Profile[] = [];
|
||||
for (let profile of input.profiles) {
|
||||
switch (profile.type) {
|
||||
case "local":
|
||||
// FIXME: (wyattjoh) add password validation here.
|
||||
|
||||
// Hash the user's password with bcrypt.
|
||||
const password = await hashPassword(profile.password);
|
||||
profile = {
|
||||
...profile,
|
||||
password,
|
||||
};
|
||||
break;
|
||||
}
|
||||
// Save a copy.
|
||||
profiles.push(profile);
|
||||
}
|
||||
|
||||
// Add in the lowercase username if it was sent.
|
||||
if (input.username) {
|
||||
defaults.lowercaseUsername = input.username.toLowerCase();
|
||||
|
||||
// FIXME: (wyattjoh) add username checking regex here.
|
||||
}
|
||||
|
||||
// FIXME: (wyattjoh) add email validation here.
|
||||
|
||||
// Merge the defaults and the input together.
|
||||
const user: Readonly<User> = {
|
||||
...defaults,
|
||||
...input,
|
||||
|
||||
// Specified last in the merge call, it will override any existing password
|
||||
// entry if it is defined.
|
||||
password: hashedPassword,
|
||||
profiles,
|
||||
};
|
||||
|
||||
// Create a query that will utilize a findOneAndUpdate to facilitate an upsert
|
||||
@@ -216,7 +246,7 @@ export async function retrieveManyUsers(
|
||||
export async function retrieveUserWithProfile(
|
||||
db: Db,
|
||||
tenantID: string,
|
||||
profile: Profile
|
||||
profile: Partial<Profile>
|
||||
) {
|
||||
return collection(db).findOne({
|
||||
tenantID,
|
||||
@@ -235,16 +265,300 @@ export async function updateUserRole(
|
||||
const result = await collection(db).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{ $set: { role } },
|
||||
{ returnOriginal: false }
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
export async function verifyUserPassword(user: User, password: string) {
|
||||
if (user.password) {
|
||||
return bcrypt.compare(password, user.password);
|
||||
const profile: LocalProfile | undefined = user.profiles.find(
|
||||
({ type }) => type === "local"
|
||||
) as LocalProfile | undefined;
|
||||
if (!profile) {
|
||||
throw new Error("no local profile exists for this user");
|
||||
}
|
||||
|
||||
return false;
|
||||
return bcrypt.compare(password, profile.password);
|
||||
}
|
||||
|
||||
export async function updateUserPassword(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
password: string
|
||||
) {
|
||||
// FIXME: (wyattjoh) add password validation here.
|
||||
|
||||
// Hash the password.
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Update the user with the new password.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
// This ensures that the document we're updating already has a local
|
||||
// profile associated with them.
|
||||
"profiles.type": "local",
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"profiles.$[profiles].password": hashedPassword,
|
||||
},
|
||||
},
|
||||
{
|
||||
arrayFilters: [{ "profiles.type": "local" }],
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
}
|
||||
|
||||
if (
|
||||
!user.profiles.some(
|
||||
profile => profile.type === "local" && profile.id === user.email
|
||||
)
|
||||
) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user does not have a local profile");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* setUserUsername will set the username of the User if the username hasn't
|
||||
* already been used before.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export async function setUserUsername(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
username: string
|
||||
) {
|
||||
// Lowercase the username.
|
||||
const lowercaseUsername = username.toLowerCase();
|
||||
|
||||
// FIXME: (wyattjoh) add username checking regex here.
|
||||
|
||||
// Search to see if this username has been used before.
|
||||
let user = await collection(mongo).findOne({
|
||||
tenantID,
|
||||
lowercaseUsername,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate username found");
|
||||
}
|
||||
|
||||
// The username wasn't found, so add it to the user.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
username: null,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername,
|
||||
"status.username.status": GQLUSER_USERNAME_STATUS.SET,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has username");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* setUserEmail will set the email address of the User if they don't already
|
||||
* have one associated with them, and it hasn't been used before.
|
||||
*
|
||||
* @param mongo the database handle
|
||||
* @param tenantID the ID to the Tenant
|
||||
* @param id the ID of the User where we are setting the email address on
|
||||
* @param emailAddress the email address we want to set
|
||||
*/
|
||||
export async function setUserEmail(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
emailAddress: string
|
||||
) {
|
||||
// Lowercase the email address.
|
||||
const email = emailAddress.toLowerCase();
|
||||
|
||||
// FIXME: (wyattjoh) add email validation here.
|
||||
|
||||
// Search to see if this email has been used before.
|
||||
let user = await collection(mongo).findOne({
|
||||
tenantID,
|
||||
email,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate email found");
|
||||
}
|
||||
|
||||
// The email wasn't found, so try to update the User.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
email: null,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
email,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has email");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* setUserLocalProfile will set the local profile for a User if they don't
|
||||
* already have one associated with them and the profile doesn't exist on any
|
||||
* other User already.
|
||||
*
|
||||
* @param mongo the database handle
|
||||
* @param tenantID the ID to the Tenant
|
||||
* @param id the ID of the User where we are setting the local profile on
|
||||
* @param emailAddress the email address we want to set
|
||||
* @param password the password we want to set
|
||||
*/
|
||||
export async function setUserLocalProfile(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
emailAddress: string,
|
||||
password: string
|
||||
) {
|
||||
// Lowercase the email address.
|
||||
const email = emailAddress.toLowerCase();
|
||||
|
||||
// FIXME: (wyattjoh) add email validation here.
|
||||
// FIXME: (wyattjoh) add password validation here.
|
||||
|
||||
// Try to see if this local profile already exists on a User.
|
||||
let user = await retrieveUserWithProfile(mongo, tenantID, {
|
||||
type: "local",
|
||||
id: email,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate profile found");
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// Create the profile that we'll use.
|
||||
const profile: LocalProfile = {
|
||||
type: "local",
|
||||
id: email,
|
||||
password: hashedPassword,
|
||||
};
|
||||
|
||||
// The profile wasn't found, so add it to the User.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
tenantID,
|
||||
id,
|
||||
// This ensures that the document we're updating does not contain a local
|
||||
// profile.
|
||||
"profiles.type": { $ne: "local" },
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
profiles: profile,
|
||||
},
|
||||
},
|
||||
{
|
||||
// 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) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
}
|
||||
|
||||
if (user.profiles.some(({ type }) => type === "local")) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has local profile");
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user