diff --git a/web/app/components/AccountMenu.tsx b/web/app/components/AccountMenu.tsx new file mode 100644 index 0000000..bfe50e9 --- /dev/null +++ b/web/app/components/AccountMenu.tsx @@ -0,0 +1,100 @@ +import { LinkOff } from "@mui/icons-material"; +import { + Box, + Button, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Stack, + Typography, +} from "@mui/material"; +import React, { FC, useState } from "react"; +import { useEthereum } from "~/context/EthereumContext"; +import { useVoter } from "~/context/VoterContext"; +import { formatAccountString } from "~/lib/utils"; + +export const AccountMenu: FC = () => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const { account, logoutEthereumAccount, loading } = useEthereum(); + const { voter } = useVoter(); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + return ( + <> + + + { + logoutEthereumAccount(); + handleClose(); + }} + > + + + + Disconnect + + + + ); +}; diff --git a/web/app/components/Layout.tsx b/web/app/components/Layout.tsx index 87a6fa6..ca56ebb 100644 --- a/web/app/components/Layout.tsx +++ b/web/app/components/Layout.tsx @@ -1,7 +1,7 @@ import { Box, Container } from "@mui/material"; import { FC } from "react"; import { useLocation } from "remix"; -import { Navbar } from "../../public/Navbar"; +import { Navbar } from "./Navbar"; export const Layout: FC = ({ children }) => { const location = useLocation(); diff --git a/web/public/Navbar.tsx b/web/app/components/Navbar.tsx similarity index 69% rename from web/public/Navbar.tsx rename to web/app/components/Navbar.tsx index 9f307da..051f40e 100644 --- a/web/public/Navbar.tsx +++ b/web/app/components/Navbar.tsx @@ -1,8 +1,10 @@ +import { Language } from "@mui/icons-material"; import { Box, Container, Stack, Typography } from "@mui/material"; import { FC } from "react"; import { Link } from "remix"; import { useEthereum } from "~/context/EthereumContext"; -import { useVoter } from "../app/context/VoterContext"; +import { useVoter } from "../context/VoterContext"; +import { AccountMenu } from "./AccountMenu"; export const Navbar: FC = () => { const { account, network } = useEthereum(); @@ -54,31 +56,25 @@ export const Navbar: FC = () => { - - Account: {account || "(not connected)"} - - {network && ( - + - Chain: {network.name} {network.chainId}{" "} - {network.connected ? "🟢" : "🔴"} - - )} - - Voter:{" "} - {voter ? `${voter.firstName} ${voter.lastName}` : "unverified"} - + + {network && {network.name}} + + diff --git a/web/app/context/BallotContext.tsx b/web/app/context/BallotContext.tsx index 7093acc..272df76 100644 --- a/web/app/context/BallotContext.tsx +++ b/web/app/context/BallotContext.tsx @@ -7,7 +7,7 @@ import { useRef, useState, } from "react"; -import { useNavigate } from "remix"; +import { useLocation, useNavigate } from "remix"; import invariant from "tiny-invariant"; import { VoteEvent, VoterAllowedEvent } from "types/ethers-contracts/Ballot"; import { TypedListener } from "types/ethers-contracts/common"; @@ -43,7 +43,8 @@ const UNPROTECTED_ROUTES = [ "/errors/no-ethereum-provider", "/errors/nonce-too-high", ]; -const isProtected = (route: string) => !UNPROTECTED_ROUTES.includes(route); +const routeRequiresBallot = (route: string) => + !UNPROTECTED_ROUTES.includes(route); const BallotContext = createContext({ loading: true, @@ -56,6 +57,7 @@ const BallotContext = createContext({ }); export const BallotProvider: FC = ({ children }) => { + const [ballotExists, setBallotExists] = useState(false); const [loading, setLoading] = useState(true); const [proposals, setProposals] = useState([]); const [voteRightReceived, setVoteRightReceived] = useState(false); @@ -63,8 +65,14 @@ export const BallotProvider: FC = ({ children }) => { useState("unknown"); const ballotRef = useRef(null); const providerRef = useRef(null); - const { ethereumExists, loading: ethereumLoading, account } = useEthereum(); + const { + ethereumExists, + loading: ethereumLoading, + account, + network, + } = useEthereum(); const navigate = useNavigate(); + const location = useLocation(); const voteEventsListener: TypedListener = (voterAccount) => { fetchProposals(); @@ -103,12 +111,11 @@ export const BallotProvider: FC = ({ children }) => { try { await ballot.chairperson(); console.log("[BallotContext] Contract is accessible 👍"); + setBallotExists(true); fetchProposals(); } catch (error) { - console.error(error); - if (isProtected(window.location.pathname)) { - navigate("/errors/ballot-not-found"); - } + console.log("[BallotContext] Contract is NOT accessible 👎"); + setBallotExists(false); } finally { setLoading(false); } @@ -121,7 +128,24 @@ export const BallotProvider: FC = ({ children }) => { ballot.off("Vote", voteEventsListener); ballot.off("VoterAllowed", voterAllowedEventsListener); }; - }, [ethereumLoading, ethereumExists]); + }, [ethereumLoading, ethereumExists, network]); + + useEffect(() => { + if (!loading) { + return; + } + + if (ballotExists) { + if (location.pathname === "/errors/ballot-not-found") { + navigate("/getstarted"); + } + } else { + // alert(`ballotExists: ${ballotExists}, loading: ${loading}`); + if (routeRequiresBallot(location.pathname)) { + navigate("/errors/ballot-not-found"); + } + } + }, [loading, location.pathname, ballotExists]); function fetchProposals() { const ballot = ballotRef.current; @@ -175,9 +199,9 @@ export const BallotProvider: FC = ({ children }) => { }; } - const value = { + const value: BallotContextInterface = { loading, - ballotExists: ballotRef.current !== null, + ballotExists, proposals, voteRightReceived, currentVoterVoteStatus, diff --git a/web/app/context/EthereumContext.tsx b/web/app/context/EthereumContext.tsx index 623a8fd..b114699 100644 --- a/web/app/context/EthereumContext.tsx +++ b/web/app/context/EthereumContext.tsx @@ -22,6 +22,7 @@ interface EthereumContextInterface { account: string | null; signer?: JsonRpcSigner; connectWithMetamask: () => void; + logoutEthereumAccount: () => void; } export const HARDHAT_CHAIN_ID = 31337; @@ -40,6 +41,7 @@ const EthereumContext = createContext({ network: null, account: null, connectWithMetamask: () => {}, + logoutEthereumAccount: () => {}, }); export const EthereumProvider: FC = ({ children }) => { @@ -68,11 +70,11 @@ export const EthereumProvider: FC = ({ children }) => { }); provider.on("network", handleNetworkChange); - - // TODO: Detect les account changes + ethereum.on("accountsChanged", handleAccountsChanged); return () => { provider.off("network", handleNetworkChange); + ethereum.removeListener("accountsChanged", handleAccountsChanged); }; } else { setLoading(false); @@ -109,6 +111,20 @@ export const EthereumProvider: FC = ({ children }) => { }); } + function handleAccountsChanged(accounts: string[]) { + if (!accounts.length) { + logoutEthereumAccount(); + } else { + setAccount(accounts[0]); + // TODO: Destroy session / login the other account and setup new session? + } + } + + function logoutEthereumAccount() { + setAccount(null); + // TODO: Destroy session + } + async function connectWithMetamask() { const provider = providerRef.current; invariant(provider, "Provider should be defined"); @@ -130,13 +146,14 @@ export const EthereumProvider: FC = ({ children }) => { } } - const value = { + const value: EthereumContextInterface = { loading, ethereumExists, network, account, signer, connectWithMetamask, + logoutEthereumAccount, }; return ( diff --git a/web/app/context/VoterContext.tsx b/web/app/context/VoterContext.tsx index 58b4800..38a139b 100644 --- a/web/app/context/VoterContext.tsx +++ b/web/app/context/VoterContext.tsx @@ -21,6 +21,7 @@ const VoterContext = createContext({ const UNPROTECTED_ROUTES = [ "/", + "/results", "/getstarted/register", "/getstarted/verify", "/errors/ballot-not-found", diff --git a/web/app/lib/utils.ts b/web/app/lib/utils.ts new file mode 100644 index 0000000..a91b5fd --- /dev/null +++ b/web/app/lib/utils.ts @@ -0,0 +1,3 @@ +export const formatAccountString = (address: string) => { + return address.slice(0, 6) + "..." + address.slice(-4); +}; diff --git a/web/app/mui/theme.ts b/web/app/mui/theme.ts index 85b71e4..ba43946 100644 --- a/web/app/mui/theme.ts +++ b/web/app/mui/theme.ts @@ -10,6 +10,22 @@ declare module "@mui/material/Typography" { } } +declare module "@mui/material/styles" { + interface Palette { + neutral: Palette["primary"]; + } + interface PaletteOptions { + neutral: PaletteOptions["primary"]; + } +} + +// Update the Button's color prop options +declare module "@mui/material/Button" { + interface ButtonPropsColorOverrides { + neutral: true; + } +} + // Create a theme instance. const theme = createTheme({ palette: { @@ -17,6 +33,9 @@ const theme = createTheme({ primary: { main: blue[800], }, + neutral: { + main: grey[800], + }, secondary: { main: grey[800], }, diff --git a/web/app/routes/errors/ballot-not-found.tsx b/web/app/routes/errors/ballot-not-found.tsx index c9f442a..3b1906e 100644 --- a/web/app/routes/errors/ballot-not-found.tsx +++ b/web/app/routes/errors/ballot-not-found.tsx @@ -1,7 +1,7 @@ import { Box, Typography } from "@mui/material"; import { GenericPageLayoutWithBackButton } from "~/components/GenericPageLayout"; -function NoEthereumProvider() { +function BallotNotFound() { return ( @@ -20,4 +20,4 @@ function NoEthereumProvider() { ); } -export default NoEthereumProvider; +export default BallotNotFound; diff --git a/web/app/routes/errors/nonce-too-high.tsx b/web/app/routes/errors/nonce-too-high.tsx index 5e7a3bf..d79e8e3 100644 --- a/web/app/routes/errors/nonce-too-high.tsx +++ b/web/app/routes/errors/nonce-too-high.tsx @@ -2,7 +2,7 @@ import { Box, Typography } from "@mui/material"; import { YoutubeEmbed } from "~/components/YoutubeEmbed"; import { GenericPageLayoutWithBackButton } from "~/components/GenericPageLayout"; -function NoEthereumProvider() { +function NonceTooHigh() { return ( @@ -29,4 +29,4 @@ function NoEthereumProvider() { ); } -export default NoEthereumProvider; +export default NonceTooHigh; diff --git a/web/app/routes/getstarted/register.tsx b/web/app/routes/getstarted/register.tsx index 81f02b3..4da5fbd 100644 --- a/web/app/routes/getstarted/register.tsx +++ b/web/app/routes/getstarted/register.tsx @@ -29,11 +29,7 @@ import { giveRightToVote } from "~/lib/ballot"; import { CustomError } from "~/lib/error"; import { registerUser } from "~/lib/users.server"; import { usePageReady } from "~/hooks/usePageReady"; -import { - generalTransition, - generalTransitionDelay, - generalButtonTransition, -} from "~/lib/transitions"; +import { generalTransition, generalTransitionDelay } from "~/lib/transitions"; // TODO: add fancy error messages // https://remix.run/docs/en/v1/guides/data-writes#animating-in-the-validation-errors @@ -187,7 +183,11 @@ export default function GetStartedRegister() { /> To verify your identity - +