diff --git a/.gitignore b/.gitignore index 6e4d09b1..c67844e2 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ stats.html .env.development .env.production .env.local +.env # Sentry Auth Token .env.sentry-build-plugin diff --git a/src/App.tsx b/src/App.tsx index e6426a84..ee4827eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,13 @@ import { CircularProgress, CssBaseline, Stack, ThemeProvider } from '@mui/materi import { FC, HTMLAttributes, memo, useCallback, useEffect, useState } from 'react' import { ErrorHandler } from '@/helpers' -import { useMetamaskZkpSnapContext, useThemeMode, useViewportSizes, useWeb3Context } from '@/hooks' +import { + useAuth, + useMetamaskZkpSnapContext, + useThemeMode, + useViewportSizes, + useWeb3Context, +} from '@/hooks' const App: FC> = ({ children }) => { const [isAppInitialized, setIsAppInitialized] = useState(false) @@ -10,6 +16,7 @@ const App: FC> = ({ children }) => { const { provider, isValidChain, init: initWeb3 } = useWeb3Context() const { theme } = useThemeMode() const { checkMetamaskExists, checkSnapExists, connectOrInstallSnap } = useMetamaskZkpSnapContext() + const { authorize } = useAuth() useViewportSizes() @@ -23,14 +30,24 @@ const App: FC> = ({ children }) => { * because only want to check is user was connected before */ await initWeb3() - if (await checkSnapExists()) await connectOrInstallSnap() + if (await checkSnapExists()) { + await connectOrInstallSnap() + await authorize() + } } } catch (error) { ErrorHandler.processWithoutFeedback(error) } setIsAppInitialized(true) - }, [provider?.address, checkMetamaskExists, initWeb3, checkSnapExists, connectOrInstallSnap]) + }, [ + provider?.address, + checkMetamaskExists, + initWeb3, + checkSnapExists, + connectOrInstallSnap, + authorize, + ]) useEffect(() => { let mountingInit = async () => { diff --git a/src/assets/icons/user-icon.svg b/src/assets/icons/user-icon.svg new file mode 100644 index 00000000..eaaef992 --- /dev/null +++ b/src/assets/icons/user-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/config.ts b/src/config.ts index ebe3e35a..09132e5d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,9 +15,6 @@ export type Config = { SUPPORTED_CHAINS_DETAILS: SupportedChainsDetails DEFAULT_CHAIN: SupportedChains ROBOTORNOT_LINK: string - CHROME_METAMASK_ADDON_LINK: string - FIREFOX_METAMASK_ADDON_LINK: string - OPERA_METAMASK_ADDON_LINK: string } export const config: Config = { @@ -27,7 +24,4 @@ export const config: Config = { SUPPORTED_CHAINS_DETAILS, DEFAULT_CHAIN: import.meta.env.VITE_DEFAULT_CHAIN, ROBOTORNOT_LINK: 'https://robotornot.mainnet-beta.rarimo.com/', - CHROME_METAMASK_ADDON_LINK: 'https://chrome.google.com/webstore/detail/metamask/', - FIREFOX_METAMASK_ADDON_LINK: 'https://addons.mozilla.org/en-US/firefox/addon/ether-metamask/', - OPERA_METAMASK_ADDON_LINK: 'https://addons.opera.com/en/extensions/details/metamask-10/', } diff --git a/src/contexts/metamask-zkp-snap.tsx b/src/contexts/metamask-zkp-snap.tsx index d26a3b34..2689f05c 100644 --- a/src/contexts/metamask-zkp-snap.tsx +++ b/src/contexts/metamask-zkp-snap.tsx @@ -18,6 +18,8 @@ import { createContext, FC, HTMLAttributes, useCallback, useState } from 'react' interface MetamaskZkpSnapContextValue { isMetamaskInstalled: boolean isSnapInstalled: boolean + userDid: string + userDidBigIntString: string isLocalSnap: (snapId: string) => boolean @@ -45,6 +47,8 @@ const CONTEXT_NOT_INITIALIZED_ERROR = new ReferenceError('MetamaskZkpSnapContext export const MetamaskZkpSnapContext = createContext({ isMetamaskInstalled: false, isSnapInstalled: false, + userDid: '', + userDidBigIntString: '', isLocalSnap: () => { throw CONTEXT_NOT_INITIALIZED_ERROR @@ -82,6 +86,8 @@ export const MetamaskZkpSnapContextProvider: FC> const [isMetamaskInstalled, setIsMetamaskInstalled] = useState(false) const [isSnapInstalled, setIsSnapInstalled] = useState(false) + const [userDid, setUserDid] = useState('') + const [userDidBigIntString, setUserDidBigIntString] = useState('') const isLocalSnap = useCallback((snapId: string) => snapId.startsWith('local:'), []) @@ -91,8 +97,10 @@ export const MetamaskZkpSnapContextProvider: FC> */ const createIdentity = useCallback(async () => { if (!connector) throw new TypeError('Connector is not defined') - - return connector.createIdentity() + const identity = await connector.createIdentity() + setUserDid(identity.identityIdString) + setUserDidBigIntString(identity.identityIdBigIntString) + return identity }, [connector]) /** @@ -163,6 +171,8 @@ export const MetamaskZkpSnapContextProvider: FC> value={{ isMetamaskInstalled, isSnapInstalled, + userDid, + userDidBigIntString, isLocalSnap, diff --git a/src/enums/icons.ts b/src/enums/icons.ts index 5f56be96..d8a86c4d 100644 --- a/src/enums/icons.ts +++ b/src/enums/icons.ts @@ -11,6 +11,7 @@ import { default as Work } from '@mui/icons-material/Work' export enum Icons { Metamask = 'metamask', + User = 'user', Wallet = 'wallet', } diff --git a/src/enums/index.ts b/src/enums/index.ts index c44d79a3..73bdd284 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -1,5 +1,4 @@ export * from './bus' export * from './icons' -export * from './locals-storage-keys' export * from './routes' export * from './theme' diff --git a/src/enums/locals-storage-keys.ts b/src/enums/locals-storage-keys.ts deleted file mode 100644 index a3905461..00000000 --- a/src/enums/locals-storage-keys.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum LocalStorageKeys { - Ui = 'dashboard-rarime/ui', - Web3 = 'dashboard-rarime-/web3', -} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index faf6b2fd..28576feb 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,4 +1,5 @@ export * from './error-handler' export * from './event-bus' +export * from './metamask' export * from './promise' export * from './store' diff --git a/src/helpers/metamask.ts b/src/helpers/metamask.ts new file mode 100644 index 00000000..aaaa1e39 --- /dev/null +++ b/src/helpers/metamask.ts @@ -0,0 +1,30 @@ +import { get } from 'lodash' + +const OTHER_BROWSER_METAMASK_LINK = 'https://metamask.io/download/' +const CHROME_METAMASK_ADDON_LINK = 'https://chrome.google.com/webstore/detail/metamask/' +const FIREFOX_METAMASK_ADDON_LINK = 'https://addons.mozilla.org/en-US/firefox/addon/ether-metamask/' +const OPERA_METAMASK_ADDON_LINK = 'https://addons.opera.com/en/extensions/details/metamask-10/' + +export function metamaskLink() { + const browserExtensionsLinks = { + chrome: CHROME_METAMASK_ADDON_LINK, + opera: OPERA_METAMASK_ADDON_LINK, + firefox: FIREFOX_METAMASK_ADDON_LINK, + } + + // Get the user-agent string + const userAgentString = navigator.userAgent + + let chromeAgent = userAgentString.indexOf('Chrome') > -1 ? 'chrome' : '' + const firefoxAgent = userAgentString.indexOf('Firefox') > -1 ? 'firefox' : '' + const operaAgent = userAgentString.indexOf('OP') > -1 ? 'opera' : '' + + // Discard Chrome since it also matches Opera + if (chromeAgent && operaAgent) chromeAgent = '' + + const currentBrowser = chromeAgent || firefoxAgent || operaAgent || '' + + if (!currentBrowser) return OTHER_BROWSER_METAMASK_LINK + + return get(browserExtensionsLinks, currentBrowser, '') +} diff --git a/src/hooks/auth.ts b/src/hooks/auth.ts new file mode 100644 index 00000000..3e08ef4c --- /dev/null +++ b/src/hooks/auth.ts @@ -0,0 +1,72 @@ +import { PROVIDERS } from '@distributedlab/w3p' +import { useCallback, useMemo, useState } from 'react' + +import { useMetamaskZkpSnapContext } from '@/hooks/metamask-zkp-snap' +import { useWeb3Context } from '@/hooks/web3' +import { authStore, useAuthState } from '@/store' + +export const useAuth = () => { + const { jwt: storeJwt } = useAuthState() + const { init, provider } = useWeb3Context() + const { connectOrInstallSnap, isSnapInstalled } = useMetamaskZkpSnapContext() + const [isJwtValid, setIsJwtValid] = useState(false) + + const isAuthorized = useMemo( + () => provider?.isConnected && isSnapInstalled && isJwtValid, + [isJwtValid, isSnapInstalled, provider?.isConnected], + ) + + const _setJwt = useCallback((jwt: string) => { + authStore.setJwt(jwt) + }, []) + + const checkJwtValid = useCallback(async () => { + //Todo: add real logic + return true + }, []) + + const logOut = useCallback(async () => { + await provider?.disconnect() + _setJwt('') + setIsJwtValid(false) + }, [_setJwt, provider]) + + const authorize = useCallback( + async (jwt?: string) => { + const currentJwt = jwt || storeJwt + + if (!currentJwt) await logOut() + + const isJwtValid = await checkJwtValid() + + if (isJwtValid) { + setIsJwtValid(true) + _setJwt(currentJwt) + return + } + + logOut() + + // TODO: Replace with real auth check + }, + [_setJwt, checkJwtValid, storeJwt, logOut], + ) + + const login = useCallback(async () => { + await init(PROVIDERS.Metamask) + await connectOrInstallSnap() + // TODO: generateProof and /login + const jwt = 'mockJwt' + + await authorize(jwt) + }, [authorize, connectOrInstallSnap, init]) + + return { + isAuthorized, + storeJwt, + login, + authorize, + logOut, + checkJwtValid, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 36a4541c..3c7328da 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './auth' export * from './form' export * from './interval' export * from './loading' diff --git a/src/locales/resources/en.json b/src/locales/resources/en.json index 33280f18..a8483e5c 100644 --- a/src/locales/resources/en.json +++ b/src/locales/resources/en.json @@ -9,7 +9,10 @@ }, "sign-in-page": { "title": "Sign in", - "description": "Manage your identity credentials and Soulbound Tokens (SBTs) easily from this dashboard" + "description": "Manage your identity credentials and Soulbound Tokens (SBTs) easily from this dashboard", + "connect-btn": "Connect Metamask", + "install-btn": "Install Metamask", + "reload-page-btn": "Please, reload page" }, "validations": { "field-error_required": "Please fill out this field", diff --git a/src/pages/SignIn.tsx b/src/pages/SignIn.tsx index aa757dee..49dbe25a 100644 --- a/src/pages/SignIn.tsx +++ b/src/pages/SignIn.tsx @@ -1,35 +1,89 @@ -import { PROVIDERS } from '@distributedlab/w3p' -import { Stack, Typography } from '@mui/material' -import { useEffect } from 'react' +import { Stack, Typography, useTheme } from '@mui/material' +import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate } from 'react-router-dom' -import { Routes } from '@/enums' -import { useMetamaskZkpSnapContext, useWeb3Context } from '@/hooks' -import { UiButton } from '@/ui' +import { BusEvents, Icons } from '@/enums' +import { bus, ErrorHandler, metamaskLink } from '@/helpers' +import { useAuth, useMetamaskZkpSnapContext } from '@/hooks' +import { UiButton, UiIcon } from '@/ui' export default function SignIn() { - const navigate = useNavigate() const { t } = useTranslation() - const { init: initWeb3, provider } = useWeb3Context() - const { connectOrInstallSnap } = useMetamaskZkpSnapContext() + const { login } = useAuth() + const [isPending, setIsPending] = useState(false) - const connectWallet = async () => { - await initWeb3(PROVIDERS.Metamask) - await connectOrInstallSnap() - } + const { palette } = useTheme() + const { isMetamaskInstalled } = useMetamaskZkpSnapContext() - useEffect(() => { - if (provider?.isConnected) { - navigate(Routes.Profiles) + const signIn = useCallback(async () => { + setIsPending(true) + + try { + await login() + } catch (error) { + ErrorHandler.process(error) } - }, [navigate, provider?.isConnected]) + + setIsPending(false) + }, [login]) + + const installMMLink = useMemo(() => { + if (isMetamaskInstalled) return '' + + return metamaskLink() + }, [isMetamaskInstalled]) + + const openInstallMetamaskLink = useCallback(() => { + if (!installMMLink) { + bus.emit(BusEvents.warning, `Your browser is not support Metamask`) + + return + } + + setIsPending(true) + + window.open(installMMLink, '_blank', 'noopener noreferrer') + }, [installMMLink]) return ( - - {t('sign-in-page.title')} - {t('sign-in-page.description')} - Connect + + + {/*Todo: add metamask not found texts*/} + + {t('sign-in-page.title')} + + + {t('sign-in-page.description')} + + {isMetamaskInstalled ? ( + } + disabled={isPending} + > + {t('sign-in-page.connect-btn')} + + ) : ( + } + disabled={isPending} + > + {isPending ? t('sign-in-page.reload-page-btn') : t('sign-in-page.install-btn')} + + )} ) } diff --git a/src/routes.tsx b/src/routes.tsx index 3e1568a5..7848b03c 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -15,6 +15,7 @@ import { Web3ProviderContextProvider, } from '@/contexts' import { Routes } from '@/enums' +import { useAuth } from '@/hooks' import Profiles from '@/pages/Profiles' import UiKit from '@/pages/UiKit' @@ -27,7 +28,7 @@ export const AppRoutes = () => { const OrgNew = lazy(() => import('@/pages/OrgNew')) // TODO: Replace with real auth check - const isAuthorized = true + const { isAuthorized } = useAuth() const signInGuard = () => (isAuthorized ? redirect(Routes.Root) : null) const authProtectedGuard = ({ request }: LoaderFunctionArgs) => { diff --git a/src/store/modules/auth.module.ts b/src/store/modules/auth.module.ts new file mode 100644 index 00000000..d77be2c4 --- /dev/null +++ b/src/store/modules/auth.module.ts @@ -0,0 +1,19 @@ +import { createStore } from '@/helpers' + +interface AuthState { + jwt: string +} + +const [authStore, useAuthState] = createStore( + 'auth', + { + jwt: '', + } as AuthState, + state => ({ + setJwt: (jwt: string) => { + state.jwt = jwt + }, + }), +) + +export { authStore, useAuthState } diff --git a/src/store/modules/index.ts b/src/store/modules/index.ts index 57932ea6..85eaad17 100644 --- a/src/store/modules/index.ts +++ b/src/store/modules/index.ts @@ -1,2 +1,3 @@ +export * from './auth.module' export * from './ui.module' export * from './web3.module' diff --git a/src/ui/UiIcon.tsx b/src/ui/UiIcon.tsx index 09162601..252c9283 100644 --- a/src/ui/UiIcon.tsx +++ b/src/ui/UiIcon.tsx @@ -1,6 +1,5 @@ -import { Box, SvgIconProps, SxProps } from '@mui/material' +import { Box, BoxProps, SvgIconProps, SxProps } from '@mui/material' import { Theme } from '@mui/material/styles' -import { HTMLAttributes } from 'react' import { ICON_COMPONENTS, Icons } from '@/enums' @@ -9,14 +8,14 @@ type Props = { sx?: SxProps } & ( | ({ - componentName: keyof typeof ICON_COMPONENTS - name?: never - } & SvgIconProps) + componentName: keyof typeof ICON_COMPONENTS + name?: never +} & SvgIconProps) | ({ - name: Icons - componentName?: never - } & HTMLAttributes) -) + name: Icons + componentName?: never +} & BoxProps<'svg'>) + ) export default function UiIcon({ size = 6, ...props }: Props) { const sx: SxProps = { @@ -47,7 +46,7 @@ export default function UiIcon({ size = 6, ...props }: Props) { className={['icon', ...(className ? [className] : [])].join(' ')} aria-hidden='true' > - + ) }