diff --git a/frontend/src/hooks/api/users/index.tsx b/frontend/src/hooks/api/users/index.tsx index 0774275f9..715c3532d 100644 --- a/frontend/src/hooks/api/users/index.tsx +++ b/frontend/src/hooks/api/users/index.tsx @@ -1,6 +1,7 @@ export { useAddUserToWsE2EE, useAddUserToWsNonE2EE, + useRemoveMyDuplicateAccounts, useRevokeMySessionById, useSendEmailVerificationCode, useVerifyEmailVerificationCode @@ -14,6 +15,7 @@ export { useDeleteOrgMembership, useGetMyAPIKeys, useGetMyAPIKeysV2, + useGetMyDuplicateAccount, useGetMyIp, useGetMyOrganizationProjects, useGetMySessions, diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 1b873b31c..ee274ab31 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -184,3 +184,12 @@ export const useRevokeMySessionById = () => { } }); }; + +export const useRemoveMyDuplicateAccounts = () => { + return useMutation({ + mutationFn: async () => { + const { data } = await apiRequest.post("/api/v1/user/remove-duplicate-accounts"); + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 06cde4d34..8bd891b4b 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -37,6 +37,21 @@ export const useGetUser = () => queryFn: fetchUserDetails }); +export const useGetMyDuplicateAccount = () => + useQuery({ + queryKey: userKeys.getMyDuplicateAccount, + queryFn: async () => { + const { data } = await apiRequest.get<{ + users: Array; + }>("/api/v1/user/duplicate-accounts"); + return data.users; + }, + select: (users) => ({ + duplicateAccounts: users.filter((el) => !el.isMyAccount), + myAccount: users?.find((el) => el.isMyAccount) + }) + }); + export const useDeleteMe = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/users/query-keys.tsx b/frontend/src/hooks/api/users/query-keys.tsx index 34d969b49..aacbb73b9 100644 --- a/frontend/src/hooks/api/users/query-keys.tsx +++ b/frontend/src/hooks/api/users/query-keys.tsx @@ -1,5 +1,6 @@ export const userKeys = { getUser: ["user"] as const, + getMyDuplicateAccount: ["user-duplicate-account"] as const, getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, userProjectFavorites: (orgId: string) => [{ orgId }, "user-project-favorites"] as const, diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx index 7dddd1a4b..38d808366 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgPage.tsx @@ -1,33 +1,20 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useNavigate } from "@tanstack/react-router"; -import axios from "axios"; -import { addSeconds, formatISO } from "date-fns"; -import { jwtDecode } from "jwt-decode"; +import { format } from "date-fns"; -import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; -import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; -import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Spinner } from "@app/components/v2"; -import { SessionStorageKeys } from "@app/const"; -import { OrgMembershipRole } from "@app/helpers/roles"; -import { useToggle } from "@app/hooks"; +import { Alert, Button, Spinner, Tooltip } from "@app/components/v2"; import { - useGetOrganizations, - useGetUser, + useGetMyDuplicateAccount, useLogoutUser, - useSelectOrganization + useRemoveMyDuplicateAccounts } from "@app/hooks/api"; -import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; -import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; -import { Organization } from "@app/hooks/api/types"; -import { AuthMethod } from "@app/hooks/api/users/types"; -import { navigateUserToOrg } from "../LoginPage/Login.utils"; +import { SelectOrganizationSection } from "./SelectOrgSection"; const LoadingScreen = () => { return ( @@ -39,24 +26,11 @@ const LoadingScreen = () => { }; export const SelectOrganizationPage = () => { - const navigate = useNavigate(); + const duplicateAccounts = useGetMyDuplicateAccount(); + const removeDuplicateEmails = useRemoveMyDuplicateAccounts(); + const [removeDuplicateLater, setRemoveDuplicateLater] = useState(false); const { t } = useTranslation(); - - const organizations = useGetOrganizations(); - const selectOrg = useSelectOrganization(); - const { data: user, isPending: userLoading } = useGetUser(); - const [shouldShowMfa, toggleShowMfa] = useToggle(false); - const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); - const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); - - const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); - - const queryParams = new URLSearchParams(window.location.search); - const orgId = queryParams.get("org_id"); - const callbackPort = queryParams.get("callback_port"); - const isAdminLogin = queryParams.get("is_admin_login") === "true"; - const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); - + const navigate = useNavigate(); const logout = useLogoutUser(true); const handleLogout = useCallback(async () => { try { @@ -68,151 +42,14 @@ export const SelectOrganizationPage = () => { } }, [logout, navigate]); - const handleSelectOrganization = useCallback( - async (organization: Organization) => { - const canBypassOrgAuth = - organization.bypassOrgAuthEnabled && - organization.userRole === OrgMembershipRole.Admin && - isAdminLogin; - - if (organization.authEnforced && !canBypassOrgAuth) { - // org has an org-level auth method enabled (e.g. SAML) - // -> logout + redirect to SAML SSO - await logout.mutateAsync(); - let url = ""; - if (organization.orgAuthMethod === AuthMethod.OIDC) { - url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ - callbackPort ? `&callbackPort=${callbackPort}` : "" - }`; - } else { - url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; - - if (callbackPort) { - url += `?callback_port=${callbackPort}`; - } - } - - window.location.href = url; - return; - } - - const { token, isMfaEnabled, mfaMethod } = await selectOrg - .mutateAsync({ - organizationId: organization.id, - userAgent: callbackPort ? UserAgentType.CLI : undefined - }) - .finally(() => setIsInitialOrgCheckLoading(false)); - - if (isMfaEnabled) { - SecurityClient.setMfaToken(token); - if (mfaMethod) { - setRequiredMfaMethod(mfaMethod); - } - toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); - return; - } - - if (callbackPort) { - const privateKey = localStorage.getItem("PRIVATE_KEY"); - - let error: string | null = null; - - if (!privateKey) error = "Private key not found"; - if (!user?.email) error = "User email not found"; - if (!token) error = "No token found"; - - if (error) { - createNotification({ - text: error, - type: "error" - }); - return; - } - - const payload = { - JTWToken: token, - email: user?.email, - privateKey - } as IsCliLoginSuccessful["loginResponse"]; - - // send request to server endpoint - const instance = axios.create(); - await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { - // if error happens to communicate we set the token with an expiry in sessino storage - // the cli-redirect page has logic to show this to user and ask them to paste it in terminal - sessionStorage.setItem( - SessionStorageKeys.CLI_TERMINAL_TOKEN, - JSON.stringify({ - expiry: formatISO(addSeconds(new Date(), 30)), - data: window.btoa(JSON.stringify(payload)) - }) - ); - }); - navigate({ to: "/cli-redirect" }); - // cli page - } else { - navigateUserToOrg(navigate, organization.id); - } - }, - [selectOrg] - ); - - const handleCliRedirect = useCallback(() => { - const authToken = getAuthToken(); - - if (authToken && !callbackPort) { - const decodedJwt = jwtDecode(authToken) as any; - - if (decodedJwt?.organizationId) { - navigateUserToOrg(navigate, decodedJwt.organizationId); - } - } - - if (!isLoggedIn()) { - navigate({ to: "/login" }); - } - }, []); - - useEffect(() => { - if (callbackPort) { - handleCliRedirect(); - } - }, [navigate]); - - useEffect(() => { - if (organizations.isPending || !organizations.data) return; - - // Case: User has no organizations. - // This can happen if the user was previously a member, but the organization was deleted or the user was removed. - if (organizations.data.length === 0) { - navigate({ to: "/organization/none" }); - } else if (organizations.data.length === 1) { - if (callbackPort) { - handleCliRedirect(); - setIsInitialOrgCheckLoading(false); - } else { - handleSelectOrganization(organizations.data[0]); - } - } else { - setIsInitialOrgCheckLoading(false); - } - }, [organizations.isPending, organizations.data]); - - useEffect(() => { - if (defaultSelectedOrg) { - handleSelectOrganization(defaultSelectedOrg); - } - }, [defaultSelectedOrg]); - - if ( - userLoading || - !user || - ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) - ) { + if (duplicateAccounts.isPending) { return ; } + if (!duplicateAccounts.data?.duplicateAccounts?.length || removeDuplicateLater) { + return ; + } + return (
@@ -222,69 +59,104 @@ export const SelectOrganizationPage = () => { - {shouldShowMfa ? ( - - ) : ( -
- -
- Infisical logo -
- -
-
-

- Choose your organization -

- -
-

- You‘re currently logged in as {user.username} -

-

- Not you?{" "} - -

-
-
-
- {organizations.isPending ? ( - - ) : ( - organizations.data?.map((org) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
handleSelectOrganization(org)} - key={org.id} - className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" - > -

{org.name}

- - +
+ +
+ Infisical logo +
+ + +
+

+ Multiple Accounts Detected +

+

+ Your current account is: {" "} + {duplicateAccounts?.data?.myAccount?.username}. +

+ + We've detected multiple accounts using variations of the same email address. + Please confirm that this account, {duplicateAccounts?.data?.myAccount?.username}, is + the account you wish to retain. + +
+
+ {duplicateAccounts?.data?.duplicateAccounts?.map((el) => { + const lastSession = el.devices.at(-1); + return ( +
+
+
{el.username}
+
+ Last login: {format(new Date(el.updatedAt), "Pp")} +
- )) - )} +
+ +
IP: {lastSession?.ip || "-"}
+
User Agent: {lastSession?.userAgent || "-"}
+
+ } + > + + +
+
+ ); + })} +
+
+
+ +
- -
- )} - + +
+ +
); diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx new file mode 100644 index 000000000..2ca179c87 --- /dev/null +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -0,0 +1,289 @@ +import { useCallback, useEffect, useState } from "react"; +import { Helmet } from "react-helmet"; +import { useTranslation } from "react-i18next"; +import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate } from "@tanstack/react-router"; +import axios from "axios"; +import { addSeconds, formatISO } from "date-fns"; +import { jwtDecode } from "jwt-decode"; + +import { Mfa } from "@app/components/auth/Mfa"; +import { createNotification } from "@app/components/notifications"; +import { IsCliLoginSuccessful } from "@app/components/utilities/attemptCliLogin"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button, Spinner } from "@app/components/v2"; +import { SessionStorageKeys } from "@app/const"; +import { OrgMembershipRole } from "@app/helpers/roles"; +import { useToggle } from "@app/hooks"; +import { + useGetOrganizations, + useGetUser, + useLogoutUser, + useSelectOrganization +} from "@app/hooks/api"; +import { MfaMethod, UserAgentType } from "@app/hooks/api/auth/types"; +import { getAuthToken, isLoggedIn } from "@app/hooks/api/reactQuery"; +import { Organization } from "@app/hooks/api/types"; +import { AuthMethod } from "@app/hooks/api/users/types"; + +import { navigateUserToOrg } from "../LoginPage/Login.utils"; + +const LoadingScreen = () => { + return ( +
+ +

Loading, please wait

+
+ ); +}; + +export const SelectOrganizationSection = () => { + const navigate = useNavigate(); + const { t } = useTranslation(); + + const organizations = useGetOrganizations(); + const selectOrg = useSelectOrganization(); + const { data: user, isPending: userLoading } = useGetUser(); + const [shouldShowMfa, toggleShowMfa] = useToggle(false); + const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL); + const [isInitialOrgCheckLoading, setIsInitialOrgCheckLoading] = useState(true); + + const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {}); + + const queryParams = new URLSearchParams(window.location.search); + const orgId = queryParams.get("org_id"); + const callbackPort = queryParams.get("callback_port"); + const isAdminLogin = queryParams.get("is_admin_login") === "true"; + const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); + + const logout = useLogoutUser(true); + const handleLogout = useCallback(async () => { + try { + console.log("Logging out..."); + await logout.mutateAsync(); + navigate({ to: "/login" }); + } catch (error) { + console.error(error); + } + }, [logout, navigate]); + + const handleSelectOrganization = useCallback( + async (organization: Organization) => { + const canBypassOrgAuth = + organization.bypassOrgAuthEnabled && + organization.userRole === OrgMembershipRole.Admin && + isAdminLogin; + + if (organization.authEnforced && !canBypassOrgAuth) { + // org has an org-level auth method enabled (e.g. SAML) + // -> logout + redirect to SAML SSO + await logout.mutateAsync(); + let url = ""; + if (organization.orgAuthMethod === AuthMethod.OIDC) { + url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ + callbackPort ? `&callbackPort=${callbackPort}` : "" + }`; + } else { + url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; + + if (callbackPort) { + url += `?callback_port=${callbackPort}`; + } + } + + window.location.href = url; + return; + } + + const { token, isMfaEnabled, mfaMethod } = await selectOrg + .mutateAsync({ + organizationId: organization.id, + userAgent: callbackPort ? UserAgentType.CLI : undefined + }) + .finally(() => setIsInitialOrgCheckLoading(false)); + + if (isMfaEnabled) { + SecurityClient.setMfaToken(token); + if (mfaMethod) { + setRequiredMfaMethod(mfaMethod); + } + toggleShowMfa.on(); + setMfaSuccessCallback(() => () => handleSelectOrganization(organization)); + return; + } + + if (callbackPort) { + const privateKey = localStorage.getItem("PRIVATE_KEY"); + + let error: string | null = null; + + if (!privateKey) error = "Private key not found"; + if (!user?.email) error = "User email not found"; + if (!token) error = "No token found"; + + if (error) { + createNotification({ + text: error, + type: "error" + }); + return; + } + + const payload = { + JTWToken: token, + email: user?.email, + privateKey + } as IsCliLoginSuccessful["loginResponse"]; + + // send request to server endpoint + const instance = axios.create(); + await instance.post(`http://127.0.0.1:${callbackPort}/`, payload).catch(() => { + // if error happens to communicate we set the token with an expiry in sessino storage + // the cli-redirect page has logic to show this to user and ask them to paste it in terminal + sessionStorage.setItem( + SessionStorageKeys.CLI_TERMINAL_TOKEN, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 30)), + data: window.btoa(JSON.stringify(payload)) + }) + ); + }); + navigate({ to: "/cli-redirect" }); + // cli page + } else { + navigateUserToOrg(navigate, organization.id); + } + }, + [selectOrg] + ); + + const handleCliRedirect = useCallback(() => { + const authToken = getAuthToken(); + + if (authToken && !callbackPort) { + const decodedJwt = jwtDecode(authToken) as any; + + if (decodedJwt?.organizationId) { + navigateUserToOrg(navigate, decodedJwt.organizationId); + } + } + + if (!isLoggedIn()) { + navigate({ to: "/login" }); + } + }, []); + + useEffect(() => { + if (callbackPort) { + handleCliRedirect(); + } + }, [navigate]); + + useEffect(() => { + if (organizations.isPending || !organizations.data) return; + + // Case: User has no organizations. + // This can happen if the user was previously a member, but the organization was deleted or the user was removed. + if (organizations.data.length === 0) { + navigate({ to: "/organization/none" }); + } else if (organizations.data.length === 1) { + if (callbackPort) { + handleCliRedirect(); + setIsInitialOrgCheckLoading(false); + } else { + handleSelectOrganization(organizations.data[0]); + } + } else { + setIsInitialOrgCheckLoading(false); + } + }, [organizations.isPending, organizations.data]); + + useEffect(() => { + if (defaultSelectedOrg) { + handleSelectOrganization(defaultSelectedOrg); + } + }, [defaultSelectedOrg]); + + if ( + userLoading || + !user || + ((isInitialOrgCheckLoading || defaultSelectedOrg) && !shouldShowMfa) + ) { + return ; + } + + return ( +
+ + {t("common.head-title", { title: t("login.title") })} + + + + + + {shouldShowMfa ? ( + + ) : ( +
+ +
+ Infisical logo +
+ +
+
+

+ Choose your organization +

+
+

+ You‘re currently logged in as {user.username} +

+

+ Not you?{" "} + +

+
+
+
+ {organizations.isPending ? ( + + ) : ( + organizations.data?.map((org) => ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
handleSelectOrganization(org)} + key={org.id} + className="group flex cursor-pointer items-center justify-between rounded-md bg-mineshaft-700 px-4 py-3 capitalize text-gray-200 shadow-md transition-colors hover:bg-mineshaft-600" + > +

{org.name}

+ + +
+ )) + )} +
+
+
+ )} +
+
+ ); +};