diff --git a/scripts/README.md b/scripts/README.md index 80ef9ba2..3ffeb222 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,7 @@ # Scripts for running D-voting locally -The following scripts are available to configure and run D-voting locally. They should be called in this order: +The following scripts are available to configure and run D-voting locally. +They should be called in this order: - `run_local.sh` - sets up a complete system with 4 nodes, the db, the authentication-server, and the frontend. @@ -10,8 +11,14 @@ The following scripts are available to configure and run D-voting locally. They For debugging Dela, you still need to re-run everything. - `local_proxies.sh` needs to be run once after the `run_local.sh` script - `local_forms.sh` creates a new form and prints its ID -- `local_votes.sh` casts the given number of votes. THE ENCRYPTION IS WRONG AND WILL NOT WORK. But it allows to test - missing votes + +Every script must be called from the root of the repository: + +```bash +./scripts/run_local.sh +./scripts/local_proxies.sh +./scripts/local_forms.sh +``` The following script is only called by the other scripts: diff --git a/scripts/local_forms.sh b/scripts/local_forms.sh index df8e98b5..9388bb91 100755 --- a/scripts/local_forms.sh +++ b/scripts/local_forms.sh @@ -4,7 +4,8 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) . "$SCRIPT_DIR/local_login.sh" echo "add form" -RESP=$(curl -sk "$FRONTEND_URL/api/evoting/forms" -X POST -H 'Content-Type: application/json' -b cookies.txt --data-raw '{"Title":{"En":"Something","Fr":"","De":""},"Scaffold":[{"ID":"99hYV4uy","Title":{"En":"More stuff","Fr":"","De":""},"Order":["0c7RSRKs"],"Ranks":[],"Selects":[{"ID":"0c7RSRKs","Title":{"En":"Choose","Fr":"","De":""},"MaxN":3,"MinN":1,"Choices":[{"Choice":"{\"en\":\"First\"}","URL":""},{"Choice":"{\"en\":\"Second\"}","URL":""},{"Choice":"{\"en\":\"Third\"}","URL":""}],"Hint":{"En":"","Fr":"","De":""}}],"Texts":[],"Subjects":[]}]}') +FORMJSON='{"Configuration":{"Title":{"En":"title","Fr":"","De":"","URL":""},"Scaffold":[{"ID":"ozCI7gKv","Title":{"En":"subtitle","Fr":"","De":"","URL":""},"Order":["nVjQ0jMK"],"Ranks":[],"Selects":[{"ID":"nVjQ0jMK","Title":{"En":"vote","Fr":"","De":"","URL":""},"MaxN":3,"MinN":1,"Choices":[{"Choice":"{\"en\":\"one\"}","URL":""},{"Choice":"{\"en\":\"two\"}","URL":""},{"Choice":"{\"en\":\"three\"}","URL":""}],"Hint":{"En":"","Fr":"","De":""}}],"Texts":[],"Subjects":[]}],"AdditionalInfo":""}}' +RESP=$(curl -sk "$FRONTEND_URL/api/evoting/forms" -X POST -H 'Content-Type: application/json' -b cookies.txt --data-raw "$FORMJSON") FORMID=$(echo "$RESP" | jq -r .FormID) echo "FORMID=$FORMID" > "$SCRIPT_DIR/formid.env" diff --git a/scripts/local_votes.sh b/scripts/local_votes.sh deleted file mode 100755 index c48a90d0..00000000 --- a/scripts/local_votes.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -if ! [[ "$1" && "$2" ]]; then - echo "Syntax is: $0 #votes FORMID" - exit 1 -fi - -SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) -. "$SCRIPT_DIR/local_login.sh" - -FORMID=$2 -echo "Casting $1 votes to form $FORMID" -for i in $(seq $1); do - echo "Casting vote #$i" - curl -sk "$FRONTEND_URL/api/evoting/forms/$FORMID/vote" -X POST -H 'Content-Type: Application/json' \ - -H "Origin: $FRONTEND_URL" -b cookies.txt \ - --data-raw '{"Ballot":[{"K":[54,152,33,11,201,233,212,157,204,176,136,138,54,213,239,198,79,55,71,26,91,244,98,215,208,239,48,253,195,53,192,94],"C":[105,121,87,164,68,242,166,194,222,179,253,231,213,63,34,66,212,41,214,175,178,83,229,156,255,38,55,234,168,222,81,185]}],"UserID":null}' \ - >/dev/null - sleep 1 -done diff --git a/scripts/run_docker.sh b/scripts/run_docker.sh index c256ff14..630af08b 100755 --- a/scripts/run_docker.sh +++ b/scripts/run_docker.sh @@ -3,7 +3,7 @@ # The script must be called from the root of the github tree, else it returns an error. # This script currently only works on Linux due to differences in network management on Windows/macOS. -if [[ $(git rev-parse --show-toplevel) != $(pwd) ]]; then +if [[ $(git rev-parse --show-toplevel) != $(readlink -fn $(pwd)) ]]; then echo "ERROR: This script must be started from the root of the git repo"; exit 1; fi diff --git a/scripts/run_local.sh b/scripts/run_local.sh index c185e5e4..0154cb89 100755 --- a/scripts/run_local.sh +++ b/scripts/run_local.sh @@ -94,13 +94,6 @@ function init_nodes() { function init_dela() { echo "Initializing dela" - echo " Share the certificate" - for n in $(seq 2 4); do - TOKEN_ARGS=$(dvoting --config ./nodes/node-1 minogrpc token) - NODEDIR=./nodes/node-$n - dvoting --config $NODEDIR minogrpc join --address grpc://localhost:2000 $TOKEN_ARGS - done - echo " Create a new chain with the nodes" for n in $(seq 4); do NODEDIR=./nodes/node-$n diff --git a/web/backend/src/authManager.ts b/web/backend/src/authManager.ts index 0c7fc2e8..b2cd1925 100644 --- a/web/backend/src/authManager.ts +++ b/web/backend/src/authManager.ts @@ -56,6 +56,18 @@ export async function addPolicy(userID: string, subject: string, permission: str await authEnforcer.addPolicy(userID, subject, permission); await authEnforcer.loadPolicy(); } + +export async function addListPolicy(userIDs: string[], subject: string, permission: string) { + const promises = userIDs.map((userID) => authEnforcer.addPolicy(userID, subject, permission)); + try { + await Promise.all(promises); + } catch (error) { + // At least one policy update has failed, but we need to reload ACLs anyway for the succeeding ones + await authEnforcer.loadPolicy(); + throw new Error(`Failed to add policies for all users: ${error}`); + } +} + export async function assignUserPermissionToOwnElection(userID: string, ElectionID: string) { return authEnforcer.addPolicy(userID, ElectionID, PERMISSIONS.ACTIONS.OWN); } @@ -87,6 +99,9 @@ export function setMapAuthorization(list: string[][]): Map // the range between 100000 and 999999, an error is thrown. export function readSCIPER(s: string): number { const n = parseInt(s, 10); + if (Number.isNaN(n)) { + throw new Error(`${s} is not a number`); + } if (n < 100000 || n > 999999) { throw new Error(`SCIPER is out of range. ${n} is not between 100000 and 999999`); } diff --git a/web/backend/src/controllers/users.ts b/web/backend/src/controllers/users.ts index 2b0a6f71..1ca5328c 100644 --- a/web/backend/src/controllers/users.ts +++ b/web/backend/src/controllers/users.ts @@ -1,6 +1,13 @@ import express from 'express'; -import { addPolicy, initEnforcer, isAuthorized, PERMISSIONS } from '../authManager'; +import { + addPolicy, + addListPolicy, + initEnforcer, + isAuthorized, + PERMISSIONS, + readSCIPER, +} from '../authManager'; export const usersRouter = express.Router(); @@ -22,7 +29,7 @@ usersRouter.get('/user_rights', (req, res) => { }); // This call (only for admins) allows an admin to add a role to a voter. -usersRouter.post('/add_role', (req, res, next) => { +usersRouter.post('/add_role', async (req, res, next) => { if (!isAuthorized(req.session.userId, PERMISSIONS.SUBJECTS.ROLES, PERMISSIONS.ACTIONS.ADD)) { res.status(400).send('Unauthorized - only admins allowed'); return; @@ -34,17 +41,31 @@ usersRouter.post('/add_role', (req, res, next) => { } } - addPolicy(req.body.userId, req.body.subject, req.body.permission) - .then(() => { - res.set(200).send(); - next(); - }) - .catch((e) => { - res.status(400).send(`Error while adding to roles: ${e}`); - }); - - // Call https://search-api.epfl.ch/api/ldap?q=228271, if the answer is - // empty then sciper unknown, otherwise add it in userDB + if ('userId' in req.body) { + try { + readSCIPER(req.body.userId); + await addPolicy(req.body.userId, req.body.subject, req.body.permission); + } catch (error) { + res.status(400).send(`Error while adding single user to roles: ${error}`); + return; + } + res.set(200).send(); + next(); + } else if ('userIds' in req.body) { + try { + req.body.userIds.every(readSCIPER); + await addListPolicy(req.body.userIds, req.body.subject, req.body.permission); + } catch (error) { + res.status(400).send(`Error while adding multiple users to roles: ${error}`); + return; + } + res.set(200).send(); + next(); + } else { + res + .status(400) + .send(`Error: at least one of 'userId' or 'userIds' must be send in the request`); + } }); // This call (only for admins) allow an admin to remove a role to a user. diff --git a/web/frontend/src/language/de.json b/web/frontend/src/language/de.json index 9b4531e7..5004f315 100644 --- a/web/frontend/src/language/de.json +++ b/web/frontend/src/language/de.json @@ -293,6 +293,10 @@ "footerBuild": "build:", "footerBuildTime": "in:", "voteNotVoter": "Wählen nicht erlaubt.", - "voteNotVoterDescription": "Sie sind nicht wahlberechtigt in dieser Wahl. Falls Sie denken, dass ein Fehler vorliegt, wenden Sie sich bitte an die verantwortliche Stelle." + "voteNotVoterDescription": "Sie sind nicht wahlberechtigt in dieser Wahl. Falls Sie denken, dass ein Fehler vorliegt, wenden Sie sich bitte an die verantwortliche Stelle.", + "addVotersLoading": "WählerInnen werden hinzugefügt...", + "sciperNaN": "'{{sciperStr}}' ist keine Zahl; ", + "sciperOutOfRange": "{{sciper}} ist nicht in dem erlaubten Bereich (100000-999999); ", + "invalidScipersFound": "Ungültige SCIPERs wurden gefunden. Es wurde keine Anfrage gesendet. Bitte korrigieren Sie folgende Fehler: {{sciperErrs}}" } } diff --git a/web/frontend/src/language/en.json b/web/frontend/src/language/en.json index 89e8bfe5..4d9c2448 100644 --- a/web/frontend/src/language/en.json +++ b/web/frontend/src/language/en.json @@ -294,6 +294,10 @@ "footerBuild": "build:", "footerBuildTime": "in:", "voteNotVoter": "Voting not allowed.", - "voteNotVoterDescription": "You are not allowed to vote in this form. If you believe this is an error, please contact the responsible of the service." + "voteNotVoterDescription": "You are not allowed to vote in this form. If you believe this is an error, please contact the responsible of the service.", + "addVotersLoading": "Adding voters...", + "sciperNaN": "'{{sciperStr}}' is not a number; ", + "sciperOutOfRange": "{{sciper}} is out of range (100000-999999); ", + "invalidScipersFound": "Invalid SCIPER numbers found. No request has been send. Please fix the following errors: {{sciperErrs}}" } } diff --git a/web/frontend/src/language/fr.json b/web/frontend/src/language/fr.json index cb79184c..be43edc6 100644 --- a/web/frontend/src/language/fr.json +++ b/web/frontend/src/language/fr.json @@ -191,7 +191,7 @@ "ballotFailure": "Une erreur est survenue lors de l'envoi de votre ballot. Merci de contacter l'administrateur du site. ", "incompleteBallot": "Certaines réponses ne sont pas complètes.", "selectMin": "Selectionnez {{minSelect}} {{singularPlural}}. ", - "selectMax": "Selectionnez au moins {{maxSelect}} {{singularPlural}}. ", + "selectMax": "Selectionnez au plus {{maxSelect}} {{singularPlural}}. ", "selectBetween": "Selectionnez entre {{minSelect}} et {{maxSelect}} réponses. ", "minSelectError": "Vous devez sélectionner au moins {{min}} {{singularPlural}}. ", "maxSelectError": "Vous ne pouvez pas sélectionner plus de {{max}} réponses. ", @@ -293,6 +293,10 @@ "footerBuild": "build:", "footerBuildTime": "en:", "voteNotVoter": "Interdit de voter.", - "voteNotVoterDescription": "Vous n'avez pas le droit de voter dans cette élection. Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le/la reponsable de service." + "voteNotVoterDescription": "Vous n'avez pas le droit de voter dans cette élection. Si vous pensez qu'il s'agit d'une erreur, veuillez contacter le/la reponsable de service.", + "addVotersLoading": "Ajout d'électeur·rice·s...", + "sciperNaN": "'{{sciperStr}}' n'est pas une chiffre; ", + "sciperOutOfRange": "{{sciper}} n'est pas dans les valeurs acceptées (100000-999999); ", + "invalidScipersFound": "Des SCIPERs invalides ont été trouvés. Aucune requête n'a été envoyée. Veuillez corriger les erreurs suivants: {{sciperErrs}}" } } diff --git a/web/frontend/src/layout/App.tsx b/web/frontend/src/layout/App.tsx index ae0ad9d6..943a99a3 100644 --- a/web/frontend/src/layout/App.tsx +++ b/web/frontend/src/layout/App.tsx @@ -69,7 +69,14 @@ const App = () => { } /> - } /> + + + + } + /> } /> { +const AddVotersButton = ({ handleAddVoters, formID, ongoingAction }) => { const { t } = useTranslation(); const { authorization, isLogged } = useContext(AuthContext); - return ( + return ongoingAction !== OngoingAction.AddVoters ? ( isManager(formID, authorization, isLogged) && ( ) + ) : ( +
+ + {t('addVotersLoading')} +
); }; export default AddVotersButton; diff --git a/web/frontend/src/pages/form/components/FormRow.tsx b/web/frontend/src/pages/form/components/FormRow.tsx index 973f1cb8..f3694654 100644 --- a/web/frontend/src/pages/form/components/FormRow.tsx +++ b/web/frontend/src/pages/form/components/FormRow.tsx @@ -1,16 +1,21 @@ -import React, { FC, useEffect, useState } from 'react'; +import React, { FC, useContext, useEffect, useState } from 'react'; import { LightFormInfo } from 'types/form'; import { Link } from 'react-router-dom'; import FormStatus from './FormStatus'; import QuickAction from './QuickAction'; import { default as i18n } from 'i18next'; +import { AuthContext } from '../../..'; type FormRowProps = { form: LightFormInfo; }; +const SUBJECT_ELECTION = 'election'; +const ACTION_CREATE = 'create'; + const FormRow: FC = ({ form }) => { const [titles, setTitles] = useState({}); + const authCtx = useContext(AuthContext); useEffect(() => { if (form.Title === undefined) return; setTitles({ En: form.Title.En, Fr: form.Title.Fr, De: form.Title.De, URL: form.Title.URL }); @@ -25,14 +30,17 @@ const FormRow: FC = ({ form }) => { formRowI18n.addResource(lang.toLowerCase(), 'form', 'title', title); } }); + const formTitle = formRowI18n.t('title', { ns: 'form', fallbackLng: 'en' }); return ( - -
- {formRowI18n.t('title', { ns: 'form', fallbackLng: 'en' })} -
- + {authCtx.isLogged && authCtx.isAllowed(SUBJECT_ELECTION, ACTION_CREATE) ? ( + +
{formTitle}
+ + ) : ( +
{formTitle}
+ )} {} diff --git a/web/frontend/src/pages/form/components/utils/useChangeAction.tsx b/web/frontend/src/pages/form/components/utils/useChangeAction.tsx index d2fd4c2c..91bab663 100644 --- a/web/frontend/src/pages/form/components/utils/useChangeAction.tsx +++ b/web/frontend/src/pages/form/components/utils/useChangeAction.tsx @@ -51,7 +51,7 @@ const useChangeAction = ( const [showModalDelete, setShowModalDelete] = useState(false); const [showModalAddVoters, setShowModalAddVoters] = useState(false); const [showModalAddVotersSucccess, setShowModalAddVotersSuccess] = useState(false); - const [newVoters, setNewVoters] = useState(''); + const [newVoters] = useState(''); const [userConfirmedProxySetup, setUserConfirmedProxySetup] = useState(false); const [userConfirmedClosing, setUserConfirmedClosing] = useState(false); @@ -314,35 +314,64 @@ const useChangeAction = ( useEffect(() => { if (userConfirmedAddVoters.length > 0) { - const newUsersArray = []; - for (const sciperStr of userConfirmedAddVoters.split('\n')) { + let sciperErrs = ''; + + const providedScipers = userConfirmedAddVoters.split('\n'); + setUserConfirmedAddVoters(''); + + for (const sciperStr of providedScipers) { + const sciper = parseInt(sciperStr, 10); + if (isNaN(sciper)) { + sciperErrs += t('sciperNaN', { sciperStr: sciperStr }); + } + if (sciper < 100000 || sciper > 999999) { + sciperErrs += t('sciperOutOfRange', { sciper: sciper }); + } + } + if (sciperErrs.length > 0) { + setTextModalError(t('invalidScipersFound', { sciperErrs: sciperErrs })); + setShowModalError(true); + return; + } + // requests to ENDPOINT_ADD_ROLE cannot be done in parallel because on the + // backend, auths are reloaded from the DB each time there is an update. + // While auths are reloaded, they cannot be checked in a predictable way. + // See isAuthorized, addPolicy, and addListPolicy in backend/src/authManager.ts + (async () => { try { - const sciper = parseInt(sciperStr, 10); - if (sciper < 100000 || sciper > 999999) { - console.error(`SCIPER is out of range. ${sciper} is not between 100000 and 999999`); - } else { - const request = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: sciper, subject: formID, permission: 'vote' }), - }; - sendFetchRequest(ENDPOINT_ADD_ROLE, request, setIsPosting) - .catch(console.error) - .then(() => { - newUsersArray.push(sciper); - setNewVoters(newUsersArray.join('\n')); - setShowModalAddVotersSuccess(true); - }); + const chunkSize = 1000; + setOngoingAction(OngoingAction.AddVoters); + for (let i = 0; i < providedScipers.length; i += chunkSize) { + await sendFetchRequest( + ENDPOINT_ADD_ROLE, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userIds: providedScipers.slice(i, i + chunkSize), + subject: formID, + permission: 'vote', + }), + }, + setIsPosting + ); } } catch (e) { console.error(`While adding voter: ${e}`); + setShowModalAddVoters(false); } - } - setUserConfirmedAddVoters(''); - setShowModalAddVoters(false); + setOngoingAction(OngoingAction.None); + })(); } - // setUserConfirmedAddVoters(false); - }, [formID, sendFetchRequest, userConfirmedAddVoters]); + }, [ + formID, + sendFetchRequest, + userConfirmedAddVoters, + t, + setTextModalError, + setShowModalError, + setOngoingAction, + ]); useEffect(() => { if (userConfirmedProxySetup) { @@ -515,7 +544,11 @@ const useChangeAction = ( formID={formID} /> - + ); case Status.Open: @@ -535,7 +568,11 @@ const useChangeAction = ( /> - + ); case Status.Closed: diff --git a/web/frontend/src/types/form.ts b/web/frontend/src/types/form.ts index 086ae2f7..d28e2bf8 100644 --- a/web/frontend/src/types/form.ts +++ b/web/frontend/src/types/form.ts @@ -42,6 +42,7 @@ export const enum OngoingAction { Decrypting, Combining, Canceling, + AddVoters, } interface FormInfo {