Finish preliminary email validation, merge user flow w saml/ldap

This commit is contained in:
Tuan Dang
2024-04-26 20:19:43 -07:00
parent d0cb06d875
commit 858a35812a
28 changed files with 225 additions and 104 deletions

View File

@@ -0,0 +1,11 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.UserAliases, (t) => {
t.string("username").nullable().alter();
});
}
export async function down(): Promise<void> {}

View File

@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const UserAliasesSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
username: z.string(),
username: z.string().nullable().optional(),
aliasType: z.string(),
externalId: z.string(),
emails: z.string().array().nullable().optional(),

View File

@@ -99,7 +99,6 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
// eslint-disable-next-line
async (req, profile, cb) => {
try {
console.log("saml login profile: ", profile);
if (!profile) throw new BadRequestError({ message: "Missing profile" });
const email = profile?.email ?? (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved
@@ -109,7 +108,6 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({
externalId: profile.nameID,
username: profile.nameID ?? email,
email,
firstName: profile.firstName as string,
lastName: profile.lastName as string,

View File

@@ -31,7 +31,7 @@ import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal
import { TUserDALFactory } from "@app/services/user/user-dal";
import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TUserAliasType } from "@app/services/user-alias/user-alias-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
@@ -395,7 +395,7 @@ export const ldapConfigServiceFactory = ({
let userAlias = await userAliasDAL.findOne({
externalId,
orgId,
aliasType: UserAliasType.LDAP
aliasType: TUserAliasType.LDAP
});
const organization = await orgDAL.findOrgById(orgId);
@@ -437,9 +437,10 @@ export const ldapConfigServiceFactory = ({
{
username: uniqueUsername,
email: emails[0],
isEmailVerified: false,
firstName,
lastName,
authMethods: [AuthMethod.LDAP], // should this be empty?
authMethods: [],
isGhost: false
},
tx
@@ -448,7 +449,7 @@ export const ldapConfigServiceFactory = ({
{
userId: newUser.id,
username,
aliasType: UserAliasType.LDAP,
aliasType: TUserAliasType.LDAP,
externalId,
emails,
orgId
@@ -556,11 +557,14 @@ export const ldapConfigServiceFactory = ({
authTokenType: AuthTokenType.PROVIDER_TOKEN,
userId: user.id,
username: user.username,
...(user.email && { email: user.email }),
firstName,
lastName,
organizationName: organization.name,
organizationId: organization.id,
organizationSlug: organization.slug,
authMethod: AuthMethod.LDAP,
authType: TUserAliasType.LDAP,
isUserCompleted,
...(relayState
? {

View File

@@ -23,10 +23,11 @@ import { BadRequestError } from "@app/lib/errors";
import { 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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TUserAliasType } from "@app/services/user-alias/user-alias-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
@@ -35,13 +36,14 @@ import { TSamlConfigDALFactory } from "./saml-config-dal";
import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } from "./saml-config-types";
type TSamlConfigServiceFactoryDep = {
samlConfigDAL: TSamlConfigDALFactory;
samlConfigDAL: TSamlConfigDALFactory; // TODO: Pick
userDAL: Pick<TUserDALFactory, "create" | "findOne" | "transaction" | "updateById" | "findById">;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick<
TOrgDALFactory,
"createMembership" | "updateMembershipById" | "findMembership" | "findOrgById" | "findOne" | "updateById"
>;
orgMembershipDAL: TOrgMembershipDALFactory; // TODO: Pick
orgBotDAL: Pick<TOrgBotDALFactory, "findOne" | "create" | "transaction">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -53,6 +55,7 @@ export const samlConfigServiceFactory = ({
samlConfigDAL,
orgBotDAL,
orgDAL,
orgMembershipDAL,
userDAL,
userAliasDAL,
permissionService,
@@ -312,7 +315,6 @@ export const samlConfigServiceFactory = ({
const samlLogin = async ({
externalId,
username, // what to do about this?
email,
firstName,
lastName,
@@ -320,31 +322,18 @@ export const samlConfigServiceFactory = ({
orgId,
relayState
}: TSamlLoginDTO) => {
console.log("samlLogin args: ", {
externalId,
username,
email,
firstName,
lastName,
authProvider,
orgId,
relayState
});
const appCfg = getConfig();
const userAlias = await userAliasDAL.findOne({
externalId,
orgId,
aliasType: UserAliasType.SAML
aliasType: TUserAliasType.SAML
});
console.log("found userAlias: ", userAlias);
const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new BadRequestError({ message: "Org not found" });
let user: TUsers;
if (userAlias) {
console.log("samlLogin A");
user = await userDAL.transaction(async (tx) => {
const foundUser = await userDAL.findById(userAlias.userId, tx);
const [orgMembership] = await orgDAL.findMembership(
@@ -355,9 +344,10 @@ export const samlConfigServiceFactory = ({
{ tx }
);
if (!orgMembership) {
await orgDAL.createMembership(
await orgMembershipDAL.create(
{
userId: userAlias.userId,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
@@ -365,7 +355,7 @@ export const samlConfigServiceFactory = ({
tx
);
// Only update the membership to Accepted if the user account is already completed.
} else if (orgMembership.status === OrgMembershipStatus.Invited && user.isAccepted) {
} else if (orgMembership.status === OrgMembershipStatus.Invited && foundUser.isAccepted) {
await orgDAL.updateMembershipById(
orgMembership.id,
{
@@ -378,9 +368,8 @@ export const samlConfigServiceFactory = ({
return foundUser;
});
} else {
console.log("samlLogin B");
user = await userDAL.transaction(async (tx) => {
const uniqueUsername = await normalizeUsername(username, userDAL);
const uniqueUsername = await normalizeUsername(externalId, userDAL);
const newUser = await userDAL.create(
{
username: uniqueUsername,
@@ -396,8 +385,7 @@ export const samlConfigServiceFactory = ({
await userAliasDAL.create(
{
userId: newUser.id,
username,
aliasType: UserAliasType.SAML,
aliasType: TUserAliasType.SAML,
externalId,
emails: email ? [email] : [],
orgId
@@ -405,10 +393,10 @@ export const samlConfigServiceFactory = ({
tx
);
await orgDAL.createMembership(
// note: this creates a duplicate membership atm
await orgMembershipDAL.create(
{
userId: newUser.id,
inviteEmail: email,
orgId,
role: OrgMembershipRole.Member,
status: OrgMembershipStatus.Invited
@@ -419,7 +407,6 @@ export const samlConfigServiceFactory = ({
return newUser;
});
}
console.log("samlLogin C");
const isUserCompleted = Boolean(user.isAccepted);
const providerAuthToken = jwt.sign(
@@ -432,7 +419,9 @@ export const samlConfigServiceFactory = ({
lastName,
organizationName: organization.name,
organizationId: organization.id,
organizationSlug: organization.slug,
authMethod: authProvider,
authType: TUserAliasType.SAML,
isUserCompleted,
...(relayState
? {

View File

@@ -46,7 +46,6 @@ export type TGetSamlCfgDTO =
export type TSamlLoginDTO = {
externalId: string;
username: string;
email?: string;
firstName: string;
lastName?: string;

View File

@@ -259,6 +259,7 @@ export const registerRoutes = async (
permissionService,
orgBotDAL,
orgDAL,
orgMembershipDAL,
userDAL,
userAliasDAL,
samlConfigDAL,
@@ -334,7 +335,6 @@ export const registerRoutes = async (
const userService = userServiceFactory({
userDAL,
userAliasDAL,
orgDAL,
orgMembershipDAL,
tokenService,
smtpService

View File

@@ -135,6 +135,11 @@ export const authSignupServiceFactory = ({
userAgent,
authorization
}: TCompleteAccountSignupDTO) => {
console.log("completeEmailAccountSignup args: ", {
email,
firstName,
lastName
});
const user = await userDAL.findOne({ username: email });
if (!user || (user && user.isAccepted)) {
throw new Error("Failed to complete account for complete user");
@@ -169,9 +174,8 @@ export const authSignupServiceFactory = ({
tx
);
// If it's SAML Auth and the organization ID is present, we should check if the user has a pending invite for this org, and accept it
if (isAuthMethodSaml(authMethod) && organizationId) {
if ((isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && organizationId) {
const [pendingOrgMembership] = await orgDAL.findMembership({
inviteEmail: email,
userId: user.id,
status: OrgMembershipStatus.Invited,
orgId: organizationId

View File

@@ -102,7 +102,8 @@ export const superAdminServiceFactory = ({
superAdmin: true,
isGhost: false,
isAccepted: true,
authMethods: [AuthMethod.EMAIL]
authMethods: [AuthMethod.EMAIL],
isEmailVerified: true
},
tx
);

View File

@@ -1,4 +1,4 @@
export enum UserAliasType {
export enum TUserAliasType {
LDAP = "ldap",
SAML = "saml"
}

View File

@@ -1,7 +1,6 @@
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";
@@ -9,14 +8,23 @@ 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;
userDAL: Pick<
TUserDALFactory,
| "find"
| "findOne"
| "findById"
| "transaction"
| "updateById"
| "deleteById"
| "findOneUserAction"
| "createUserAction"
| "findUserEncKeyByUserId"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "find" | "insertMany">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find" | "insertMany">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser" | "validateTokenForUser">;
smtpService: Pick<TSmtpService, "sendMail">;
};
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
@@ -24,13 +32,11 @@ export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
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);
if (!user) throw new BadRequestError({ name: "Failed to find user" });
if (!user.email)
@@ -38,13 +44,11 @@ export const userServiceFactory = ({
if (user.isEmailVerified)
throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" });
console.log("sendEmailVerificationCode user: ", user);
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id
});
console.log("sendEmailVerificationCode 2");
await smtpService.sendMail({
template: SmtpTemplates.EmailVerification,
subjectLine: "Infisical confirmation code",
@@ -56,11 +60,6 @@ export const userServiceFactory = ({
};
const verifyEmailVerificationCode = async (userId: string, code: string) => {
console.log("verifyEmailVerificationCode args: ", {
userId,
code
});
const user = await userDAL.findById(userId);
if (!user) throw new BadRequestError({ name: "Failed to find user" });
if (user.isEmailVerified)

View File

@@ -6,6 +6,7 @@ export const publicPaths = [
"/signup",
"/signup/sso",
"/login",
"/login/ldap",
"/blog",
"/docs",
"/changelog",

View File

@@ -3,7 +3,8 @@ export {
useAddUserToWsNonE2EE,
useMergeUsers,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode} from "./mutation";
useVerifyEmailVerificationCode
} from "./mutation";
export {
fetchOrgUsers,
fetchUsersWithMyEmail,
@@ -26,4 +27,5 @@ export {
useRevokeMySessions,
useUpdateMfaEnabled,
useUpdateOrgUserRole,
useUpdateUserAuthMethods} from "./queries";
useUpdateUserAuthMethods
} from "./queries";

View File

@@ -5,6 +5,7 @@ import {
encryptAssymmetric
} from "@app/components/utilities/cryptography/crypto";
import { apiRequest } from "@app/config/request";
import { setAuthToken } from "@app/reactQuery";
import { workspaceKeys } from "../workspace/queries";
import { userKeys } from "./queries";
@@ -63,10 +64,14 @@ export const useAddUserToWsNonE2EE = () => {
});
};
export const sendEmailVerificationCode = async () => {
return apiRequest.post("/api/v2/users/me/emails/code");
};
export const useSendEmailVerificationCode = () => {
return useMutation({
mutationFn: async () => {
await apiRequest.post("/api/v2/users/me/emails/code");
await sendEmailVerificationCode();
return {};
}
});
@@ -88,12 +93,29 @@ export const useVerifyEmailVerificationCode = () => {
};
export const useMergeUsers = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ username }: { username: string }) => {
const { data } = await apiRequest.post<{ user: User }>("/api/v2/users/me/users/merge-user", {
username
});
return data;
},
onSuccess: () => {
setAuthToken("");
// Delete the cookie by not setting a value; Alternatively clear the local storage
localStorage.removeItem("protectedKey");
localStorage.removeItem("protectedKeyIV");
localStorage.removeItem("protectedKeyTag");
localStorage.removeItem("publicKey");
localStorage.removeItem("encryptedPrivateKey");
localStorage.removeItem("iv");
localStorage.removeItem("tag");
localStorage.removeItem("PRIVATE_KEY");
localStorage.removeItem("orgData.id");
localStorage.removeItem("projectData.id");
queryClient.clear();
}
});
};

View File

@@ -27,6 +27,11 @@ export type User = {
id: string;
};
export enum UserAliasType {
LDAP = "ldap",
SAML = "saml"
}
export type UserEnc = {
encryptionVersion?: number;
protectedKey?: string;

View File

@@ -7,7 +7,6 @@ import { Login } from "@app/views/Login";
export default function LoginPage() {
const { t } = useTranslation();
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">
<Head>

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import Head from "next/head";
import Image from "next/image";
import Link from "next/link";
import { LoginLDAP } from "@app/views/Login";
export default function LoginLDAPPage() {
const { t } = useTranslation();
return (
<div className="flex h-screen flex-col justify-center bg-gradient-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6 pb-28 ">
<Head>
<title>{t("common.head-title", { title: t("login.title") })}</title>
<link rel="icon" href="/infisical.ico" />
<meta property="og:image" content="/images/message.png" />
<meta property="og:title" content={t("login.og-title") ?? ""} />
<meta name="og:description" content={t("login.og-description") ?? ""} />
</Head>
<Link href="/">
<div className="mb-4 mt-20 flex justify-center">
<Image src="/images/gradientLogo.svg" height={90} width={120} alt="Infisical logo" />
</div>
</Link>
<LoginLDAP />
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import { isLoggedIn } from "@app/reactQuery";
import { InitialStep, LDAPStep, MFAStep, SAMLSSOStep } from "./components";
import { InitialStep, MFAStep, SAMLSSOStep } from "./components";
import { navigateUserToSelectOrg } from "./Login.utils";
export const Login = () => {
@@ -58,8 +58,6 @@ export const Login = () => {
);
case 2:
return <SAMLSSOStep setStep={setStep} />;
case 3:
return <LDAPStep setStep={setStep} />;
default:
return <div />;
}

View File

@@ -1,24 +1,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/router";
import { createNotification } from "@app/components/notifications";
import { Button, Input } from "@app/components/v2";
import { loginLDAPRedirect } from "@app/hooks/api/auth/queries";
type Props = {
setStep: (step: number) => void;
};
export const LoginLDAP = () => {
const router = useRouter();
const queryParams = new URLSearchParams(window.location.search);
const passedOrgSlug = queryParams.get("organizationSlug");
const passedUsername = queryParams.get("username");
export const LDAPStep = ({ setStep }: Props) => {
const [organizationSlug, setOrganizationSlug] = useState("");
const [username, setUsername] = useState("");
const [organizationSlug, setOrganizationSlug] = useState(passedOrgSlug || "");
const [username, setUsername] = useState(passedUsername || "");
const [password, setPassword] = useState("");
const { t } = useTranslation();
// const queryParams = new URLSearchParams(window.location.search);
const handleSubmission = async (e: React.FormEvent) => {
e.preventDefault();
try {
@@ -42,7 +41,6 @@ export const LDAPStep = ({ setStep }: Props) => {
type: "success"
});
// redirects either to /login/sso or /signup/sso
window.open(nextUrl);
window.close();
} catch (err) {
@@ -76,6 +74,7 @@ export const LDAPStep = ({ setStep }: Props) => {
autoComplete="email"
id="email"
className="h-12"
isDisabled={passedOrgSlug !== null}
/>
</div>
</div>
@@ -90,6 +89,7 @@ export const LDAPStep = ({ setStep }: Props) => {
autoComplete="email"
id="email"
className="h-12"
isDisabled={passedUsername !== null}
/>
</div>
</div>
@@ -122,7 +122,7 @@ export const LDAPStep = ({ setStep }: Props) => {
<div className="mt-4 flex flex-row items-center justify-center">
<button
onClick={() => {
setStep(0);
router.push("/login");
}}
type="button"
className="mt-2 cursor-pointer text-sm text-bunker-300 duration-200 hover:text-bunker-200 hover:underline hover:decoration-primary-700 hover:underline-offset-4"

View File

@@ -25,7 +25,7 @@ type Props = {
export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: Props) => {
const router = useRouter();
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const [loginError, setLoginError] = useState(false);
@@ -33,7 +33,10 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
const queryParams = new URLSearchParams(window.location.search);
useEffect(() => {
if (process.env.NEXT_PUBLIC_SAML_ORG_SLUG && process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default") {
if (
process.env.NEXT_PUBLIC_SAML_ORG_SLUG &&
process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default"
) {
const callbackPort = queryParams.get("callback_port");
window.open(
`/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${
@@ -42,7 +45,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
);
window.close();
}
}, [])
}, []);
const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -196,7 +199,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
colorSchema="primary"
variant="outline_bg"
onClick={() => {
setStep(3);
router.push("/login/ldap");
}}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
className="mx-0 h-10 w-full"

View File

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

View File

@@ -1,5 +1,4 @@
export { InitialStep } from "./InitialStep";
export { LDAPStep } from "./LDAPStep";
export { MFAStep } from "./MFAStep";
export { SAMLSSOStep } from "./SAMLSSOStep";

View File

@@ -1,2 +1,3 @@
export { Login } from "./Login";
export { LoginLDAP } from "./LoginLDAP";
export { LoginSSO } from "./LoginSSO";

View File

@@ -49,7 +49,6 @@ type Props = {
};
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Props) => {
const { subscription } = useSubscription();
const { currentOrg } = useOrganization();
const { user } = useUser();
@@ -150,6 +149,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
[members, searchMemberFilter]
);
console.log("filterdUser: ", filterdUser);
return (
<div>
<Input
@@ -218,7 +219,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop
variant="outline_bg"
onClick={() => onResendInvite(email)}
>
Resend Invite
Resend invite
</Button>
)}
</>

View File

@@ -5,7 +5,8 @@ import {
BackupPDFStep,
EmailConfirmationStep,
MergeUsersStep,
UserInfoSSOStep} from "./components";
UserInfoSSOStep
} from "./components";
type Props = {
providerAuthToken: string;
@@ -15,9 +16,8 @@ export const SignupSSO = ({ providerAuthToken }: Props) => {
const [step, setStep] = useState(0);
const [password, setPassword] = useState("");
const { username, email, organizationName, firstName, lastName } = jwt_decode(
providerAuthToken
) as any;
const { username, email, organizationName, organizationSlug, firstName, lastName, authType } =
jwt_decode(providerAuthToken) as any;
const renderView = () => {
switch (step) {
@@ -37,7 +37,13 @@ export const SignupSSO = ({ providerAuthToken }: Props) => {
case 1:
return <EmailConfirmationStep email={email} setStep={setStep} />;
case 2:
return <MergeUsersStep username={username} />;
return (
<MergeUsersStep
username={username}
authType={authType}
organizationSlug={organizationSlug}
/>
);
case 3:
return (
<BackupPDFStep email={username} password={password} name={`${firstName} ${lastName}`} />

View File

@@ -6,11 +6,11 @@ import ReactCodeInput from "react-code-input";
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 {
fetchUsersWithMyEmail,
useSendEmailVerificationCode,
useVerifyEmailVerificationCode} from "@app/hooks/api";
useVerifyEmailVerificationCode
} from "@app/hooks/api";
type Props = {
email: string;
@@ -56,7 +56,6 @@ const propsPhone = {
} as const;
export const EmailConfirmationStep = ({ email, setStep }: Props) => {
const { user } = useUser();
const [code, setCode] = useState("");
const [codeError, setCodeError] = useState(false);
const [isResendingVerificationEmail] = useState(false);
@@ -106,9 +105,6 @@ export const EmailConfirmationStep = ({ email, setStep }: Props) => {
<p className="text-md flex justify-center text-bunker-200">
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}
</p>
<div className="mx-auto hidden w-max min-w-[20rem] md:block">
<ReactCodeInput
name=""

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { useRouter } from "next/router";
import { faUsers } from "@fortawesome/free-solid-svg-icons";
@@ -5,6 +6,8 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
EmptyState,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
@@ -15,28 +18,49 @@ import {
Tr
} from "@app/components/v2";
import { useListUsersWithMyEmail, useMergeUsers } from "@app/hooks/api";
import { UserAliasType } from "@app/hooks/api/users/types";
type Props = {
username: string;
authType?: UserAliasType;
organizationSlug: string;
};
export const MergeUsersStep = ({ username }: Props) => {
export const MergeUsersStep = ({ username, authType, organizationSlug }: Props) => {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [targetUsername, setTargetUsername] = useState("");
const { data: users, isLoading: isLoadingUsers } = useListUsersWithMyEmail();
const { mutateAsync: mergeUser, isLoading: isLoadingMerge } = useMergeUsers();
const handleMergeUser = async (targetUsername: string) => {
const handleMergeUser = async (mergeWithUsername: string) => {
try {
console.log("merge A");
await mergeUser({ username: targetUsername });
// TODO: logout, make user re-login
console.log("merge B");
if (!mergeWithUsername) return;
await mergeUser({ username: mergeWithUsername });
createNotification({
text: "Successfully merged user",
type: "success"
});
router.push("/login");
setIsOpen(false);
switch (authType) {
case UserAliasType.SAML: {
window.open(`/api/v1/sso/redirect/saml2/organizations/${organizationSlug}`);
window.close();
break;
}
case UserAliasType.LDAP: {
router.push(`/login/ldap?organizationSlug=${organizationSlug}`);
break;
}
default: {
router.push("/login");
break;
}
}
setTargetUsername("");
} catch (err) {
console.error(err);
createNotification({
@@ -72,14 +96,16 @@ export const MergeUsersStep = ({ username }: Props) => {
return (
<Tr className="h-10 items-center" key={`same-email-user-${user.id}`}>
<Td>{`${user.firstName ?? ""} ${user.lastName ?? ""}`}</Td>
<Td>{username}</Td>
<Td>{user.username}</Td>
<Td>
<Button
isLoading={isLoadingMerge}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleMergeUser(user.username)}
onClick={() => {
setIsOpen(true);
setTargetUsername(user.username);
}}
>
Merge
</Button>
@@ -97,6 +123,35 @@ export const MergeUsersStep = ({ username }: Props) => {
</TBody>
</Table>
</TableContainer>
<Modal isOpen={isOpen} onOpenChange={setIsOpen}>
<ModalContent title="Merge User Confirmation">
<p className="mb-4 text-bunker-300">
The merge operation will transfer / consolidate your existing organization membership to
the target user you&apos;re merging with.
</p>
<p className="mb-4 text-bunker-300">
If the target user is not yet part of the same organization, then they will be added to
it under your current organization membership. Conversely, if the target user is already
part of the organization, then their existing organization membership will remain.
</p>
<p className="text-bunker-300">
Once the merge operation is complete, you&apos;ll be prompted to re-login.
</p>
<div className="mt-8 flex items-center">
<Button
isLoading={isLoadingMerge}
colorSchema="primary"
onClick={async () => handleMergeUser(targetUsername)}
className="mr-4"
>
Confirm
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => setIsOpen(false)}>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
</div>
);
};

View File

@@ -17,6 +17,7 @@ import SecurityClient from "@app/components/utilities/SecurityClient";
import { Button, Input } from "@app/components/v2";
import { completeAccountSignup, useSelectOrganization } from "@app/hooks/api/auth/queries";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { sendEmailVerificationCode } from "@app/hooks/api/users/mutation";
import ProjectService from "@app/services/ProjectService";
// eslint-disable-next-line new-cap
@@ -205,10 +206,11 @@ export const UserInfoSSOStep = ({
if (email) {
// move to verify email
await sendEmailVerificationCode();
setStep(1);
} else {
// move to backup PDF step
setStep(2);
setStep(3);
}
} catch (error) {
setIsLoading(false);