diff --git a/.vscode/settings.json b/.vscode/settings.json index 0f801b6f5..c54c7f3cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,9 @@ "typescript.tsdk": "node_modules/typescript/lib", "eslint.packageManager": "yarn", "prettier.useEditorConfig": true, - "prettier.configPath": "prettier-config/.prettierrc.js" + "prettier.configPath": "prettier-config/.prettierrc.js", + "sonarlint.connectedMode.project": { + "connectionId": "kleros", + "projectKey": "kleros_kleros-v2" + } } diff --git a/web/netlify/functions/update-settings.ts b/web/netlify/functions/update-settings.ts new file mode 100644 index 000000000..5ae1ea5fb --- /dev/null +++ b/web/netlify/functions/update-settings.ts @@ -0,0 +1,112 @@ +import { Handler } from "@netlify/functions"; +import { verifyTypedData } from "viem"; +import { createClient } from "@supabase/supabase-js"; +import { Database } from "../../src/types/supabase-notification"; +import messages from "../../src/consts/eip712-messages"; +import { EMAIL_REGEX, TELEGRAM_REGEX, ETH_ADDRESS_REGEX, ETH_SIGNATURE_REGEX } from "../../src/consts/index"; + +type NotificationSettings = { + email?: string; + telegram?: string; + nonce: `${number}`; + address: `0x${string}`; + signature: string; +}; + +const parse = (inputString: string): NotificationSettings => { + let input; + try { + input = JSON.parse(inputString); + } catch (err) { + throw new Error("Invalid JSON format"); + } + + const requiredKeys: (keyof NotificationSettings)[] = ["nonce", "address", "signature"]; + 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 (!/^\d+$/.test(input.nonce)) { + throw new Error("Invalid nonce format. Expected an integer as a string."); + } + + if (!ETH_ADDRESS_REGEX.test(input.address)) { + throw new Error("Invalid Ethereum address format"); + } + + if (!ETH_SIGNATURE_REGEX.test(input.signature)) { + throw new Error("Invalid signature format"); + } + + return { + email: input.email.trim(), + telegram: input.telegram.trim(), + nonce: input.nonce, + address: input.address.trim().toLowerCase(), + signature: input.signature.trim(), + }; +}; + +export const handler: Handler = async (event) => { + try { + if (!event.body) { + throw new Error("No body provided"); + } + const { email, telegram, nonce, address, signature } = parse(event.body); + const lowerCaseAddress = address.toLowerCase() as `0x${string}`; + // Note: this does NOT work for smart contract wallets, but viem's publicClient.verifyMessage() fails to verify atm. + // https://viem.sh/docs/utilities/verifyTypedData.html + const data = messages.contactDetails(address, nonce, telegram, email); + const isValid = await verifyTypedData({ + ...data, + signature, + }); + if (!isValid) { + // If the recovered address does not match the provided address, return an error + throw new Error("Signature verification failed"); + } + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_CLIENT_API_KEY!); + + // If the message is empty, delete the user record + if (email === "" && telegram === "") { + const { error } = await supabase.from("users").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: `Error: ${err}` }) }; + } +}; diff --git a/web/package.json b/web/package.json index f1e51624c..a0e8e8a4a 100644 --- a/web/package.json +++ b/web/package.json @@ -34,7 +34,8 @@ "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:hooks": "NODE_NO_WARNINGS=1 wagmi generate", + "generate:supabase": "scripts/generateSupabaseTypes.sh" }, "prettier": "@kleros/kleros-v2-prettier-config", "devDependencies": { @@ -54,7 +55,7 @@ "@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/parser": "^5.61.0", "@typescript-eslint/utils": "^5.58.0", - "@wagmi/cli": "^1.3.0", + "@wagmi/cli": "^1.5.2", "eslint": "^8.38.0", "eslint-config-prettier": "^8.8.0", "eslint-import-resolver-parcel": "^1.10.6", @@ -62,14 +63,16 @@ "eslint-plugin-react-hooks": "^4.6.0", "lru-cache": "^7.18.3", "parcel": "2.8.3", + "supabase": "^1.102.2", "typescript": "^4.9.5" }, "dependencies": { "@filebase/client": "^0.0.5", "@kleros/kleros-v2-contracts": "workspace:^", - "@kleros/ui-components-library": "^2.6.1", + "@kleros/ui-components-library": "^2.6.2", "@sentry/react": "^7.55.2", "@sentry/tracing": "^7.55.2", + "@supabase/supabase-js": "^2.33.1", "@tanstack/react-query": "^4.28.0", "@types/react-modal": "^3.16.0", "@web3modal/ethereum": "^2.7.1", @@ -99,7 +102,7 @@ "react-use": "^17.4.0", "styled-components": "^5.3.9", "viem": "^1.0.0", - "wagmi": "^1.1.0" + "wagmi": "^1.4.3" }, "volta": { "node": "16.20.1", diff --git a/web/scripts/generateSupabaseTypes.sh b/web/scripts/generateSupabaseTypes.sh new file mode 100755 index 000000000..1b63c8dea --- /dev/null +++ b/web/scripts/generateSupabaseTypes.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +mkdir -p $SCRIPT_DIR/../src/types + +# Docs: https://supabase.com/docs/guides/api/rest/generating-types#generating-types-using-supabase-cli +supabase=$SCRIPT_DIR/../../node_modules/supabase/bin/supabase +$supabase gen types typescript --project-id elokscrfhzodpgvaixfd --schema public > $SCRIPT_DIR/../src/types/supabase-notification.ts +$supabase gen types typescript --project-id pyyfdntuqbsfquzzatkz --schema public > $SCRIPT_DIR/../src/types/supabase-datalake.ts diff --git a/web/src/consts/eip712-messages.ts b/web/src/consts/eip712-messages.ts new file mode 100644 index 000000000..01ba1494d --- /dev/null +++ b/web/src/consts/eip712-messages.ts @@ -0,0 +1,24 @@ +export default { + contactDetails: (address: `0x${string}`, nonce, telegram = "", email = "") => + ({ + address: address.toLowerCase() as `0x${string}`, + domain: { + name: "Kleros v2", + version: "1", + chainId: 421_613, + }, + types: { + ContactDetails: [ + { name: "email", type: "string" }, + { name: "telegram", type: "string" }, + { name: "nonce", type: "string" }, + ], + }, + primaryType: "ContactDetails", + message: { + email, + telegram, + nonce, + }, + } as const), +}; diff --git a/web/src/consts/index.ts b/web/src/consts/index.ts index a6e7f4a90..2f4f110fb 100644 --- a/web/src/consts/index.ts +++ b/web/src/consts/index.ts @@ -10,3 +10,10 @@ export const GIT_HASH = gitCommitShortHash; export const GIT_DIRTY = clean ? "" : "-dirty"; export const GIT_URL = `https://github.com/kleros/kleros-v2/tree/${gitCommitHash}/web`; export const RELEASE_VERSION = version; + +// https://www.w3.org/TR/2012/WD-html-markup-20120329/input.email.html#input.email.attrs.value.single +// eslint-disable-next-line security/detect-unsafe-regex +export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; +export const TELEGRAM_REGEX = /^@\w{5,32}$/; +export const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; +export const ETH_SIGNATURE_REGEX = /^0x[a-fA-F0-9]{130}$/; diff --git a/web/src/layout/Header/navbar/Menu/Help.tsx b/web/src/layout/Header/navbar/Menu/Help.tsx index a242ba067..c1bf4905d 100644 --- a/web/src/layout/Header/navbar/Menu/Help.tsx +++ b/web/src/layout/Header/navbar/Menu/Help.tsx @@ -8,6 +8,7 @@ import Bug from "svgs/icons/bug.svg"; import ETH from "svgs/icons/eth.svg"; import Faq from "svgs/menu-icons/help.svg"; import Telegram from "svgs/socialmedia/telegram.svg"; +import { IHelp } from ".."; const Container = styled.div` display: flex; @@ -96,10 +97,6 @@ const ITEMS = [ }, ]; -interface IHelp { - toggleIsHelpOpen: () => void; -} - const Help: React.FC = ({ toggleIsHelpOpen }) => { const containerRef = useRef(null); useFocusOutside(containerRef, () => { diff --git a/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/FormContact.tsx b/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/FormContact.tsx new file mode 100644 index 000000000..b0df0478c --- /dev/null +++ b/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/FormContact.tsx @@ -0,0 +1,67 @@ +import React, { Dispatch, SetStateAction, useMemo, useEffect } from "react"; +import styled from "styled-components"; + +import { Field } from "@kleros/ui-components-library"; + +const StyledLabel = styled.label` + display: flex; + justify-content: space-between; + margin-bottom: 10px; +`; + +const StyledField = styled(Field)` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +`; + +interface IForm { + contactLabel: string; + contactPlaceholder: string; + contactInput: string; + contactIsValid: boolean; + setContactInput: Dispatch>; + setContactIsValid: Dispatch>; + validator: RegExp; +} + +const FormContact: React.FC = ({ + contactLabel, + contactPlaceholder, + contactInput, + contactIsValid, + setContactInput, + setContactIsValid, + validator, +}) => { + useEffect(() => { + setContactIsValid(validator.test(contactInput)); + }, [contactInput, setContactIsValid, validator]); + + const handleInputChange = (event: React.ChangeEvent) => { + event.preventDefault(); + setContactInput(event.target.value); + }; + + const fieldVariant = useMemo(() => { + if (contactInput === "") { + return undefined; + } + return contactIsValid ? "success" : "error"; + }, [contactInput, contactIsValid]); + + return ( + <> + {contactLabel} + + + ); +}; + +export default FormContact; diff --git a/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/index.tsx b/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/index.tsx new file mode 100644 index 000000000..7bed3fa07 --- /dev/null +++ b/web/src/layout/Header/navbar/Menu/Settings/Notifications/FormContactDetails/index.tsx @@ -0,0 +1,96 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import { useWalletClient, useAccount } from "wagmi"; +import { Button } from "@kleros/ui-components-library"; +import { uploadSettingsToSupabase } from "utils/uploadSettingsToSupabase"; +import FormContact from "./FormContact"; +import messages from "../../../../../../../consts/eip712-messages"; +import { EMAIL_REGEX, TELEGRAM_REGEX } from "../../../../../../../consts/index"; +import { ISettings } from "../../../index"; + +const FormContainer = styled.form` + position: relative; + display: flex; + flex-direction: column; + padding: 0 calc(12px + (32 - 12) * ((100vw - 300px) / (1250 - 300))); + padding-bottom: 16px; +`; + +const ButtonContainer = styled.div` + display: flex; + justify-content: end; +`; + +const FormContactContainer = styled.div` + display: flex; + flex-direction: column; + margin-bottom: 24px; +`; + +const FormContactDetails: React.FC = ({ toggleIsSettingsOpen }) => { + const [telegramInput, setTelegramInput] = useState(""); + const [emailInput, setEmailInput] = useState(""); + const [telegramIsValid, setTelegramIsValid] = useState(false); + const [emailIsValid, setEmailIsValid] = useState(false); + const { data: walletClient } = useWalletClient(); + const { address } = useAccount(); + + // TODO: after the user is authenticated, retrieve the current email/telegram from the database and populate the form + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!address) { + throw new Error("Missing address"); + } + const nonce = new Date().getTime().toString(); + const signature = await walletClient?.signTypedData( + messages.contactDetails(address, nonce, telegramInput, emailInput) + ); + if (!signature) { + throw new Error("Missing signature"); + } + const data = { + email: emailInput, + telegram: telegramInput, + nonce, + address, + signature, + }; + const response = await uploadSettingsToSupabase(data); + if (response.ok) { + toggleIsSettingsOpen(); + } + }; + return ( + + + + + + + + + +