From 3ed3b0615e530feedb7ca8c9885651311abbcb48 Mon Sep 17 00:00:00 2001 From: Mehdi Torabi <46302001+mehdi-torabiv@users.noreply.github.com> Date: Sun, 1 Sep 2024 23:07:43 +0300 Subject: [PATCH] Feat/refactor app (#66) * remove router directory * add eslint sort import plugin * implement useSiweAuth hook * update protectedRoute * remove dashboard route * update vite.config * refactor identifiers page * fix import * refactor attestation page * fix issue * remove garbage codes * add reveal functionality * fix loading issue * add test case * fix --- .eslintrc.cjs | 39 +- package-lock.json | 11 + package.json | 1 + src/App.tsx | 99 +-- src/ProtectedRoute.tsx | 24 +- .../layouts/AccountPopover.spec.tsx | 5 +- src/components/layouts/AccountPopover.tsx | 10 +- src/components/layouts/AppbarApp.spec.tsx | 3 +- src/components/layouts/SidebarApp.spec.tsx | 8 +- src/components/layouts/SidebarApp.tsx | 5 +- .../pages/attestations/StepOne.test.tsx | 26 + src/components/pages/attestations/StepOne.tsx | 121 ++++ .../pages/attestations/StepThree.tsx | 96 +++ src/components/pages/attestations/StepTwo.tsx | 92 +++ .../shared/AccessControlButton.spec.tsx | 74 ++- src/components/shared/AccessControlButton.tsx | 29 +- src/components/shared/CustomSnackbar.tsx | 2 +- src/components/shared/CustomStepper.tsx | 139 ++-- src/components/shared/CustomTable.tsx | 11 +- .../shared/StepperComponent.test.tsx | 30 +- src/context/authContext.tsx | 62 -- src/enums/index.ts | 4 + src/hooks/LitProvider.tsx | 4 +- src/hooks/useAttestations.tsx | 68 ++ src/hooks/useSessionSigs.tsx | 8 +- src/hooks/useSiweAuth.tsx | 72 ++ src/interfaces/index.ts | 15 + src/layouts/DefaultLayout.tsx | 3 +- src/libs/constants.ts | 4 +- src/libs/oci/index.ts | 20 +- src/libs/theme.tsx | 21 +- src/main.tsx | 4 +- src/pages/Auth/Login/Login.tsx | 2 +- src/pages/Auth/Login/index.ts | 2 +- src/pages/Callback/Callback.tsx | 4 +- src/pages/Dashboard/Dashboard.spec.tsx | 8 - src/pages/Dashboard/Dashboard.tsx | 3 - src/pages/Dashboard/index.ts | 3 - .../Identifiers/Attestation/Attestation.tsx | 305 ++------- src/pages/Identifiers/Attestation/index.ts | 2 +- src/pages/Identifiers/Identifiers.tsx | 629 ++++++++---------- src/pages/Identifiers/index.ts | 2 +- src/pages/Permissions/Permissions.tsx | 28 +- src/router/index.tsx | 65 -- src/services/api/auth/index.ts | 8 +- src/services/api/eas/query.ts | 1 + src/services/api/index.ts | 1 + src/services/eas.service.ts | 173 +++++ src/services/eas/query.ts | 5 +- src/setupTests.ts | 3 + src/utils/eas-wagmi-utils.ts | 4 +- src/utils/helper/index.ts | 3 + vite.config.ts | 2 +- 53 files changed, 1297 insertions(+), 1066 deletions(-) create mode 100644 src/components/pages/attestations/StepOne.test.tsx create mode 100644 src/components/pages/attestations/StepOne.tsx create mode 100644 src/components/pages/attestations/StepThree.tsx create mode 100644 src/components/pages/attestations/StepTwo.tsx delete mode 100644 src/context/authContext.tsx create mode 100644 src/enums/index.ts create mode 100644 src/hooks/useAttestations.tsx create mode 100644 src/hooks/useSiweAuth.tsx delete mode 100644 src/pages/Dashboard/Dashboard.spec.tsx delete mode 100644 src/pages/Dashboard/Dashboard.tsx delete mode 100644 src/pages/Dashboard/index.ts delete mode 100644 src/router/index.tsx create mode 100644 src/services/eas.service.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index ddba321..bca81c5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -17,18 +17,39 @@ module.exports = { parserOptions: { ecmaVersion: 'latest', sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json', "./tsconfig.app.json"], + project: ['./tsconfig.json', './tsconfig.node.json', './tsconfig.app.json'], }, - plugins: ['react', '@typescript-eslint', 'prettier'], + plugins: ['react', '@typescript-eslint', 'prettier', 'simple-import-sort'], rules: { + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // 1. Side effect imports at the start. For me this is important because I want to import reset.css and global styles at the top of my main file. + ['^\\u0000'], + // 2. `react` and packages: Things that start with a letter (or digit or underscore), or `@` followed by a letter. + ['^react$', '^@?\\w'], + // 3. Absolute imports and other imports such as Vue-style `@/foo`. + // Anything not matched in another group. (also relative imports starting with "../") + ['^@', '^'], + // 4. relative imports from same folder "./" (I like to have them grouped together) + ['^\\./'], + // 5. style module imports always come last, this helps to avoid CSS order issues + ['^.+\\.(module.css|module.scss)$'], + // 6. media imports + ['^.+\\.(gif|png|svg|jpg)$'], + ], + }, + ], 'react/react-in-jsx-scope': 0, 'import/no-extraneous-dependencies': 'off', - 'import/prefer-default-export': 'warn', - "import/prefer-default-export": "off", - "import/order": 'warn', - "import/extensions": 'warn', + 'import/prefer-default-export': 'off', + 'import/order': 'warn', + 'import/extensions': 'warn', '@typescript-eslint/no-exp': 'off', - "react/no-array": 'off', - "react/function-component-definition": "off" + 'react/no-array': 'off', + 'react/function-component-definition': 'off', + 'react-hooks/exhaustive-deps': 'off', + 'react/prop-types': 'off', }, -}; \ No newline at end of file +}; diff --git a/package-lock.json b/package-lock.json index 7baff60..0658be5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^15.8.0", "husky": "^9.0.11", "jsdom": "^24.1.0", @@ -16653,6 +16654,16 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz", + "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", diff --git a/package.json b/package.json index e3a5fcd..99eaa36 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.34.3", "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-simple-import-sort": "^12.1.1", "globals": "^15.8.0", "husky": "^9.0.11", "jsdom": "^24.1.0", diff --git a/src/App.tsx b/src/App.tsx index 33dd9fd..77eabbc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,36 +1,31 @@ -import React, { useEffect, useState } from 'react'; import './App.css'; import '@rainbow-me/rainbowkit/styles.css'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ThemeProvider } from '@mui/material/styles'; + +import React from 'react'; +import { LitNetwork } from '@lit-protocol/constants'; import CssBaseline from '@mui/material/CssBaseline'; -import { Route, Routes, Navigate, useNavigate } from 'react-router-dom'; -import { WagmiProvider } from 'wagmi'; +import { ThemeProvider } from '@mui/material/styles'; import { - AuthenticationStatus, - createAuthenticationAdapter, getDefaultConfig, RainbowKitAuthenticationProvider, RainbowKitProvider, } from '@rainbow-me/rainbowkit'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import { WagmiProvider } from 'wagmi'; import { sepolia } from 'wagmi/chains'; -import { getAddress } from 'viem'; -import { createSiweMessage } from 'viem/siwe'; -import { LitNetwork } from '@lit-protocol/constants'; -import Login from './pages/Auth/Login'; -import theme from './libs/theme'; -import { api } from './services/api'; +import { CustomSnackbar } from './components/shared/CustomSnackbar'; +import { LitProvider } from './hooks/LitProvider'; +import useSiweAuth from './hooks/useSiweAuth'; import DefaultLayout from './layouts/DefaultLayout'; - -// import Dashboard from './pages/Dashboard'; +import theme from './libs/theme'; +import Login from './pages/Auth/Login'; +import Callback from './pages/Callback'; import Identifiers from './pages/Identifiers'; -import Permissions from './pages/Permissions'; import Attestation from './pages/Identifiers/Attestation'; -import Callback from './pages/Callback'; +import Permissions from './pages/Permissions'; import ProtectedRoute from './ProtectedRoute'; -import { LitProvider } from './hooks/LitProvider'; -import { CustomSnackbar } from './components/shared/CustomSnackbar'; const queryClient = new QueryClient({ defaultOptions: { @@ -50,69 +45,7 @@ const config = getDefaultConfig({ }); const App: React.FC = () => { - const navigate = useNavigate(); - const [authStatus, setAuthStatus] = - useState('unauthenticated'); - - const authenticationAdapter = createAuthenticationAdapter({ - getNonce: async () => { - const { data } = await api.get('auth/siwe/nonce'); - return data.nonce; - }, - createMessage: ({ nonce, address, chainId }) => { - return createSiweMessage({ - address: getAddress(address), - chainId, - domain: window.location.host, - nonce, - uri: window.location.origin, - version: '1', - statement: 'Sign in with Ethereum to the app.', - }); - }, - getMessageBody: ({ message }) => message, - verify: async ({ message, signature }) => { - const { data } = await api.post('auth/siwe/verify', { - message, - signature, - chainId: 11155111, - }); - - if (!data) { - throw new Error('Verification response data is empty'); - } - - if (data?.jwt) { - localStorage.setItem('OCI_TOKEN', data.jwt); - setAuthStatus('authenticated'); - return true; - } - - return false; - }, - signOut: async () => { - localStorage.removeItem('OCI_TOKEN'); - navigate('/auth/login'); - setAuthStatus('unauthenticated'); - }, - }); - - useEffect(() => { - const checkStoredToken = () => { - const OCI_TOKEN = localStorage.getItem('OCI_TOKEN'); - if (OCI_TOKEN) { - setAuthStatus('authenticated'); - } else { - setAuthStatus('unauthenticated'); - } - }; - - checkStoredToken(); - }, []); - - useEffect(() => { - console.log('authStatus', authStatus); - }, [authStatus]); + const { authStatus, authenticationAdapter } = useSiweAuth(); globalThis.Buffer = Buffer; @@ -148,7 +81,7 @@ const App: React.FC = () => { } /> } /> } /> } /> diff --git a/src/ProtectedRoute.tsx b/src/ProtectedRoute.tsx index 79d2b9f..d14c356 100644 --- a/src/ProtectedRoute.tsx +++ b/src/ProtectedRoute.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; -import { Navigate } from 'react-router-dom'; -import CircularProgress from '@mui/material/CircularProgress'; import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; +import { Navigate } from 'react-router-dom'; import { useAccount } from 'wagmi'; interface ProtectedRouteProps { @@ -15,23 +15,19 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const { isConnected } = useAccount(); useEffect(() => { - if (!isConnected) { - setIsAuthenticated(false); - localStorage.removeItem('OCI_TOKEN'); - } - }, [isConnected]); - - useEffect(() => { - const checkAuthStatus = async () => { - const token = localStorage.getItem('OCI_TOKEN'); - if (token) { - setIsAuthenticated(true); + const checkAuthStatus = () => { + if (isConnected) { + const token = localStorage.getItem('OCI_TOKEN'); + setIsAuthenticated(!!token); + } else { + setIsAuthenticated(false); + localStorage.removeItem('OCI_TOKEN'); } setLoading(false); }; checkAuthStatus(); - }, []); + }, [isConnected]); if (loading) { return ( diff --git a/src/components/layouts/AccountPopover.spec.tsx b/src/components/layouts/AccountPopover.spec.tsx index 9dd58e8..fc9f31a 100644 --- a/src/components/layouts/AccountPopover.spec.tsx +++ b/src/components/layouts/AccountPopover.spec.tsx @@ -1,5 +1,6 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + import AccountPopover from './AccountPopover'; describe('AccountPopover', () => { diff --git a/src/components/layouts/AccountPopover.tsx b/src/components/layouts/AccountPopover.tsx index 965e26e..119d5c0 100644 --- a/src/components/layouts/AccountPopover.tsx +++ b/src/components/layouts/AccountPopover.tsx @@ -1,14 +1,14 @@ -import { useState, MouseEvent } from 'react'; +import { MouseEvent, useState } from 'react'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import LogoutIcon from '@mui/icons-material/Logout'; import { Avatar, - Popover, - MenuItem, IconButton, + MenuItem, + Popover, Typography, } from '@mui/material'; import { alpha } from '@mui/material/styles'; -import AccountCircleIcon from '@mui/icons-material/AccountCircle'; -import LogoutIcon from '@mui/icons-material/Logout'; function AccountPopover() { const [anchorEl, setAnchorEl] = useState(null); diff --git a/src/components/layouts/AppbarApp.spec.tsx b/src/components/layouts/AppbarApp.spec.tsx index ced36b0..76da9f6 100644 --- a/src/components/layouts/AppbarApp.spec.tsx +++ b/src/components/layouts/AppbarApp.spec.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + import AppbarApp from './AppbarApp'; // Mocking the ConnectButton from RainbowKit diff --git a/src/components/layouts/SidebarApp.spec.tsx b/src/components/layouts/SidebarApp.spec.tsx index f3f051e..3617777 100644 --- a/src/components/layouts/SidebarApp.spec.tsx +++ b/src/components/layouts/SidebarApp.spec.tsx @@ -1,9 +1,11 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; -import SidebarApp from './SidebarApp'; +import { describe, expect, it, vi } from 'vitest'; + import { SIDEBAR_MENU } from '../../libs/constants'; +import SidebarApp from './SidebarApp'; + // Mock the constants vi.mock('../../libs/constants', () => ({ DRAWER_WIDTH: 240, diff --git a/src/components/layouts/SidebarApp.tsx b/src/components/layouts/SidebarApp.tsx index f4a02c0..46780d4 100644 --- a/src/components/layouts/SidebarApp.tsx +++ b/src/components/layouts/SidebarApp.tsx @@ -1,15 +1,16 @@ import { + Box, + Divider, Drawer, List, ListItemButton, ListItemIcon, ListItemText, - Box, Toolbar, - Divider, Typography, } from '@mui/material'; import { useLocation, useNavigate } from 'react-router-dom'; + import { DRAWER_WIDTH, SIDEBAR_MENU } from '../../libs/constants'; function SidebarApp() { diff --git a/src/components/pages/attestations/StepOne.test.tsx b/src/components/pages/attestations/StepOne.test.tsx new file mode 100644 index 0000000..2df26ed --- /dev/null +++ b/src/components/pages/attestations/StepOne.test.tsx @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom'; + +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { Provider } from '../../../enums'; + +import StepOne from './StepOne'; + +describe('StepOne Component', () => { + it('renders the component with the correct provider', () => { + render( + + {}} /> + + ); + + expect(screen.getByText('Let’s get started!')).toBeInTheDocument(); + expect( + screen.getByText('Please authenticate with Google to continue.') + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Authorize with Google/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/pages/attestations/StepOne.tsx b/src/components/pages/attestations/StepOne.tsx new file mode 100644 index 0000000..e9ec822 --- /dev/null +++ b/src/components/pages/attestations/StepOne.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { jwtDecode } from 'jwt-decode'; +import { FaDiscord, FaGoogle } from 'react-icons/fa'; +import { useLocation, useNavigate } from 'react-router-dom'; + +import { Provider } from '../../../enums'; +import { platformAuthentication } from '../../../services/api/auth'; +import { capitalize } from '../../../utils/helper'; + +type Token = { token: string; exp: number; provider: Provider }; +type DecodedToken = { provider: Provider; iat: number; exp: number }; + +interface StepOneProps { + provider: Provider | undefined; + handleNextStep: () => void; +} + +const StepOne: React.FC = ({ provider, handleNextStep }) => { + const location = useLocation(); + const navigate = useNavigate(); + const [isAuthorizing, setIsAuthorizing] = useState(false); + const [authError, setAuthError] = useState(null); + + const hasHandledNextStep = useRef(false); + + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const jwtToken = searchParams.get('jwt'); + + if (jwtToken && !hasHandledNextStep.current) { + try { + const decoded: DecodedToken = jwtDecode(jwtToken); + const { provider: jwtProvider } = decoded; + + const existingTokens: Token[] = JSON.parse( + localStorage.getItem('OCI_PROVIDER_TOKENS') || '[]' + ); + const updatedTokens = existingTokens.filter( + (token) => token.provider !== jwtProvider + ); + + updatedTokens.push({ + token: jwtToken, + exp: decoded.exp, + provider: jwtProvider, + }); + localStorage.setItem( + 'OCI_PROVIDER_TOKENS', + JSON.stringify(updatedTokens) + ); + + navigate(location.pathname, { replace: true }); + + hasHandledNextStep.current = true; + handleNextStep(); + } catch (error) { + setAuthError('Failed to decode JWT token. Please try again.'); + console.error('Invalid JWT token:', error); + } + } + }, [location.search, location.pathname, navigate, handleNextStep]); + + const handleAuthorizeWithProvider = async () => { + if (!provider) throw new Error('Provider is not defined'); + + setIsAuthorizing(true); + try { + platformAuthentication({ platformType: provider }); + } finally { + setIsAuthorizing(false); + } + }; + + if (!provider) { + return null; + } + + return ( + + + Let’s get started! + + + Please authenticate with {capitalize(provider)} to continue. + + + + + + We use {capitalize(provider)} to verify your identity. + + {authError && ( + + {authError} + + )} + + ); +}; + +export default StepOne; diff --git a/src/components/pages/attestations/StepThree.tsx b/src/components/pages/attestations/StepThree.tsx new file mode 100644 index 0000000..4141b2a --- /dev/null +++ b/src/components/pages/attestations/StepThree.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Stack, + Typography, +} from '@mui/material'; +import { FaLink } from 'react-icons/fa'; +import { useNavigate } from 'react-router-dom'; +import { Address } from 'viem'; + +import { AttestPayload } from '../../../interfaces'; +import EASService from '../../../services/eas.service'; +import useSnackbarStore from '../../../store/useSnackbarStore'; +import sepoliaChain from '../../../utils/contracts/eas/sepoliaChain.json'; +import { useSigner } from '../../../utils/eas-wagmi-utils'; + +interface StepThreeProps { + attestedSignutare: AttestPayload | null; +} + +const StepThree: React.FC = ({ attestedSignutare }) => { + const { showSnackbar } = useSnackbarStore(); + const navigate = useNavigate(); + const signer = useSigner(); + const [isLoading, setIsLoading] = useState(false); + + const easService = signer + ? new EASService(sepoliaChain.easContractAddress as Address, signer) + : null; + + const handleAttestByDelegation = async () => { + if (!easService) { + throw new Error('EAS service not initialized'); + } + if (!attestedSignutare) throw new Error('No attested signature provided'); + + setIsLoading(true); + try { + await easService.attestByDelegation(attestedSignutare); + showSnackbar('Attestation successfully completed.', { + severity: 'success', + }); + navigate('/identifiers'); + } catch (error) { + console.error('Error attesting identifier:', error); + showSnackbar('Failed to complete the attestation. Please try again.', { + severity: 'error', + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + + Finalize Delegated Attestation + + + To complete the process, you will be asked to sign a message with your + wallet, confirming ownership of the provided address. + + + + + + You need to pay some gas to complete the process. + + + ); +}; + +export default StepThree; diff --git a/src/components/pages/attestations/StepTwo.tsx b/src/components/pages/attestations/StepTwo.tsx new file mode 100644 index 0000000..afbd283 --- /dev/null +++ b/src/components/pages/attestations/StepTwo.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { + Box, + Button, + CircularProgress, + Stack, + Typography, +} from '@mui/material'; +import { FaLink } from 'react-icons/fa6'; + +import { Provider } from '../../../enums'; +import { AttestPayload } from '../../../interfaces'; +import { useLinkIdentifierMutation } from '../../../services/api/eas/query'; +import { capitalize, getTokenForProvider } from '../../../utils/helper'; + +interface StepTwoProps { + provider: Provider | undefined; + handlePrepareAttestation: (payload: AttestPayload) => void; +} + +const StepTwo: React.FC = ({ + provider, + handlePrepareAttestation, +}) => { + const { mutate: mutateIdentifier, isPending } = useLinkIdentifierMutation(); + + const handleGenerateSignedDelegation = async () => { + const siweJwt = localStorage.getItem('OCI_TOKEN'); + if (!siweJwt || !provider) return; + + const anyJwt = getTokenForProvider(provider); + + mutateIdentifier( + { + siweJwt, + anyJwt, + }, + { + onSuccess: (response) => { + const { data } = response; + handlePrepareAttestation(data); + }, + onError: (error) => { + console.error(error); + }, + } + ); + }; + + if (!provider) { + return null; + } + + return ( + + + Connect Your {capitalize(provider)} Account to Your Wallet + + + To proceed, please verify your account by linking it to your wallet + address. This step ensures your {capitalize(provider)} account is + securely associated with your wallet. + + + + + + ); +}; + +export default StepTwo; diff --git a/src/components/shared/AccessControlButton.spec.tsx b/src/components/shared/AccessControlButton.spec.tsx index 51b0019..a9b443b 100644 --- a/src/components/shared/AccessControlButton.spec.tsx +++ b/src/components/shared/AccessControlButton.spec.tsx @@ -1,37 +1,51 @@ // AccessControlButton.test.tsx -import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; + +import { fireEvent, render, screen } from '@testing-library/react'; import { vi } from 'vitest'; + import AccessControlButton from './AccessControlButton'; describe('AccessControlButton', () => { - it('renders with "Grant Access" when hasAccess is false', () => { - const handleToggleAccess = vi.fn(); - render(); - - const button = screen.getByRole('button', { name: /grant access/i }); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent('Grant Access'); - expect(button).toHaveClass('MuiButton-containedPrimary'); - }); - - it('renders with "Revoke Access" when hasAccess is true', () => { - const handleToggleAccess = vi.fn(); - render(); - - const button = screen.getByRole('button', { name: /revoke access/i }); - expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent('Revoke Access'); - expect(button).toHaveClass('MuiButton-outlinedError'); - }); - - it('calls onToggleAccess when button is clicked', () => { - const handleToggleAccess = vi.fn(); - render(); - - const button = screen.getByRole('button', { name: /grant access/i }); - fireEvent.click(button); - - expect(handleToggleAccess).toHaveBeenCalledTimes(1); - }); + it('renders with "Grant Access" when hasAccess is false', () => { + const handleToggleAccess = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: /grant access/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Grant Access'); + expect(button).toHaveClass('MuiButton-containedPrimary'); + }); + + it('renders with "Revoke Access" when hasAccess is true', () => { + const handleToggleAccess = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: /revoke access/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Revoke Access'); + expect(button).toHaveClass('MuiButton-outlinedError'); + }); + + it('calls onToggleAccess when button is clicked', () => { + const handleToggleAccess = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: /grant access/i }); + fireEvent.click(button); + + expect(handleToggleAccess).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/components/shared/AccessControlButton.tsx b/src/components/shared/AccessControlButton.tsx index 8bbad7c..ade4f1e 100644 --- a/src/components/shared/AccessControlButton.tsx +++ b/src/components/shared/AccessControlButton.tsx @@ -1,21 +1,24 @@ import { Button } from '@mui/material'; interface AccessControlButtonProps { - hasAccess: boolean; - onToggleAccess: () => void; + hasAccess: boolean; + onToggleAccess: () => void; } -const AccessControlButton: React.FC = ({ hasAccess, onToggleAccess }) => { - return ( - - ); +const AccessControlButton: React.FC = ({ + hasAccess, + onToggleAccess, +}) => { + return ( + + ); }; export default AccessControlButton; diff --git a/src/components/shared/CustomSnackbar.tsx b/src/components/shared/CustomSnackbar.tsx index 800aea6..15ee57f 100644 --- a/src/components/shared/CustomSnackbar.tsx +++ b/src/components/shared/CustomSnackbar.tsx @@ -1,4 +1,4 @@ -import { Snackbar, Alert } from '@mui/material'; +import { Alert, Snackbar } from '@mui/material'; import useSnackbarStore from '../../store/useSnackbarStore'; diff --git a/src/components/shared/CustomStepper.tsx b/src/components/shared/CustomStepper.tsx index 41c486a..a571339 100644 --- a/src/components/shared/CustomStepper.tsx +++ b/src/components/shared/CustomStepper.tsx @@ -1,81 +1,98 @@ +/* eslint-disable react/destructuring-assignment */ import React from 'react'; -import { Stepper, Step, StepLabel, StepIconProps, StepConnector, styled, stepConnectorClasses } from '@mui/material'; import Check from '@mui/icons-material/Check'; +import { + Step, + StepConnector, + stepConnectorClasses, + StepIconProps, + StepLabel, + Stepper, + styled, +} from '@mui/material'; -interface Step { - label: string; +interface IStep { + label: string; } interface StepperComponentProps { - steps: Step[]; - activeStep: number; + steps: IStep[]; + activeStep: number; } +const gradientBackground = 'linear-gradient(136deg, #4200FF 0%, #4200FF 100%)'; + const CustomConnector = styled(StepConnector)(({ theme }) => ({ - [`&.${stepConnectorClasses.alternativeLabel}`]: { - top: 22, - }, - [`&.${stepConnectorClasses.active}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundImage: 'linear-gradient( 95deg, #4200FF 0%, #4200FF 50%, #4200FF 100%)', - }, - }, - [`&.${stepConnectorClasses.completed}`]: { - [`& .${stepConnectorClasses.line}`]: { - backgroundImage: 'linear-gradient( 95deg, #4200FF 0%, #4200FF 50%, #4200FF 100%)', - }, + [`&.${stepConnectorClasses.alternativeLabel}`]: { + top: 22, + }, + [`&.${stepConnectorClasses.active}`]: { + [`& .${stepConnectorClasses.line}`]: { + backgroundImage: gradientBackground, }, + }, + [`&.${stepConnectorClasses.completed}`]: { [`& .${stepConnectorClasses.line}`]: { - height: 3, - border: 0, - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[800] : '#eaeaf0', - borderRadius: 1, + backgroundImage: gradientBackground, }, + }, + [`& .${stepConnectorClasses.line}`]: { + height: 3, + border: 0, + backgroundColor: + theme.palette.mode === 'dark' ? theme.palette.grey[800] : '#eaeaf0', + borderRadius: 1, + }, })); const CustomStepIconRoot = styled('div')<{ - ownerState: { completed?: boolean; active?: boolean }; + ownerState: { completed?: boolean; active?: boolean }; }>(({ theme, ownerState }) => ({ - backgroundColor: theme.palette.mode === 'dark' ? theme.palette.grey[700] : '#ccc', - zIndex: 1, - color: '#fff', - width: 50, - height: 50, - display: 'flex', - borderRadius: '50%', - justifyContent: 'center', - alignItems: 'center', - ...(ownerState.active && { - backgroundImage: - 'linear-gradient( 136deg, #4200FF 0%, #4200FF 50%, #4200FF 100%)', - boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)', - }), - ...(ownerState.completed && { - backgroundImage: - 'linear-gradient( 136deg, #4200FF 0%, #4200FF 50%, #4200FF 100%)', - }), + backgroundColor: + theme.palette.mode === 'dark' ? theme.palette.grey[700] : '#ccc', + zIndex: 1, + color: '#fff', + width: 50, + height: 50, + display: 'flex', + borderRadius: '50%', + justifyContent: 'center', + alignItems: 'center', + ...(ownerState.active && { + backgroundImage: gradientBackground, + boxShadow: '0 4px 10px 0 rgba(0,0,0,.25)', + }), + ...(ownerState.completed && { + backgroundImage: gradientBackground, + }), })); -function CustomStepIcon(props: StepIconProps) { - const { active, completed, className } = props; - - return ( - - {completed ? :
{props.icon}
} -
- ); -} +const CustomStepIcon: React.FC = ({ + active, + completed, + className, + icon, +}) => ( + + {completed ? :
{icon}
} +
+); -const StepperComponent: React.FC = ({ steps, activeStep }) => { - return ( - }> - {steps.map((step, index) => ( - - {step.label} - - ))} - - ); -}; +const StepperComponent: React.FC = ({ + steps, + activeStep, +}) => ( + } + > + {steps.map((step) => ( + + {step.label} + + ))} + +); -export default StepperComponent; +export default React.memo(StepperComponent); diff --git a/src/components/shared/CustomTable.tsx b/src/components/shared/CustomTable.tsx index 4fb81cc..79bf71e 100644 --- a/src/components/shared/CustomTable.tsx +++ b/src/components/shared/CustomTable.tsx @@ -1,14 +1,15 @@ import { - TableContainer, + Avatar, + Card, Table, + TableBody, + TableCell, + TableContainer, TableHead, TableRow, - TableCell, - TableBody, - Avatar, Typography, - Card, } from '@mui/material'; + import AccessControlButton from './AccessControlButton'; export interface Platform { diff --git a/src/components/shared/StepperComponent.test.tsx b/src/components/shared/StepperComponent.test.tsx index 6496af9..1ff061e 100644 --- a/src/components/shared/StepperComponent.test.tsx +++ b/src/components/shared/StepperComponent.test.tsx @@ -1,26 +1,24 @@ -import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; + +import { render, screen } from '@testing-library/react'; + import StepperComponent from './CustomStepper'; describe('StepperComponent', () => { - const steps = [ - { label: 'Auth' }, - { label: 'Attest' }, - { label: 'Transact' } - ]; + const steps = [{ label: 'Auth' }, { label: 'Attest' }, { label: 'Transact' }]; - it('renders all step labels correctly', () => { - render(); + it('renders all step labels correctly', () => { + render(); - steps.forEach(step => { - expect(screen.getByText(step.label)).toBeInTheDocument(); - }); + steps.forEach((step) => { + expect(screen.getByText(step.label)).toBeInTheDocument(); }); + }); - it('highlights the active step correctly', () => { - render(); + it('highlights the active step correctly', () => { + render(); - const activeStep = screen.getByText('Attest').closest('.MuiStep-root'); - expect(activeStep).toHaveClass('MuiStep-horizontal'); - }); + const activeStep = screen.getByText('Attest').closest('.MuiStep-root'); + expect(activeStep).toHaveClass('MuiStep-horizontal'); + }); }); diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx deleted file mode 100644 index 8328ee3..0000000 --- a/src/context/authContext.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - createContext, - useState, - useContext, - ReactNode, - useEffect, - useMemo, -} from 'react'; - -interface AuthContextType { - isAuthenticated: boolean; - jwt: string | null; - setAuthInfo: (jwt: string) => void; - signOut: () => void; -} - -const AuthContext = createContext(undefined); - -export const AuthProvider = ({ children }: { children: ReactNode }) => { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [jwt, setJwt] = useState(null); - - useEffect(() => { - const token = localStorage.getItem('OCI_TOKEN'); - if (token) { - setJwt(token); - setIsAuthenticated(true); - } - }, []); - - const setAuthInfo = (token: string) => { - setJwt(token); - setIsAuthenticated(true); - localStorage.setItem('OCI_TOKEN', token); - }; - - const signOut = () => { - setJwt(null); - setIsAuthenticated(false); - localStorage.removeItem('OCI_TOKEN'); - }; - - const value = useMemo( - () => ({ - isAuthenticated, - jwt, - setAuthInfo, - signOut, - }), - [isAuthenticated, jwt] - ); - - return {children}; -}; - -export const useAuth = (): AuthContextType => { - const context = useContext(AuthContext); - if (context === undefined) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000..37cc3cb --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1,4 @@ +export enum Provider { + Google = 'google', + Discord = 'discord', +} diff --git a/src/hooks/LitProvider.tsx b/src/hooks/LitProvider.tsx index c40e5bf..958fca9 100644 --- a/src/hooks/LitProvider.tsx +++ b/src/hooks/LitProvider.tsx @@ -1,13 +1,13 @@ import { - ReactElement, createContext, + ReactElement, useContext, useEffect, useMemo, useState, } from 'react'; -import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { LitNetwork } from '@lit-protocol/constants'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; interface ILitProvider { litNetwork: LitNetwork; diff --git a/src/hooks/useAttestations.tsx b/src/hooks/useAttestations.tsx new file mode 100644 index 0000000..bec71c9 --- /dev/null +++ b/src/hooks/useAttestations.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react'; +import { SchemaDecodedItem } from '@ethereum-attestation-service/eas-sdk'; +import { useAccount } from 'wagmi'; + +import { decodeAttestationData } from '../libs/oci'; +import { useGetAttestations } from '../services/eas/query'; + +interface ProcessedAttestation { + provider: string | undefined; + decodedData: SchemaDecodedItem[]; + uid: `0x${string}`; + schema: `0x${string}`; + refUID: `0x${string}`; + time: bigint; + expirationTime: bigint; + revocationTime: bigint; + recipient: `0x${string}`; + attester: `0x${string}`; + revocable: boolean; + data: `0x${string}`; + id?: string; +} + +const useAttestations = () => { + const { address, chainId } = useAccount(); + const { + data: attestationsResponse, + error, + isLoading, + refetch, + } = useGetAttestations(address as `0x${string}`); + + const [attestations, setAttestations] = useState([]); + + useEffect(() => { + if (attestationsResponse) { + const processedAttestations: ProcessedAttestation[] = + attestationsResponse.map((attestation) => { + const decodedData = decodeAttestationData(attestation.data); + + const providerData = decodedData.find( + (data) => data.name === 'provider' + ); + + return { + ...attestation, + provider: + typeof providerData?.value.value === 'string' + ? providerData.value.value + : undefined, + decodedData, + }; + }); + + setAttestations(processedAttestations); + } + }, [attestationsResponse]); + + return { + chainId, + attestations, + isLoading, + error, + refetch, + }; +}; + +export default useAttestations; diff --git a/src/hooks/useSessionSigs.tsx b/src/hooks/useSessionSigs.tsx index ef53bb3..a7c1f20 100644 --- a/src/hooks/useSessionSigs.tsx +++ b/src/hooks/useSessionSigs.tsx @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-shadow */ import { useCallback, useState } from 'react'; import { - generateAuthSig, - createSiweMessageWithRecaps, AuthSig, - LitActionResource, + createSiweMessageWithRecaps, + generateAuthSig, LitAbility, + LitActionResource, } from '@lit-protocol/auth-helpers'; +import { LitNodeClient } from '@lit-protocol/lit-node-client'; import { AuthCallbackParams, SessionSigsMap } from '@lit-protocol/types'; import { Signer } from 'ethers'; -import { LitNodeClient } from '@lit-protocol/lit-node-client'; interface ICreateSessionSigs { signer: Signer; diff --git a/src/hooks/useSiweAuth.tsx b/src/hooks/useSiweAuth.tsx new file mode 100644 index 0000000..b5007e9 --- /dev/null +++ b/src/hooks/useSiweAuth.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import { + AuthenticationStatus, + createAuthenticationAdapter, +} from '@rainbow-me/rainbowkit'; +import { useNavigate } from 'react-router-dom'; +import { getAddress } from 'viem'; +import { createSiweMessage } from 'viem/siwe'; + +import { api } from '../services/api'; + +const useSiweAuth = () => { + const navigate = useNavigate(); + const [authStatus, setAuthStatus] = + useState('unauthenticated'); + + const authenticationAdapter = createAuthenticationAdapter({ + getNonce: async () => { + const { data } = await api.get('auth/siwe/nonce'); + return data.nonce; + }, + createMessage: ({ nonce, address, chainId }) => { + return createSiweMessage({ + address: getAddress(address), + chainId, + domain: window.location.host, + nonce, + uri: window.location.origin, + version: '1', + statement: 'Sign in with Ethereum to the app.', + }); + }, + getMessageBody: ({ message }) => message, + verify: async ({ message, signature }) => { + const { data } = await api.post('auth/siwe/verify', { + message, + signature, + chainId: 11155111, + }); + + if (data?.jwt) { + localStorage.setItem('OCI_TOKEN', data.jwt); + setAuthStatus('authenticated'); + return true; + } + + return false; + }, + signOut: async () => { + localStorage.removeItem('OCI_TOKEN'); + navigate('/auth/login'); + setAuthStatus('unauthenticated'); + }, + }); + + useEffect(() => { + const checkStoredToken = () => { + const OCI_TOKEN = localStorage.getItem('OCI_TOKEN'); + if (OCI_TOKEN) { + setAuthStatus('authenticated'); + } else { + setAuthStatus('unauthenticated'); + } + }; + + checkStoredToken(); + }, []); + + return { authStatus, authenticationAdapter }; +}; + +export default useSiweAuth; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 7413147..59f91f0 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -10,6 +10,21 @@ export interface MenuItem { children?: MenuItem[]; } +export interface IAttestation { + uid: `0x${string}`; + schema: `0x${string}`; + refUID: `0x${string}`; + time: bigint; + expirationTime: bigint; + revocationTime: bigint; + recipient: `0x${string}`; + attester: `0x${string}`; + revocable: boolean; + data: `0x${string}`; + id?: string; + provider?: string; +} + export interface PlatformAuthenticationParams { platformType: 'DISCORD' | 'GOOGLE'; } diff --git a/src/layouts/DefaultLayout.tsx b/src/layouts/DefaultLayout.tsx index b224fd8..0e61b44 100644 --- a/src/layouts/DefaultLayout.tsx +++ b/src/layouts/DefaultLayout.tsx @@ -1,7 +1,8 @@ import { Box } from '@mui/material'; import { Outlet } from 'react-router-dom'; -import SidebarApp from '../components/layouts/SidebarApp'; + import AppbarApp from '../components/layouts/AppbarApp'; +import SidebarApp from '../components/layouts/SidebarApp'; function DefaultLayout() { return ( diff --git a/src/libs/constants.ts b/src/libs/constants.ts index ece2e56..f586553 100644 --- a/src/libs/constants.ts +++ b/src/libs/constants.ts @@ -1,8 +1,8 @@ // import SpaceDashboardIcon from '@mui/icons-material/SpaceDashboard'; -import FingerprintIcon from '@mui/icons-material/Fingerprint'; -import { SiAdguard } from 'react-icons/si'; import { SvgIconComponent } from '@mui/icons-material'; +import FingerprintIcon from '@mui/icons-material/Fingerprint'; import { IconType } from 'react-icons'; +import { SiAdguard } from 'react-icons/si'; export interface MenuItem { title: string; diff --git a/src/libs/oci/index.ts b/src/libs/oci/index.ts index 49355a4..bc1ff88 100644 --- a/src/libs/oci/index.ts +++ b/src/libs/oci/index.ts @@ -1,28 +1,16 @@ -import { Address, parseAbiItem } from 'viem'; import { SchemaDecodedItem, SchemaEncoder, } from '@ethereum-attestation-service/eas-sdk'; - import * as LitJsSdk from '@lit-protocol/lit-node-client'; import { EncryptToJsonPayload, SessionSigsMap } from '@lit-protocol/types'; -import { publicClient } from './client'; -import sepoliaChain from '../../utils/contracts/eas/sepoliaChain.json'; +import { Address, parseAbiItem } from 'viem'; +import { IAttestation } from '../../interfaces'; import { SCHEMA_TYPES } from '../../utils/contracts/eas/constants'; +import sepoliaChain from '../../utils/contracts/eas/sepoliaChain.json'; -export interface IAttestation { - uid: `0x${string}`; - schema: `0x${string}`; - refUID: `0x${string}`; - time: bigint; - expirationTime: bigint; - revocationTime: bigint; - recipient: `0x${string}`; - attester: `0x${string}`; - revocable: boolean; - data: `0x${string}`; -} +import { publicClient } from './client'; export interface ISchema { key: string; diff --git a/src/libs/theme.tsx b/src/libs/theme.tsx index 0166cac..9475330 100644 --- a/src/libs/theme.tsx +++ b/src/libs/theme.tsx @@ -1,14 +1,23 @@ import { createTheme } from '@mui/material/styles'; const theme = createTheme({ - palette: { - primary: { - main: '#4200FF' - } + palette: { + primary: { + main: '#4200FF', }, - typography: { - fontFamily: ['DM Sans', 'sans-serif'].join(','), + }, + components: { + MuiButton: { + styleOverrides: { + root: { + textTransform: 'none', + }, + }, }, + }, + typography: { + fontFamily: ['DM Sans', 'sans-serif'].join(','), + }, }); export default theme; diff --git a/src/main.tsx b/src/main.tsx index 4e9f77b..059589b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,9 @@ +import './index.css'; + import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; import { BrowserRouter } from 'react-router-dom'; + import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/pages/Auth/Login/Login.tsx b/src/pages/Auth/Login/Login.tsx index 9ac00ac..362a1b3 100644 --- a/src/pages/Auth/Login/Login.tsx +++ b/src/pages/Auth/Login/Login.tsx @@ -1,5 +1,5 @@ -import { ConnectButton } from '@rainbow-me/rainbowkit'; import { Box, Typography } from '@mui/material'; +import { ConnectButton } from '@rainbow-me/rainbowkit'; export function Login() { return ( diff --git a/src/pages/Auth/Login/index.ts b/src/pages/Auth/Login/index.ts index d448e12..3313208 100644 --- a/src/pages/Auth/Login/index.ts +++ b/src/pages/Auth/Login/index.ts @@ -1,3 +1,3 @@ -import { Login } from "./Login"; +import { Login } from './Login'; export default Login; diff --git a/src/pages/Callback/Callback.tsx b/src/pages/Callback/Callback.tsx index a0e1f20..4ad0296 100644 --- a/src/pages/Callback/Callback.tsx +++ b/src/pages/Callback/Callback.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { jwtDecode } from 'jwt-decode'; import { Backdrop, CircularProgress } from '@mui/material'; +import { jwtDecode } from 'jwt-decode'; +import { useLocation, useNavigate } from 'react-router-dom'; interface DecodedJwt { exp: number; diff --git a/src/pages/Dashboard/Dashboard.spec.tsx b/src/pages/Dashboard/Dashboard.spec.tsx deleted file mode 100644 index 120b5b3..0000000 --- a/src/pages/Dashboard/Dashboard.spec.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { Dashboard } from './Dashboard'; - -test('renders Dashboard text', () => { - render(); - const dashboardElement = screen.getByText(/Dashboard/i); - expect(dashboardElement).toBeInTheDocument(); -}); diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx deleted file mode 100644 index 6bd3d90..0000000 --- a/src/pages/Dashboard/Dashboard.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Dashboard() { - return
Dashboard
; -} diff --git a/src/pages/Dashboard/index.ts b/src/pages/Dashboard/index.ts deleted file mode 100644 index 6b95d8c..0000000 --- a/src/pages/Dashboard/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Dashboard } from "./Dashboard"; - -export default Dashboard; diff --git a/src/pages/Identifiers/Attestation/Attestation.tsx b/src/pages/Identifiers/Attestation/Attestation.tsx index baec5b0..887c96c 100644 --- a/src/pages/Identifiers/Attestation/Attestation.tsx +++ b/src/pages/Identifiers/Attestation/Attestation.tsx @@ -1,280 +1,65 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useState, useEffect } from 'react'; -import Paper from '@mui/material/Paper'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { jwtDecode } from 'jwt-decode'; -import { Address } from 'viem'; -import { useAccount } from 'wagmi'; -import { - DelegatedAttestationRequest, - EAS, -} from '@ethereum-attestation-service/eas-sdk'; -import StepperComponent from '../../../components/shared/CustomStepper'; -import { platformAuthentication } from '../../../services/api/auth'; -import { useLinkIdentifierMutation } from '../../../services/api/eas/query'; -import sepoliaChain from '../../../utils/contracts/eas/sepoliaChain.json'; -import { useSigner } from '../../../utils/eas-wagmi-utils'; +import { useState } from 'react'; +import { Alert, AlertTitle, Paper } from '@mui/material'; +import { useParams } from 'react-router-dom'; + +import StepOne from '../../../components/pages/attestations/StepOne'; +import StepThree from '../../../components/pages/attestations/StepThree'; +import StepTwo from '../../../components/pages/attestations/StepTwo'; +import CustomStepper from '../../../components/shared/CustomStepper'; +import { Provider } from '../../../enums'; import { AttestPayload } from '../../../interfaces'; -import { - convertStringsToBigInts, - getTokenForProvider, -} from '../../../utils/helper'; -import useSnackbarStore from '../../../store/useSnackbarStore'; -const steps = [{ label: 'Auth' }, { label: 'Attest' }, { label: 'Transact' }]; +const steps = [ + { label: 'Authenticate' }, + { label: 'Attest' }, + { label: 'Transact' }, +]; -type Provider = 'DISCORD' | 'GOOGLE'; -type Token = { token: string; exp: number; provider: Provider }; -type DecodedToken = { provider: Provider; iat: number; exp: number }; - -export function Attestation() { - const { isConnected, address } = useAccount(); - const signer = useSigner(); - const { showSnackbar } = useSnackbarStore(); - const { providers } = useParams<{ providers: 'DISCORD' | 'GOOGLE' }>(); - const location = useLocation(); - const navigate = useNavigate(); - const { - mutate: mutateIdentifier, - data: linkingIdentifier, - isPending, - } = useLinkIdentifierMutation(); - - const [activeStep, setActiveStep] = useState(0); - const [linkingIdentifierRequest, setLinkingIdentifierRequest] = +export default function Attestation() { + const [activeStep, setActiveStep] = useState(0); + const { provider } = useParams<{ provider: Provider }>(); + const [attestedSignutare, setAttestedSignature] = useState(null); - const [isAuthorizing, setIsAuthorizing] = useState(false); - const [isAttesting, setIsAttesting] = useState(false); - - useEffect(() => { - if (!isConnected) { - console.error('Not connected'); - } - }, [isConnected, address]); - - const handleNext = () => { - setActiveStep((prevActiveStep) => - Math.min(prevActiveStep + 1, steps.length - 1) - ); - }; - - useEffect(() => { - if (linkingIdentifier) { - const payload: AttestPayload = convertStringsToBigInts( - linkingIdentifier.data - ) as AttestPayload; - - setLinkingIdentifierRequest(payload); - handleNext(); - } - }, [linkingIdentifier]); - - useEffect(() => { - const searchParams = new URLSearchParams(location.search); - const jwtToken = searchParams.get('jwt'); - - if (jwtToken) { - try { - const decoded: DecodedToken = jwtDecode(jwtToken); - const { provider: jwtProvider } = decoded; - - const existingTokens: Token[] = JSON.parse( - localStorage.getItem('OCI_PROVIDER_TOKENS') || '[]' - ); - const updatedTokens = existingTokens.filter( - (token) => token.provider !== jwtProvider - ); - - updatedTokens.push({ - token: jwtToken, - exp: decoded.exp, - provider: jwtProvider, - }); - localStorage.setItem( - 'OCI_PROVIDER_TOKENS', - JSON.stringify(updatedTokens) - ); - - navigate(location.pathname, { replace: true }); - - setActiveStep(1); - } catch (error) { - console.error('Invalid JWT token:', error); - } - } - }, [location.search, location.pathname, navigate]); - - const handleAuthorize = async () => { - if (!providers) return; - - setIsAuthorizing(true); - try { - await platformAuthentication({ platformType: providers }); - } finally { - setIsAuthorizing(false); - } - }; - - const handleAttest = async () => { - setIsAttesting(true); - try { - const eas = new EAS(sepoliaChain.easContractAddress as Address); - - if (!signer) throw new Error('Signer not found'); - - if (!linkingIdentifierRequest) throw new Error('No linking identifier'); - - eas.connect(signer); - - const transformedPayload: DelegatedAttestationRequest = { - schema: linkingIdentifierRequest?.message?.schema, - data: { - recipient: linkingIdentifierRequest.message.recipient, - expirationTime: linkingIdentifierRequest.message.expirationTime, - revocable: linkingIdentifierRequest.message.revocable, - refUID: linkingIdentifierRequest.message.refUID, - data: linkingIdentifierRequest.message.data, - }, - signature: linkingIdentifierRequest.signature, - attester: linkingIdentifierRequest.message.attester as string, - deadline: 0n, - }; - console.log({ transformedPayload }); - - const tx = await eas.attestByDelegation(transformedPayload); - - console.log({ tx }); - - const newAttestationUID = await tx.wait(); - - showSnackbar('Attestation created successfully', { - severity: 'success', - }); - - navigate('/identifiers'); - - console.log('New attestation UID:', newAttestationUID); - - console.log('Transaction receipt:', tx.receipt); - } catch (error: any) { - const errorCode = error?.info?.error?.code || ''; - - if (errorCode === 4001) { - showSnackbar( - `${errorCode}, you reject the transaction. please try again...`, - { - severity: 'error', - } - ); - } - } finally { - setIsAttesting(false); - } + const handleNextStep = () => { + setActiveStep((prevStep) => prevStep + 1); }; - const handleLinkIdentifier = async () => { - const siweJwt = localStorage.getItem('OCI_TOKEN'); - if (!siweJwt || !providers) return; - - const anyJwt = getTokenForProvider(providers); - - mutateIdentifier({ - siweJwt, - anyJwt, - }); + const handlePrepareAttestation = (attested: AttestPayload) => { + setAttestedSignature(attested); + handleNextStep(); }; return ( - -
- {activeStep === 0 && ( -
- - Let’s get started! - - - Please sign in with {providers}. - - -
- )} - {activeStep === 1 && ( -
- - Generate an attestation. - - - An attestation is a proof that links your {providers} account to - your wallet address. - - -
- )} - {activeStep === 2 && ( -
- - Sign Transaction. - - - Signing the transaction will put your attestation on-chain. - - - - This will cost a small amount of gas. - -
- )} -
+ + Link Your Social Media Accounts + Attest your social media accounts by linking them to your wallet + address. This allows you to prove ownership over these accounts. + + + + {activeStep === 0 && ( + + )} + {activeStep === 1 && ( + + )} + {activeStep === 2 && }
); } - -export default Attestation; diff --git a/src/pages/Identifiers/Attestation/index.ts b/src/pages/Identifiers/Attestation/index.ts index bb6b951..35367c0 100644 --- a/src/pages/Identifiers/Attestation/index.ts +++ b/src/pages/Identifiers/Attestation/index.ts @@ -1,3 +1,3 @@ -import { Attestation } from './Attestation'; +import Attestation from './Attestation'; export default Attestation; diff --git a/src/pages/Identifiers/Identifiers.tsx b/src/pages/Identifiers/Identifiers.tsx index ac84857..59bae42 100644 --- a/src/pages/Identifiers/Identifiers.tsx +++ b/src/pages/Identifiers/Identifiers.tsx @@ -1,214 +1,87 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable react-hooks/exhaustive-deps */ -import React, { useEffect, useState, useCallback } from 'react'; +/* eslint-disable @typescript-eslint/no-shadow */ +import { useCallback, useEffect, useState } from 'react'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import VerifiedIcon from '@mui/icons-material/Verified'; import { + Alert, + AlertTitle, + Avatar, + Badge, + Box, + Button, + CircularProgress, + ClickAwayListener, + IconButton, List, ListItem, - ListItemText, ListItemSecondaryAction, - Button, + ListItemText, + Tooltip, Typography, - Divider, - Paper, - Box, - Avatar, - CircularProgress, - IconButton, - Backdrop, - Stack, } from '@mui/material'; -import VerifiedIcon from '@mui/icons-material/Verified'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import clsx from 'clsx'; import { FaDiscord, FaGoogle } from 'react-icons/fa'; import { useNavigate } from 'react-router-dom'; -import clsx from 'clsx'; -import { - DelegatedRevocationRequest, - EAS, -} from '@ethereum-attestation-service/eas-sdk'; import { Address } from 'viem'; -import { useAccount } from 'wagmi'; -import { useGetAttestations } from '../../services/eas/query'; -import { decodeAttestationData, IAttestation } from '../../libs/oci'; -import sepoliaChain from '../../utils/contracts/eas/sepoliaChain.json'; -import { useSigner } from '../../utils/eas-wagmi-utils'; + +import useAttestations from '../../hooks/useAttestations'; +import { IAttestation, RevokePayload } from '../../interfaces'; import { useDecryptAttestationsSecretMutation, useRevokeIdentifierMutation, } from '../../services/api/eas/query'; -import { RevokePayload } from '../../interfaces'; -import { convertStringsToBigInts } from '../../utils/helper'; +import EASService from '../../services/eas.service'; import useSnackbarStore from '../../store/useSnackbarStore'; +import sepoliaChain from '../../utils/contracts/eas/sepoliaChain.json'; +import { useSigner } from '../../utils/eas-wagmi-utils'; -interface IdentifierItemProps { - identifier: { - name: string; - icon: React.ElementType; - verified: boolean; - uid: string; - }; - onRevoke: (uid: string) => void; - onConnect: (name: string) => void; - isLoading: boolean; - isRevealedPending: boolean; - isRevealed: string; - onReveal: () => void; +interface Identifier { + name: string; + icon: React.ElementType; + verified: boolean; + uid: string; + revealedSecret?: string; } -const IdentifierItem: React.FC = ({ - identifier, - onRevoke, - onConnect, - isLoading, - isRevealedPending, - isRevealed, - onReveal, -}) => ( - - - - - - - - {identifier.verified && ( - - )} - {identifier.name} - {identifier.verified && ( -
- {isRevealedPending ? ( - - ) : ( - <> - {isRevealed !== '*********' ? isRevealed : '*********'} - - {isRevealed !== '*********' ? ( - - ) : ( - - )} - - - )} -
- )} - - } - sx={{ ml: 2 }} - /> - - {identifier.verified ? ( - - ) : ( - - )} - -
-
-
-); - -export function Identifiers() { - const { showSnackbar } = useSnackbarStore(); - const { chainId, address } = useAccount(); +export default function Identifiers() { const navigate = useNavigate(); + const { showSnackbar } = useSnackbarStore(); + const [userIdentifiers, setUserIdentifiers] = useState([ + { name: 'Discord', icon: FaDiscord, verified: false, uid: '' }, + { name: 'Google', icon: FaGoogle, verified: false, uid: '' }, + ]); + const [openTooltips, setOpenTooltips] = useState<{ [key: string]: boolean }>( + {} + ); + const [loading, setLoading] = useState<{ [key: string]: boolean }>({}); + const [revoking, setRevoking] = useState<{ [key: string]: boolean }>({}); const signer = useSigner(); - const [identifiers, setIdentifiers] = useState([ - { - name: 'Discord', - icon: FaDiscord, - verified: false, - uid: '', - }, - { name: 'Google', icon: FaGoogle, verified: false, uid: '' }, - ]); + const easService = signer + ? new EASService(sepoliaChain.easContractAddress as Address, signer) + : null; - const [attestations, setAttestations] = useState< - (IAttestation & { provider?: string; id?: string })[] - >([]); const { - data: attestationsResponse, + attestations, + isLoading: attestationsLoading, + error, + chainId, refetch, - isLoading, - } = useGetAttestations(address as `0x${string}`); - - const { mutate: mutateRevokeIdentifier, data: revokeIdentifierResponse } = - useRevokeIdentifierMutation(); + } = useAttestations(); + const { mutate: mutateRevoke } = useRevokeIdentifierMutation(); const { mutate: mutateDecryptAttestationsSecret } = useDecryptAttestationsSecretMutation(); - const [loadingIdentifiers, setLoadingIdentifiers] = useState<{ - [uid: string]: boolean; - }>({}); - - const [revealedIdentifiers, setRevealedIdentifiers] = useState<{ - [uid: string]: string; - }>({}); - - const [revealing, setRevealing] = useState<{ [uid: string]: boolean }>({}); - - useEffect(() => { - const processAttestations = () => { - if (!attestationsResponse) { - return; - } - - const attestationsData = attestationsResponse.map((attestation) => { - const decodedData = decodeAttestationData(attestation.data); - - const providerData = decodedData.find( - (data) => data.name === 'provider' - ); - - return { - ...attestation, - provider: - typeof providerData?.value.value === 'string' - ? providerData.value.value - : undefined, - decodedData, - }; - }); - - setAttestations(attestationsData); - }; - - processAttestations(); - }, [attestationsResponse]); - - useEffect(() => { - const updatedIdentifiers = identifiers.map((identifier) => { - const matchingAttestation = attestations.find( + const matchIdentifiersWithAttestations = ( + identifiers: Identifier[], + attestationList: IAttestation[] + ): Identifier[] => { + return identifiers.map((identifier) => { + const matchingAttestation = attestationList.find( (attestation) => - (attestation.provider as string)?.toLowerCase() === - identifier.name.toLowerCase() + attestation.provider?.toLowerCase() === identifier.name.toLowerCase() ); return { @@ -217,226 +90,258 @@ export function Identifiers() { uid: matchingAttestation?.id || '', }; }); + }; - setIdentifiers(updatedIdentifiers); - - const initialRevealedState = updatedIdentifiers.reduce( - (acc, identifier) => { - acc[identifier.uid] = '*********'; - return acc; - }, - {} as { [uid: string]: string } - ); - - setRevealedIdentifiers(initialRevealedState); + useEffect(() => { + if (attestations && attestations.length > 0) { + const resolvedIdentifiers = matchIdentifiersWithAttestations( + userIdentifiers, + attestations + ); + setUserIdentifiers(resolvedIdentifiers); + } }, [attestations]); - const handleRevoke = useCallback( - (uid: string) => { - const siweJwt = localStorage.getItem('OCI_TOKEN'); + const revokeDelegation = async (response: RevokePayload, uid: string) => { + if (!easService) { + throw new Error('EAS service not initialized'); + } - if (!siweJwt) throw new Error('OCI SIWE token not found'); + setRevoking((prev) => ({ ...prev, [uid]: true })); - setLoadingIdentifiers((prev) => ({ ...prev, [uid]: true })); + try { + await easService.revokeByDelegation(response); - mutateRevokeIdentifier({ - uid, - siweJwt, - chainId, + showSnackbar('Attestation Revoke successfully completed.', { + severity: 'success', }); - }, - [mutateRevokeIdentifier, chainId] - ); - const handleConnect = useCallback( - (identifier: string) => { - navigate(`/identifiers/${identifier.toLowerCase()}/attestation`); - }, - [navigate] - ); + await refetch(); + } catch (error) { + console.error('Error revoking identifier:', error); + showSnackbar( + 'Failed to complete the attestation revoke. Please try again.', + { + severity: 'error', + } + ); + } finally { + setRevoking((prev) => ({ ...prev, [uid]: false })); + } + }; - const handleReveal = useCallback( - (uid: string) => { - // Toggle between showing and hiding the identifier - if (revealedIdentifiers[uid] !== '*********') { - setRevealedIdentifiers((prev) => ({ - ...prev, - [uid]: '*********', - })); - return; + const handleRevokeAttestation = useCallback( + async (uid: string) => { + if (!easService) { + throw new Error('EAS service not initialized'); } - setRevealing((prev) => ({ - ...prev, - [uid]: true, - })); + const siweJwt = localStorage.getItem('OCI_TOKEN'); + + if (!siweJwt) throw new Error('OCI SIWE token not found'); + mutateRevoke( + { uid, siweJwt, chainId }, + { + onSuccess: (response) => { + revokeDelegation(response.data as RevokePayload, uid); + }, + onError: (error) => { + console.error('Error revoking identifier:', error); + }, + } + ); + }, + [mutateRevoke, chainId, easService] + ); + + const handleReveal = useCallback( + (identifier: Identifier) => { const siweJwt = localStorage.getItem('OCI_TOKEN'); if (!siweJwt) throw new Error('OCI SIWE token not found'); + setLoading((prev) => ({ ...prev, [identifier.uid]: true })); + + if (identifier.revealedSecret) { + setOpenTooltips((prev) => ({ ...prev, [identifier.uid]: true })); + setLoading((prev) => ({ ...prev, [identifier.uid]: false })); + return; + } + mutateDecryptAttestationsSecret( { - uid, + uid: identifier.uid, siweJwt, chainId, }, { onSuccess: (response) => { - console.log('Decrypted secret:', response); - - setRevealedIdentifiers((prev) => ({ - ...prev, - [uid]: response.data.id, - })); - setRevealing((prev) => ({ - ...prev, - [uid]: false, - })); + setUserIdentifiers((prev) => + prev.map((id) => { + if (id.uid === identifier.uid) { + return { + ...id, + revealedSecret: response.data.id, + }; + } + return id; + }) + ); + setOpenTooltips((prev) => ({ ...prev, [identifier.uid]: true })); }, onError: (error) => { console.error('Error decrypting secret:', error); - setRevealing((prev) => ({ - ...prev, - [uid]: false, - })); + }, + onSettled: () => { + setLoading((prev) => ({ ...prev, [identifier.uid]: false })); }, } ); }, - [chainId, mutateDecryptAttestationsSecret, revealedIdentifiers] + [mutateDecryptAttestationsSecret, chainId] ); - useEffect(() => { - const revokeIdentifier = async () => { - if (revokeIdentifierResponse) { - console.log('Revoke identifier response:', revokeIdentifierResponse); - - const payload: RevokePayload = convertStringsToBigInts( - revokeIdentifierResponse.data - ) as RevokePayload; - - console.log('Payload:', payload); - - try { - const eas = new EAS(sepoliaChain.easContractAddress as Address); - - if (!signer) throw new Error('Signer not found'); - - if (!revokeIdentifierResponse) - throw new Error('No linking identifier'); - - eas.connect(signer); - - if ('revoker' in payload.message) { - const transformedPayload: DelegatedRevocationRequest = { - schema: payload.message.schema, - data: { - uid: payload.message.uid, - }, - signature: payload.signature, - revoker: payload.message.revoker, - deadline: 0n, - }; - - const tx = await eas.revokeByDelegation(transformedPayload); - await tx.wait(); - console.log({ tx }); - - showSnackbar('Identifier revoked successfully', { - severity: 'success', - }); - - setLoadingIdentifiers((prev) => ({ - ...prev, - [payload.message.uid]: false, - })); - } else { - throw new Error('Invalid message type for revocation'); - } - } catch (error: any) { - const errorCode = error?.info?.error?.code || ''; - - if (errorCode === 4001) { - showSnackbar( - `${errorCode}, you reject the transaction. please try again...`, - { - severity: 'error', - } - ); - } - - if ('uid' in payload.message) { - setLoadingIdentifiers((prev) => ({ - ...prev, - [payload.message.uid]: false, - })); - } - } finally { - refetch(); - } - } - }; + const handleConnectAttestation = useCallback( + (name: string) => { + navigate(`/identifiers/${name.toLowerCase()}/attestation`); + }, + [navigate] + ); + + const handleTooltipClose = (uid: string) => { + setOpenTooltips((prev) => ({ ...prev, [uid]: false })); + }; - revokeIdentifier(); - }, [revokeIdentifierResponse]); + const renderButtonContent = (identifier: Identifier) => { + if (revoking[identifier.uid]) { + return ( + <> + + Revoking... + + ); + } - if (isLoading) { + return identifier.verified ? 'Revoke' : 'Connect'; + }; + + if (attestationsLoading) { + return ; + } + + if (error) { return ( - theme.zIndex.drawer + 1, - background: '#fff', - color: 'black', - }} - > - - - - Loading... - - - + + Error + An error occurred while fetching identifiers + ); } return ( -
- + + Identifiers - - - - - - Actions - - - - - {identifiers.map((identifier) => ( - handleReveal(identifier.uid)} - /> - ))} - -
+ + Connect your identifiers to start using the service. + + + {userIdentifiers.map((identifier) => ( + + + + ) : null + } + > + + + + + +
+ {identifier.name} + {identifier.verified && ( + handleTooltipClose(identifier.uid)} + > +
+ + ) : ( + identifier.revealedSecret && + `Account ID: ${identifier.revealedSecret}` + ) + } + arrow + open={openTooltips[identifier.uid] || false} + onClose={() => handleTooltipClose(identifier.uid)} + disableHoverListener + disableTouchListener + disableFocusListener + placement="top" + > + handleReveal(identifier)} + sx={{ + ml: 1, + p: 0, + }} + > + {loading[identifier.uid] ? ( + + ) : ( + + )} + + +
+
+ )} +
+
+ + + +
+
+ ))} + ); } - -export default Identifiers; diff --git a/src/pages/Identifiers/index.ts b/src/pages/Identifiers/index.ts index f7c14b8..3047184 100644 --- a/src/pages/Identifiers/index.ts +++ b/src/pages/Identifiers/index.ts @@ -1,3 +1,3 @@ -import { Identifiers } from './Identifiers'; +import Identifiers from './Identifiers'; export default Identifiers; diff --git a/src/pages/Permissions/Permissions.tsx b/src/pages/Permissions/Permissions.tsx index efedd47..46d57ab 100644 --- a/src/pages/Permissions/Permissions.tsx +++ b/src/pages/Permissions/Permissions.tsx @@ -1,14 +1,6 @@ /* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useEffect, useMemo, useState } from 'react'; -import { - useReadContract, - useReadContracts, - useWriteContract, - useWaitForTransactionReceipt, - useAccount, -} from 'wagmi'; -import { Address, Abi } from 'viem'; import { Alert, Backdrop, @@ -19,15 +11,25 @@ import { Typography, } from '@mui/material'; import { useNavigate } from 'react-router-dom'; -import { useGetAttestations } from '../../services/eas/query'; -import sepoliaChainAppConctract from '../../utils/contracts/app/sepoliaChain.json'; -import sepoliaChainOidonctract from '../../utils/contracts/oid/sepoliaChain.json'; -import { decodeAttestationData, IAttestation } from '../../libs/oci'; +import { Abi, Address } from 'viem'; +import { + useAccount, + useReadContract, + useReadContracts, + useWaitForTransactionReceipt, + useWriteContract, +} from 'wagmi'; + import CustomTable, { - Platform, AccessData, + Platform, } from '../../components/shared/CustomTable'; +import { IAttestation } from '../../interfaces'; +import { decodeAttestationData } from '../../libs/oci'; +import { useGetAttestations } from '../../services/eas/query'; import useSnackbarStore from '../../store/useSnackbarStore'; +import sepoliaChainAppConctract from '../../utils/contracts/app/sepoliaChain.json'; +import sepoliaChainOidonctract from '../../utils/contracts/oid/sepoliaChain.json'; export function Permissions() { const { showSnackbar } = useSnackbarStore(); diff --git a/src/router/index.tsx b/src/router/index.tsx deleted file mode 100644 index c97f4b4..0000000 --- a/src/router/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { createBrowserRouter } from 'react-router-dom'; - -import Login from '../pages/Auth/Login'; -import Dashboard from '../pages/Dashboard'; -import Identifiers from '../pages/Identifiers'; -import Permissions from '../pages/Permissions'; -import Attestation from '../pages/Identifiers/Attestation'; -import Callback from '../pages/Callback'; - -import DefaultLayout from '../layouts/DefaultLayout'; -import ProtectedRoute from '../ProtectedRoute'; - -export const router = createBrowserRouter([ - { - path: '/auth/login', - element: , - }, - { - path: '/', - element: , - children: [ - { - path: '/', - element: ( - - - - ), - index: true, - }, - { - path: '/identifiers', - element: ( - - - - ), - }, - { - path: 'identifiers/:provider/attestation', - element: ( - - - - ), - }, - { - path: '/permissions', - element: ( - - - - ), - }, - ], - }, - { - path: '/callback', - element: , - }, - { - path: '*', - element:
Not found
, - }, -]); diff --git a/src/services/api/auth/index.ts b/src/services/api/auth/index.ts index 3b0fdab..d0adbe8 100644 --- a/src/services/api/auth/index.ts +++ b/src/services/api/auth/index.ts @@ -1,7 +1,11 @@ +import { Provider } from '../../../enums'; import { baseURL } from '..'; -import { PlatformAuthenticationParams } from '@/interfaces'; -export const platformAuthentication = async ({ +interface PlatformAuthenticationParams { + platformType: Provider; +} + +export const platformAuthentication = ({ platformType, }: PlatformAuthenticationParams) => { window.location.replace(`${baseURL}auth/${platformType}/authenticate`); diff --git a/src/services/api/eas/query.ts b/src/services/api/eas/query.ts index facf128..3f44a05 100644 --- a/src/services/api/eas/query.ts +++ b/src/services/api/eas/query.ts @@ -1,4 +1,5 @@ import { useMutation } from '@tanstack/react-query'; + import { decryptAttestationsSecret, DecryptAttestationsSecretParams, diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 2802abf..098e80b 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import { useNavigate } from 'react-router-dom'; + import useSnackbarStore from '../../store/useSnackbarStore'; export const baseURL = import.meta.env.VITE_API_BASE_URL; diff --git a/src/services/eas.service.ts b/src/services/eas.service.ts new file mode 100644 index 0000000..bbfae19 --- /dev/null +++ b/src/services/eas.service.ts @@ -0,0 +1,173 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + DelegatedAttestationRequest, + DelegatedRevocationRequest, + EAS, +} from '@ethereum-attestation-service/eas-sdk'; +import { JsonRpcSigner } from 'ethers'; +import { Address } from 'viem'; + +import { AttestPayload, RevokePayload } from '../interfaces'; + +class EASService { + private eas: EAS | null = null; + + constructor( + contractAddress: Address, + private signer: JsonRpcSigner | undefined + ) { + this.initializeEAS(contractAddress); + this.connect(); + } + + /** + * Initializes the EAS instance with the provided contract address. + * @param contractAddress The contract address to use for EAS. + */ + private initializeEAS(contractAddress: Address): void { + this.eas = new EAS(contractAddress); + } + + /** + * Connects the EAS instance to the provided signer. + */ + private connect(): void { + if (!this.eas) { + throw new Error('EAS instance is not initialized'); + } + if (!this.signer) { + throw new Error('Signer is not provided'); + } + this.eas.connect(this.signer); + } + + /** + * Converts string representations of numbers in a payload to BigInts. + * @param obj The object containing strings to convert. + */ + private convertStringsToBigInts = (obj: unknown): unknown => { + if (typeof obj === 'string' && /^[0-9]+$/.test(obj)) { + return BigInt(obj); + } + if (Array.isArray(obj)) { + return obj.map(this.convertStringsToBigInts); + } + if (typeof obj === 'object' && obj !== null) { + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [ + k, + this.convertStringsToBigInts(v), + ]) + ); + } + return obj; + }; + + /** + * Static method to prepare the revocation request from a payload. + * @param payload The payload containing the revocation details. + */ + private static prepareRevocationRequest( + payload: RevokePayload + ): DelegatedRevocationRequest { + if (!('revoker' in payload.message)) { + throw new Error('Invalid payload: Missing revoker field.'); + } + + return { + schema: payload.message.schema, + data: { + uid: payload.message.uid, + }, + signature: payload.signature, + revoker: payload.message.revoker, + deadline: 0n, + }; + } + + /** + * Static method to prepare the attestation request from a payload. + * @param payload The payload containing the attestation details. + */ + private static prepareAttestationRequest( + payload: AttestPayload + ): DelegatedAttestationRequest { + return { + schema: payload.message.schema, + data: { + recipient: payload.message.recipient, + expirationTime: payload.message.expirationTime, + revocable: payload.message.revocable, + refUID: payload.message.refUID, + data: payload.message.data, + }, + signature: payload.signature, + attester: payload.message.attester as string, + deadline: 0n, + }; + } + + /** + * Revokes an attestation by delegation. + * @param payload The payload containing the revocation details. + */ + public async revokeByDelegation(payload: RevokePayload): Promise { + if (!this.eas) { + throw new Error('EAS is not initialized'); + } + + const convertedPayload = this.convertStringsToBigInts( + payload + ) as RevokePayload; + const revocationRequest = + EASService.prepareRevocationRequest(convertedPayload); + + try { + const tx = await this.eas.revokeByDelegation(revocationRequest); + await tx.wait(); + console.log('Revocation successful:', tx); + } catch (error: any) { + console.error('Revocation failed:', error); + throw new Error(`Revocation failed: ${error.message}`); + } + } + + /** + * Attests by delegation. + * @param attestationPayload The payload containing the attestation details. + */ + public async attestByDelegation( + attestationPayload: AttestPayload + ): Promise { + if (!this.eas) { + throw new Error('EAS is not initialized'); + } + + const convertedPayload = this.convertStringsToBigInts( + attestationPayload + ) as AttestPayload; + const attestationRequest = + EASService.prepareAttestationRequest(convertedPayload); + + try { + const tx = await this.eas.attestByDelegation(attestationRequest); + await tx.wait(); + console.log('Attestation successful:', tx); + } catch (error: any) { + console.error('Attestation failed:', error); + throw new Error(`Attestation failed: ${error.message}`); + } + } + + /** + * Returns the connected EAS instance for further operations. + */ + public getEASInstance(): EAS { + if (!this.eas) { + throw new Error('EAS instance is not initialized'); + } + return this.eas; + } +} + +export default EASService; diff --git a/src/services/eas/query.ts b/src/services/eas/query.ts index 84eea9d..0d3759f 100644 --- a/src/services/eas/query.ts +++ b/src/services/eas/query.ts @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query'; import { gql } from 'graphql-request'; import { Address } from 'viem'; -import { ATTESTER_ADDRESS, graphQLClient } from '.'; + +import { IAttestation } from '../../interfaces'; import { EAS_SCHEMA_ID } from '../../utils/contracts/eas/constants'; -import { IAttestation } from '../../libs/oci'; +import { ATTESTER_ADDRESS, graphQLClient } from '.'; interface AttestationsResponse { attestations: IAttestation[]; diff --git a/src/setupTests.ts b/src/setupTests.ts index 55f303a..ad3827c 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,5 +1,8 @@ import '@testing-library/jest-dom'; + import * as matchers from '@testing-library/jest-dom/matchers'; import { expect } from 'vitest'; +process.env.VITE_API_BASE_URL = 'https://onchain.togethercrew.de/api/v1/'; + expect.extend(matchers); diff --git a/src/utils/eas-wagmi-utils.ts b/src/utils/eas-wagmi-utils.ts index ae14a79..1b2c9fe 100644 --- a/src/utils/eas-wagmi-utils.ts +++ b/src/utils/eas-wagmi-utils.ts @@ -1,12 +1,12 @@ // import { type PublicClient, type WalletClient } from '@wagmi/core'; +import { useEffect, useState } from 'react'; import { BrowserProvider, FallbackProvider, JsonRpcProvider, JsonRpcSigner, } from 'ethers'; -import { PublicClient, WalletClient, type HttpTransport } from 'viem'; -import { useEffect, useState } from 'react'; +import { type HttpTransport, PublicClient, WalletClient } from 'viem'; // import type { JsonRpcProvider, JsonRpcSigner } from '@ethersproject/providers'; import { usePublicClient, useWalletClient } from 'wagmi'; diff --git a/src/utils/helper/index.ts b/src/utils/helper/index.ts index 13e3480..9bf36f5 100644 --- a/src/utils/helper/index.ts +++ b/src/utils/helper/index.ts @@ -22,3 +22,6 @@ export const convertStringsToBigInts = (obj: unknown): unknown => { } return obj; }; + +export const capitalize = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); diff --git a/vite.config.ts b/vite.config.ts index 9856648..0290b9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,9 @@ /// /// +import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; -import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({