diff --git a/packages/commonwealth/client/scripts/controllers/app/login.ts b/packages/commonwealth/client/scripts/controllers/app/login.ts index f1c5d5e43f2..15220942178 100644 --- a/packages/commonwealth/client/scripts/controllers/app/login.ts +++ b/packages/commonwealth/client/scripts/controllers/app/login.ts @@ -10,6 +10,9 @@ import { initAppState } from 'state'; import { OpenFeature } from '@openfeature/web-sdk'; import axios from 'axios'; +import { getUniqueUserAddresses } from 'client/scripts/helpers/user'; +import { fetchProfilesByAddress } from 'client/scripts/state/api/profiles/fetchProfilesByAddress'; +import { authModal } from 'client/scripts/state/ui/modals/authModal'; import { welcomeOnboardModal } from 'client/scripts/state/ui/modals/welcomeOnboardModal'; import moment from 'moment'; import app from 'state'; @@ -358,13 +361,17 @@ export async function startLoginWithMagicLink({ const magic = await constructMagic(isCosmos, chain); if (email) { + const authModalState = authModal.getState(); // email-based login const bearer = await magic.auth.loginWithMagicLink({ email }); - const address = await handleSocialLoginCallback({ + const { address, isAddressNew } = await handleSocialLoginCallback({ bearer, walletSsoSource: WalletSsoSource.Email, + returnEarlyIfNewAddress: + authModalState.shouldOpenGuidanceModalAfterMagicSSORedirect, }); - return { bearer, address }; + + return { bearer, address, isAddressNew }; } else { const params = `?redirectTo=${ redirectTo ? encodeURIComponent(redirectTo) : '' @@ -418,11 +425,13 @@ export async function handleSocialLoginCallback({ bearer, chain, walletSsoSource, + returnEarlyIfNewAddress = false, }: { bearer?: string; chain?: string; walletSsoSource?: string; -}): Promise { + returnEarlyIfNewAddress?: boolean; +}): Promise<{ address: string; isAddressNew: boolean }> { // desiredChain may be empty if social login was initialized from // a page without a chain, in which case we default to an eth login const desiredChain = app.chain?.meta || app.config.chains.getById(chain); @@ -460,6 +469,25 @@ export async function handleSocialLoginCallback({ } } + // check if this address exists in db + const profileAddresses = await fetchProfilesByAddress({ + currentChainId: '', + profileAddresses: [magicAddress], + profileChainIds: [isCosmos ? ChainBase.CosmosSDK : ChainBase.Ethereum], + initiateProfilesAfterFetch: false, + }); + + const isAddressNew = profileAddresses?.length === 0; + const isAttemptingToConnectAddressToCommunity = + app.isLoggedIn() && app.activeChainId(); + if ( + isAddressNew && + !isAttemptingToConnectAddressToCommunity && + returnEarlyIfNewAddress + ) { + return { address: magicAddress, isAddressNew }; + } + let authedSessionPayload, authedSignature; try { // Sign a session @@ -577,19 +605,25 @@ export async function handleSocialLoginCallback({ await app.user.updateEmail(ssoEmail, false); } + const userUniqueAddresses = getUniqueUserAddresses({}); + // if account is created in last few minutes and has a single - // profile (no account linking) then open the welcome modal. + // profile and address (no account linking) then open the welcome modal. const isCreatedInLast5Minutes = accountCreatedTime && moment().diff(moment(accountCreatedTime), 'minutes') < 5; - if (isCreatedInLast5Minutes && profiles?.length === 1) { + if ( + isCreatedInLast5Minutes && + profiles?.length === 1 && + userUniqueAddresses.length === 1 + ) { setTimeout(() => { welcomeOnboardModal.getState().setIsWelcomeOnboardModalOpen(true); }, 1000); } } - return magicAddress; + return { address: magicAddress, isAddressNew }; } else { throw new Error(`Social auth unsuccessful: ${response.status}`); } diff --git a/packages/commonwealth/client/scripts/helpers/user.ts b/packages/commonwealth/client/scripts/helpers/user.ts new file mode 100644 index 00000000000..0005fb08552 --- /dev/null +++ b/packages/commonwealth/client/scripts/helpers/user.ts @@ -0,0 +1,21 @@ +import app from '../state'; + +type GetUniqueUserAddressesArgs = { + forChain?: string; +}; + +export const getUniqueUserAddresses = ({ + forChain, +}: GetUniqueUserAddressesArgs) => { + const addresses = app?.user?.addresses || []; + + const filteredAddresses = forChain + ? addresses.filter((x) => x?.community?.base === forChain) + : addresses; + + const addressStrings = filteredAddresses.map((x) => x.address); + + const uniqueAddresses = [...new Set(addressStrings)]; + + return uniqueAddresses; +}; diff --git a/packages/commonwealth/client/scripts/hooks/useWallets.tsx b/packages/commonwealth/client/scripts/hooks/useWallets.tsx index 082446a9215..b787ad71b21 100644 --- a/packages/commonwealth/client/scripts/hooks/useWallets.tsx +++ b/packages/commonwealth/client/scripts/hooks/useWallets.tsx @@ -32,7 +32,11 @@ import { setDarkMode } from '../helpers/darkMode'; import { getAddressFromWallet, loginToNear } from '../helpers/wallet'; import Account from '../models/Account'; import IWebWallet from '../models/IWebWallet'; -import { DISCOURAGED_NONREACTIVE_fetchProfilesByAddress } from '../state/api/profiles/fetchProfilesByAddress'; +import { + DISCOURAGED_NONREACTIVE_fetchProfilesByAddress, + fetchProfilesByAddress, +} from '../state/api/profiles/fetchProfilesByAddress'; +import { authModal } from '../state/ui/modals/authModal'; import { breakpointFnValidator, isWindowMediumSmallInclusive, @@ -73,6 +77,7 @@ type IuseWalletProps = { initialWallets?: IWebWallet[]; onSuccess?: (address?: string | undefined, isNewlyCreated?: boolean) => void; onModalClose: () => void; + onUnrecognizedAddressReceived?: () => boolean; useSessionKeyLoginFlow?: boolean; }; @@ -207,14 +212,33 @@ const useWallets = (walletProps: IuseWalletProps) => { try { const isCosmos = app.chain?.base === ChainBase.CosmosSDK; - const { address: magicAddress } = await startLoginWithMagicLink({ - email: tempEmailToUse, - isCosmos, - redirectTo: document.location.pathname + document.location.search, - chain: app.chain?.id, - }); + const isAttemptingToConnectAddressToCommunity = + app.isLoggedIn() && app.activeChainId(); + const { address: magicAddress, isAddressNew } = + await startLoginWithMagicLink({ + email: tempEmailToUse, + isCosmos, + redirectTo: document.location.pathname + document.location.search, + chain: app.chain?.id, + }); setIsMagicLoading(false); + // if SSO account address is not already present in db, + // and `shouldOpenGuidanceModalAfterMagicSSORedirect` is `true`, + // and the user isn't trying to link address to community, + // then open the user auth type guidance modal + // else clear state of `shouldOpenGuidanceModalAfterMagicSSORedirect` + if ( + isAddressNew && + !isAttemptingToConnectAddressToCommunity && + !app.isLoggedIn() + ) { + authModal + .getState() + .validateAndOpenAuthTypeGuidanceModalOnSSORedirectReceived(); + return; + } + if (walletProps.onSuccess) { walletProps.onSuccess(magicAddress, isNewlyCreated); } @@ -583,6 +607,35 @@ const useWallets = (walletProps: IuseWalletProps) => { } else { const selectedAddress = getAddressFromWallet(wallet); + // check if address exists + const profileAddresses = await fetchProfilesByAddress({ + currentChainId: '', + profileAddresses: [ + wallet.chain === ChainBase.Substrate + ? addressSwapper({ + address: selectedAddress, + currentPrefix: parseInt( + (app.chain as Substrate)?.meta.ss58Prefix, + 10, + ), + }) + : selectedAddress, + ], + profileChainIds: [app.activeChainId() ?? wallet.chain], + initiateProfilesAfterFetch: false, + }); + const addressExists = profileAddresses?.length > 0; + const isAttemptingToConnectAddressToCommunity = + app.isLoggedIn() && app.activeChainId(); + if ( + !addressExists && + !isAttemptingToConnectAddressToCommunity && + walletProps.onUnrecognizedAddressReceived + ) { + const shouldContinue = walletProps.onUnrecognizedAddressReceived(); + if (!shouldContinue) return; + } + if (walletProps.useSessionKeyLoginFlow) { await onSessionKeyRevalidation(wallet, selectedAddress); } else { diff --git a/packages/commonwealth/client/scripts/state/api/profiles/fetchProfilesByAddress.ts b/packages/commonwealth/client/scripts/state/api/profiles/fetchProfilesByAddress.ts index 6ce2379ec98..86f146133fe 100644 --- a/packages/commonwealth/client/scripts/state/api/profiles/fetchProfilesByAddress.ts +++ b/packages/commonwealth/client/scripts/state/api/profiles/fetchProfilesByAddress.ts @@ -10,22 +10,26 @@ interface FetchProfilesByAddressProps { currentChainId: string; profileChainIds: string[]; profileAddresses: string[]; + initiateProfilesAfterFetch?: boolean; } -const fetchProfilesByAddress = async ({ +export const fetchProfilesByAddress = async ({ currentChainId, profileAddresses, profileChainIds, + initiateProfilesAfterFetch = true, }: FetchProfilesByAddressProps) => { const response = await axios.post( `${app.serverUrl()}${ApiEndpoints.FETCH_PROFILES}`, { addresses: profileAddresses, communities: profileChainIds, - jwt: app.user.jwt, + ...(app?.user?.jwt && { jwt: app.user.jwt }), }, ); + if (!initiateProfilesAfterFetch) return response.data.result; + return response.data.result.map((t) => { const profile = new MinimumProfile(t.address, currentChainId); profile.initialize( @@ -48,6 +52,7 @@ const useFetchProfilesByAddressesQuery = ({ profileChainIds, profileAddresses = [], apiCallEnabled = true, + initiateProfilesAfterFetch = true, }: UseFetchProfilesByAddressesQuery) => { return useQuery({ // eslint-disable-next-line @tanstack/query/exhaustive-deps @@ -64,6 +69,7 @@ const useFetchProfilesByAddressesQuery = ({ currentChainId, profileAddresses, profileChainIds, + initiateProfilesAfterFetch, }), staleTime: PROFILES_STALE_TIME, enabled: apiCallEnabled, diff --git a/packages/commonwealth/client/scripts/state/api/profiles/fetchSelfProfile.ts b/packages/commonwealth/client/scripts/state/api/profiles/fetchSelfProfile.ts index 028645b0770..12438519eb8 100644 --- a/packages/commonwealth/client/scripts/state/api/profiles/fetchSelfProfile.ts +++ b/packages/commonwealth/client/scripts/state/api/profiles/fetchSelfProfile.ts @@ -45,7 +45,7 @@ const useFetchSelfProfileQuery = ({ updateAddressesOnSuccess = false, }: UseFetchSelfProfileQuery) => { return useQuery({ - queryKey: [ApiEndpoints.FETCH_PROFILES], + queryKey: [ApiEndpoints.FETCH_SELF_PROFILE], queryFn: fetchSelfProfile, onSuccess: (profile) => { if ( diff --git a/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts b/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts new file mode 100644 index 00000000000..9f155cec5cc --- /dev/null +++ b/packages/commonwealth/client/scripts/state/ui/modals/authModal.ts @@ -0,0 +1,68 @@ +import { AuthModalType } from 'client/scripts/views/modals/AuthModal/types'; +import { createBoundedUseStore } from 'state/ui/utils'; +import { devtools, persist } from 'zustand/middleware'; +import { createStore } from 'zustand/vanilla'; + +interface AuthModalStore { + shouldOpenGuidanceModalAfterMagicSSORedirect: boolean; + setShouldOpenGuidanceModalAfterMagicSSORedirect: ( + shouldOpen: boolean, + ) => void; + triggerOpenModalType: AuthModalType; + setTriggerOpenModalType: (modalType: AuthModalType) => void; + validateAndOpenAuthTypeGuidanceModalOnSSORedirectReceived: () => void; +} + +export const authModal = createStore()( + devtools( + persist( + (set) => ({ + shouldOpenGuidanceModalAfterMagicSSORedirect: false, + setShouldOpenGuidanceModalAfterMagicSSORedirect: (shouldOpen) => { + set((state) => { + return { + ...state, + shouldOpenGuidanceModalAfterMagicSSORedirect: shouldOpen, + }; + }); + }, + triggerOpenModalType: null, + setTriggerOpenModalType: (modalType) => { + set((state) => { + return { + ...state, + triggerOpenModalType: modalType, + }; + }); + }, + validateAndOpenAuthTypeGuidanceModalOnSSORedirectReceived: () => { + set((state) => { + if (state.shouldOpenGuidanceModalAfterMagicSSORedirect) { + return { + ...state, + triggerOpenModalType: AuthModalType.AccountTypeGuidance, + shouldOpenGuidanceModalAfterMagicSSORedirect: false, + }; + } + + return { + ...state, + shouldOpenGuidanceModalAfterMagicSSORedirect: false, + }; + }); + }, + }), + { + name: 'auth-modal', // unique name + partialize: (state) => ({ + shouldOpenGuidanceModalAfterMagicSSORedirect: + state.shouldOpenGuidanceModalAfterMagicSSORedirect, + }), // persist only shouldOpenGuidanceModalAfterMagicSSORedirect + }, + ), + ), +); + +const useAuthModalStore = createBoundedUseStore(authModal); + +export default useAuthModalStore; diff --git a/packages/commonwealth/client/scripts/state/ui/modals/index.ts b/packages/commonwealth/client/scripts/state/ui/modals/index.ts index d0ac69dc837..c5f848bf9db 100644 --- a/packages/commonwealth/client/scripts/state/ui/modals/index.ts +++ b/packages/commonwealth/client/scripts/state/ui/modals/index.ts @@ -1,8 +1,10 @@ +import useAuthModalStore from './authModal'; import useManageCommunityStakeModalStore from './manageCommunityStakeModal'; import useNewTopicModalStore from './newTopicModal'; import useWelcomeOnboardModal from './welcomeOnboardModal'; export { + useAuthModalStore, useManageCommunityStakeModalStore, useNewTopicModalStore, useWelcomeOnboardModal, diff --git a/packages/commonwealth/client/scripts/views/Sublayout.tsx b/packages/commonwealth/client/scripts/views/Sublayout.tsx index fe60053a20c..9bb957050b9 100644 --- a/packages/commonwealth/client/scripts/views/Sublayout.tsx +++ b/packages/commonwealth/client/scripts/views/Sublayout.tsx @@ -9,11 +9,12 @@ import app from 'state'; import useSidebarStore from 'state/ui/sidebar'; import { SublayoutHeader } from 'views/components/SublayoutHeader'; import { Sidebar } from 'views/components/sidebar'; +import { getUniqueUserAddresses } from '../helpers/user'; import { useFlag } from '../hooks/useFlag'; import useNecessaryEffect from '../hooks/useNecessaryEffect'; import useStickyHeader from '../hooks/useStickyHeader'; import useUserLoggedIn from '../hooks/useUserLoggedIn'; -import { useWelcomeOnboardModal } from '../state/ui/modals'; +import { useAuthModalStore, useWelcomeOnboardModal } from '../state/ui/modals'; import { Footer } from './Footer'; import { SublayoutBanners } from './SublayoutBanners'; import { AdminOnboardingSlider } from './components/AdminOnboardingSlider'; @@ -50,6 +51,14 @@ const Sublayout = ({ onResize: () => setResizing(true), resizeListenerUpdateDeps: [resizing], }); + const { triggerOpenModalType, setTriggerOpenModalType } = useAuthModalStore(); + + useEffect(() => { + if (triggerOpenModalType) { + setAuthModalType(triggerOpenModalType); + setTriggerOpenModalType(undefined); + } + }, [triggerOpenModalType, setTriggerOpenModalType]); const profileId = app?.user?.addresses?.[0]?.profile?.id; @@ -72,8 +81,14 @@ const Sublayout = ({ (addr) => addr?.profile?.name && addr.profile?.name !== 'Anonymous', ); - // open welcome modal if user is not onboarded - if (!hasUsername && !onboardedProfiles[profileId]) { + const userUniqueAddresses = getUniqueUserAddresses({}); + + // open welcome modal if user is not onboarded and there is a single connected address + if ( + !hasUsername && + !onboardedProfiles[profileId] && + userUniqueAddresses.length === 1 + ) { setIsWelcomeOnboardModalOpen(true); } @@ -149,7 +164,7 @@ const Sublayout = ({ onMobile={isWindowExtraSmall} isInsideCommunity={isInsideCommunity} onAuthModalOpen={(modalType) => - setAuthModalType(modalType || 'sign-in') + setAuthModalType(modalType || AuthModalType.SignIn) } /> onButtonClick('create-account')} + onClick={() => onButtonClick(AuthModalType.CreateAccount)} /> onButtonClick('sign-in')} + onClick={() => onButtonClick(AuthModalType.SignIn)} /> ); diff --git a/packages/commonwealth/client/scripts/views/components/SublayoutHeader/useUserMenuItems.tsx b/packages/commonwealth/client/scripts/views/components/SublayoutHeader/useUserMenuItems.tsx index a9c20924b1a..340dea18c43 100644 --- a/packages/commonwealth/client/scripts/views/components/SublayoutHeader/useUserMenuItems.tsx +++ b/packages/commonwealth/client/scripts/views/components/SublayoutHeader/useUserMenuItems.tsx @@ -2,6 +2,7 @@ import axios from 'axios'; import React, { useEffect, useState } from 'react'; import { WalletId, WalletSsoSource } from '@hicommonwealth/shared'; +import { getUniqueUserAddresses } from 'client/scripts/helpers/user'; import { setActiveAccount } from 'controllers/app/login'; import { notifyError, notifySuccess } from 'controllers/app/notifications'; import WebWalletController from 'controllers/app/web_wallets'; @@ -16,7 +17,6 @@ import { CWToggle, toggleDarkMode, } from 'views/components/component_kit/cw_toggle'; -import { getUniqueUserAddressesForChainBase } from '../../modals/ManageCommunityStakeModal/utils'; import { useCommunityStake } from '../CommunityStake'; import UserMenuItem from './UserMenuItem'; import useCheckAuthenticatedAddresses from './useCheckAuthenticatedAddresses'; @@ -82,9 +82,9 @@ const useUserMenuItems = ({ const user = app.user?.addresses?.[0]; const profileId = user?.profileId || user?.profile.id; - const uniqueChainAddresses = getUniqueUserAddressesForChainBase( - app?.chain?.base, - ); + const uniqueChainAddresses = getUniqueUserAddresses({ + forChain: app?.chain?.base, + }); const shouldShowAddressesSwitcherForNonMember = stakeEnabled && app.activeChainId() && diff --git a/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthModal.tsx b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthModal.tsx index c69da71ee90..6b2b6d39df6 100644 --- a/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthModal.tsx +++ b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthModal.tsx @@ -1,24 +1,25 @@ +import { getUniqueUserAddresses } from 'client/scripts/helpers/user'; import { useFlag } from 'client/scripts/hooks/useFlag'; import { useWelcomeOnboardModal } from 'client/scripts/state/ui/modals'; import React, { useEffect, useState } from 'react'; import { CWModal } from '../../components/component_kit/new_designs/CWModal'; import './AuthModal.scss'; +import { AuthTypeGuidanceModal } from './AuthTypeGuidanceModal'; import { CreateAccountModal } from './CreateAccountModal'; import { SignInModal } from './SignInModal'; -import { AuthModalProps } from './types'; +import { AuthModalProps, AuthModalType } from './types'; const AuthModal = ({ - type = 'sign-in', + type = AuthModalType.SignIn, isOpen, onClose, onSuccess, showWalletsFor, onSignInClick, }: AuthModalProps) => { + const userOnboardingEnabled = useFlag('userOnboardingEnabled'); const [modalType, setModalType] = useState(type); - const { setIsWelcomeOnboardModalOpen } = useWelcomeOnboardModal(); - const userOnboardingEnabled = useFlag('userOnboardingEnabled'); useEffect(() => { // reset `modalType` state whenever modal is opened @@ -27,15 +28,22 @@ const AuthModal = ({ const handleOnSignInClick = () => { // switch to sign-in modal if user click on `Sign in`. - if (modalType === 'create-account') { - setModalType('sign-in'); + if (modalType === AuthModalType.CreateAccount) { + setModalType(AuthModalType.SignIn); } onSignInClick(); }; const handleSuccess = (isNewlyCreated) => { - if (userOnboardingEnabled && isNewlyCreated) { + const userUniqueAddresses = getUniqueUserAddresses({}); + + // open welcome modal only if there is a single connected address + if ( + userOnboardingEnabled && + isNewlyCreated && + userUniqueAddresses.length === 1 + ) { // using timeout to make the modal transition smooth setTimeout(() => { setIsWelcomeOnboardModalOpen(true); @@ -44,29 +52,50 @@ const AuthModal = ({ onSuccess?.(isNewlyCreated); }; - return ( - { + switch (modalType) { + case AuthModalType.AccountTypeGuidance: { + return ( + setModalType(selectedType)} /> - ) : ( + ); + } + case AuthModalType.CreateAccount: { + return ( setModalType(selectedType)} /> - ) + ); } + case AuthModalType.SignIn: { + return ( + setModalType(selectedType)} + /> + ); + } + } + }; + + return ( + ); }; diff --git a/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.scss b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.scss new file mode 100644 index 00000000000..f538bbd0599 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.scss @@ -0,0 +1,8 @@ +@import '../../../../../styles/shared'; + +.AuthTypeGuidanceModal { + display: flex; + width: 100%; + flex-direction: column; + gap: 24px; +} diff --git a/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.tsx b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.tsx new file mode 100644 index 00000000000..a40c86d364c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/modals/AuthModal/AuthTypeGuidanceModal/AuthTypeGuidanceModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ModalBase } from '../common/ModalBase'; +import { AuthModalType, ModalVariantProps } from '../types'; +import './AuthTypeGuidanceModal.scss'; +import { Option } from './Option'; + +const AuthTypeGuidanceModal = ({ + onClose, + onSuccess, + showWalletsFor, + onChangeModalType, +}: ModalVariantProps) => { + return ( + +