diff --git a/src/constants/index.ts b/src/constants/index.ts index c71fa4e63a..63347d9dc0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -233,6 +233,10 @@ export const APP_PATHS = { PROFILE_MANAGE: '/manage', ELASTIC_LEGACY: '/elastic-legacy', VERIFY_AUTH: '/auth', + + IAM_LOGIN: '/login', + IAM_LOGOUT: '/logout', + IAM_CONSENT: '/consent', } as const export const TERM_FILES_PATH = { diff --git a/src/pages/App.tsx b/src/pages/App.tsx index 0d636745c7..30123dfd20 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -43,6 +43,10 @@ import { isAddressString, isSupportLimitOrder, shortenAddress } from 'utils' import ElasticLegacyNotice from './ElasticLegacy/ElasticLegacyNotice' import VerifyAuth from './Verify/VerifyAuth' +const Login = lazy(() => import('./Oauth/Login')) +const Logout = lazy(() => import('./Oauth/Logout')) +const Consent = lazy(() => import('./Oauth/Consent')) + // test page for swap only through elastic const ElasticSwap = lazy(() => import('./ElasticSwap')) const SwapV2 = lazy(() => import('./SwapV2')) @@ -426,6 +430,9 @@ export default function App() { } /> } /> + } /> + } /> + } /> } /> diff --git a/src/pages/Oauth/Consent.tsx b/src/pages/Oauth/Consent.tsx new file mode 100644 index 0000000000..af418b7292 --- /dev/null +++ b/src/pages/Oauth/Consent.tsx @@ -0,0 +1,27 @@ +import KyberOauth2 from '@kybernetwork/oauth2' +import { useEffect } from 'react' + +import { ENV_KEY } from 'constants/env' +import useParsedQueryString from 'hooks/useParsedQueryString' +import { PageContainer } from 'pages/Oauth/styled' + +function Page() { + const { consent_challenge } = useParsedQueryString<{ consent_challenge: string }>() + + useEffect(() => { + if (!consent_challenge) return + KyberOauth2.initialize({ mode: ENV_KEY }) + KyberOauth2.oauthUi + .getFlowConsent(consent_challenge) + .then(data => { + console.debug('resp consent', data) + }) + .catch(err => { + console.debug('err consent', err) + }) + }, [consent_challenge]) + + return +} + +export default Page diff --git a/src/pages/Oauth/Login.tsx b/src/pages/Oauth/Login.tsx new file mode 100644 index 0000000000..405542416f --- /dev/null +++ b/src/pages/Oauth/Login.tsx @@ -0,0 +1,173 @@ +import KyberOauth2, { LoginFlow, LoginMethod } from '@kybernetwork/oauth2' +import { useCallback, useEffect, useRef, useState } from 'react' + +import Loader from 'components/Loader' +import { didUserReject } from 'constants/connectors/utils' +import { ENV_KEY } from 'constants/env' +import { useActiveWeb3React, useWeb3React } from 'hooks' +import useParsedQueryString from 'hooks/useParsedQueryString' +import { Col, Container, KyberLogo, TextDesc } from 'pages/Oauth/styled' +import getShortenAddress from 'utils/getShortenAddress' +import { queryStringToObject } from 'utils/string' +import { formatSignature } from 'utils/transaction' + +import AuthForm from './components/AuthForm' +import { BUTTON_IDS } from './constants/index' +import { createSignMessage, getSupportLoginMethods } from './utils' + +const getErrorMsg = (error: any) => { + const data = error?.response?.data + const isExpired = data?.error?.id === 'self_service_flow_expired' + if (isExpired) + return ( + + Time to sign-in is Expired, please{' '} + go back and try again. + + ) + return data?.ui?.messages?.[0]?.text || data?.error?.reason || data?.error?.message || error?.message || error + '' +} + +function Login() { + const { account: address, chainId } = useActiveWeb3React() + const { library: provider } = useWeb3React() + + const [processingSignEth, setProcessingSign] = useState(false) + const [authFormConfig, setAuthFormConfig] = useState() + const [error, setError] = useState('') + const [autoLogin, setAutoLogin] = useState(false) // not waiting for click btn + + const { wallet_address } = useParsedQueryString<{ wallet_address: string }>() + + const loginMethods = getSupportLoginMethods(authFormConfig) + const isSignInEth = loginMethods.includes(LoginMethod.ETH) + const isMismatchEthAddress = + !loginMethods.includes(LoginMethod.GOOGLE) && + isSignInEth && + wallet_address && + address && + wallet_address?.toLowerCase() !== address?.toLowerCase() + + const connectingWallet = useRef(false) + + const signInWithEth = useCallback(async () => { + try { + const siweConfig = authFormConfig?.oauth_client?.metadata?.siwe_config + if (isMismatchEthAddress || !siweConfig || connectingWallet.current || !provider || !address || !chainId) { + return + } + setProcessingSign(true) + const { ui, challenge, issued_at } = authFormConfig + connectingWallet.current = true + const csrf = ui.nodes.find(e => e.attributes.name === 'csrf_token')?.attributes?.value ?? '' + const message = createSignMessage({ + address, + chainId, + nonce: challenge, + issuedAt: issued_at, + ...siweConfig, + }) + + const signature = await provider.getSigner().signMessage(message) + const resp = await KyberOauth2.oauthUi.loginEthereum({ + address, + signature: formatSignature(signature), + csrf, + chainId, + }) + + if (resp) { + connectingWallet.current = false + setProcessingSign(false) + } + } catch (error: any) { + if (!didUserReject(error)) { + setError(getErrorMsg(error)) + } + console.error('signInWithEthereum err', error) + connectingWallet.current = false + setProcessingSign(false) + } + }, [address, provider, authFormConfig, chainId, isMismatchEthAddress]) + + useEffect(() => { + const getFlowLogin = async () => { + try { + KyberOauth2.initialize({ mode: ENV_KEY }) + const loginFlow = await KyberOauth2.oauthUi.getFlowLogin() + if (!loginFlow) return + setAuthFormConfig(loginFlow) + + const { client_id } = loginFlow.oauth_client + const loginMethods = getSupportLoginMethods(loginFlow) + + let autoLogin = false + const isIncludeGoogle = loginMethods.includes(LoginMethod.GOOGLE) + if (loginMethods.length === 1) { + if (loginMethods.includes(LoginMethod.ANONYMOUS)) { + throw new Error('Not found login method for this app') + } + if (isIncludeGoogle) { + autoLogin = true + } + } + // todo + if (loginMethods.includes(LoginMethod.ETH) && !isIncludeGoogle) { + setTimeout(() => document.getElementById(BUTTON_IDS.LOGIN_ETH)?.click(), 200) + } + setAutoLogin(autoLogin) + KyberOauth2.initialize({ clientId: client_id, mode: ENV_KEY }) + + if (autoLogin) setTimeout(() => document.getElementById(BUTTON_IDS.LOGIN_GOOGLE)?.click(), 200) + } catch (error: any) { + const { error_description } = queryStringToObject(window.location.search) + setError(error_description || getErrorMsg(error)) + } + } + getFlowLogin() + }, []) + + const appName = authFormConfig?.oauth_client?.client_name || authFormConfig?.oauth_client?.client_id + + const renderEthMsg = () => + isMismatchEthAddress ? ( + + Your address is mismatched. The expected address is {getShortenAddress(wallet_address)}, but the address + provided is {getShortenAddress(address)}. Please change your wallet address accordingly. + + ) : ( + address && ( + + To get started, please sign-in to verify your ownership of this wallet address {getShortenAddress(address)} + + ) + ) + + return ( + + + + {error ? ( + {error} + ) : autoLogin ? ( + + Checking data ... + + ) : isSignInEth && address ? ( + renderEthMsg() + ) : ( + appName && Please sign in to continue with {appName} + )} + + + + ) +} + +export default Login diff --git a/src/pages/Oauth/Logout.tsx b/src/pages/Oauth/Logout.tsx new file mode 100644 index 0000000000..b131e543d8 --- /dev/null +++ b/src/pages/Oauth/Logout.tsx @@ -0,0 +1,27 @@ +import KyberOauth2 from '@kybernetwork/oauth2' +import { useEffect } from 'react' + +import { ENV_KEY } from 'constants/env' +import useParsedQueryString from 'hooks/useParsedQueryString' +import { PageContainer } from 'pages/Oauth/styled' + +function Logout() { + const { logout_challenge } = useParsedQueryString<{ logout_challenge: string }>() + + useEffect(() => { + if (!logout_challenge) return + KyberOauth2.initialize({ mode: ENV_KEY }) + KyberOauth2.oauthUi + .acceptLogout(logout_challenge) + .then(data => { + console.debug('logout resp', data) + }) + .catch(err => { + console.debug('err logout', err) + }) + }, [logout_challenge]) + + return +} + +export default Logout diff --git a/src/pages/Oauth/components/AuthForm/AuthFormField.tsx b/src/pages/Oauth/components/AuthForm/AuthFormField.tsx new file mode 100644 index 0000000000..a4ab45b476 --- /dev/null +++ b/src/pages/Oauth/components/AuthForm/AuthFormField.tsx @@ -0,0 +1,28 @@ +import { LoginFlowUiNode } from '@kybernetwork/oauth2' +import React from 'react' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' + +import { BUTTON_IDS } from '../../constants/index' + +interface AuthFormFieldProps extends React.InputHTMLAttributes { + field: LoginFlowUiNode + outline?: boolean +} + +const AuthFormField: React.FC = ({ field, outline }) => { + const attributes = field.attributes + if (field.group === 'oidc') { + const props = { + height: '36px', + id: BUTTON_IDS.LOGIN_GOOGLE, + type: 'submit', + value: attributes.value, + name: attributes.name, + children: <>Sign-In with Google, + } + return React.createElement(outline ? ButtonOutlined : ButtonPrimary, props) + } + return null +} +export default AuthFormField diff --git a/src/pages/Oauth/components/AuthForm/AuthFormFieldMessage.tsx b/src/pages/Oauth/components/AuthForm/AuthFormFieldMessage.tsx new file mode 100644 index 0000000000..40eeac866a --- /dev/null +++ b/src/pages/Oauth/components/AuthForm/AuthFormFieldMessage.tsx @@ -0,0 +1,20 @@ +import { Text } from 'rebass' + +import useTheme from 'hooks/useTheme' + +const AuthFormFieldMessage: React.FC<{ messages?: { type: string; text: string }[] }> = ({ messages }) => { + const theme = useTheme() + if (!messages?.length) return null + + const messageList: JSX.Element[] = messages.map((value, index) => { + return ( + + {value.text} + + ) + }) + + return
{messageList}
+} + +export default AuthFormFieldMessage diff --git a/src/pages/Oauth/components/AuthForm/index.tsx b/src/pages/Oauth/components/AuthForm/index.tsx new file mode 100644 index 0000000000..4198aa4478 --- /dev/null +++ b/src/pages/Oauth/components/AuthForm/index.tsx @@ -0,0 +1,124 @@ +import { LoginFlow, LoginFlowUiNode, LoginMethod } from '@kybernetwork/oauth2' +import React from 'react' +import { Flex, Text } from 'rebass' +import styled from 'styled-components' + +import { ButtonOutlined, ButtonPrimary } from 'components/Button' +import Wallet from 'components/Icons/Wallet' +import Loader from 'components/Loader' +import { useActiveWeb3React } from 'hooks' +import useParsedQueryString from 'hooks/useParsedQueryString' +import { useWalletModalToggle } from 'state/application/hooks' + +import { BUTTON_IDS } from '../../constants/index' +import { getSupportLoginMethods, navigateToUrl } from '../../utils' +import AuthFormField from './AuthFormField' +import AuthFormFieldMessage from './AuthFormFieldMessage' + +const Form = styled.form` + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; +` + +interface AuthFormProps extends React.FormHTMLAttributes { + formConfig: LoginFlow | undefined + autoLogin: boolean + processingSignEth: boolean + signInWithEth: () => void + disableEth: boolean +} + +const Splash = () =>
+ +const AuthForm: React.FC = ({ + children, + formConfig, + autoLogin, + signInWithEth, + processingSignEth, + disableEth, + ...otherProps +}) => { + const { back_uri } = useParsedQueryString<{ back_uri: string }>() + + const loginMethods = getSupportLoginMethods(formConfig) + const showEth = loginMethods.includes(LoginMethod.ETH) && !autoLogin + const hasGoogle = loginMethods.includes(LoginMethod.GOOGLE) + const { account } = useActiveWeb3React() + const toggleWalletModal = useWalletModalToggle() + + const onClickEth = (e: React.MouseEvent) => { + e.preventDefault() + !account ? toggleWalletModal() : signInWithEth() + } + + const renderBtnEth = () => ( + + {processingSignEth ? ( + <> +   Signing In + + ) : ( + <> + +   Sign-In with Wallet + + )} + + ) + + if (!formConfig) return null + const { ui } = formConfig + + const showBtnCancel = !hasGoogle && back_uri && !processingSignEth + const hasBothEthAndGoogle = hasGoogle && showEth + return ( +
+ + {showEth && + (showBtnCancel ? ( + + {showBtnCancel && ( + navigateToUrl(back_uri)} + height={'36px'} + > + Cancel + + )} + {renderBtnEth()} + + ) : ( + renderBtnEth() + ))} + {hasBothEthAndGoogle && ( +
+ or +
+ )} + {hasGoogle && + ui?.nodes?.map((field: LoginFlowUiNode, index: number) => ( + + ))} + + ) +} +export default AuthForm diff --git a/src/pages/Oauth/constants/index.ts b/src/pages/Oauth/constants/index.ts new file mode 100644 index 0000000000..dade1a84e5 --- /dev/null +++ b/src/pages/Oauth/constants/index.ts @@ -0,0 +1,4 @@ +export const BUTTON_IDS = { + LOGIN_GOOGLE: 'btnLoginGoogle', + LOGIN_ETH: 'btnLoginEth', +} diff --git a/src/pages/Oauth/styled.tsx b/src/pages/Oauth/styled.tsx new file mode 100644 index 0000000000..ab1a513232 --- /dev/null +++ b/src/pages/Oauth/styled.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components' + +import backgroundImage from 'assets/images/truesight-v2/landing-page/background-gradient.png' +import Loader from 'components/Loader' +import { useIsDarkMode } from 'state/user/hooks' + +export const Container = styled.div` + flex: 1; + justify-content: center; + padding: 20px 0; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + background-image: url(${backgroundImage}); + background-size: 100%; + background-repeat: repeat-y; +` + +export const Col = styled.div` + gap: 30px; + display: flex; + flex-direction: column; + align-items: center; + ${({ theme }) => theme.mediaWidth.upToSmall` + gap: 16px; + `}; +` +export const TextDesc = styled.div` + font-size: 20px; + line-height: 24px; + color: ${({ theme }) => theme.subText}; + ${({ theme }) => theme.mediaWidth.upToSmall` + font-size: 16px; + line-height: 20px; + `}; +` + +export const KyberLogo = () => { + const iseDark = useIsDarkMode() + return ( + loading-icon + ) +} + +export function PageContainer({ msg }: { msg: string }) { + return ( + + + + + {msg} + + + + ) +} diff --git a/src/pages/Oauth/utils.ts b/src/pages/Oauth/utils.ts new file mode 100644 index 0000000000..00d67528c1 --- /dev/null +++ b/src/pages/Oauth/utils.ts @@ -0,0 +1,67 @@ +import { LoginFlow } from '@kybernetwork/oauth2' + +export const getSupportLoginMethods = (loginFlow: LoginFlow | undefined) => { + return loginFlow?.oauth_client?.metadata?.allowed_login_methods ?? [] +} + +const whiteListDomains = [/https:\/\/(.+?\.)?kyberswap.com/, /https:\/\/(.+)\.kyberengineering.io/] +const isValidRedirectURL = (url: string | undefined) => { + try { + if (!url) return false + const newUrl = new URL(url) // valid url + if ( + url.endsWith('.js') || + newUrl.pathname.endsWith('.js') || + !whiteListDomains.some(regex => newUrl.origin.match(regex)) + ) { + return false + } + return newUrl.protocol === 'http:' || newUrl.protocol === 'https:' + } catch (error) { + return false + } +} + +export const navigateToUrl = (url: string | undefined) => { + if (url && isValidRedirectURL(url)) window.location.href = url +} + +type MessageParams = { + domain: string + uri: string + address: string + version: string + nonce: string + chainId: number + issuedAt: string + statement: string +} + +// message follow eip https://eips.ethereum.org/EIPS/eip-4361 +export const createSignMessage = ({ + domain, + uri, + address, + version, + nonce, + chainId, + issuedAt, + statement, +}: MessageParams): string => { + let prefix = [`${domain} wants you to sign in with your Ethereum account:`, address].join('\n') + + prefix = [prefix, statement].join('\n\n') + if (statement) { + prefix += '\n' + } + + const suffix = [ + `URI: ${uri}`, + `Version: ${version}`, + `Chain ID: ` + chainId, + `Nonce: ${nonce}`, + `Issued At: ${issuedAt}`, + ].join('\n') + + return [prefix, suffix].join('\n') +} diff --git a/src/pages/TrueSightV2/components/TruesightFooter.tsx b/src/pages/TrueSightV2/components/TruesightFooter.tsx index 8557d9b152..1f2566c7fa 100644 --- a/src/pages/TrueSightV2/components/TruesightFooter.tsx +++ b/src/pages/TrueSightV2/components/TruesightFooter.tsx @@ -41,7 +41,12 @@ export default function TruesightFooter() { const toggle = useWalletModalToggle() const { account } = useActiveWeb3React() const location = useLocation() - if (account || location.pathname.startsWith(APP_PATHS.MY_EARNINGS)) { + if ( + account || + [APP_PATHS.MY_EARNINGS, APP_PATHS.IAM_LOGIN, APP_PATHS.IAM_CONSENT, APP_PATHS.IAM_LOGOUT].some(path => + location.pathname.startsWith(path), + ) + ) { return null } diff --git a/src/utils/transaction.ts b/src/utils/transaction.ts index 1a2e35a0ed..51d866cbec 100644 --- a/src/utils/transaction.ts +++ b/src/utils/transaction.ts @@ -1,3 +1,5 @@ +import { ethers } from 'ethers' + import { GROUP_TRANSACTION_BY_TYPE, TRANSACTION_GROUP, @@ -22,3 +24,14 @@ export const getTransactionStatus = (transaction: TransactionDetails) => { error: !pending && transaction?.receipt?.status !== 1, } } + +// todo danh update limit order use this function +export const formatSignature = (rawSignature: string) => { + const bytes = ethers.utils.arrayify(rawSignature) + const lastByte = bytes[64] + if (lastByte === 0 || lastByte === 1) { + // to support hardware wallet https://ethereum.stackexchange.com/a/113727 + bytes[64] += 27 + } + return ethers.utils.hexlify(bytes) +}