diff --git a/.gitignore b/.gitignore index 82eac680c..9d18cd92c 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,9 @@ web_modules/ # TypeScript cache *.tsbuildinfo +# parcel cache +.parcel-cache + # Optional npm cache directory .npm diff --git a/kleros-app/README.md b/kleros-app/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/kleros-app/eslint.config.mjs b/kleros-app/eslint.config.mjs new file mode 100644 index 000000000..d00b2c977 --- /dev/null +++ b/kleros-app/eslint.config.mjs @@ -0,0 +1,120 @@ +import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import react from "eslint-plugin-react"; +import reactHooks from "eslint-plugin-react-hooks"; +import security from "eslint-plugin-security"; +import _import from "eslint-plugin-import"; +import globals from "globals"; +import tsParser from "@typescript-eslint/parser"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: ["src/assets"], + }, + ...fixupConfigRules( + compat.extends( + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:import/recommended", + "plugin:import/react", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "prettier" + ) + ), + { + plugins: { + react: fixupPluginRules(react), + "react-hooks": fixupPluginRules(reactHooks), + security: fixupPluginRules(security), + import: fixupPluginRules(_import), + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + Atomics: "readonly", + SharedArrayBuffer: "readonly", + }, + + parser: tsParser, + ecmaVersion: 2020, + sourceType: "module", + + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, + + settings: { + react: { + version: "^18.3.1", + }, + + "import/resolver": { + typescript: { + project: "./tsconfig.json", + }, + }, + }, + + rules: { + "max-len": [ + "warn", + { + code: 120, + }, + ], + + "react/prop-types": 0, + "no-unused-vars": "off", + + "@typescript-eslint/no-unused-vars": [ + "error", + { + varsIgnorePattern: "(^_+[0-9]*$)|([iI]gnored$)|(^ignored)", + argsIgnorePattern: "(^_+[0-9]*$)|([iI]gnored$)|(^ignored)", + }, + ], + + "no-console": [ + "error", + { + allow: ["warn", "error", "info", "debug"], + }, + ], + + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-explicit-any": "off", + "security/detect-object-injection": "off", + "security/detect-non-literal-fs-filename": "off", + + "import/extensions": [ + "error", + "ignorePackages", + { + js: "never", + jsx: "never", + ts: "never", + tsx: "never", + }, + ], + + "import/no-unresolved": "off", + }, + }, +]; diff --git a/kleros-app/package.json b/kleros-app/package.json new file mode 100644 index 000000000..56f29fa37 --- /dev/null +++ b/kleros-app/package.json @@ -0,0 +1,63 @@ +{ + "name": "@kleros/kleros-app", + "version": "1.0.0", + "source": "src/lib/index.ts", + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/index.d.ts", + "scripts": { + "clear": "rm -r ../.parcel-cache", + "clean": "rm -rf dist", + "start": "parcel src/index.html", + "build": "yarn clear & yarn clean & yarn parcel build", + "check-style": "eslint 'src/**/*.{ts,tsx}' -fix", + "check-types": "tsc --noEmit" + }, + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/kleros/kleros-v2.git" + }, + "keywords": [ + "kleros", + "dapp", + "atlas" + ], + "author": "Kleros", + "license": "MIT", + "bugs": { + "url": "https://github.com/kleros/kleros-v2/issues" + }, + "homepage": "https://github.com/kleros/kleros-v2#readme", + "description": "", + "files": [ + "dist" + ], + "prettier": "@kleros/kleros-v2-prettier-config", + "devDependencies": { + "@eslint/compat": "^1.2.2", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.14.0", + "@kleros/kleros-v2-eslint-config": "workspace:^", + "@kleros/kleros-v2-prettier-config": "workspace:^", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "eslint": "^9.14.0", + "eslint-config-prettier": "^9.1.0", + "globals": "^15.12.0", + "parcel": "^2.12.0", + "typescript": "^5.6.3" + }, + "dependencies": { + "@kleros/ui-components-library": "^2.15.0", + "jose": "^5.9.6" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.59.20", + "graphql": "^16.9.0", + "graphql-request": "^7.1.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "viem": "^2.21.42", + "wagmi": "^2.12.27" + } +} diff --git a/kleros-app/src/App.tsx b/kleros-app/src/App.tsx new file mode 100644 index 000000000..51426bc3c --- /dev/null +++ b/kleros-app/src/App.tsx @@ -0,0 +1,18 @@ +import { createRoot } from "react-dom/client"; +import React from "react"; + +const App = () => { + return ( + +
+

