diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 9179fde32..5046ad125 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -26,8 +26,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogsRetentionDays: 0, samlSSO: true, scim: true, - ldap: false, - groups: false, + ldap: true, + groups: true, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index f88182e61..c1d270ee1 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -23,6 +23,7 @@ import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -33,6 +34,7 @@ import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } f type TSamlConfigServiceFactoryDep = { samlConfigDAL: TSamlConfigDALFactory; userDAL: Pick; + userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, "createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById" @@ -360,6 +362,7 @@ export const samlConfigServiceFactory = ({ { username, email, + isEmailVerified: false, firstName, lastName, authMethods: [AuthMethod.EMAIL], @@ -382,6 +385,7 @@ export const samlConfigServiceFactory = ({ authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + ...(user.email && { email: user.email }), firstName, lastName, organizationName: organization.name, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 31a13aaeb..70ba31ac8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -86,6 +86,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; import { projectServiceFactory } from "@app/services/project/project-service"; @@ -153,6 +154,7 @@ export const registerRoutes = async ( const authDAL = authDALFactory(db); const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); + const orgMembershipDAL = orgMembershipDALFactory(db); const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); @@ -328,7 +330,14 @@ export const registerRoutes = async ( }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL }); - const userService = userServiceFactory({ userDAL, tokenService, smtpService }); + const userService = userServiceFactory({ + userDAL, + userAliasDAL, + orgDAL, + orgMembershipDAL, + tokenService, + smtpService + }); const loginService = authLoginServiceFactory({ userDAL, smtpService, tokenService, orgDAL, tokenDAL: authTokenDAL }); const passwordService = authPaswordServiceFactory({ tokenService, diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index dd49f6d5e..5760fb593 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { AuthTokenSessionsSchema, OrganizationsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { getConfig } from "@app/lib/config/env"; import { authRateLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; @@ -68,6 +69,38 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/me/users/merge-user", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + username: z.string().trim() + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + const appCfg = getConfig(); + const user = await server.services.user.mergeUsers(req.permission.id, req.body.username); + void res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + return { + user + }; + } + }); + server.route({ method: "PATCH", url: "/me/mfa", diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index bd35f3ae1..5d68a4e94 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -30,13 +30,14 @@ export const getTokenConfig = (tokenType: TokenType) => { case TokenType.TOKEN_EMAIL_VERIFICATION: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 86400000); - return { token, expiresAt }; + return { token, triesLeft, expiresAt }; } case TokenType.TOKEN_EMAIL_MFA: { // generate random 6-digit code const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1)); - const triesLeft = 5; + const triesLeft = 3; const expiresAt = new Date(new Date().getTime() + 300000); return { token, triesLeft, expiresAt }; } diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts new file mode 100644 index 000000000..9990d9c3d --- /dev/null +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TOrgMembershipDALFactory = ReturnType; + +export const orgMembershipDALFactory = (db: TDbClient) => { + const orgMembershipOrm = ormify(db, TableName.OrgMembership); + + return { + ...orgMembershipOrm + }; +}; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 453848bf7..552ddff11 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,20 +1,34 @@ import { BadRequestError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; import { AuthMethod } from "../auth/auth-type"; import { TUserDALFactory } from "./user-dal"; +// TODO: Pick all of these type TUserServiceFactoryDep = { userDAL: TUserDALFactory; + userAliasDAL: TUserAliasDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; }; export type TUserServiceFactory = ReturnType; -export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUserServiceFactoryDep) => { +export const userServiceFactory = ({ + userDAL, + userAliasDAL, + // orgDAL, + orgMembershipDAL, + tokenService, + smtpService +}: TUserServiceFactoryDep) => { const sendEmailVerificationCode = async (userId: string) => { console.log("sendEmailVerificationCode userId: ", userId); const user = await userDAL.findById(userId); @@ -78,6 +92,68 @@ export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUser return users; }; + /** + * Merges two users with the same email. Specifically: + * - Deletes the current user with id [userId] and transfers any resources to the user with username [username] + * @param userId + * @param username + */ + const mergeUsers = async (userId: string, username: string) => { + const targetUser = await userDAL.transaction(async (tx) => { + const myUser = await userDAL.findById(userId, tx); + if (!myUser || !myUser.isEmailVerified) throw new BadRequestError({}); + + const mergeUser = await userDAL.findOne( + { + username + }, + tx + ); + if (!mergeUser || !mergeUser.isEmailVerified) throw new BadRequestError({}); + + if (myUser.email !== mergeUser.email) throw new BadRequestError({}); + + const mergeUserOrgMembershipSet = new Set( + (await orgMembershipDAL.find({ userId: mergeUser.id }, { tx })).map((m) => m.orgId) + ); + const myOrgMemberships = (await orgMembershipDAL.find({ userId: myUser.id }, { tx })).filter( + (m) => !mergeUserOrgMembershipSet.has(m.orgId) + ); + + const userAliases = await userAliasDAL.find( + { + userId: myUser.id + }, + { tx } + ); + await userDAL.deleteById(myUser.id, tx); + + if (myOrgMemberships.length) { + await orgMembershipDAL.insertMany( + myOrgMemberships.map((orgMembership) => ({ + ...orgMembership, + userId: mergeUser.id + })), + tx + ); + } + + if (userAliases.length) { + await userAliasDAL.insertMany( + userAliases.map((userAlias) => ({ + ...userAlias, + userId: mergeUser.id + })), + tx + ); + } + + return mergeUser; + }); + + return targetUser; + }; + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { const user = await userDAL.findById(userId); @@ -143,6 +219,7 @@ export const userServiceFactory = ({ userDAL, tokenService, smtpService }: TUser sendEmailVerificationCode, verifyEmailVerificationCode, listUsersWithSameEmail, + mergeUsers, toggleUserMfa, updateUserName, updateAuthMethods, diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index a8ad89f4c..27f4d30e4 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,11 +1,12 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useMergeUsers, useSendEmailVerificationCode, - useVerifyEmailVerificationCode -} from "./mutation"; + useVerifyEmailVerificationCode} from "./mutation"; export { fetchOrgUsers, + fetchUsersWithMyEmail, useAddUserToOrg, useCreateAPIKey, useDeleteAPIKey, @@ -19,10 +20,10 @@ export { useGetOrgUsers, useGetUser, useGetUserAction, + useListUsersWithMyEmail, useLogoutUser, useRegisterUserAction, useRevokeMySessions, useUpdateMfaEnabled, useUpdateOrgUserRole, - useUpdateUserAuthMethods -} from "./queries"; + useUpdateUserAuthMethods} from "./queries"; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index e1e939105..9bebd84cd 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -7,7 +7,8 @@ import { import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace/queries"; -import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE } from "./types"; +import { userKeys } from "./queries"; +import { AddUserToWsDTOE2EE, AddUserToWsDTONonE2EE, User } from "./types"; export const useAddUserToWsE2EE = () => { const queryClient = useQueryClient(); @@ -72,12 +73,27 @@ export const useSendEmailVerificationCode = () => { }; export const useVerifyEmailVerificationCode = () => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ code }: { code: string }) => { await apiRequest.post("/api/v2/users/me/emails/verify", { code }); return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries(userKeys.usersWithMyEmail); + } + }); +}; + +export const useMergeUsers = () => { + return useMutation({ + mutationFn: async ({ username }: { username: string }) => { + const { data } = await apiRequest.post<{ user: User }>("/api/v2/users/me/users/merge-user", { + username + }); + return data; } }); }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index a443c6750..9bec3f19e 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -26,7 +26,8 @@ export const userKeys = { myAPIKeys: ["api-keys"] as const, myAPIKeysV2: ["api-keys-v2"] as const, mySessions: ["sessions"] as const, - myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const + myOrganizationProjects: (orgId: string) => [{ orgId }, "organization-projects"] as const, + usersWithMyEmail: ["users-with-my-email"] as const }; export const fetchUserDetails = async () => { @@ -351,3 +352,20 @@ export const useGetMyOrganizationProjects = (orgId: string) => { enabled: true }); }; + +export const fetchUsersWithMyEmail = async () => { + const { + data: { users } + } = await apiRequest.get<{ users: User[] }>("/api/v2/users/me/users/same-email"); + return users; +}; + +export const useListUsersWithMyEmail = () => { + return useQuery({ + queryKey: userKeys.usersWithMyEmail, + queryFn: async () => { + return fetchUsersWithMyEmail(); + }, + enabled: true + }); +}; diff --git a/frontend/src/views/Signup/SignupSSO.tsx b/frontend/src/views/Signup/SignupSSO.tsx index d74da3639..d56a26347 100644 --- a/frontend/src/views/Signup/SignupSSO.tsx +++ b/frontend/src/views/Signup/SignupSSO.tsx @@ -1,7 +1,11 @@ import { useState } from "react"; import jwt_decode from "jwt-decode"; -import { BackupPDFStep, EmailConfirmationStep,UserInfoSSOStep } from "./components"; +import { + BackupPDFStep, + EmailConfirmationStep, + MergeUsersStep, + UserInfoSSOStep} from "./components"; type Props = { providerAuthToken: string; @@ -11,7 +15,9 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { const [step, setStep] = useState(0); const [password, setPassword] = useState(""); - const { username, organizationName, firstName, lastName } = jwt_decode(providerAuthToken) as any; + const { username, email, organizationName, firstName, lastName } = jwt_decode( + providerAuthToken + ) as any; const renderView = () => { switch (step) { @@ -19,6 +25,7 @@ export const SignupSSO = ({ providerAuthToken }: Props) => { return ( { /> ); case 1: - return ; + return ; case 2: + return ; + case 3: return ( ); diff --git a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx index e9b0a6e84..c22db1ee0 100644 --- a/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/views/Signup/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -3,11 +3,19 @@ import { useState } from "react"; import ReactCodeInput from "react-code-input"; -// import Error from "@app/components/basic/Error"; +import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import { Button } from "@app/components/v2"; import { useUser } from "@app/context"; -import { useSendEmailVerificationCode, useVerifyEmailVerificationCode } from "@app/hooks/api"; +import { + fetchUsersWithMyEmail, + useSendEmailVerificationCode, + useVerifyEmailVerificationCode} from "@app/hooks/api"; + +type Props = { + email: string; + setStep: (step: number) => void; +}; // The style for the verification code input const props = { @@ -47,10 +55,10 @@ const propsPhone = { } } as const; -export const EmailConfirmationStep = () => { +export const EmailConfirmationStep = ({ email, setStep }: Props) => { const { user } = useUser(); const [code, setCode] = useState(""); - // const [codeError, setCodeError] = useState(false); + const [codeError, setCodeError] = useState(false); const [isResendingVerificationEmail] = useState(false); const [isLoading] = useState(false); @@ -59,22 +67,32 @@ export const EmailConfirmationStep = () => { const checkCode = async () => { try { - console.log("checkCode code: ", code); await verifyEmailVerificationCode({ code }); - console.log("checkCode 2"); + setCodeError(false); + + const usersWithSameEmail = await fetchUsersWithMyEmail(); + + if (usersWithSameEmail.length > 1) { + setStep(2); + } + + createNotification({ + text: "Successfully verified code", + type: "success" + }); } catch (err) { createNotification({ text: "Failed to verify code", type: "error" }); } + + setCode(""); }; const resendCode = async () => { try { - console.log("resendCode"); await sendEmailVerificationCode(); - console.log("resendCode"); } catch (err) { createNotification({ text: "Failed to resend code", @@ -86,7 +104,7 @@ export const EmailConfirmationStep = () => { return (

- We've sent a verification code to + We've sent a verification code to {email}

{user?.email} @@ -113,7 +131,7 @@ export const EmailConfirmationStep = () => { className="mt-2 mb-2" />

- {/* {codeError && } */} + {codeError && }