Skip to content

Commit 19e40f2

Browse files
authoredMay 7, 2024··
Merge pull request #157 from c4dt/feat_batch_voters
Add voters in batches
2 parents 5218665 + 6f748b2 commit 19e40f2

File tree

9 files changed

+138
-45
lines changed

9 files changed

+138
-45
lines changed
 

‎scripts/run_docker.sh

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# The script must be called from the root of the github tree, else it returns an error.
44
# This script currently only works on Linux due to differences in network management on Windows/macOS.
55

6-
if [[ $(git rev-parse --show-toplevel) != $(pwd) ]]; then
6+
if [[ $(git rev-parse --show-toplevel) != $(readlink -fn $(pwd)) ]]; then
77
echo "ERROR: This script must be started from the root of the git repo";
88
exit 1;
99
fi

‎web/backend/src/authManager.ts

+15
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ export async function addPolicy(userID: string, subject: string, permission: str
5656
await authEnforcer.addPolicy(userID, subject, permission);
5757
await authEnforcer.loadPolicy();
5858
}
59+
60+
export async function addListPolicy(userIDs: string[], subject: string, permission: string) {
61+
const promises = userIDs.map((userID) => authEnforcer.addPolicy(userID, subject, permission));
62+
try {
63+
await Promise.all(promises);
64+
} catch (error) {
65+
// At least one policy update has failed, but we need to reload ACLs anyway for the succeeding ones
66+
await authEnforcer.loadPolicy();
67+
throw new Error(`Failed to add policies for all users: ${error}`);
68+
}
69+
}
70+
5971
export async function assignUserPermissionToOwnElection(userID: string, ElectionID: string) {
6072
return authEnforcer.addPolicy(userID, ElectionID, PERMISSIONS.ACTIONS.OWN);
6173
}
@@ -87,6 +99,9 @@ export function setMapAuthorization(list: string[][]): Map<String, Array<String>
8799
// the range between 100000 and 999999, an error is thrown.
88100
export function readSCIPER(s: string): number {
89101
const n = parseInt(s, 10);
102+
if (Number.isNaN(n)) {
103+
throw new Error(`${s} is not a number`);
104+
}
90105
if (n < 100000 || n > 999999) {
91106
throw new Error(`SCIPER is out of range. ${n} is not between 100000 and 999999`);
92107
}

‎web/backend/src/controllers/users.ts

+34-13
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import express from 'express';
22

3-
import { addPolicy, initEnforcer, isAuthorized, PERMISSIONS } from '../authManager';
3+
import {
4+
addPolicy,
5+
addListPolicy,
6+
initEnforcer,
7+
isAuthorized,
8+
PERMISSIONS,
9+
readSCIPER,
10+
} from '../authManager';
411

512
export const usersRouter = express.Router();
613

@@ -22,7 +29,7 @@ usersRouter.get('/user_rights', (req, res) => {
2229
});
2330

2431
// This call (only for admins) allows an admin to add a role to a voter.
25-
usersRouter.post('/add_role', (req, res, next) => {
32+
usersRouter.post('/add_role', async (req, res, next) => {
2633
if (!isAuthorized(req.session.userId, PERMISSIONS.SUBJECTS.ROLES, PERMISSIONS.ACTIONS.ADD)) {
2734
res.status(400).send('Unauthorized - only admins allowed');
2835
return;
@@ -34,17 +41,31 @@ usersRouter.post('/add_role', (req, res, next) => {
3441
}
3542
}
3643

37-
addPolicy(req.body.userId, req.body.subject, req.body.permission)
38-
.then(() => {
39-
res.set(200).send();
40-
next();
41-
})
42-
.catch((e) => {
43-
res.status(400).send(`Error while adding to roles: ${e}`);
44-
});
45-
46-
// Call https://search-api.epfl.ch/api/ldap?q=228271, if the answer is
47-
// empty then sciper unknown, otherwise add it in userDB
44+
if ('userId' in req.body) {
45+
try {
46+
readSCIPER(req.body.userId);
47+
await addPolicy(req.body.userId, req.body.subject, req.body.permission);
48+
} catch (error) {
49+
res.status(400).send(`Error while adding single user to roles: ${error}`);
50+
return;
51+
}
52+
res.set(200).send();
53+
next();
54+
} else if ('userIds' in req.body) {
55+
try {
56+
req.body.userIds.every(readSCIPER);
57+
await addListPolicy(req.body.userIds, req.body.subject, req.body.permission);
58+
} catch (error) {
59+
res.status(400).send(`Error while adding multiple users to roles: ${error}`);
60+
return;
61+
}
62+
res.set(200).send();
63+
next();
64+
} else {
65+
res
66+
.status(400)
67+
.send(`Error: at least one of 'userId' or 'userIds' must be send in the request`);
68+
}
4869
});
4970

5071
// This call (only for admins) allow an admin to remove a role to a user.

‎web/frontend/src/language/de.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@
293293
"footerBuild": "build:",
294294
"footerBuildTime": "in:",
295295
"voteNotVoter": "Wählen nicht erlaubt.",
296-
"voteNotVoterDescription": "Sie sind nicht wahlberechtigt in dieser Wahl. Falls Sie denken, dass ein Fehler vorliegt, wenden Sie sich bitte an die verantwortliche Stelle."
296+
"voteNotVoterDescription": "Sie sind nicht wahlberechtigt in dieser Wahl. Falls Sie denken, dass ein Fehler vorliegt, wenden Sie sich bitte an die verantwortliche Stelle.",
297+
"addVotersLoading": "WählerInnen werden hinzugefügt...",
298+
"sciperNaN": "'{{sciperStr}}' ist keine Zahl; ",
299+
"sciperOutOfRange": "{{sciper}} ist nicht in dem erlaubten Bereich (100000-999999); ",
300+
"invalidScipersFound": "Ungültige SCIPERs wurden gefunden. Es wurde keine Anfrage gesendet. Bitte korrigieren Sie folgende Fehler: {{sciperErrs}}"
297301
}
298302
}

‎web/frontend/src/language/en.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,10 @@
294294
"footerBuild": "build:",
295295
"footerBuildTime": "in:",
296296
"voteNotVoter": "Voting not allowed.",
297-
"voteNotVoterDescription": "You are not allowed to vote in this form. If you believe this is an error, please contact the responsible of the service."
297+
"voteNotVoterDescription": "You are not allowed to vote in this form. If you believe this is an error, please contact the responsible of the service.",
298+
"addVotersLoading": "Adding voters...",
299+
"sciperNaN": "'{{sciperStr}}' is not a number; ",
300+
"sciperOutOfRange": "{{sciper}} is out of range (100000-999999); ",
301+
"invalidScipersFound": "Invalid SCIPER numbers found. No request has been send. Please fix the following errors: {{sciperErrs}}"
298302
}
299303
}

‎web/frontend/src/language/fr.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,10 @@
293293
"footerBuild": "build:",
294294
"footerBuildTime": "en:",
295295
"voteNotVoter": "Interdit de voter.",
296-
"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."
296+
"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.",
297+
"addVotersLoading": "Ajout d'électeur·rice·s...",
298+
"sciperNaN": "'{{sciperStr}}' n'est pas une chiffre; ",
299+
"sciperOutOfRange": "{{sciper}} n'est pas dans les valeurs acceptées (100000-999999); ",
300+
"invalidScipersFound": "Des SCIPERs invalides ont été trouvés. Aucune requête n'a été envoyée. Veuillez corriger les erreurs suivants: {{sciperErrs}}"
297301
}
298302
}

‎web/frontend/src/pages/form/components/ActionButtons/AddVotersButton.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { useTranslation } from 'react-i18next';
33
import { isManager } from './../../../../utils/auth';
44
import { AuthContext } from 'index';
55
import { useContext } from 'react';
6+
import IndigoSpinnerIcon from '../IndigoSpinnerIcon';
7+
import { OngoingAction } from 'types/form';
68

7-
const AddVotersButton = ({ handleAddVoters, formID }) => {
9+
const AddVotersButton = ({ handleAddVoters, formID, ongoingAction }) => {
810
const { t } = useTranslation();
911
const { authorization, isLogged } = useContext(AuthContext);
1012

11-
return (
13+
return ongoingAction !== OngoingAction.AddVoters ? (
1214
isManager(formID, authorization, isLogged) && (
1315
<button data-testid="addVotersButton" onClick={handleAddVoters}>
1416
<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">
@@ -17,6 +19,11 @@ const AddVotersButton = ({ handleAddVoters, formID }) => {
1719
</div>
1820
</button>
1921
)
22+
) : (
23+
<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">
24+
<IndigoSpinnerIcon />
25+
{t('addVotersLoading')}
26+
</div>
2027
);
2128
};
2229
export default AddVotersButton;

‎web/frontend/src/pages/form/components/utils/useChangeAction.tsx

+63-26
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const useChangeAction = (
5151
const [showModalDelete, setShowModalDelete] = useState(false);
5252
const [showModalAddVoters, setShowModalAddVoters] = useState(false);
5353
const [showModalAddVotersSucccess, setShowModalAddVotersSuccess] = useState(false);
54-
const [newVoters, setNewVoters] = useState('');
54+
const [newVoters] = useState('');
5555

5656
const [userConfirmedProxySetup, setUserConfirmedProxySetup] = useState(false);
5757
const [userConfirmedClosing, setUserConfirmedClosing] = useState(false);
@@ -314,35 +314,64 @@ const useChangeAction = (
314314

315315
useEffect(() => {
316316
if (userConfirmedAddVoters.length > 0) {
317-
const newUsersArray = [];
318-
for (const sciperStr of userConfirmedAddVoters.split('\n')) {
317+
let sciperErrs = '';
318+
319+
const providedScipers = userConfirmedAddVoters.split('\n');
320+
setUserConfirmedAddVoters('');
321+
322+
for (const sciperStr of providedScipers) {
323+
const sciper = parseInt(sciperStr, 10);
324+
if (isNaN(sciper)) {
325+
sciperErrs += t('sciperNaN', { sciperStr: sciperStr });
326+
}
327+
if (sciper < 100000 || sciper > 999999) {
328+
sciperErrs += t('sciperOutOfRange', { sciper: sciper });
329+
}
330+
}
331+
if (sciperErrs.length > 0) {
332+
setTextModalError(t('invalidScipersFound', { sciperErrs: sciperErrs }));
333+
setShowModalError(true);
334+
return;
335+
}
336+
// requests to ENDPOINT_ADD_ROLE cannot be done in parallel because on the
337+
// backend, auths are reloaded from the DB each time there is an update.
338+
// While auths are reloaded, they cannot be checked in a predictable way.
339+
// See isAuthorized, addPolicy, and addListPolicy in backend/src/authManager.ts
340+
(async () => {
319341
try {
320-
const sciper = parseInt(sciperStr, 10);
321-
if (sciper < 100000 || sciper > 999999) {
322-
console.error(`SCIPER is out of range. ${sciper} is not between 100000 and 999999`);
323-
} else {
324-
const request = {
325-
method: 'POST',
326-
headers: { 'Content-Type': 'application/json' },
327-
body: JSON.stringify({ userId: sciper, subject: formID, permission: 'vote' }),
328-
};
329-
sendFetchRequest(ENDPOINT_ADD_ROLE, request, setIsPosting)
330-
.catch(console.error)
331-
.then(() => {
332-
newUsersArray.push(sciper);
333-
setNewVoters(newUsersArray.join('\n'));
334-
setShowModalAddVotersSuccess(true);
335-
});
342+
const chunkSize = 1000;
343+
setOngoingAction(OngoingAction.AddVoters);
344+
for (let i = 0; i < providedScipers.length; i += chunkSize) {
345+
await sendFetchRequest(
346+
ENDPOINT_ADD_ROLE,
347+
{
348+
method: 'POST',
349+
headers: { 'Content-Type': 'application/json' },
350+
body: JSON.stringify({
351+
userIds: providedScipers.slice(i, i + chunkSize),
352+
subject: formID,
353+
permission: 'vote',
354+
}),
355+
},
356+
setIsPosting
357+
);
336358
}
337359
} catch (e) {
338360
console.error(`While adding voter: ${e}`);
361+
setShowModalAddVoters(false);
339362
}
340-
}
341-
setUserConfirmedAddVoters('');
342-
setShowModalAddVoters(false);
363+
setOngoingAction(OngoingAction.None);
364+
})();
343365
}
344-
// setUserConfirmedAddVoters(false);
345-
}, [formID, sendFetchRequest, userConfirmedAddVoters]);
366+
}, [
367+
formID,
368+
sendFetchRequest,
369+
userConfirmedAddVoters,
370+
t,
371+
setTextModalError,
372+
setShowModalError,
373+
setOngoingAction,
374+
]);
346375

347376
useEffect(() => {
348377
if (userConfirmedProxySetup) {
@@ -515,7 +544,11 @@ const useChangeAction = (
515544
formID={formID}
516545
/>
517546
<DeleteButton handleDelete={handleDelete} formID={formID} />
518-
<AddVotersButton handleAddVoters={handleAddVoters} formID={formID} />
547+
<AddVotersButton
548+
handleAddVoters={handleAddVoters}
549+
formID={formID}
550+
ongoingAction={ongoingAction}
551+
/>
519552
</>
520553
);
521554
case Status.Open:
@@ -535,7 +568,11 @@ const useChangeAction = (
535568
/>
536569
<VoteButton status={status} formID={formID} />
537570
<DeleteButton handleDelete={handleDelete} formID={formID} />
538-
<AddVotersButton handleAddVoters={handleAddVoters} formID={formID} />
571+
<AddVotersButton
572+
handleAddVoters={handleAddVoters}
573+
formID={formID}
574+
ongoingAction={ongoingAction}
575+
/>
539576
</>
540577
);
541578
case Status.Closed:

‎web/frontend/src/types/form.ts

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const enum OngoingAction {
4242
Decrypting,
4343
Combining,
4444
Canceling,
45+
AddVoters,
4546
}
4647

4748
interface FormInfo {

0 commit comments

Comments
 (0)
Please sign in to comment.