Kleros

+
+
+ ); +}; + +const app = document.getElementById("app"); +if (app) { + const root = createRoot(app); + root.render(); +} diff --git a/kleros-app/src/index.html b/kleros-app/src/index.html new file mode 100644 index 000000000..b3ab534ee --- /dev/null +++ b/kleros-app/src/index.html @@ -0,0 +1,12 @@ + + + + + + Kleros App + + +
+ + + diff --git a/kleros-app/src/lib/atlas/hooks/useSessionStorage.ts b/kleros-app/src/lib/atlas/hooks/useSessionStorage.ts new file mode 100644 index 000000000..5cac8fb6e --- /dev/null +++ b/kleros-app/src/lib/atlas/hooks/useSessionStorage.ts @@ -0,0 +1,23 @@ +import { useState } from "react"; + +export function useSessionStorage(keyName: string, defaultValue: T) { + const [storedValue, setStoredValue] = useState(() => { + try { + const value = window.sessionStorage.getItem(keyName); + + return value ? JSON.parse(value) : defaultValue; + } catch (err) { + return defaultValue; + } + }); + + const setValue = (newValue: T) => { + try { + window.sessionStorage.setItem(keyName, JSON.stringify(newValue)); + } finally { + setStoredValue(newValue); + } + }; + + return [storedValue, setValue] as [T, (newValue: T) => void]; +} diff --git a/kleros-app/src/lib/atlas/index.ts b/kleros-app/src/lib/atlas/index.ts new file mode 100644 index 000000000..21968d708 --- /dev/null +++ b/kleros-app/src/lib/atlas/index.ts @@ -0,0 +1,2 @@ +export * from "./providers"; +export * from "./utils"; diff --git a/kleros-app/src/lib/atlas/providers/AtlasProvider.tsx b/kleros-app/src/lib/atlas/providers/AtlasProvider.tsx new file mode 100644 index 000000000..826407d0f --- /dev/null +++ b/kleros-app/src/lib/atlas/providers/AtlasProvider.tsx @@ -0,0 +1,343 @@ +import React, { useMemo, createContext, useContext, useState, useCallback, useEffect } from "react"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { GraphQLClient } from "graphql-request"; +import { decodeJwt } from "jose"; +import { useAccount, useChainId, useSignMessage } from "wagmi"; +import { + createMessage, + getNonce, + loginUser, + addUser as addUserToAtlas, + fetchUser, + updateEmail as updateEmailInAtlas, + confirmEmail as confirmEmailInAtlas, + uploadToIpfs, + type User, + type AddUserData, + type UpdateEmailData, + type ConfirmEmailData, + type ConfirmEmailResponse, + Roles, + Products, + AuthorizationError, +} from "../utils"; + +import { GraphQLError } from "graphql"; +import { useSessionStorage } from "../hooks/useSessionStorage"; +import { isUndefined } from "../../../utils"; + +interface IAtlasProvider { + isVerified: boolean; + isSigningIn: boolean; + isAddingUser: boolean; + isFetchingUser: boolean; + isUpdatingUser: boolean; + isUploadingFile: boolean; + user: User | undefined; + userExists: boolean; + authoriseUser: () => Promise; + addUser: (userSettings: AddUserData) => Promise; + updateEmail: (userSettings: UpdateEmailData) => Promise; + uploadFile: (file: File, role: Roles) => Promise; + confirmEmail: (userSettings: ConfirmEmailData) => Promise< + ConfirmEmailResponse & { + isError: boolean; + } + >; +} + +const Context = createContext(undefined); + +interface AtlasConfig { + uri: string; + product: Products; + queryClient: QueryClient; +} + +export const AtlasProvider: React.FC<{ config: AtlasConfig; children?: React.ReactNode }> = ({ children, config }) => { + const { address } = useAccount(); + const chainId = useChainId(); + const queryClient = useQueryClient(config.queryClient); + + const [authToken, setAuthToken] = useSessionStorage("authToken", undefined); + const [isSigningIn, setIsSigningIn] = useState(false); + const [isAddingUser, setIsAddingUser] = useState(false); + const [isUpdatingUser, setIsUpdatingUser] = useState(false); + const [isVerified, setIsVerified] = useState(false); + const [isUploadingFile, setIsUploadingFile] = useState(false); + const { signMessageAsync } = useSignMessage(); + + const atlasGqlClient = useMemo(() => { + const headers = authToken + ? { + authorization: `Bearer ${authToken}`, + } + : undefined; + return new GraphQLClient(`${config.uri}/graphql`, { headers }); + }, [authToken]); + + /** + * @description verifies user authorisation + * @returns boolean - true if user is authorized + */ + const verifySession = useCallback(() => { + try { + if (!authToken || !address) return false; + + const payload = decodeJwt(authToken); + + if ((payload?.sub as string)?.toLowerCase() !== address.toLowerCase()) return false; + if (payload.exp && payload.exp < Date.now() / 1000) return false; + + return true; + } catch { + return false; + } + }, [authToken, address]); + + useEffect(() => { + let timeoutId: ReturnType; + + const verifyAndSchedule = () => { + // initial verify check + const isValid = verifySession(); + setIsVerified(isValid); + + if (isValid && authToken) { + try { + const payload = decodeJwt(authToken); + const expiresIn = (payload.exp as number) * 1000 - Date.now(); + + timeoutId = setTimeout(verifyAndSchedule, Math.max(0, expiresIn)); + } catch (err) { + console.error("Error decoding JWT:", err); + setIsVerified(false); + } + } + }; + + verifyAndSchedule(); + + return () => { + clearTimeout(timeoutId); + }; + }, [authToken, verifySession, address]); + + const { + data: user, + isLoading: isFetchingUser, + refetch: refetchUser, + } = useQuery( + { + queryKey: [`UserSettings`], + enabled: isVerified && !isUndefined(address), + queryFn: async () => { + try { + if (!isVerified || isUndefined(address)) return undefined; + return await fetchUser(atlasGqlClient); + } catch { + return undefined; + } + }, + }, + queryClient + ); + + useEffect(() => { + if (!isVerified) return; + refetchUser(); + }, [isVerified, refetchUser]); + + // remove old user's data on address change + useEffect(() => { + queryClient.removeQueries({ queryKey: ["UserSettings"] }); + }, [address, queryClient]); + + // this would change based on the fields we have and what defines a user to be existing + const userExists = useMemo(() => { + if (!user) return false; + return !isUndefined(user.email); + }, [user]); + + async function fetchWithAuthErrorHandling(request: () => Promise): Promise { + try { + return await request(); + } catch (error) { + if ( + error instanceof AuthorizationError || + (error instanceof GraphQLError && error?.extensions?.["code"] === "UNAUTHENTICATED") + ) { + setIsVerified(false); + } + throw error; + } + } + + /** + * @description authorise user and enable authorised calls + */ + const authoriseUser = useCallback( + async (statement?: string) => { + try { + if (!address || !chainId) return; + setIsSigningIn(true); + const nonce = await getNonce(atlasGqlClient, address); + + const message = createMessage(address, nonce, chainId, statement); + const signature = await signMessageAsync({ message }); + + const token = await loginUser(atlasGqlClient, { message, signature }); + setAuthToken(token); + } catch (err: any) { + throw new Error(err); + } finally { + setIsSigningIn(false); + } + }, + [address, chainId, setAuthToken, signMessageAsync, atlasGqlClient] + ); + + /** + * @description adds a new user to atlas + * @param {AddUserData} userSettings - object containing data to be added + * @returns {Promise} A promise that resolves to true if the user was added successfully + */ + const addUser = useCallback( + async (userSettings: AddUserData) => { + try { + if (!address || !isVerified) return false; + setIsAddingUser(true); + + const userAdded = await fetchWithAuthErrorHandling(() => addUserToAtlas(atlasGqlClient, userSettings)); + refetchUser(); + + return userAdded; + } catch (err: any) { + throw new Error(err); + } finally { + setIsAddingUser(false); + } + }, + [address, isVerified, setIsAddingUser, atlasGqlClient, refetchUser] + ); + + /** + * @description updates user email in atlas + * @param {UpdateEmailData} userSettings - object containing data to be updated + * @returns {Promise} A promise that resolves to true if email was updated successfully + */ + const updateEmail = useCallback( + async (userSettings: UpdateEmailData) => { + try { + if (!address || !isVerified) return false; + setIsUpdatingUser(true); + + const emailUpdated = await fetchWithAuthErrorHandling(() => updateEmailInAtlas(atlasGqlClient, userSettings)); + refetchUser(); + + return emailUpdated; + } catch (err: any) { + throw new Error(err); + } finally { + setIsUpdatingUser(false); + } + }, + [address, isVerified, setIsUpdatingUser, atlasGqlClient, refetchUser] + ); + + /** + * @description upload file to ipfs + * @param {File} file - file to be uploaded + * @param {Roles} role - role for which file is being uploaded + * @returns {Promise} A promise that resolves to the ipfs cid if file was uploaded successfully else + * null + */ + const uploadFile = useCallback( + async (file: File, role: Roles) => { + try { + if (!address || !isVerified || !config.uri || !authToken) return null; + setIsUploadingFile(true); + + const hash = await fetchWithAuthErrorHandling(() => + uploadToIpfs({ baseUrl: config.uri, authToken }, { file, name: file.name, role, product: config.product }) + ); + return hash ? `/ipfs/${hash}` : null; + } catch (err: any) { + throw new Error(err); + } finally { + setIsUploadingFile(false); + } + }, + [address, isVerified, setIsUploadingFile, authToken] + ); + + /** + * @description confirms user email in atlas + * @param {ConfirmEmailData} userSettings - object containing data to be sent + * @returns {Promise} A promise that resolves to true if email was confirmed successfully + */ + const confirmEmail = useCallback( + async (userSettings: ConfirmEmailData): Promise => { + try { + setIsUpdatingUser(true); + + const emailConfirmed = await confirmEmailInAtlas(atlasGqlClient, userSettings); + + return { ...emailConfirmed, isError: false }; + } catch (err: any) { + // eslint-disable-next-line + console.log("Confirm Email Error : ", err?.message); + return { isConfirmed: false, isTokenExpired: false, isTokenInvalid: false, isError: true }; + } + }, + [atlasGqlClient] + ); + + return ( + ({ + isVerified, + isSigningIn, + isAddingUser, + authoriseUser, + addUser, + user, + isFetchingUser, + updateEmail, + isUpdatingUser, + userExists, + isUploadingFile, + uploadFile, + confirmEmail, + }), + [ + isVerified, + isSigningIn, + isAddingUser, + authoriseUser, + addUser, + user, + isFetchingUser, + updateEmail, + isUpdatingUser, + userExists, + isUploadingFile, + uploadFile, + ] + )} + > + {children} + + ); +}; + +export const useAtlasProvider = () => { + const context = useContext(Context); + if (!context) { + throw new Error("Context Provider not found."); + } + return context; +}; + +export default AtlasProvider; diff --git a/kleros-app/src/lib/atlas/providers/index.ts b/kleros-app/src/lib/atlas/providers/index.ts new file mode 100644 index 000000000..fbe2073a3 --- /dev/null +++ b/kleros-app/src/lib/atlas/providers/index.ts @@ -0,0 +1 @@ +export * from "./AtlasProvider"; diff --git a/kleros-app/src/lib/atlas/utils/addUser.ts b/kleros-app/src/lib/atlas/utils/addUser.ts new file mode 100644 index 000000000..0c4da84a8 --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/addUser.ts @@ -0,0 +1,37 @@ +import { GraphQLError } from "graphql"; +import { gql, type GraphQLClient } from "graphql-request"; + +const query = gql` + mutation AddUser($settings: AddUserSettingsDto!) { + addUser(addUserSettings: $settings) + } +`; + +export type AddUserData = { + email: string; +}; + +type AddUserResponse = { + addUser: boolean; +}; + +export function addUser(client: GraphQLClient, userData: AddUserData): Promise { + const variables = { + settings: userData, + }; + + return client + .request(query, variables) + .then(async (response) => response.addUser) + .catch((errors) => { + // eslint-disable-next-line no-console + console.log("Add User error:", { errors }); + + const error = errors?.response?.errors?.[0]; + + if (error) { + throw new GraphQLError(error?.message, { ...error }); + } + throw new Error("Unknown Error"); + }); +} diff --git a/web/src/utils/atlas/confirmEmail.ts b/kleros-app/src/lib/atlas/utils/confirmEmail.ts similarity index 100% rename from web/src/utils/atlas/confirmEmail.ts rename to kleros-app/src/lib/atlas/utils/confirmEmail.ts diff --git a/web/src/utils/atlas/createMessage.ts b/kleros-app/src/lib/atlas/utils/createMessage.ts similarity index 76% rename from web/src/utils/atlas/createMessage.ts rename to kleros-app/src/lib/atlas/utils/createMessage.ts index 9eb24ade9..ebb5531bb 100644 --- a/web/src/utils/atlas/createMessage.ts +++ b/kleros-app/src/lib/atlas/utils/createMessage.ts @@ -1,8 +1,6 @@ import { createSiweMessage } from "viem/siwe"; -import { DEFAULT_CHAIN } from "consts/chains"; - -export const createMessage = (address: `0x${string}`, nonce: string, chainId: number = DEFAULT_CHAIN) => { +export const createMessage = (address: `0x${string}`, nonce: string, chainId: number, statement?: string) => { const domain = window.location.host; const origin = window.location.origin; @@ -12,7 +10,7 @@ export const createMessage = (address: `0x${string}`, nonce: string, chainId: nu const message = createSiweMessage({ domain, address, - statement: "Sign In to Kleros with Ethereum.", + statement: statement ?? "Sign In to Kleros with Ethereum.", uri: origin, version: "1", chainId, diff --git a/kleros-app/src/lib/atlas/utils/fetchUser.ts b/kleros-app/src/lib/atlas/utils/fetchUser.ts new file mode 100644 index 000000000..5fe38d294 --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/fetchUser.ts @@ -0,0 +1,34 @@ +import { gql, type GraphQLClient } from "graphql-request"; + +export type User = { + email: string; + isEmailVerified: string; + emailUpdateableAt: string | null; +}; + +type GetUserResponse = { + user: User; +}; +const query = gql` + query GetUser { + user { + email + isEmailVerified + emailUpdateableAt + } + } +`; + +export async function fetchUser(client: GraphQLClient): Promise { + return client + .request(query) + .then((response) => response.user) + .catch((errors) => { + // eslint-disable-next-line no-console + console.log("Error fetching user :", { errors }); + const errorMessage = Array.isArray(errors?.response?.errors) + ? errors.response.errors[0]?.message + : "Error fetching user"; + throw Error(errorMessage); + }); +} diff --git a/kleros-app/src/lib/atlas/utils/getNonce.ts b/kleros-app/src/lib/atlas/utils/getNonce.ts new file mode 100644 index 000000000..951503a48 --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/getNonce.ts @@ -0,0 +1,29 @@ +import { gql, type GraphQLClient } from "graphql-request"; + +type GetNonce = { + nonce: string; +}; + +const query = gql` + mutation GetNonce($address: Address!) { + nonce(address: $address) + } +`; + +export async function getNonce(client: GraphQLClient, address: string): Promise { + const variables = { + address, + }; + + return client + .request(query, variables) + .then((response) => response.nonce) + .catch((errors) => { + // eslint-disable-next-line no-console + console.log("Error fetching nonce for address:", address, { errors }); + const errorMessage = Array.isArray(errors?.response?.errors) + ? errors.response.errors[0]?.message + : "Error fetching nonce"; + throw Error(errorMessage); + }); +} diff --git a/kleros-app/src/lib/atlas/utils/index.ts b/kleros-app/src/lib/atlas/utils/index.ts new file mode 100644 index 000000000..46e105e64 --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/index.ts @@ -0,0 +1,32 @@ +export enum Products { + CourtV1 = "CourtV1", + CourtV2 = "CourtV2", + Curate = "Curate", + Escrow = "Escrow", + Governor = "Governor", + ProofOfHumanity = "ProofOfHumanity", + Reality = "Reality", + Test = "Test", +} + +export enum Roles { + Evidence = "evidence", + Generic = "generic", + IdentificationVideo = "identification-video", + CurateItemImage = "curate-item-image", + CurateItemFile = "curate-item-file", + Logo = "logo", + MetaEvidence = "meta-evidence", + Photo = "photo", + Policy = "policy", + Test = "test", +} + +export * from "./loginUser"; +export * from "./getNonce"; +export * from "./createMessage"; +export * from "./addUser"; +export * from "./fetchUser"; +export * from "./updateEmail"; +export * from "./confirmEmail"; +export * from "./uploadToIpfs"; diff --git a/kleros-app/src/lib/atlas/utils/loginUser.ts b/kleros-app/src/lib/atlas/utils/loginUser.ts new file mode 100644 index 000000000..968d6652f --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/loginUser.ts @@ -0,0 +1,38 @@ +import { gql, type GraphQLClient } from "graphql-request"; + +const query = gql` + mutation Login($message: String!, $signature: String!) { + login(message: $message, signature: $signature) + } +`; + +type AuthoriseUserData = { + signature: `0x${string}`; + message: string; +}; + +type Login = { + login: { + accessToken: string; + }; +}; + +export async function loginUser(client: GraphQLClient, authData: AuthoriseUserData): Promise { + const variables = { + message: authData.message, + signature: authData.signature, + }; + + return client + .request(query, variables) + .then(async (response) => response.login.accessToken) + .catch((errors) => { + // eslint-disable-next-line no-console + console.log("Authorization error:", { errors }); + + const errorMessage = Array.isArray(errors?.response?.errors) + ? errors.response.errors[0]?.message + : "Unknown error"; + throw new Error(errorMessage); + }); +} diff --git a/kleros-app/src/lib/atlas/utils/updateEmail.ts b/kleros-app/src/lib/atlas/utils/updateEmail.ts new file mode 100644 index 000000000..c217f5c5a --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/updateEmail.ts @@ -0,0 +1,35 @@ +import { GraphQLError } from "graphql"; +import { gql, type GraphQLClient } from "graphql-request"; + +const query = gql` + mutation UpdateEmail($newEmail: String!) { + updateEmail(newEmail: $newEmail) + } +`; + +export type UpdateEmailData = { + newEmail: string; +}; + +type UpdateEmailResponse = { + updateEmail: boolean; +}; + +export async function updateEmail(client: GraphQLClient, userData: UpdateEmailData): Promise { + const variables = userData; + + return client + .request(query, variables) + .then(async (response) => response.updateEmail) + .catch((errors) => { + // eslint-disable-next-line no-console + console.log("Update Email error:", { errors }); + + const error = errors?.response?.errors?.[0]; + + if (error) { + throw new GraphQLError(error?.message, { ...error }); + } + throw new Error("Unknown Error"); + }); +} diff --git a/kleros-app/src/lib/atlas/utils/uploadToIpfs.ts b/kleros-app/src/lib/atlas/utils/uploadToIpfs.ts new file mode 100644 index 000000000..b3283890a --- /dev/null +++ b/kleros-app/src/lib/atlas/utils/uploadToIpfs.ts @@ -0,0 +1,49 @@ +import { Products, Roles } from "."; + +export type IpfsUploadPayload = { + file: File; + name: string; + product: Products; + role: Roles; +}; + +type Config = { + baseUrl: string; + authToken: string; +}; + +export async function uploadToIpfs(config: Config, payload: IpfsUploadPayload): Promise { + const formData = new FormData(); + formData.append("file", payload.file, payload.name); + formData.append("name", payload.name); + formData.append("product", payload.product); + formData.append("role", payload.role); + + return fetch(`${config.baseUrl}/ipfs/file`, { + method: "POST", + headers: { + authorization: `Bearer ${config.authToken}`, + }, + body: formData, + }).then(async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: "Error uploading to IPFS" })); + + if (response.status === 401) throw new AuthorizationError(error.message); + throw new Error(error.message); + } + + return await response.text(); + }); +} + +export class AuthorizationError extends Error { + readonly name = "AuthorizationError" as const; + constructor(message: string) { + super(message); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} diff --git a/kleros-app/src/lib/index.ts b/kleros-app/src/lib/index.ts new file mode 100644 index 000000000..d057e151a --- /dev/null +++ b/kleros-app/src/lib/index.ts @@ -0,0 +1 @@ +export * from "./atlas"; diff --git a/kleros-app/src/utils/index.ts b/kleros-app/src/utils/index.ts new file mode 100644 index 000000000..b0d6c5b33 --- /dev/null +++ b/kleros-app/src/utils/index.ts @@ -0,0 +1,2 @@ +export const isUndefined = (maybeObject: any): maybeObject is undefined | null => + typeof maybeObject === "undefined" || maybeObject === null; diff --git a/kleros-app/tsconfig.json b/kleros-app/tsconfig.json new file mode 100644 index 000000000..a657db74d --- /dev/null +++ b/kleros-app/tsconfig.json @@ -0,0 +1,35 @@ +{ + "extends": "@kleros/kleros-v2-tsconfig/react-library.json", + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "rootDir": "src", + "outDir": "dist", + "allowJs": true, + "forceConsistentCasingInFileNames": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "removeComments": true, + "isolatedModules": true, + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": false, + "resolveJsonModule": true + }, + "include": [ + "src/lib/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/package.json b/package.json index dadf545c3..b4c84a67b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "web-devtools", "eslint-config", "prettier-config", - "tsconfig" + "tsconfig", + "kleros-app" ], "packageManager": "yarn@4.5.1", "volta": { @@ -35,6 +36,8 @@ "devDependencies": { "@commitlint/cli": "^17.8.1", "@commitlint/config-conventional": "^17.8.1", + "@parcel/packager-ts": "2.12.0", + "@parcel/transformer-typescript-types": "2.12.0", "assert": "^2.0.0", "buffer": "^5.7.1", "conventional-changelog-cli": "^2.2.2", @@ -44,7 +47,8 @@ "os-browserify": "^0.3.0", "path-browserify": "^1.0.0", "process": "^0.11.10", - "string_decoder": "^1.3.0" + "string_decoder": "^1.3.0", + "typescript": ">=3.0.0" }, "resolutions": { "async@npm^2.4.0": "^2.6.4", diff --git a/prettier-config/package.json b/prettier-config/package.json index b237e8474..0f76e95a1 100644 --- a/prettier-config/package.json +++ b/prettier-config/package.json @@ -5,7 +5,7 @@ "license": "MIT", "dependencies": { "eslint": "^8.57.1", - "prettier": "^2.8.8", + "prettier": "^3.3.3", "prettier-plugin-solidity": "^1.3.1" }, "scripts": { diff --git a/web/package.json b/web/package.json index cd93ab570..837a544d6 100644 --- a/web/package.json +++ b/web/package.json @@ -74,6 +74,7 @@ }, "dependencies": { "@cyntler/react-doc-viewer": "^1.16.3", + "@kleros/kleros-app": "workspace:^", "@kleros/kleros-sdk": "workspace:^", "@kleros/kleros-v2-contracts": "workspace:^", "@kleros/ui-components-library": "^2.15.0", diff --git a/web/src/components/EnsureAuth.tsx b/web/src/components/EnsureAuth.tsx index 3414bdf72..7852aade5 100644 --- a/web/src/components/EnsureAuth.tsx +++ b/web/src/components/EnsureAuth.tsx @@ -1,10 +1,11 @@ -import React from "react"; +import React, { useCallback } from "react"; import { useAccount } from "wagmi"; import { Button } from "@kleros/ui-components-library"; - -import { useAtlasProvider } from "context/AtlasProvider"; +import { useAtlasProvider } from "@kleros/kleros-app"; +import { toast } from "react-toastify"; +import { OPTIONS as toastOptions } from "utils/wrapWithToast"; interface IEnsureAuth { children: React.ReactElement; @@ -14,12 +15,23 @@ interface IEnsureAuth { const EnsureAuth: React.FC = ({ children, className }) => { const { address } = useAccount(); const { isVerified, isSigningIn, authoriseUser } = useAtlasProvider(); + + const handleClick = useCallback(() => { + toast.info(`Signing in User...`, toastOptions); + + authoriseUser() + .then(() => toast.success("Signed In successfully!", toastOptions)) + .catch((err) => { + console.log(err); + toast.error(`Sign-In failed: ${err?.message}`, toastOptions); + }); + }, [authoriseUser]); return isVerified ? ( children ) : (