diff --git a/packages/chakra-components/src/client.tsx b/packages/chakra-components/src/client.tsx index 3c580651..09951cdd 100644 --- a/packages/chakra-components/src/client.tsx +++ b/packages/chakra-components/src/client.tsx @@ -31,6 +31,8 @@ export const useClientProvider = ({ env: e, client: c, signer: s }: ClientProvid const opts: ClientOptions = { env: env as EnvOptions, + // TODO: REMOVE THE FOLLOWING csp_url when https://github.com/vocdoni/vocdoni-sdk/issues/163 is ready + csp_url: 'http://localhost:5000/v1', } if (signer) { @@ -147,12 +149,29 @@ export const useClientProvider = ({ env: e, client: c, signer: s }: ClientProvid client.wallet = signer } + const generateSigner = (seed?: string | string[]) => { + if (!client) { + throw new Error('No client initialized') + } + + let signer: Wallet + if (!seed) { + client.generateRandomWallet() + signer = client.wallet as Wallet + } else { + signer = VocdoniSDKClient.generateWalletFromData(seed) + } + + return signer + } + return { account, balance, client, env, signer, + generateSigner, fetchAccount, fetchBalance, setClient, diff --git a/packages/chakra-components/src/components/Election/Election.tsx b/packages/chakra-components/src/components/Election/Election.tsx index dd542050..f7950583 100644 --- a/packages/chakra-components/src/components/Election/Election.tsx +++ b/packages/chakra-components/src/components/Election/Election.tsx @@ -1,7 +1,7 @@ import { ChakraProps } from '@chakra-ui/system' import { Signer } from '@ethersproject/abstract-signer' import { Wallet } from '@ethersproject/wallet' -import { PublishedElection, Vote } from '@vocdoni/sdk' +import { CensusType, CspVote, ElectionStatus, PublishedElection, Vote } from '@vocdoni/sdk' import { ComponentType, PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { FieldValues } from 'react-hook-form' import { useClient } from '../../client' @@ -43,6 +43,11 @@ export const useElectionProvider = ({ const [isAbleToVote, setIsAbleToVote] = useState(undefined) const [votesLeft, setVotesLeft] = useState(0) const [isInCensus, setIsInCensus] = useState(false) + const [censusType, setCensusType] = useState(undefined) + const [voteInstance, setVoteInstance] = useState(undefined) + const [cspVotingToken, setCspVotingToken] = useState(undefined) + const [authToken, setAuthToken] = useState(null) + const [handler, setHandler] = useState('facebook') // Hardcoded until we let to choose // set signer in case it has been specified in the election // provider (rather than the client provider). Not sure if this is useful tho... @@ -52,6 +57,12 @@ export const useElectionProvider = ({ setSigner(s) }, [signer, client, s]) + useEffect(() => { + if (cspVotingToken && voteInstance) { + cspVote(cspVotingToken, voteInstance) + } + }, [cspVotingToken, voteInstance]) + // fetch election useEffect(() => { if (election || !id || loaded || !client) return @@ -80,9 +91,10 @@ export const useElectionProvider = ({ useEffect(() => { if (!fetchCensus || !signer || !election || !loaded || !client || isAbleToVote !== undefined) return ;(async () => { + const censusType: CensusType = election.census.type const isIn = await client.isInCensus(election.id) let left = 0 - if (isIn) { + if (isIn || censusType == CensusType.WEIGHTED) { // no need to check votes left if member ain't in census left = await client.votesLeftCount(election.id) setVotesLeft(left) @@ -90,11 +102,54 @@ export const useElectionProvider = ({ const voted = await client.hasAlreadyVoted(election.id) setVoted(voted) } + setCensusType(censusType) setIsInCensus(isIn) - setIsAbleToVote(left > 0 && isIn) + setIsAbleToVote((left > 0 && isIn) || censusType == CensusType.CSP) })() }, [fetchCensus, election, loaded, client, isAbleToVote, signer]) + // CSP OAuth flow + // Listening for the popup window meessage (oauth flows) + useEffect(() => { + ;(async () => { + const handleMessage = (event: any) => { + if (event.data.code && event.data.handler) { + getOAuthToken(client, event.data.code, event.data.handler) + } + } + + if (window.opener || !client || censusType !== CensusType.CSP) { + return + } + + window.addEventListener('message', handleMessage) + + return () => { + window.removeEventListener('message', handleMessage) + } + })() + }, [client, censusType]) + + // CSP OAuth flow + // Posting the message to the main window + useEffect(() => { + ;(async () => { + if (typeof window == 'undefined') return + if (window.location.href.split('?').length < 2) return + + const params: URLSearchParams = new URLSearchParams(window.location.search); + const code: string | null = params.get('code'); + const handler: string | null = params.get('handler'); + if (!code || !handler) return + + if (window.opener) { + // If it is, send the code to the parent window and close the popup + window.opener.postMessage({ code, handler }, '*') + window.close() + } + })() + }, []) + // context vote function (the one to be used with the given components) const vote = async (values: FieldValues) => { if (!client) { @@ -114,18 +169,21 @@ export const useElectionProvider = ({ // map questions back to expected Vote[] values const mapped = election.questions.map((q, k) => parseInt(values[k.toString()], 10)) + let vote = new Vote(mapped) + setVoteInstance(vote) + if (typeof beforeSubmit === 'function' && !beforeSubmit(vote)) { + return + } + try { - const vote = new Vote(mapped) - if (typeof beforeSubmit === 'function' && !beforeSubmit(vote)) { - return + if (censusType == CensusType.CSP) { + await cspAuthAndVote() + } else if (censusType == CensusType.WEIGHTED) { + const vid = await weightedVote(vote) + setVoted(vid) + setVotesLeft(votesLeft - 1) + setIsAbleToVote(isInCensus && votesLeft - 1 > 0) } - - const vid = await client.submitVote(vote) - setVoted(vid) - setVotesLeft(votesLeft - 1) - setIsAbleToVote(isInCensus && votesLeft - 1 > 0) - - return vid } catch (e: any) { if ('reason' in e) { return setError(e.reason as string) @@ -139,6 +197,131 @@ export const useElectionProvider = ({ } } + const weightedVote = async (vote: Vote): Promise => { + if (!vote) { + throw new Error('no vote instance') + } + if (censusType != CensusType.WEIGHTED) { + throw new Error('not a Weighted election') + } + + return await client.submitVote(vote) + } + + // CSP OAuth flow + const cspAuthAndVote = async () => { + if (!client) { + throw new Error('no client initialized') + } + if (!election) { + throw new Error('no election initialized') + } + if (censusType != CensusType.CSP) { + throw new Error('not a CSP election') + } + + const params: URLSearchParams = new URLSearchParams(window.location.search); + + if (!params.has('electionId')) { + params.append('electionId', election.id); + } + + if (!params.has('handler')) { + params.append('handler', handler); + } + + const redirectURL: string = `${window.location.origin}${window.location.pathname}?${params.toString()}${window.location.hash}`; + + let step0: any + try { + step0 = await client.cspStep(0, [handler, redirectURL]) + } catch (e: any) { + if ('reason' in e) { + return setError(e.reason as string) + } + } + + setAuthToken(step0.authToken) + openLoginPopup(handler, step0['response'][0]) + } + + // CSP OAuth flow + // Opens a popup window to the service login page + const openLoginPopup = (handler: string, url: string) => { + const width = 600 + const height = 600 + const left = window.outerWidth / 2 - width / 2 + const top = window.outerHeight / 2 - height / 2 + const params = [ + `width=${width}`, + `height=${height}`, + `top=${top}`, + `left=${left}`, + `status=no`, + `resizable=yes`, + `scrollbars=yes`, + ].join(',') + + window.open(url, handler, params) + } + + // CSP OAuth flow + const getOAuthToken = async (vocdoniClient: any, code: string, handler: string) => { + if (cspVotingToken) { + return + } + + if (!code) { + throw new Error('no code provided') + } + if (!handler) { + throw new Error('no handler provided') + } + + // Extract the electionId query param from the redirectURL + const existingParams = new URLSearchParams(window.location.search); + const electionId = existingParams.get('electionId'); + const params: URLSearchParams = new URLSearchParams(); + params.append('electionId', electionId as string); + params.append('handler', handler); + + const redirectURL = `${window.location.origin}${window.location.pathname}?${params.toString()}${window.location.hash}`; + + let step1 + try { + step1 = await vocdoniClient.cspStep(1, [handler, code, redirectURL], authToken) + setCspVotingToken(step1.token) + } catch (e) { + setError('Not authorized to vote') + return false + } + } + + const cspVote = async (token: string, vote: Vote) => { + if (!client) { + throw new Error('no client initialized') + } + + if (censusType != CensusType.CSP) { + throw new Error('not a CSP election') + } + + try { + const walletAddress: string = (await client.wallet?.getAddress()) as string + const signature: string = await client.cspSign(walletAddress, token) + const cspVote: CspVote = client.cspVote(vote as Vote, signature) + const vid: string = await client.submitVote(cspVote) + setVoted(vid) + setVotesLeft(votesLeft - 1) + setCspVotingToken(undefined) + setVoteInstance(undefined) + return vid + } catch (e) { + setError('Error submitting vote') + return false + } + } + return { ...rest, election,