Commit 1b01022b authored by Djordje's avatar Djordje

Implement Voting flow

parent 424c9d2d
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"i18next": "^21.9.1", "i18next": "^21.9.1",
"i18next-browser-languagedetector": "^6.1.5", "i18next-browser-languagedetector": "^6.1.5",
"jwt-decode": "^3.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^11.18.4", "react-i18next": "^11.18.4",
...@@ -11690,6 +11691,11 @@ ...@@ -11690,6 +11691,11 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/kind-of": { "node_modules/kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
...@@ -25424,6 +25430,11 @@ ...@@ -25424,6 +25430,11 @@
"object.assign": "^4.1.2" "object.assign": "^4.1.2"
} }
}, },
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"kind-of": { "kind-of": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"i18next": "^21.9.1", "i18next": "^21.9.1",
"i18next-browser-languagedetector": "^6.1.5", "i18next-browser-languagedetector": "^6.1.5",
"jwt-decode": "^3.1.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^11.18.4", "react-i18next": "^11.18.4",
......
...@@ -11,15 +11,16 @@ const theme = createTheme({ ...@@ -11,15 +11,16 @@ const theme = createTheme({
palette: { palette: {
primary: { primary: {
main: 'rgb(19, 71, 175)' main: 'rgb(19, 71, 175)'
// dark: "#D24944"
} }
}, },
typography: { typography: {
fontFamily: 'Inter', fontFamily: 'Inter',
body1: { body1: {
fontSize: '1rem', fontSize: '1rem',
color: 'rgb(30, 41, 60)'
}, },
allVariants: {
color: 'rgb(39, 49, 58)'
}
}, },
}); });
......
...@@ -230,16 +230,7 @@ export function getBuilding(buildingId, callback) { ...@@ -230,16 +230,7 @@ export function getBuilding(buildingId, callback) {
// } // }
export function getTenentVoting(buildingId, callback) { export function getTenentVoting(buildingId, callback) {
return thunkRequestTenant('/' + buildingId + '/voting', null, (err, data, dsp) => { return thunkRequestTenant('/' + buildingId + '/voting', null, callback, "GET", false)(null)
if (err) {
if (err.status === 401) {
dsp(genericAction(UNAUTHENTICATED))
}
console.error("Can not get tenant voting")
return
}
dsp(genericAction(TENANT_VOTING_RECEIVED, data))
}, "GET", false)(null)
} }
export function getTenantInfo(callback) { export function getTenantInfo(callback) {
......
import { Box } from "@mui/material" import { Box } from "@mui/material"
import jwtDecode from "jwt-decode";
import { useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { JWT_TENANT } from "../actions/network-manager";
import { BuildingInvoices } from "./BuildingInvoices/BuildingInvoices"; import { BuildingInvoices } from "./BuildingInvoices/BuildingInvoices";
import { Settings } from "./Settings/Settings";
import { Voting } from "./Voting/Voting";
const classes = { const classes = {
wrapper: { wrapper: {
...@@ -10,21 +15,32 @@ const classes = { ...@@ -10,21 +15,32 @@ const classes = {
boxSizing: 'border-box', boxSizing: 'border-box',
paddingBlock: '3%', paddingBlock: '3%',
paddingInline: '11%', paddingInline: '11%',
'@media (max-width: 800px)': {
paddingInline: '5%',
}
} }
} as const ; } as const ;
export const ApplicationTab = () => { export const ApplicationTab = () => {
const getBuildingId = () => {
let res: any = jwtDecode(localStorage.getItem(JWT_TENANT)!);
return res.bl[0].id;
}
const [buildingId, setBuildingId] = useState(getBuildingId());
return ( return (
<Box sx={classes.wrapper}> <Box sx={classes.wrapper}>
<Routes> <Routes>
<Route path='overview' element={<div>Hello from overview</div>} /> <Route path='overview' element={<div>Hello from overview</div>} />
<Route path='notifications' element={<div>Hello from notifications</div>} /> <Route path='notifications' element={<div>Hello from notifications</div>} />
<Route path='issues' element={<div>Hello from issues</div>} /> <Route path='issues' element={<div>Hello from issues</div>} />
<Route path='voting' element={<div>Hello from voting</div>} /> <Route path='voting' element={<Voting buildingId={buildingId}/>} />
<Route path='invoices' element={<div>Hello from invoices</div>} /> <Route path='invoices' element={<div>Hello from invoices</div>} />
<Route path='building-invoices' element={<BuildingInvoices />} /> <Route path='building-invoices' element={<BuildingInvoices buildingId={buildingId}/>} />
<Route path='documents' element={<div>Hello from documents</div>} /> <Route path='documents' element={<div>Hello from documents</div>} />
<Route path='settings' element={<div>Hello from settings</div>} /> <Route path='settings' element={<Settings />} />
<Route path='*' element={<Navigate replace to="overview" />} /> <Route path='*' element={<Navigate replace to="overview" />} />
</Routes> </Routes>
</Box> </Box>
......
import { Box, Typography } from "@mui/material";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getBuilding } from "../../actions/data-manager"; import { getBuilding } from "../../actions/data-manager";
import { TabWrapper } from "../TabWrapper/TabWrapper";
import { BuildingInvoicesList } from "./BuildingInvoicesList"; import { BuildingInvoicesList } from "./BuildingInvoicesList";
const classes = { export const BuildingInvoices = ({ buildingId }: { buildingId: string }) => {
wrapper: {
display: 'flex',
flexDirection: 'column',
gap: '50px',
},
header: {
fontWeight: 600,
fontSize: '1.6em',
color: '#27313a'
}
} as const;
export const BuildingInvoices = () => {
const [building, setBuilding] = useState<any>(); const [building, setBuilding] = useState<any>();
useEffect(() => { useEffect(() => {
getBuilding('62a1ca0cf2c321d1f5d4aa8d', (err: any, data: any) => {setBuilding(data)}); getBuilding(buildingId, (err: any, data: any) => { setBuilding(data) });
}, []); }, []);
return ( return (
<> <>
{ building && {building &&
<Box sx={classes.wrapper}> <TabWrapper header='Finansije Zgrade'>
<Typography variant="h5" sx={classes.header}>Finansije Zgrade</Typography> <BuildingInvoicesList building={building} />
<BuildingInvoicesList building={building}/> </TabWrapper>
</Box>
} }
</> </>
); );
......
import { Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card } from "../Card/Card";
import { PayableStatus } from "./PayableStatus"; import { PayableStatus } from "./PayableStatus";
const classes = { const classes = {
tableWrapper: {
borderRadius: '15px',
boxSizing: 'border-box',
paddingBlock: '10px',
paddingInline: '40px',
display: 'flex',
flexDirection: 'column',
gap: '30px',
backgroundColor: 'white',
boxShadow: 'rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px'
},
tableHead: { tableHead: {
'& .MuiTableCell-root': { '& .MuiTableCell-root': {
color: '#64748b', color: '#64748b',
...@@ -67,7 +57,7 @@ export const BuildingInvoicesList = ({ building }: { building: any }) => { ...@@ -67,7 +57,7 @@ export const BuildingInvoicesList = ({ building }: { building: any }) => {
payables.sort((a: any, b: any) => Date.parse(a.created) - Date.parse(b.created)) payables.sort((a: any, b: any) => Date.parse(a.created) - Date.parse(b.created))
return ( return (
<Box style={classes.tableWrapper}> <Card>
<TableContainer > <TableContainer >
<Table size="medium"> <Table size="medium">
<TableHead> <TableHead>
...@@ -92,6 +82,6 @@ export const BuildingInvoicesList = ({ building }: { building: any }) => { ...@@ -92,6 +82,6 @@ export const BuildingInvoicesList = ({ building }: { building: any }) => {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
</Box> </Card>
); );
} }
\ No newline at end of file
.card {
border-radius: 15px;
padding-block: 10px;
padding-inline: 40px;
display: flex;
flex-direction: column;
gap: 15px;
background-color: white;
box-shadow: rgba(0, 0, 0, 0.12) 0px 1px 3px, rgba(0, 0, 0, 0.24) 0px 1px 2px;
position: relative;
}
\ No newline at end of file
import { ReactNode } from "react"
import './Card.css';
export const Card = ({children}: {children: ReactNode}) => {
return (<div className="card">{children}</div>)
}
\ No newline at end of file
import { Box, Button, TextField, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { getTenantInfo } from "../../actions/data-manager";
import { Card } from "../Card/Card";
import { CheckMark } from "../icons/CheckMark";
import { keyToBase64, PRIVATE_KEY } from "../register/RegisterForm";
import { TabWrapper } from "../TabWrapper/TabWrapper";
import { Buffer } from "buffer";
import nacl, { verify } from "tweetnacl";
const classes = {
votingSeetings: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
},
textWrapper: {
display: 'flex',
flexDirection: 'column',
gap: '15px'
},
header: {
color: 'gray',
fontSize: '0.9rem'
},
button: {
marginLeft: 'auto',
borderRadius: '50px'
}
} as const;
export const Settings = () => {
const [privateKey, setPrivateKey] = useState(localStorage.getItem(PRIVATE_KEY));
const [publicKey, setPublicKey] = useState();
const [newPrivateKey, setNewPrivateKey] = useState('');
const [helperText, setHelperText] = useState('');
useEffect(() => {
getTenantInfo((err: any, user: any) => {
if (user.voting.hasVotingKey) {
setPublicKey(user.votingKey);
}
});
}, []);
const verifyKey = () => {
try {
let buf = Buffer.from(newPrivateKey, 'base64');
let keyPair = nacl.sign.keyPair.fromSecretKey(buf);
if (keyToBase64(keyPair.publicKey) === publicKey) {
setPrivateKey(newPrivateKey);
localStorage.setItem(PRIVATE_KEY, newPrivateKey);
setHelperText('');
}
} catch (error) {
setHelperText('Neispravan kljuc!');
}
}
return (
<TabWrapper header="Podešavanja">
<Card>
{!!privateKey &&
<Box sx={classes.votingSeetings}>
<Box sx={classes.textWrapper}>
<Typography sx={classes.header}>Glasanje</Typography>
<Typography>Privatni ključ je uspešno podešen u aplikaciji.</Typography>
</Box>
<CheckMark />
</Box>
}
{
!privateKey &&
<>
<Typography sx={classes.header}>Glasanje</Typography>
<Typography>Kako bi mogli da učestvujete u glasanju, molimo vas unesite vaš privatni ključ.</Typography>
<TextField
error={!!helperText}
helperText={helperText}
onChange={(e) => {
setNewPrivateKey(e.target.value);
}} size='small' />
<Button variant='contained' sx={classes.button} onClick={verifyKey}>Potvrdi</Button>
</>
}
</Card>
</TabWrapper>
);
}
\ No newline at end of file
...@@ -26,11 +26,15 @@ const classes = { ...@@ -26,11 +26,15 @@ const classes = {
cursor: 'pointer', cursor: 'pointer',
':hover': { ':hover': {
backgroundColor: 'rgba(150, 150, 150, 0.2)' backgroundColor: 'rgba(150, 150, 150, 0.2)'
} },
'& .MuiTypography-root': {
color: 'white'
},
}, },
selectedItem: { selectedItem: {
backgroundColor: 'rgba(200, 200, 200, 0.3)', backgroundColor: 'rgba(200, 200, 200, 0.3)',
'& .MuiTypography-root': { '& .MuiTypography-root': {
color: 'white',
fontWeight: 'bold' fontWeight: 'bold'
}, },
'&:hover': { '&:hover': {
...@@ -57,7 +61,7 @@ export const SideMenuItem = ({ content, selectedItem, changeSelectedItem }: { co ...@@ -57,7 +61,7 @@ export const SideMenuItem = ({ content, selectedItem, changeSelectedItem }: { co
} }
}}> }}>
{!!item.icon && item.icon} {!!item.icon && item.icon}
<Typography sx={{color: 'white'}}>{item.title}</Typography> <Typography>{item.title}</Typography>
</Box> </Box>
))} ))}
</Box> </Box>
......
.wrapper { .wrapper {
box-sizing: border-box; display: flex;
padding-block: 3%; flex-direction: column;
padding-inline: 11%; gap: 50px;
min-height: 100%; }
.wrapper .MuiTypography-root {
font-weight: 600;
font-size: 1.6em;
} }
\ No newline at end of file
import { Box, Typography } from "@mui/material"
import { ReactNode } from "react" import { ReactNode } from "react"
import './TabWrapper.css' import './TabWrapper.css'
export const TabWrapper = ({children}: {children: ReactNode}) => { const classes = {
wrapper: {
display: 'flex',
flexDirection: 'column',
gap: '50px'
},
header: {
fontWeight: 600,
fontSize: '1.6em'
}
} as const;
export const TabWrapper = ({header, children}: {header: string, children?: ReactNode}) => {
return ( return (
<div className="wrapper"> <Box sx={classes.wrapper}>
<Typography variant='h5' sx={classes.header}>{header}</Typography>
{children} {children}
</div> </Box>
) )
} }
\ No newline at end of file
import { Box, Button, FormControl, FormControlLabel, FormLabel, Radio, RadioGroup, Typography } from "@mui/material";
import jwtDecode from "jwt-decode";
import { useEffect, useMemo, useState } from "react";
import nacl from "tweetnacl";
import { getTenentVoting, postVote } from "../../actions/data-manager";
import { JWT_TENANT } from "../../actions/network-manager";
import { VotingEntity } from "../../internal-types";
import { keyToBase64, PRIVATE_KEY } from "../register/RegisterForm";
import { Warning } from "./Warning";
import { Buffer } from "buffer";
import { TabWrapper } from "../TabWrapper/TabWrapper";
import { Card } from "../Card/Card";
const classes = {
wrapper: {
display: 'flex',
flexDirection: 'column',
gap: '50px',
},
header: {
fontWeight: 600,
fontSize: '1.6em',
},
subheader: {
color: 'gray',
fontSize: '0.9rem'
},
subject: {
fontSize: '1.2rem'
},
button: {
marginLeft: 'auto',
borderRadius: '50px'
}
} as const;
export const Voting = ({ buildingId }: { buildingId: string }): JSX.Element => {
const [status, setStatus] = useState();
const [voting, setVoting] = useState<VotingEntity>();
const [selectedCandidateId, setSelectedCandidateId] = useState('');
const [errorText, setErrorText] = useState('');
const [privateKey, setPrivateKey] = useState(localStorage.getItem(PRIVATE_KEY));
useEffect(() => {
getTenentVoting(buildingId, (err: any, res: any, disp: any) => {
console.log(res);
setStatus(res.status || "not allowed");
setVoting(res);
})
}, []);
const handleVote = () => {
let voteData = { votingId: voting!._id, votedFor: selectedCandidateId, time: new Date() };
let voteDataStr = JSON.stringify(voteData);
let dataBuffer = Buffer.from(voteDataStr);
let buffKey = Buffer.from(privateKey!, 'base64');
const signatureRaw = nacl.sign.detached(dataBuffer, buffKey);
let signature = keyToBase64(signatureRaw);
let vote = { vote: voteDataStr, signature };
postVote(buildingId, vote, (err: any, data: any) => {
if (err) {
if (err.status === 406) {
setErrorText("Signature not valid!");
} else {
setErrorText(err.errorMessage);
}
} else {
setStatus(data.status)
}
})(null);
}
return (<>
{
!!voting &&
<TabWrapper header="Glasanje">
{!!privateKey &&
<>
{status === "not allowed" && <Warning text='Vaš nalog nije potvrđen kao vlasnik ni jedne jedinice. Molimo obratite se upravniku stambene zajednice.' />}
{status !== "not allowed" &&
<Card>
<Typography sx={classes.subheader}>Tekuće glasanje</Typography>
{status === "no voting" && <Typography>U ovom trenutku nema aktivnog glasanja.</Typography>}
{status === "in progress" &&
<>
<Typography sx={classes.subject}>{voting.subject}</Typography>
<FormControl>
<FormLabel>Opcije</FormLabel>
<RadioGroup value={selectedCandidateId} onChange={(event) => { setSelectedCandidateId(event.target.value) }}>
{voting.candidates.map((candidate, ind: number) => (<FormControlLabel value={candidate._id} control={<Radio />} label={candidate.name} key={ind} />))}
</RadioGroup>
</FormControl>
<Button variant='contained' sx={classes.button} disabled={selectedCandidateId === ''} onClick={handleVote}>Glasaj</Button>
</>
}
{status === "voted" && <Typography>Hvala vam na glasanju. </Typography>}
</Card>
}
</>
}
{
!privateKey && <Warning text='Glasanje je onemogućeno jer privatan ključ nije sačuvan u aplikaciji. Molimo vas, podesite ključ u podešavanjima!' />
}
</TabWrapper>
}
</>
);
}
\ No newline at end of file
import { Typography } from "@mui/material";
import { Box } from "@mui/system";
import { WarningIcon } from "../icons/WarningIcon";
const classes = {
wrapper: {
padding: '20px',
backgroundColor: '#FFCC00',
borderRadius: '10px',
'& .MuiTypography-root': {
fontWeight: 'bolder'
},
display: 'flex',
alignItems: 'center',
gap: '20px'
}
} as const;
export const Warning = ({text}: {text: string}) => {
return (
<Box sx={classes.wrapper}>
<WarningIcon />
<Typography>{text}</Typography>
</Box>
);
}
\ No newline at end of file
import './Icons.css';
export const CheckMark = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="icons-stroke" style={{stroke: '#4BB543', marginLeft: 'auto', width: '50px'}}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
...@@ -8,6 +8,11 @@ ...@@ -8,6 +8,11 @@
width: 3%; width: 3%;
} }
.alert {
width: 6%;
stroke: rgb(39, 49, 58);
}
.logout:hover { .logout:hover {
stroke: white; stroke: white;
} }
...@@ -19,3 +24,10 @@ ...@@ -19,3 +24,10 @@
.user-icon { .user-icon {
width: 25%; width: 25%;
} }
@media (max-width: 800px) {
.alert {
width: 100px;
}
}
\ No newline at end of file
import './Icons.css';
export const WarningIcon = ({style}: {style?: any}) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="alert" style={style}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);
}
...@@ -29,3 +29,38 @@ export type SubMenuItem = { ...@@ -29,3 +29,38 @@ export type SubMenuItem = {
title: string, title: string,
path?: string path?: string
} }
export type Vote = {
votedTime: Date,
unit: string,
user: string,
vote: string,
signature: string,
type: string
}
export type Candidate = {
_id: string,
name: string,
link: string,
votes: Vote[]
}
export type VotingEntity = {
_id: string,
subject: string,
description?: string,
start: Date,
end: Date,
status: string,
candidates: Candidate[],
selection: string,
winner: string,
currentResults: {
voted: number,
totalSelection: number,
quorum: number,
unavailable: number
},
repeating: number
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment