diff --git a/web/frontend/src/components/modal/AddAdminUserModal.tsx b/web/frontend/src/components/modal/AddAdminUserModal.tsx deleted file mode 100644 index edc337f46..000000000 --- a/web/frontend/src/components/modal/AddAdminUserModal.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import React, { FC, Fragment, useContext, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; -import { Dialog, Listbox, Transition } from '@headlessui/react'; -import { CheckIcon, SelectorIcon } from '@heroicons/react/solid'; - -import { ENDPOINT_ADD_ROLE } from 'components/utils/Endpoints'; -import { useTranslation } from 'react-i18next'; -import SpinnerIcon from 'components/utils/SpinnerIcon'; -import { UserAddIcon } from '@heroicons/react/outline'; -import ShortUniqueId from 'short-unique-id'; -import { FlashContext, FlashLevel } from 'index'; - -const uid = new ShortUniqueId({ length: 8 }); - -type AddAdminUserModalProps = { - open: boolean; - setOpen(opened: boolean): void; - handleAddRoleUser(user: object): void; -}; - -const roles = ['Admin', 'Operator']; - -const AddAdminUserModal: FC = ({ open, setOpen, handleAddRoleUser }) => { - const { t } = useTranslation(); - const fctx = useContext(FlashContext); - - const [loading, setLoading] = useState(false); - const [sciperValue, setSciperValue] = useState(''); - const [selectedRole, setSelectedRole] = useState(roles[0]); - - const handleClose = () => setOpen(false); - - const handleUserInput = (e: any) => { - setSciperValue(e.target.value); - }; - - const handleAddUser = async () => { - const userToAdd = { id: uid(), sciper: sciperValue, role: selectedRole }; - const requestOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(userToAdd), - }; - - try { - setLoading(true); - const res = await fetch(ENDPOINT_ADD_ROLE, requestOptions); - if (res.status !== 200) { - const response = await res.text(); - fctx.addMessage( - `Error HTTP ${res.status} (${res.statusText}) : ${response}`, - FlashLevel.Error - ); - } else { - setSciperValue(''); - setSelectedRole(roles[0]); - handleAddRoleUser(userToAdd); - fctx.addMessage(`${t('successAddUser')}`, FlashLevel.Info); - } - } catch (error) { - fctx.addMessage(`${t('errorAddUser')}: ${error.message}`, FlashLevel.Error); - } - setLoading(false); - setOpen(false); - }; - const cancelButtonRef = useRef(null); - - return ( - - -
- - - - - {/* This element is to trick the browser into centering the modal contents. */} - - -
-
-
- - {t('enterSciper')} - - -
- -
- - {selectedRole} - - - - - - {roles.map((role, personIdx) => ( - - `relative cursor-default select-none py-2 pl-10 pr-4 ${ - active ? 'bg-indigo-100 text-indigo-900' : 'text-gray-900' - }` - } - value={role}> - {({ selected }) => ( - <> - - {role} - - {selected ? ( - - - ) : null} - - )} - - ))} - - -
-
-
-
-
-
- - -
-
-
-
-
-
- ); -}; - -AddAdminUserModal.propTypes = { - open: PropTypes.bool.isRequired, - setOpen: PropTypes.func.isRequired, -}; - -export default AddAdminUserModal; diff --git a/web/frontend/src/components/modal/RemoveAdminUserModal.tsx b/web/frontend/src/components/modal/RemoveAdminUserModal.tsx deleted file mode 100644 index 0e64f2a01..000000000 --- a/web/frontend/src/components/modal/RemoveAdminUserModal.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import React, { FC, Fragment, useContext, useRef, useState } from 'react'; -import { ENDPOINT_REMOVE_ROLE } from '../utils/Endpoints'; -import PropTypes from 'prop-types'; -import { Dialog, Transition } from '@headlessui/react'; -import { UserRemoveIcon } from '@heroicons/react/outline'; -import SpinnerIcon from 'components/utils/SpinnerIcon'; -import { useTranslation } from 'react-i18next'; -import { FlashContext, FlashLevel } from 'index'; - -type RemoveAdminUserModalProps = { - open: boolean; - setOpen(opened: boolean): void; - sciper: number; - handleRemoveRoleUser(): void; -}; - -const RemoveAdminUserModal: FC = ({ - open, - setOpen, - sciper, - handleRemoveRoleUser, -}) => { - const { t } = useTranslation(); - const fctx = useContext(FlashContext); - - const [loading, setLoading] = useState(false); - - const handleClose = () => setOpen(false); - - const handleDelete = async () => { - const requestOptions = { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sciper: sciper }), - }; - - try { - setLoading(true); - const res = await fetch(ENDPOINT_REMOVE_ROLE, requestOptions); - if (res.status !== 200) { - const response = await res.text(); - fctx.addMessage( - `Error HTTP ${res.status} (${res.statusText}) : ${response}`, - FlashLevel.Error - ); - } else { - handleRemoveRoleUser(); - fctx.addMessage(t('successRemoveUser'), FlashLevel.Info); - } - } catch (error) { - fctx.addMessage(`${t('errorRemoveUser')}: ${error.message}`, FlashLevel.Error); - } - setLoading(false); - setOpen(false); - }; - const cancelButtonRef = useRef(null); - - return ( -
- - -
- - - - - {/* This element is to trick the browser into centering the modal contents. */} - - -
-
-
- - {t('confirmDeleteUserSciper')} {sciper} - -
-
-
- - -
-
-
-
-
-
-
- ); -}; - -RemoveAdminUserModal.propTypes = { - open: PropTypes.bool.isRequired, - setOpen: PropTypes.func.isRequired, - sciper: PropTypes.number.isRequired, -}; - -export default RemoveAdminUserModal; diff --git a/web/frontend/src/language/en.json b/web/frontend/src/language/en.json index 69c240502..32ad2bdf9 100644 --- a/web/frontend/src/language/en.json +++ b/web/frontend/src/language/en.json @@ -60,6 +60,7 @@ "addCandidate": "Add a candidate", "addUser": "Add user", "role": "Role", + "roles": "Roles", "edit": "Edit", "nothingToAdd": "There is nothing to add.", "duplicateCandidate": "This candidate has already been added.", @@ -214,6 +215,20 @@ "filterByStatus": "Filter by status", "all": "All", "resetFilter": "Reset filter", - "showingNOverMOfXResults": "Showing {{n}}/{{m}} of {{x}} results." + "showingNOverMOfXResults": "Showing {{n}}/{{m}} of {{x}} results.", + "addProxy": "Add proxy", + "editProxy": "Edit the proxy address", + "proxy": "Proxy", + "confirmDeleteProxy": "Do confirm deleting this node address", + "nodeDetails": "Add, edit or remove the mapping between a node address and its proxy address.", + "inputNodeProxyError": "Error: the address of the node and the proxy cannot be empty.", + "proxySuccessfullyEdited": "The proxy address was successfully modified !", + "nodeProxySuccessfullyAdded": "The node and proxy addresses were successfully added !", + "proxySuccessfullyDeleted": "The node and proxy addresses were successfully deleted !", + "addNodeProxyError": "An error occurred while trying to add the node and proxy addresses. Error: ", + "editProxyError": "An error occurred while trying to edit the proxy address. Error: ", + "removeProxyError": "An error occurred while trying to remove the node and proxy addresses. Error: ", + "enterNodeProxy": "Please enter the addresses of the node and the proxy", + "invalidProxyError": "Error: the address you entered is not a valid URL." } } diff --git a/web/frontend/src/layout/App.tsx b/web/frontend/src/layout/App.tsx index c434b5b1d..a3ce2f13c 100644 --- a/web/frontend/src/layout/App.tsx +++ b/web/frontend/src/layout/App.tsx @@ -14,7 +14,7 @@ import { import Login from '../pages/session/Login'; import Home from '../pages/Home'; import About from '../pages/About'; -import Admin from '../pages/Admin'; +import Admin from 'pages/admin/Admin'; import ElectionIndex from '../pages/election/Index'; import ElectionCreate from '../pages/election/New'; import ElectionResult from '../pages/election/Result'; diff --git a/web/frontend/src/mocks/handlers.ts b/web/frontend/src/mocks/handlers.ts index efa0b22c4..af6378d9f 100644 --- a/web/frontend/src/mocks/handlers.ts +++ b/web/frontend/src/mocks/handlers.ts @@ -34,14 +34,14 @@ const mockUserID = 561934; const { mockElections, mockResults, mockDKG, mockNodeProxyAddresses } = setupMockElection(); -var mockUserDB = setupMockUserDB(); +let mockUserDB = setupMockUserDB(); const RESPONSE_TIME = 500; const CHANGE_STATUS_TIMER = 2000; -const INIT_TIMER = 3000; -const SETUP_TIMER = 4000; +const INIT_TIMER = 2000; +const SETUP_TIMER = 3000; const SHUFFLE_TIMER = 2000; -const DECRYPT_TIMER = 8000; +const DECRYPT_TIMER = 4000; const isAuthorized = (roles: UserRole[]): boolean => { const id = sessionStorage.getItem('id'); @@ -168,7 +168,7 @@ export const handlers = [ rest.put(endpoints.editElection(':ElectionID'), async (req, res, ctx) => { const body = req.body as EditElectionBody; const { ElectionID } = req.params; - var status = Status.Initial; + let status = Status.Initial; const Result = []; await new Promise((r) => setTimeout(r, RESPONSE_TIME)); @@ -243,7 +243,7 @@ export const handlers = [ switch (body.Action) { case Action.Setup: const newDKGStatus = new Map(mockDKG.get(ElectionID as string)); - var node = ''; + let node = ''; mockElections.get(ElectionID as string).Roster.forEach((n) => { const p = mockNodeProxyAddresses.get(n); @@ -279,7 +279,7 @@ export const handlers = [ rest.get(endpoints.getDKGActors('*', ':ElectionID'), async (req, res, ctx) => { const { ElectionID } = req.params; const Proxy = req.params[0]; - var node = ''; + let node = ''; mockElections.get(ElectionID as string).Roster.forEach((n) => { const p = mockNodeProxyAddresses.get(n); @@ -408,4 +408,14 @@ export const handlers = [ return res(ctx.status(200), ctx.text('Action successfully done')); }), + + rest.delete(endpoints.editProxyAddress('*'), async (req, res, ctx) => { + const NodeAddr = req.params[0]; + + mockNodeProxyAddresses.delete(decodeURIComponent(NodeAddr as string)); + + await new Promise((r) => setTimeout(r, RESPONSE_TIME)); + + return res(ctx.status(200), ctx.text('Action successfully done')); + }), ]; diff --git a/web/frontend/src/mocks/setupMockUserDB.ts b/web/frontend/src/mocks/setupMockUserDB.ts index 957f7f05a..7ed08ff75 100644 --- a/web/frontend/src/mocks/setupMockUserDB.ts +++ b/web/frontend/src/mocks/setupMockUserDB.ts @@ -1,34 +1,34 @@ import ShortUniqueId from 'short-unique-id'; -import { UserRole } from 'types/userRole'; +import { User, UserRole } from 'types/userRole'; const uid = new ShortUniqueId({ length: 8 }); -const mockUser1 = { +const mockUser1: User = { id: uid(), sciper: '123456', role: UserRole.Admin, }; -const mockUser2 = { +const mockUser2: User = { id: uid(), sciper: '234567', role: UserRole.Operator, }; -const mockUser3 = { +const mockUser3: User = { id: uid(), sciper: '345678', role: UserRole.Voter, }; -const user = { +const user: User = { id: uid(), sciper: '561934', role: UserRole.Admin, }; -const setupMockUserDB = () => { - const userDB = []; +const setupMockUserDB = (): User[] => { + const userDB: User[] = []; userDB.push(mockUser1); userDB.push(mockUser2); userDB.push(mockUser3); diff --git a/web/frontend/src/pages/Admin.tsx b/web/frontend/src/pages/Admin.tsx deleted file mode 100644 index ac1cab024..000000000 --- a/web/frontend/src/pages/Admin.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { useContext, useEffect, useState } from 'react'; - -import { ENDPOINT_USER_RIGHTS } from 'components/utils/Endpoints'; - -import AddAdminUserModal from 'components/modal/AddAdminUserModal'; -import { useTranslation } from 'react-i18next'; -import RemoveAdminUserModal from 'components/modal/RemoveAdminUserModal'; -import Loading from './Loading'; -import { FlashContext, FlashLevel } from 'index'; - -const SCIPERS_PER_PAGE = 10; - -const Admin = () => { - const { t } = useTranslation(); - const fctx = useContext(FlashContext); - - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [newUserOpen, setNewUserOpen] = useState(false); - const [scipersToDisplay, setScipersToDisplay] = useState([]); - const [sciperToDelete, setSciperToDelete] = useState(0); - const [pageIndex, setPageIndex] = useState(0); - - const openModal = () => setNewUserOpen(true); - - useEffect(() => { - setLoading(true); - fetch(ENDPOINT_USER_RIGHTS) - .then((resp) => { - setLoading(false); - if (resp.status === 200) { - const jsonData = resp.json(); - jsonData.then((result) => { - setUsers(result); - }); - } else { - setUsers([]); - fctx.addMessage(t('errorFetchingUsers'), FlashLevel.Error); - } - }) - .catch((error) => { - setLoading(false); - fctx.addMessage(`${t('errorFetchingUsers')}: ${error.message}`, FlashLevel.Error); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const partitionArray = (array: any[], size: number) => - array.map((v, i) => (i % size === 0 ? array.slice(i, i + size) : null)).filter((v) => v); - - useEffect(() => { - if (users.length) { - setScipersToDisplay(partitionArray(users, SCIPERS_PER_PAGE)[pageIndex]); - } - }, [users, pageIndex]); - - const handleDelete = (sciper: number): void => { - setSciperToDelete(sciper); - setShowDeleteModal(true); - }; - - const handlePrevious = (): void => { - if (pageIndex > 0) { - setPageIndex(pageIndex - 1); - } - }; - const handleNext = (): void => { - if (partitionArray(users, SCIPERS_PER_PAGE).length > pageIndex + 1) { - setPageIndex(pageIndex + 1); - } - }; - - const handleAddRoleUser = (user: object): void => { - setUsers([...users, user]); - }; - const handleRemoveRoleUser = (): void => { - setUsers(users.filter((user) => user.sciper !== sciperToDelete)); - }; - - return !loading ? ( -
- - -
-
-

- {t('admin')} -

-
-
{t('adminDetails')}
-
-
-
- - - -
-
- -
-
-
-
- - - - - - - - - - {scipersToDisplay.map((user) => ( - - - - - - ))} - -
- Sciper - - {t('role')} - - {t('edit')} -
- {user.sciper} - - {user.role} - -
handleDelete(user.sciper)}> - {t('delete')} -
-
- -
-
-
-
-
- ) : ( - - ); -}; -export default Admin; diff --git a/web/frontend/src/pages/admin/Admin.tsx b/web/frontend/src/pages/admin/Admin.tsx new file mode 100644 index 000000000..9852d415a --- /dev/null +++ b/web/frontend/src/pages/admin/Admin.tsx @@ -0,0 +1,101 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { ENDPOINT_USER_RIGHTS } from 'components/utils/Endpoints'; +import { FlashContext, FlashLevel } from 'index'; +import Loading from 'pages/Loading'; +import { useTranslation } from 'react-i18next'; +import AdminTable from './AdminTable'; +import DKGTable from './DKGTable'; +import useFetchCall from 'components/utils/useFetchCall'; +import * as endpoints from 'components/utils/Endpoints'; + +const Admin: FC = () => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + + const abortController = new AbortController(); + const signal = abortController.signal; + + const request = { + method: 'GET', + signal: signal, + }; + + const [nodeProxyObject, nodeProxyLoading, nodeProxyError] = useFetchCall( + endpoints.getProxiesAddresses, + request + ); + const [, setNodeProxyLoading] = useState(true); + + const [nodeProxyAddresses, setNodeProxyAddresses] = useState>(null); + + useEffect(() => { + if (nodeProxyError !== null) { + fctx.addMessage(t('errorRetrievingProxy') + nodeProxyError.message, FlashLevel.Error); + setNodeProxyLoading(false); + } + + if (nodeProxyObject !== null) { + const newNodeProxyAddresses = new Map(); + + nodeProxyObject.Proxies.forEach((value) => { + Object.entries(value).forEach(([node, proxy]) => { + newNodeProxyAddresses.set(node, proxy); + }); + }); + + setNodeProxyAddresses(newNodeProxyAddresses); + setNodeProxyLoading(false); + } + + return () => { + abortController.abort(); + }; + }, [nodeProxyObject, nodeProxyError]); + + useEffect(() => { + fetch(ENDPOINT_USER_RIGHTS) + .then((resp) => { + setLoading(false); + if (resp.status === 200) { + const jsonData = resp.json(); + jsonData.then((result) => { + setUsers(result); + }); + } else { + setUsers([]); + fctx.addMessage(t('errorFetchingUsers'), FlashLevel.Error); + } + }) + .catch((error) => { + setLoading(false); + fctx.addMessage(`${t('errorFetchingUsers')}: ${error.message}`, FlashLevel.Error); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return !loading && !nodeProxyLoading ? ( +
+
+
+

+ {t('admin')} +

+
+
+ + +
+ +
+
+ ) : ( + + ); +}; + +export default Admin; diff --git a/web/frontend/src/pages/admin/AdminTable.tsx b/web/frontend/src/pages/admin/AdminTable.tsx new file mode 100644 index 000000000..9ffc2a0b2 --- /dev/null +++ b/web/frontend/src/pages/admin/AdminTable.tsx @@ -0,0 +1,165 @@ +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { User } from 'types/userRole'; +import AddAdminUserModal from './components/AddAdminUserModal'; +import RemoveAdminUserModal from './components/RemoveAdminUserModal'; + +const SCIPERS_PER_PAGE = 5; + +type AdminTableProps = { + users: User[]; + setUsers: (users: User[]) => void; +}; + +const AdminTable: FC = ({ users, setUsers }) => { + const { t } = useTranslation(); + + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [newUserOpen, setNewUserOpen] = useState(false); + const [scipersToDisplay, setScipersToDisplay] = useState([]); + const [sciperToDelete, setSciperToDelete] = useState(0); + const [pageIndex, setPageIndex] = useState(0); + + const openModal = () => setNewUserOpen(true); + + const partitionArray = (array: User[], size: number) => + array.map((_v, i) => (i % size === 0 ? array.slice(i, i + size) : null)).filter((v) => v); + + useEffect(() => { + if (users.length) { + setScipersToDisplay(partitionArray(users, SCIPERS_PER_PAGE)[pageIndex]); + } + }, [users, pageIndex]); + + const handleDelete = (sciper: number): void => { + setSciperToDelete(sciper); + setShowDeleteModal(true); + }; + + const handlePrevious = (): void => { + if (pageIndex > 0) { + setPageIndex(pageIndex - 1); + } + }; + + const handleNext = (): void => { + if (partitionArray(users, SCIPERS_PER_PAGE).length > pageIndex + 1) { + setPageIndex(pageIndex + 1); + } + }; + + const handleAddRoleUser = (user: User): void => { + const newUsers = [...users, user]; + setUsers(newUsers); + setPageIndex(partitionArray(newUsers, SCIPERS_PER_PAGE).length - 1); + }; + + const handleRemoveRoleUser = (): void => { + const newUsers = users.filter((user) => user.sciper !== sciperToDelete.toString()); + setUsers(newUsers); + + if (newUsers.length % SCIPERS_PER_PAGE === 0) { + setPageIndex(pageIndex - 1); + } + }; + + return ( +
+ + +
+
+
{t('roles')}
+ +
+
{t('adminDetails')}
+
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + {scipersToDisplay !== undefined && + scipersToDisplay.map((user) => ( + + + + + + ))} + +
+ Sciper + + {t('role')} + + {t('edit')} +
+ {user.sciper} + {user.role} +
handleDelete(user.sciper)}> + {t('delete')} +
+
+ + +
+
+ ); +}; +export default AdminTable; diff --git a/web/frontend/src/pages/admin/DKGTable.tsx b/web/frontend/src/pages/admin/DKGTable.tsx index b13143abf..7dd1640c8 100644 --- a/web/frontend/src/pages/admin/DKGTable.tsx +++ b/web/frontend/src/pages/admin/DKGTable.tsx @@ -1,80 +1,173 @@ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import AddProxyModal from './components/AddProxyModal'; +import EditProxyModal from './components/EditProxyModal'; +import RemoveProxyModal from './components/RemoveProxyModal'; import ProxyRow from './ProxyRow'; -import * as endpoints from 'components/utils/Endpoints'; -import useFetchCall from 'components/utils/useFetchCall'; -import { FlashContext, FlashLevel } from 'index'; -import Loading from 'pages/Loading'; -const DKGTable: FC = () => { - const { t } = useTranslation(); - const fcxt = useContext(FlashContext); +export const NODE_PROXY_PER_PAGE = 5; + +type DKGTableProps = { + nodeProxyAddresses: Map; + setNodeProxyAddresses: (nodeProxyAddress: Map) => void; +}; - const abortController = new AbortController(); - const signal = abortController.signal; +const DKGTable: FC = ({ nodeProxyAddresses, setNodeProxyAddresses }) => { + const { t } = useTranslation(); + const [nodeProxyToDisplay, setNodeProxyToDisplay] = useState>([]); + const [pageIndex, setPageIndex] = useState(0); + const [showAddProxy, setShowAddProxy] = useState(false); + const [showEditProxy, setShowEditProxy] = useState(false); + const [showDeleteProxy, setShowDeleteProxy] = useState(false); + const [nodeToEdit, setNodeToEdit] = useState(null); - const request = { - method: 'GET', - signal: signal, + const partitionMap = (nodeProxy: Map, size: number): [string, string][][] => { + const array: [string, string][] = Array.from(nodeProxy); + return array + .map((_value, index) => (index % size === 0 ? array.slice(index, index + size) : null)) + .filter((v) => v); }; - const [nodeProxyObject, nodeProxyLoading, nodeProxyError] = useFetchCall( - endpoints.getProxiesAddresses, - request - ); + useEffect(() => { + if (nodeProxyAddresses.size) { + setNodeProxyToDisplay(partitionMap(nodeProxyAddresses, NODE_PROXY_PER_PAGE)[pageIndex]); + } + }, [nodeProxyAddresses, pageIndex]); - const [nodeProxyAddresses, setNodeProxyAddresses] = useState(null); + const handlePrevious = (): void => { + if (pageIndex > 0) { + setPageIndex(pageIndex - 1); + } + }; - useEffect(() => { - if (nodeProxyError !== null) { - fcxt.addMessage(nodeProxyError.message, FlashLevel.Error); + const handleNext = (): void => { + if (partitionMap(nodeProxyAddresses, NODE_PROXY_PER_PAGE).length > pageIndex + 1) { + setPageIndex(pageIndex + 1); } + }; - if (nodeProxyObject !== null) { - const newNodeProxyAddresses = new Map(); + const handleAddProxy = (node: string, proxy: string) => { + const newNodeProxy = new Map(nodeProxyAddresses); + newNodeProxy.set(node, proxy); + setNodeProxyAddresses(newNodeProxy); - nodeProxyObject.Proxies.forEach((value) => { - Object.entries(value).forEach(([node, proxy]) => { - newNodeProxyAddresses.set(node, proxy); - }); - }); + setPageIndex(partitionMap(newNodeProxy, NODE_PROXY_PER_PAGE).length - 1); + }; + + const handleDeleteProxy = () => { + const newNodeProxy = new Map(nodeProxyAddresses); + newNodeProxy.delete(nodeToEdit); + setNodeProxyAddresses(newNodeProxy); - setNodeProxyAddresses(newNodeProxyAddresses); + if (newNodeProxy.size % NODE_PROXY_PER_PAGE === 0) { + setPageIndex(pageIndex - 1); } + }; + + return ( +
+ + + - return () => { - abortController.abort(); - }; - }, [nodeProxyObject, nodeProxyError]); - - return !nodeProxyLoading ? ( -
- - - - - - - - - - <> - {nodeProxyAddresses !== null && - Array.from(nodeProxyAddresses).map(([node, proxy], index) => ( - + + +
+
+
{t('nodes')}
+ +
+
{t('nodeDetails')}
+
+
+ +
+ + + +
+
+ +
+
- {t('nodes')} - - {t('proxies')} - - {t('edit')} -
+ + + + + + + + + {nodeProxyToDisplay !== undefined && + nodeProxyToDisplay.map(([node, proxy], index) => ( + ))} - - -
+ {t('node')} + + {t('proxy')} + + {t('edit')} +
+ + + + +
- ) : ( - ); }; diff --git a/web/frontend/src/pages/admin/ProxyRow.tsx b/web/frontend/src/pages/admin/ProxyRow.tsx index 523507eda..0565fe5b9 100644 --- a/web/frontend/src/pages/admin/ProxyRow.tsx +++ b/web/frontend/src/pages/admin/ProxyRow.tsx @@ -1,132 +1,47 @@ -import { AuthContext, FlashContext, FlashLevel } from 'index'; -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import * as endpoints from 'components/utils/Endpoints'; -import { UserRole } from 'types/userRole'; -import usePostCall from 'components/utils/usePostCall'; type ProxyRowProps = { node: string; proxy: string; index: number; + setShowEditProxy: (show: boolean) => void; + setShowDeleteProxy: (show: boolean) => void; + setNodeToEdit: (node: string) => void; }; -const ProxyRow: FC = ({ node, proxy, index }) => { +const ProxyRow: FC = ({ + node, + proxy, + setShowEditProxy, + setShowDeleteProxy, + setNodeToEdit, +}) => { const { t } = useTranslation(); - const fctx = useContext(FlashContext); - const authCtx = useContext(AuthContext); - - const isAuthorized = - authCtx.isLogged && (authCtx.role === UserRole.Admin || authCtx.role === UserRole.Operator); - - const [currentProxy, setCurrentProxy] = useState(null); - const [previousProxy, setPreviousProxy] = useState(null); - const [isEditMode, setIsEditMode] = useState(false); - const [error, setError] = useState(null); - const [isPosting, setIsPosting] = useState(false); - const [postError, setPostError] = useState(null); - - const sendFetchRequest = usePostCall(setPostError); - - useEffect(() => { - if (postError !== null) { - fctx.addMessage(postError, FlashLevel.Error); - setPostError(null); - } - }, [postError]); - - useEffect(() => { - if (proxy !== null) { - setCurrentProxy(proxy); - setPreviousProxy(proxy); - } - }, [proxy]); - - const proxyAddressUpdate = async () => { - const req = { - method: 'PUT', - body: JSON.stringify({ - Proxy: currentProxy, - }), - headers: { - 'Content-Type': 'application/json', - }, - }; - return sendFetchRequest(endpoints.editProxyAddress(node), req, setIsPosting); - }; - - const handleTextInput = (e: React.ChangeEvent) => { - setCurrentProxy(e.target.value); - setError(null); - }; const handleEdit = () => { - setIsEditMode(true); + setShowEditProxy(true); + setNodeToEdit(node); }; - const handleSave = () => { - if (proxy !== '') { - setIsEditMode(false); - setError(null); - setPreviousProxy(currentProxy); - proxyAddressUpdate(); - } else { - setError(t('inputProxyAddressError')); - } - }; - - const handleCancel = () => { - setCurrentProxy(previousProxy); - setIsEditMode(false); - setError(null); + const handleDelete = () => { + setShowDeleteProxy(true); + setNodeToEdit(node); }; return ( - - - {t('node')} {index} ({node}) - - - {isEditMode ? ( - <> - handleTextInput(e)} - placeholder={currentProxy === '' ? 'https:// ...' : ''} - value={currentProxy} - /> - <>{error !== null &&
{error}
} - - ) : ( - <>{currentProxy} - )} + + + {node} + {proxy} - {isAuthorized && ( - <> - {isEditMode ? ( -
- - -
- ) : ( - - )} - - )} + + ); diff --git a/web/frontend/src/pages/admin/components/AddAdminUserModal.tsx b/web/frontend/src/pages/admin/components/AddAdminUserModal.tsx new file mode 100644 index 000000000..031c3853f --- /dev/null +++ b/web/frontend/src/pages/admin/components/AddAdminUserModal.tsx @@ -0,0 +1,157 @@ +import React, { FC, Fragment, useContext, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Dialog, Listbox, Transition } from '@headlessui/react'; +import { CheckIcon, SelectorIcon } from '@heroicons/react/solid'; + +import { useTranslation } from 'react-i18next'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; +import { UserAddIcon } from '@heroicons/react/outline'; +import ShortUniqueId from 'short-unique-id'; +import { FlashContext, FlashLevel } from 'index'; +import { UserRole } from 'types/userRole'; +import { ENDPOINT_ADD_ROLE } from 'components/utils/Endpoints'; +import AdminModal from './AdminModal'; + +const uid = new ShortUniqueId({ length: 8 }); + +type AddAdminUserModalProps = { + open: boolean; + setOpen(opened: boolean): void; + handleAddRoleUser(user: object): void; +}; + +const roles = [UserRole.Admin, UserRole.Operator]; + +const AddAdminUserModal: FC = ({ open, setOpen, handleAddRoleUser }) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + + const [loading, setLoading] = useState(false); + const [sciperValue, setSciperValue] = useState(''); + const [selectedRole, setSelectedRole] = useState(roles[0]); + + const handleCancel = () => setOpen(false); + + const handleUserInput = (e: any) => { + setSciperValue(e.target.value); + }; + + const handleAddUser = async () => { + const userToAdd = { id: uid(), sciper: sciperValue, role: selectedRole }; + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(userToAdd), + }; + + try { + setLoading(true); + const res = await fetch(ENDPOINT_ADD_ROLE, requestOptions); + if (res.status !== 200) { + const response = await res.text(); + fctx.addMessage( + `Error HTTP ${res.status} (${res.statusText}) : ${response}`, + FlashLevel.Error + ); + } else { + setSciperValue(''); + setSelectedRole(roles[0]); + handleAddRoleUser(userToAdd); + fctx.addMessage(`${t('successAddUser')}`, FlashLevel.Info); + } + } catch (error) { + fctx.addMessage(`${t('errorAddUser')}: ${error.message}`, FlashLevel.Error); + } + setLoading(false); + setOpen(false); + }; + + const modalBody = ( + <> + + {t('enterSciper')} + + +
+ +
+ + {selectedRole} + + + + + + {roles.map((role, personIdx) => ( + + `relative cursor-default select-none py-2 pl-10 pr-4 ${ + active ? 'bg-indigo-100 text-indigo-900' : 'text-gray-900' + }` + } + value={role}> + {({ selected }) => ( + <> + + {role} + + {selected ? ( + + + ) : null} + + )} + + ))} + + +
+
+
+ + ); + + const actionButton = ( + + ); + + return ( + + ); +}; + +AddAdminUserModal.propTypes = { + open: PropTypes.bool.isRequired, + setOpen: PropTypes.func.isRequired, +}; + +export default AddAdminUserModal; diff --git a/web/frontend/src/pages/admin/components/AddProxyModal.tsx b/web/frontend/src/pages/admin/components/AddProxyModal.tsx new file mode 100644 index 000000000..c54217ae3 --- /dev/null +++ b/web/frontend/src/pages/admin/components/AddProxyModal.tsx @@ -0,0 +1,147 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { Dialog } from '@headlessui/react'; +import { PlusIcon } from '@heroicons/react/outline'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; +import { FlashContext, FlashLevel } from 'index'; +import { useTranslation } from 'react-i18next'; +import * as endpoints from 'components/utils/Endpoints'; +import usePostCall from 'components/utils/usePostCall'; +import AdminModal from './AdminModal'; + +type AddProxyModalProps = { + open: boolean; + setOpen(opened: boolean): void; + handleAddProxy(node: string, proxy: string): void; +}; + +const AddProxyModal: FC = ({ open, setOpen, handleAddProxy }) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + const [error, setError] = useState(null); + const [postError, setPostError] = useState(null); + const [, setIsPosting] = useState(false); + const [loading, setLoading] = useState(false); + const [node, setNode] = useState(''); + const [proxy, setProxy] = useState(''); + + const sendFetchRequest = usePostCall(setPostError); + + useEffect(() => { + if (postError !== null) { + fctx.addMessage(t('addNodeProxyError') + postError, FlashLevel.Error); + setPostError(null); + } + }, [postError]); + + const handleNodeInput = (e: any) => { + setNode(e.target.value); + setError(null); + }; + + const handleProxyInput = (e: any) => { + setProxy(e.target.value); + setError(null); + }; + + const saveMapping = async () => { + const request = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + NodeAddr: node, + Proxy: proxy, + }), + }; + return sendFetchRequest(endpoints.newProxyAddress, request, setIsPosting); + }; + + const handleAdd = async () => { + setLoading(true); + if (node !== '' && proxy !== '') { + try { + new URL(proxy); + setError(null); + + const response = await saveMapping(); + + if (response) { + handleAddProxy(node, proxy); + fctx.addMessage(t('nodeProxySuccessfullyAdded'), FlashLevel.Info); + setNode(''); + setProxy(''); + } + + setOpen(false); + } catch { + setError(t('invalidProxyError')); + } + } else { + setError(t('inputNodeProxyError')); + } + setLoading(false); + }; + + const handleCancel = () => { + setError(null); + setOpen(false); + }; + + const modalBody = ( + <> + + {t('enterNodeProxy')} + +
+ + +
+ +
+ + +
+ + {error !== null &&
{error}
} + + ); + + const actionButton = ( + <> + + + ); + + return ( + + ); +}; + +export default AddProxyModal; diff --git a/web/frontend/src/pages/admin/components/AdminModal.tsx b/web/frontend/src/pages/admin/components/AdminModal.tsx new file mode 100644 index 000000000..c35fb7252 --- /dev/null +++ b/web/frontend/src/pages/admin/components/AdminModal.tsx @@ -0,0 +1,76 @@ +import React, { FC, Fragment, useRef } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { useTranslation } from 'react-i18next'; + +type AdminModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + modalBody: JSX.Element; + actionButton: JSX.Element; + handleCancel(): void; +}; + +const AdminModal: FC = ({ + open, + setOpen, + modalBody, + actionButton, + handleCancel, +}) => { + const { t } = useTranslation(); + const cancelButtonRef = useRef(null); + + return ( + + +
+ + + + + {/* This element is to trick the browser into centering the modal contents. */} + + +
+
+
{modalBody}
+
+
+ {actionButton} + +
+
+
+
+
+
+ ); +}; + +export default AdminModal; diff --git a/web/frontend/src/pages/admin/components/EditProxyModal.tsx b/web/frontend/src/pages/admin/components/EditProxyModal.tsx new file mode 100644 index 000000000..e3b0cef49 --- /dev/null +++ b/web/frontend/src/pages/admin/components/EditProxyModal.tsx @@ -0,0 +1,149 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { Dialog } from '@headlessui/react'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; +import { CubeTransparentIcon } from '@heroicons/react/outline'; +import { useTranslation } from 'react-i18next'; +import usePostCall from 'components/utils/usePostCall'; +import * as endpoints from 'components/utils/Endpoints'; +import { FlashContext, FlashLevel } from 'index'; +import AdminModal from './AdminModal'; + +type EditProxyModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + nodeProxy: Map; + setNodeProxy: (nodeProxy: Map) => void; + node: string; +}; + +const EditProxyModal: FC = ({ + open, + setOpen, + nodeProxy, + setNodeProxy, + node, +}) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + const [currentProxy, setCurrentProxy] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [, setIsPosting] = useState(false); + const [postError, setPostError] = useState(null); + + const sendFetchRequest = usePostCall(setPostError); + + useEffect(() => { + if (postError !== null) { + fctx.addMessage(t('editProxyError') + postError, FlashLevel.Error); + setPostError(null); + } + }, [postError]); + + useEffect(() => { + if (nodeProxy !== null) { + setCurrentProxy(nodeProxy.get(node)); + } + }, [nodeProxy, node]); + + const proxyAddressUpdate = async () => { + const req = { + method: 'PUT', + body: JSON.stringify({ + Proxy: currentProxy, + }), + headers: { + 'Content-Type': 'application/json', + }, + }; + + return sendFetchRequest(endpoints.editProxyAddress(node), req, setIsPosting); + }; + + const handleTextInput = (e: React.ChangeEvent) => { + setCurrentProxy(e.target.value); + setError(null); + }; + + const handleSave = async () => { + setLoading(true); + + if (currentProxy !== '') { + try { + new URL(currentProxy); + setError(null); + const response = await proxyAddressUpdate(); + + if (response) { + const newNodeProxy = new Map(nodeProxy); + newNodeProxy.set(node, currentProxy); + setNodeProxy(newNodeProxy); + fctx.addMessage(t('proxySuccessfullyEdited'), FlashLevel.Info); + } + + setOpen(false); + } catch { + setError(t('invalidProxyError')); + } + } else { + setError(t('inputProxyAddressError')); + } + setLoading(false); + }; + + const handleCancel = () => { + setError(null); + setOpen(false); + }; + + const modalBody = ( + <> + + {t('editProxy')} + +
+

+ {t('node')}: {node} +

+ + handleTextInput(e)} + placeholder="https:// ..." + value={currentProxy} + /> + {error !== null &&
{error}
} +
+ + ); + + const actionButton = ( + + ); + + return ( + + ); +}; + +export default EditProxyModal; diff --git a/web/frontend/src/pages/admin/components/RemoveAdminUserModal.tsx b/web/frontend/src/pages/admin/components/RemoveAdminUserModal.tsx new file mode 100644 index 000000000..870c7ee3d --- /dev/null +++ b/web/frontend/src/pages/admin/components/RemoveAdminUserModal.tsx @@ -0,0 +1,95 @@ +import React, { FC, useContext, useState } from 'react'; +import { ENDPOINT_REMOVE_ROLE } from 'components/utils/Endpoints'; +import PropTypes from 'prop-types'; +import { Dialog } from '@headlessui/react'; +import { UserRemoveIcon } from '@heroicons/react/outline'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; +import { useTranslation } from 'react-i18next'; +import { FlashContext, FlashLevel } from 'index'; +import AdminModal from './AdminModal'; + +type RemoveAdminUserModalProps = { + open: boolean; + setOpen(opened: boolean): void; + sciper: number; + handleRemoveRoleUser(): void; +}; + +const RemoveAdminUserModal: FC = ({ + open, + setOpen, + sciper, + handleRemoveRoleUser, +}) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + + const [loading, setLoading] = useState(false); + + const handleCancel = () => setOpen(false); + + const handleDelete = async () => { + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sciper: sciper }), + }; + + try { + setLoading(true); + const res = await fetch(ENDPOINT_REMOVE_ROLE, requestOptions); + if (res.status !== 200) { + const response = await res.text(); + fctx.addMessage( + `Error HTTP ${res.status} (${res.statusText}) : ${response}`, + FlashLevel.Error + ); + } else { + handleRemoveRoleUser(); + fctx.addMessage(t('successRemoveUser'), FlashLevel.Info); + } + } catch (error) { + fctx.addMessage(`${t('errorRemoveUser')}: ${error.message}`, FlashLevel.Error); + } + setLoading(false); + setOpen(false); + }; + + const modalBody = ( + + {t('confirmDeleteUserSciper')} {sciper} + + ); + + const actionButton = ( + + ); + + return ( + + ); +}; + +RemoveAdminUserModal.propTypes = { + open: PropTypes.bool.isRequired, + setOpen: PropTypes.func.isRequired, + sciper: PropTypes.number.isRequired, +}; + +export default RemoveAdminUserModal; diff --git a/web/frontend/src/pages/admin/components/RemoveProxyModal.tsx b/web/frontend/src/pages/admin/components/RemoveProxyModal.tsx new file mode 100644 index 000000000..8fd019ba9 --- /dev/null +++ b/web/frontend/src/pages/admin/components/RemoveProxyModal.tsx @@ -0,0 +1,96 @@ +import React, { FC, useContext, useEffect, useState } from 'react'; +import { MinusCircleIcon } from '@heroicons/react/outline'; +import { Dialog } from '@headlessui/react'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; +import { FlashContext, FlashLevel } from 'index'; +import { useTranslation } from 'react-i18next'; +import usePostCall from 'components/utils/usePostCall'; +import * as endpoints from 'components/utils/Endpoints'; +import AdminModal from './AdminModal'; + +type RemoveProxyModalProps = { + open: boolean; + setOpen: (open: boolean) => void; + node: string; + handleDeleteProxy(): void; +}; + +const RemoveProxyModal: FC = ({ + open, + setOpen, + node, + handleDeleteProxy, +}) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); + + const [loading, setLoading] = useState(false); + const [postError, setPostError] = useState(null); + const [, setIsPosting] = useState(false); + + const sendFetchRequest = usePostCall(setPostError); + + useEffect(() => { + if (postError !== null) { + fctx.addMessage(t('removeProxyError') + postError, FlashLevel.Error); + setPostError(null); + } + }, [postError]); + + const handleDelete = async () => { + setLoading(true); + + const req = { + method: 'DELETE', + }; + + const response = await sendFetchRequest(endpoints.editProxyAddress(node), req, setIsPosting); + + if (response) { + handleDeleteProxy(); + fctx.addMessage(t('proxySuccessfullyDeleted'), FlashLevel.Info); + } + + setOpen(false); + setLoading(false); + }; + + const handleCancel = () => setOpen(false); + + const modalBody = ( + <> + + {t('confirmDeleteProxy')} + +
+ {t('node')}: {node} +
+ + ); + + const actionButton = ( + + ); + + return ( + + ); +}; + +export default RemoveProxyModal; diff --git a/web/frontend/src/pages/election/Result.tsx b/web/frontend/src/pages/election/Result.tsx index bc11a773f..738115976 100644 --- a/web/frontend/src/pages/election/Result.tsx +++ b/web/frontend/src/pages/election/Result.tsx @@ -42,7 +42,6 @@ const ElectionResult: FC = () => { const [rankResult, setRankResult] = useState(null); const [selectResult, setSelectResult] = useState(null); const [textResult, setTextResult] = useState(null); - const [downloadedResults, setDownloadedResults] = useState(null); // Group the different results by the ID of the question, const groupByID = ( @@ -135,28 +134,22 @@ const ElectionResult: FC = () => { }); }; - useEffect(() => { - if (result !== null) { - const dataToDownload: DownloadedResults[] = []; - - configuration.Scaffold.forEach((subject: Subject) => { - getResultData(subject, dataToDownload); - }); + const exportJSONData = () => { + const fileName = 'result.json'; - const data = { - Title: configuration.MainTitle, - NumberOfVotes: result.length, - Results: dataToDownload, - }; + const dataToDownload: DownloadedResults[] = []; - setDownloadedResults(data); - } - }, [result]); + configuration.Scaffold.forEach((subject: Subject) => { + getResultData(subject, dataToDownload); + }); - const exportJSONData = () => { - const fileName = 'result.json'; + const data = { + Title: configuration.MainTitle, + NumberOfVotes: result.length, + Results: dataToDownload, + }; - const fileToSave = new Blob([JSON.stringify(downloadedResults, null, 2)], { + const fileToSave = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json', }); diff --git a/web/frontend/src/types/userRole.ts b/web/frontend/src/types/userRole.ts index 61fdef421..3017c378d 100644 --- a/web/frontend/src/types/userRole.ts +++ b/web/frontend/src/types/userRole.ts @@ -1,5 +1,13 @@ +interface User { + id: string; + sciper: string; + role: UserRole; +} + export const enum UserRole { Admin = 'admin', Operator = 'operator', Voter = 'voter', } + +export type { User };