diff --git a/web/.env.testnet.public b/web/.env.testnet.public index 85341ef..dfa8a9c 100644 --- a/web/.env.testnet.public +++ b/web/.env.testnet.public @@ -2,3 +2,4 @@ export REACT_APP_DEPLOYMENT=testnet export REACT_APP_ARBSEPOLIA_SUBGRAPH= export REACT_APP_STATUS_URL=https://escrow-v2.betteruptime.com/badge +export REACT_APP_ATLAS_URI=https://atlas.staging.kleros.link diff --git a/web/netlify/config/index.ts b/web/netlify/config/index.ts deleted file mode 100644 index 18c0898..0000000 --- a/web/netlify/config/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -const config = { - /***** jwt variables *****/ - jwtIssuer: process.env.JWT_ISSUER ?? "Kleros", // ex :- Kleros - jwtAudience: process.env.JWT_AUDIENCE ?? "Escrow", // ex :- Court, Curate, Escrow - jwtExpTime: process.env.JWT_EXP_TIME ?? "2h", - jwtSecret: process.env.JWT_SECRET, - - /***** supabase variables *****/ - supabaseUrl: process.env.SUPABASE_URL, - supabaseApiKey: process.env.SUPABASE_CLIENT_API_KEY, - - /***** ipfs variables *****/ - filebaseToken: process.env.FILEBASE_TOKEN, - rabbitMqUrl: process.env.RABBITMQ_URL, -}; - -export default config; diff --git a/web/netlify/functions/authUser.ts b/web/netlify/functions/authUser.ts deleted file mode 100644 index 23166ea..0000000 --- a/web/netlify/functions/authUser.ts +++ /dev/null @@ -1,117 +0,0 @@ -import middy from "@middy/core"; -import jsonBodyParser from "@middy/http-json-body-parser"; -import { createClient } from "@supabase/supabase-js"; -import { ethers } from "ethers"; -import * as jwt from "jose"; -import { SiweMessage } from "siwe"; - -import { ETH_SIGNATURE_REGEX, DEFAULT_CHAIN, isProductionDeployment } from "consts/processEnvConsts"; - -import { netlifyUri, netlifyDeployUri, netlifyDeployPrimeUri } from "src/generatedNetlifyInfo.json"; -import { Database } from "src/types/supabase-notification"; - -import config from "../config"; - -const authUser = async (event) => { - try { - if (!event.body) { - throw new Error("No body provided"); - } - - const signature = event?.body?.signature; - if (!signature) { - throw new Error("Missing key : signature"); - } - - if (!ETH_SIGNATURE_REGEX.test(signature)) { - throw new Error("Invalid signature"); - } - - const message = event?.body?.message; - if (!message) { - throw new Error("Missing key : message"); - } - - const address = event?.body?.address; - if (!address) { - throw new Error("Missing key : address"); - } - - const siweMessage = new SiweMessage(message); - - if ( - !( - (netlifyUri && netlifyUri === siweMessage.uri) || - (netlifyDeployUri && netlifyDeployUri === siweMessage.uri) || - (netlifyDeployPrimeUri && netlifyDeployPrimeUri === siweMessage.uri) - ) - ) { - console.debug( - `Invalid URI: expected one of [${netlifyUri} ${netlifyDeployUri} ${netlifyDeployPrimeUri}] but got ${siweMessage.uri}` - ); - throw new Error(`Invalid URI`); - } - - if (siweMessage.chainId !== DEFAULT_CHAIN) { - console.debug(`Invalid chain ID: expected ${DEFAULT_CHAIN} but got ${siweMessage.chainId}`); - throw new Error(`Invalid chain ID`); - } - - const lowerCaseAddress = siweMessage.address.toLowerCase(); - if (lowerCaseAddress !== address.toLowerCase()) { - throw new Error("Address mismatch in provided address and message"); - } - - if (!config.supabaseUrl || !config.supabaseApiKey) { - throw new Error("Supabase URL or API key is undefined"); - } - const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); - - // get nonce from db, if its null that means it was already used - const { error: nonceError, data: nonceData } = await supabase - .from("user-nonce") - .select("nonce") - .eq("address", lowerCaseAddress) - .single(); - - if (nonceError || !nonceData?.nonce) { - throw new Error("Unable to fetch nonce from DB"); - } - - try { - // If the main Alchemy API key is permissioned, it won't work in a Netlify Function so we use a dedicated API key - const alchemyApiKey = process.env.ALCHEMY_FUNCTIONS_API_KEY ?? process.env.ALCHEMY_API_KEY; - const alchemyChain = isProductionDeployment() ? "arb-mainnet" : "arb-sepolia"; - const alchemyRpcURL = `https://${alchemyChain}.g.alchemy.com/v2/${alchemyApiKey}`; - const provider = new ethers.providers.JsonRpcProvider(alchemyRpcURL); - await siweMessage.verify({ signature, nonce: nonceData.nonce, time: new Date().toISOString() }, { provider }); - } catch (err) { - throw new Error("Invalid signer: " + JSON.stringify(err)); - } - - const { error } = await supabase.from("user-nonce").delete().match({ address: lowerCaseAddress }); - - if (error) { - throw new Error("Error updating nonce in DB"); - } - - if (!config.jwtSecret) { - throw new Error("Secret not set in environment"); - } - // user verified, generate auth token - const encodedSecret = new TextEncoder().encode(config.jwtSecret); - - const token = await new jwt.SignJWT({ id: address.toLowerCase() }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuer(config.jwtIssuer) - .setAudience(config.jwtAudience) - .setExpirationTime(config.jwtExpTime) - .sign(encodedSecret); - - return { statusCode: 200, body: JSON.stringify({ message: "User authorised", token }) }; - } catch (err) { - return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) }; - } -}; - -export const handler = middy(authUser).use(jsonBodyParser()); diff --git a/web/netlify/functions/fetch-settings.ts b/web/netlify/functions/fetch-settings.ts deleted file mode 100644 index faa4c75..0000000 --- a/web/netlify/functions/fetch-settings.ts +++ /dev/null @@ -1,38 +0,0 @@ -import middy from "@middy/core"; -import { createClient } from "@supabase/supabase-js"; - -import { Database } from "../../src/types/supabase-notification"; -import { authMiddleware } from "../middleware/authMiddleware"; - -import config from "../config"; - -const fetchSettings = async (event) => { - try { - const address = event.auth.id; - const lowerCaseAddress = address.toLowerCase() as `0x${string}`; - - if (!config.supabaseUrl || !config.supabaseApiKey) { - throw new Error("Supabase URL or API key is undefined"); - } - const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); - - const { error, data } = await supabase - .from("user-settings") - .select("address, email, telegram") - .eq("address", lowerCaseAddress) - .single(); - - if (!data) { - return { statusCode: 404, message: "Error : User not found" }; - } - - if (error) { - throw error; - } - return { statusCode: 200, body: JSON.stringify({ data }) }; - } catch (err) { - return { statusCode: 500, message: `Error ${err?.message ?? err}` }; - } -}; - -export const handler = middy(fetchSettings).use(authMiddleware()); diff --git a/web/netlify/functions/getNonce.ts b/web/netlify/functions/getNonce.ts deleted file mode 100644 index 38dcc03..0000000 --- a/web/netlify/functions/getNonce.ts +++ /dev/null @@ -1,55 +0,0 @@ -import middy from "@middy/core"; -import { createClient } from "@supabase/supabase-js"; -import { generateNonce } from "siwe"; - -import { ETH_ADDRESS_REGEX } from "src/consts/processEnvConsts"; - -import { Database } from "src/types/supabase-notification"; - -import config from "../config"; - -const getNonce = async (event) => { - try { - const { queryStringParameters } = event; - - if (!queryStringParameters?.address) { - return { - statusCode: 400, - body: JSON.stringify({ message: "Invalid query parameters" }), - }; - } - - const { address } = queryStringParameters; - - if (!ETH_ADDRESS_REGEX.test(address)) { - throw new Error("Invalid Ethereum address format"); - } - - const lowerCaseAddress = address.toLowerCase() as `0x${string}`; - - if (!config.supabaseUrl || !config.supabaseApiKey) { - throw new Error("Supabase URL or API key is undefined"); - } - const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); - - // generate nonce and save in db - const nonce = generateNonce(); - - const { error } = await supabase - .from("user-nonce") - .upsert({ address: lowerCaseAddress, nonce: nonce }) - .eq("address", lowerCaseAddress); - - if (error) { - throw error; - } - - return { statusCode: 200, body: JSON.stringify({ nonce }) }; - } catch (err) { - console.log(err); - - return { statusCode: 500, message: `Error ${err?.message ?? err}` }; - } -}; - -export const handler = middy(getNonce); diff --git a/web/netlify/functions/update-settings.ts b/web/netlify/functions/update-settings.ts deleted file mode 100644 index d56742b..0000000 --- a/web/netlify/functions/update-settings.ts +++ /dev/null @@ -1,98 +0,0 @@ -import middy from "@middy/core"; -import jsonBodyParser from "@middy/http-json-body-parser"; -import { createClient } from "@supabase/supabase-js"; - -import { EMAIL_REGEX, TELEGRAM_REGEX, ETH_ADDRESS_REGEX } from "consts/processEnvConsts"; - -import { Database } from "src/types/supabase-notification"; - -import { authMiddleware } from "../middleware/authMiddleware"; - -import config from "../config"; - -type NotificationSettings = { - email?: string; - telegram?: string; - address: `0x${string}`; -}; - -const validate = (input: any): NotificationSettings => { - const requiredKeys: (keyof NotificationSettings)[] = ["address"]; - const optionalKeys: (keyof NotificationSettings)[] = ["email", "telegram"]; - const receivedKeys = Object.keys(input); - - for (const key of requiredKeys) { - if (!receivedKeys.includes(key)) { - throw new Error(`Missing key: ${key}`); - } - } - - const allExpectedKeys = [...requiredKeys, ...optionalKeys]; - for (const key of receivedKeys) { - if (!allExpectedKeys.includes(key as keyof NotificationSettings)) { - throw new Error(`Unexpected key: ${key}`); - } - } - - const email = input.email ? input.email.trim() : ""; - if (email && !EMAIL_REGEX.test(email)) { - throw new Error("Invalid email format"); - } - - const telegram = input.telegram ? input.telegram.trim() : ""; - if (telegram && !TELEGRAM_REGEX.test(telegram)) { - throw new Error("Invalid Telegram username format"); - } - - if (!ETH_ADDRESS_REGEX.test(input.address)) { - throw new Error("Invalid Ethereum address format"); - } - - return { - email: input.email?.trim(), - telegram: input.telegram?.trim(), - address: input.address.trim().toLowerCase(), - }; -}; - -const updateSettings = async (event) => { - try { - if (!event.body) { - throw new Error("No body provided"); - } - - const { email, telegram, address } = validate(event.body); - const lowerCaseAddress = address.toLowerCase() as `0x${string}`; - - // Prevent using someone else's token - if (event?.auth?.id.toLowerCase() !== lowerCaseAddress) { - throw new Error("Unauthorised user"); - } - - if (!config.supabaseUrl || !config.supabaseApiKey) { - throw new Error("Supabase URL or API key is undefined"); - } - const supabase = createClient(config.supabaseUrl, config.supabaseApiKey); - - // If the message is empty, delete the user record - if (email === "" && telegram === "") { - const { error } = await supabase.from("user-settings").delete().match({ address: lowerCaseAddress }); - if (error) throw error; - return { statusCode: 200, body: JSON.stringify({ message: "Record deleted successfully." }) }; - } - - // For a user matching this address, upsert the user record - const { error } = await supabase - .from("user-settings") - .upsert({ address: lowerCaseAddress, email: email, telegram: telegram }) - .match({ address: lowerCaseAddress }); - if (error) { - throw error; - } - return { statusCode: 200, body: JSON.stringify({ message: "Record updated successfully." }) }; - } catch (err) { - return { statusCode: 500, body: JSON.stringify({ message: `${err}` }) }; - } -}; - -export const handler = middy(updateSettings).use(jsonBodyParser()).use(authMiddleware()); diff --git a/web/netlify/functions/uploadToIPFS.ts b/web/netlify/functions/uploadToIPFS.ts deleted file mode 100644 index d9676bf..0000000 --- a/web/netlify/functions/uploadToIPFS.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { File, FilebaseClient } from "@filebase/client"; -import amqp, { Connection } from "amqplib"; -import busboy from "busboy"; -import middy from "@middy/core"; -import { authMiddleware } from "../middleware/authMiddleware"; - -import config from "../config"; - -const filebase = new FilebaseClient({ token: config.filebaseToken ?? "" }); - -type FormElement = - | { isFile: true; filename: string; mimeType: string; content: Buffer } - | { isFile: false; content: string }; -type FormData = { [key: string]: FormElement }; - -const emitRabbitMQLog = async (cid: string, operation: string) => { - let connection: Connection | undefined; - try { - connection = await amqp.connect(config.rabbitMqUrl ?? ""); - const channel = await connection.createChannel(); - - await channel.assertExchange("ipfs", "topic"); - channel.publish("ipfs", operation, Buffer.from(cid)); - - //eslint-disable-next-line no-console - console.log(`Sent IPFS CID '${cid}' to exchange 'ipfs'`); - } catch (err) { - console.warn(err); - } finally { - if (typeof connection !== "undefined") await connection.close(); - } -}; - -const parseMultipart = ({ headers, body, isBase64Encoded }) => - new Promise((resolve, reject) => { - const fields: FormData = {}; - - const bb = busboy({ headers }); - - bb.on("file", (name, file, { filename, mimeType }) => - file.on("data", (content) => { - fields[name] = { isFile: true, filename, mimeType, content }; - }) - ) - .on("field", (name, value) => { - if (value) fields[name] = { isFile: false, content: value }; - }) - .on("close", () => resolve(fields)) - .on("error", (err) => reject(err)); - - bb.write(body, isBase64Encoded ? "base64" : "binary"); - bb.end(); - }); - -const pinToFilebase = async (data: FormData, operation: string): Promise> => { - const cids = new Array(); - for (const [_, dataElement] of Object.entries(data)) { - if (dataElement.isFile) { - const { filename, mimeType, content } = dataElement; - const path = `${filename}`; - const cid = await filebase.storeDirectory([new File([content], path, { type: mimeType })]); - await emitRabbitMQLog(cid, operation); - cids.push(`/ipfs/${cid}/${path}`); - } - } - - return cids; -}; - -export const uploadToIpfs = async (event) => { - const { queryStringParameters } = event; - - if (!queryStringParameters?.operation) { - return { - statusCode: 400, - body: JSON.stringify({ message: "Invalid query parameters, missing query : operation " }), - }; - } - - const { operation } = queryStringParameters; - - try { - const parsed = await parseMultipart(event); - const cids = await pinToFilebase(parsed, operation); - - return { - statusCode: 200, - body: JSON.stringify({ - message: "File has been stored successfully", - cids, - }), - }; - } catch (err: any) { - return { - statusCode: 500, - body: JSON.stringify({ message: err.message }), - }; - } -}; - -export const handler = middy(uploadToIpfs).use(authMiddleware()); diff --git a/web/netlify/middleware/authMiddleware.ts b/web/netlify/middleware/authMiddleware.ts deleted file mode 100644 index 25976b5..0000000 --- a/web/netlify/middleware/authMiddleware.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as jwt from "jose"; -import config from "../config"; - -export const authMiddleware = () => { - return { - before: async (request) => { - const { event } = request; - - const authToken = event?.headers?.["x-auth-token"]; - if (!authToken) { - return { - statusCode: 400, - body: JSON.stringify({ message: "Error : Missing x-auth-token in Header" }), - }; - } - - try { - if (!config.jwtSecret) { - throw new Error("Secret not set in environment"); - } - - const encodedSecret = new TextEncoder().encode(config.jwtSecret); - - const { payload } = await jwt.jwtVerify(authToken, encodedSecret, { - issuer: config.jwtIssuer, - audience: config.jwtAudience, - }); - - // add auth details to event - request.event.auth = payload; - } catch (err) { - return { - statusCode: 401, - body: JSON.stringify({ message: `Error : ${err?.message ?? "Not Authorised"}` }), - }; - } - }, - }; -}; diff --git a/web/package.json b/web/package.json index 51b3ca9..4dcd77a 100644 --- a/web/package.json +++ b/web/package.json @@ -39,16 +39,12 @@ "check-types": "tsc --noEmit", "generate": "yarn generate:gql && yarn generate:hooks", "generate:gql": "graphql-codegen --require tsconfig-paths/register", - "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate", - "generate:supabase": "scripts/generateSupabaseTypes.sh" + "generate:hooks": "NODE_NO_WARNINGS=1 wagmi generate" }, "prettier": "@kleros/escrow-v2-prettier-config", "devDependencies": { "@graphql-codegen/cli": "^4.0.1", "@graphql-codegen/client-preset": "^4.1.0", - "@netlify/functions": "^1.6.0", - "@types/amqplib": "^0.10.4", - "@types/busboy": "^1.5.3", "@types/react": "^18.2.59", "@types/react-dom": "^18.2.18", "@types/react-modal": "^3.16.3", @@ -62,8 +58,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "lru-cache": "^7.18.3", - "supabase": "^1.133.3", - "typescript": "^5.3.3", + "typescript": "^5.7.2", "vite": "^5.4.2", "vite-plugin-node-polyfills": "^0.21.0", "vite-plugin-svgr": "^4.2.0", @@ -71,25 +66,20 @@ }, "dependencies": { "@cyntler/react-doc-viewer": "^1.16.3", - "@filebase/client": "^0.0.5", + "@kleros/kleros-app": "^2.0.1", "@kleros/ui-components-library": "^2.15.0", - "@middy/core": "^5.3.5", - "@middy/http-json-body-parser": "^5.3.5", "@sentry/react": "^7.93.0", "@sentry/tracing": "^7.93.0", - "@supabase/supabase-js": "^2.39.3", - "@tanstack/react-query": "^5.40.1", + "@tanstack/react-query": "^5.61.5", "@web3modal/wagmi": "^5.1.4", "@yornaath/batshit": "^0.9.0", "alchemy-sdk": "^3.3.1", - "amqplib": "^0.10.4", "chart.js": "^3.9.1", "chartjs-adapter-moment": "^1.0.1", "core-js": "^3.35.0", "ethers": "^5.7.2", - "graphql": "^16.8.1", - "graphql-request": "~6.1.0", - "jose": "^5.3.0", + "graphql": "^16.9.0", + "graphql-request": "^7.1.2", "moment": "^2.30.1", "overlayscrollbars": "^2.4.6", "overlayscrollbars-react": "^0.5.3", @@ -106,9 +96,8 @@ "react-scripts": "^5.0.1", "react-toastify": "^9.1.3", "react-use": "^17.4.3", - "siwe": "^2.3.2", "styled-components": "^5.3.11", - "viem": "^2.1.0", - "wagmi": "^2.12.8" + "viem": "^2.21.51", + "wagmi": "^2.13.2" } } diff --git a/web/src/app.tsx b/web/src/app.tsx index fa31619..301a884 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -13,27 +13,32 @@ import NewTransaction from "./pages/NewTransaction"; import MyTransactions from "./pages/MyTransactions"; import { NewTransactionProvider } from "./context/NewTransactionContext"; import AttachmentDisplay from "./pages/AttachmentDisplay"; +import AtlasProvider from "./context/AtlasProvider"; +import Settings from "./pages/Settings"; const App: React.FC = () => { return ( - - - - - }> - } /> - } /> - } /> - } /> - 404 not found} /> - - - - - + + + + + + }> + } /> + } /> + } /> + } /> + } /> + 404 not found} /> + + + + + + diff --git a/web/src/assets/svgs/icons/minus-circle.svg b/web/src/assets/svgs/icons/minus-circle.svg new file mode 100644 index 0000000..2d03196 --- /dev/null +++ b/web/src/assets/svgs/icons/minus-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/web/src/assets/svgs/icons/warning-outline.svg b/web/src/assets/svgs/icons/warning-outline.svg new file mode 100644 index 0000000..fc09d8c --- /dev/null +++ b/web/src/assets/svgs/icons/warning-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/web/src/components/EnsureAuth.tsx b/web/src/components/EnsureAuth.tsx index 33637a8..39b3636 100644 --- a/web/src/components/EnsureAuth.tsx +++ b/web/src/components/EnsureAuth.tsx @@ -1,16 +1,12 @@ -import React, { useMemo, useState } from "react"; +import React, { useCallback } from "react"; -import * as jwt from "jose"; -import { SiweMessage } from "siwe"; -import { useAccount, useSignMessage } from "wagmi"; +import { useAccount } from "wagmi"; import { Button } from "@kleros/ui-components-library"; -import { DEFAULT_CHAIN } from "consts/chains"; -import { useSessionStorage } from "hooks/useSessionStorage"; -import { authoriseUser, getNonce } from "utils/authoriseUser"; - import styled from "styled-components"; +import { useAtlasProvider } from "@kleros/kleros-app"; +import { errorToast, infoToast, successToast } from "utils/wrapWithToast"; const Container = styled.div` display: flex; @@ -33,53 +29,19 @@ interface IEnsureAuth { } export const EnsureAuth: React.FC = ({ children, message, buttonText, className }) => { - const localToken = window.sessionStorage.getItem("auth-token"); - const [isLoading, setIsLoading] = useState(false); - - const [authToken, setAuthToken] = useSessionStorage("auth-token", localToken); - const { address, chain } = useAccount(); - - const { signMessageAsync } = useSignMessage(); - - const isVerified = useMemo(() => { - if (!authToken || !address) return false; - - const payload = jwt.decodeJwt(authToken); - - if ((payload?.id as string).toLowerCase() !== address.toLowerCase()) return false; - if (payload.exp && payload.exp < Date.now() / 1000) return false; - - return true; - }, [authToken, address]); - - const handleSignIn = async () => { - try { - setIsLoading(true); - if (!address) return; - - const message = await createSiweMessage(address, "Sign In to Kleros with Ethereum.", chain.id); - - const signature = await signMessageAsync({ message }); - - if (!signature) return; - - authoriseUser({ - address, - signature, - message, - }) - .then(async (res) => { - const response = await res.json(); - setAuthToken(response["token"]); - }) - .catch((err) => console.log({ err })) - .finally(() => setIsLoading(false)); - } catch (err) { - setIsLoading(false); - console.log({ err }); - } - }; - + const { address } = useAccount(); + const { isVerified, isSigningIn, authoriseUser } = useAtlasProvider(); + + const handleClick = useCallback(() => { + infoToast(`Signing in User...`); + + authoriseUser() + .then(() => successToast("Signed In successfully!")) + .catch((err) => { + console.log(err); + errorToast(`Sign-In failed: ${err?.message}`); + }); + }, [authoriseUser]); return isVerified ? ( children ) : ( @@ -87,33 +49,11 @@ export const EnsureAuth: React.FC = ({ children, message, buttonTex {message ? {message} : null}