Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add voters in batches #157

Merged
merged 14 commits into from
May 7, 2024
2 changes: 1 addition & 1 deletion scripts/run_docker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions web/backend/src/authManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -87,6 +99,9 @@ export function setMapAuthorization(list: string[][]): Map<String, Array<String>
// 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`);
}
Expand Down
47 changes: 34 additions & 13 deletions web/backend/src/controllers/users.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand All @@ -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;
Expand All @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion web/frontend/src/language/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
}
6 changes: 5 additions & 1 deletion web/frontend/src/language/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
}
6 changes: 5 additions & 1 deletion web/frontend/src/language/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { useTranslation } from 'react-i18next';
import { isManager } from './../../../../utils/auth';
import { AuthContext } from 'index';
import { useContext } from 'react';
import IndigoSpinnerIcon from '../IndigoSpinnerIcon';
import { OngoingAction } from 'types/form';

const AddVotersButton = ({ handleAddVoters, formID }) => {
const AddVotersButton = ({ handleAddVoters, formID, ongoingAction }) => {
const { t } = useTranslation();
const { authorization, isLogged } = useContext(AuthContext);

return (
return ongoingAction !== OngoingAction.AddVoters ? (
isManager(formID, authorization, isLogged) && (
<button data-testid="addVotersButton" onClick={handleAddVoters}>
<div className="whitespace-nowrap inline-flex items-center justify-center px-4 py-1 mr-2 border border-gray-300 text-sm rounded-full font-medium text-gray-700 hover:text-red-500">
Expand All @@ -17,6 +19,11 @@ const AddVotersButton = ({ handleAddVoters, formID }) => {
</div>
</button>
)
) : (
<div className="whitespace-nowrap inline-flex items-center justify-center px-4 py-1 mr-2 border border-gray-300 text-sm rounded-full font-medium text-gray-700">
<IndigoSpinnerIcon />
{t('addVotersLoading')}
</div>
);
};
export default AddVotersButton;
89 changes: 63 additions & 26 deletions web/frontend/src/pages/form/components/utils/useChangeAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
ineiti marked this conversation as resolved.
Show resolved Hide resolved
}
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) {
Expand Down Expand Up @@ -515,7 +544,11 @@ const useChangeAction = (
formID={formID}
/>
<DeleteButton handleDelete={handleDelete} formID={formID} />
<AddVotersButton handleAddVoters={handleAddVoters} formID={formID} />
<AddVotersButton
handleAddVoters={handleAddVoters}
formID={formID}
ongoingAction={ongoingAction}
/>
</>
);
case Status.Open:
Expand All @@ -535,7 +568,11 @@ const useChangeAction = (
/>
<VoteButton status={status} formID={formID} />
<DeleteButton handleDelete={handleDelete} formID={formID} />
<AddVotersButton handleAddVoters={handleAddVoters} formID={formID} />
<AddVotersButton
handleAddVoters={handleAddVoters}
formID={formID}
ongoingAction={ongoingAction}
/>
</>
);
case Status.Closed:
Expand Down
1 change: 1 addition & 0 deletions web/frontend/src/types/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const enum OngoingAction {
Decrypting,
Combining,
Canceling,
AddVoters,
}

interface FormInfo {
Expand Down
Loading