Continue merge user

This commit is contained in:
Tuan Dang
2024-04-25 17:02:55 -07:00
parent d9005e8665
commit 8ff407927c
16 changed files with 337 additions and 26 deletions

View File

@@ -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,

View File

@@ -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<TUserDALFactory, "create" | "findOne" | "transaction" | "updateById">;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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 };
}

View File

@@ -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<typeof orgMembershipDALFactory>;
export const orgMembershipDALFactory = (db: TDbClient) => {
const orgMembershipOrm = ormify(db, TableName.OrgMembership);
return {
...orgMembershipOrm
};
};

View File

@@ -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<typeof userServiceFactory>;
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,

View File

@@ -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";

View File

@@ -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;
}
});
};

View File

@@ -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
});
};

View File

@@ -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 (
<UserInfoSSOStep
username={username}
email={email}
name={`${firstName} ${lastName}`}
providerOrganizationName={organizationName}
password={password}
@@ -28,8 +35,10 @@ export const SignupSSO = ({ providerAuthToken }: Props) => {
/>
);
case 1:
return <EmailConfirmationStep />;
return <EmailConfirmationStep email={email} setStep={setStep} />;
case 2:
return <MergeUsersStep username={username} />;
case 3:
return (
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />
);

View File

@@ -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 (
<div className="mx-auto h-full w-full pb-4 md:px-8">
<p className="text-md flex justify-center text-bunker-200">
We&apos;ve sent a verification code to
We&apos;ve sent a verification code to {email}
</p>
<p className="text-md my-1 flex justify-center font-semibold text-bunker-200">
{user?.email}
@@ -113,7 +131,7 @@ export const EmailConfirmationStep = () => {
className="mt-2 mb-2"
/>
</div>
{/* {codeError && <Error text="Oops. Your code is wrong. Please try again." />} */}
{codeError && <Error text="Oops. Your code is wrong. Please try again." />}
<div className="mx-auto mt-2 flex w-1/4 min-w-[20rem] max-w-xs flex-col items-center justify-center text-center text-sm md:max-w-md md:text-left lg:w-[19%]">
<div className="text-l w-full py-1 text-lg">
<Button

View File

@@ -0,0 +1,102 @@
import { useRouter } from "next/router";
import { faUsers } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import {
Button,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useListUsersWithMyEmail, useMergeUsers } from "@app/hooks/api";
type Props = {
username: string;
};
export const MergeUsersStep = ({ username }: Props) => {
const router = useRouter();
const { data: users, isLoading: isLoadingUsers } = useListUsersWithMyEmail();
const { mutateAsync: mergeUser, isLoading: isLoadingMerge } = useMergeUsers();
const handleMergeUser = async (targetUsername: string) => {
try {
console.log("merge A");
await mergeUser({ username: targetUsername });
// TODO: logout, make user re-login
console.log("merge B");
createNotification({
text: "Successfully merged user",
type: "success"
});
router.push("/login");
} catch (err) {
console.error(err);
createNotification({
text: "Failed to merge user",
type: "error"
});
}
};
return (
<div className="mx-auto h-full max-w-xl">
<p className="text-md flex justify-center text-bunker-200">
We found an account with the same verified email.
</p>
<p className="text-md mb-8 flex justify-center text-bunker-200">
Select the account to merge with it.
</p>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Username</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoadingUsers && <TableSkeleton columns={3} innerKey="same-email-users" />}
{!isLoadingUsers &&
users
?.filter((user) => user.username !== username)
?.map((user) => {
return (
<Tr className="h-10 items-center" key={`same-email-user-${user.id}`}>
<Td>{`${user.firstName ?? ""} ${user.lastName ?? ""}`}</Td>
<Td>{username}</Td>
<Td>
<Button
isLoading={isLoadingMerge}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleMergeUser(user.username)}
>
Merge
</Button>
</Td>
</Tr>
);
})}
{!isLoadingUsers && !users?.length && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No users found with the same email" icon={faUsers} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export { MergeUsersStep } from "./MergeUsersStep";

View File

@@ -25,6 +25,7 @@ const client = new jsrp.client();
type Props = {
setStep: (step: number) => void;
username: string;
email?: string;
password: string;
setPassword: (value: string) => void;
name: string;
@@ -58,6 +59,7 @@ type Errors = {
*/
export const UserInfoSSOStep = ({
username,
email,
name,
providerOrganizationName,
password,
@@ -201,7 +203,13 @@ export const UserInfoSSOStep = ({
localStorage.setItem("orgData.id", orgId);
localStorage.setItem("projectData.id", project.id);
setStep(1);
if (email) {
// move to verify email
setStep(1);
} else {
// move to backup PDF step
setStep(2);
}
} catch (error) {
setIsLoading(false);
console.error(error);

View File

@@ -1,3 +1,4 @@
export { BackupPDFStep } from "./BackupPDFStep";
export { EmailConfirmationStep } from "./EmailConfirmationStep";
export { MergeUsersStep } from "./MergeUsersStep";
export { UserInfoSSOStep } from "./UserInfoSSOStep";