diff --git a/contracts/evoting/controller/action.go b/contracts/evoting/controller/action.go index 1d1e5e190..a030613be 100644 --- a/contracts/evoting/controller/action.go +++ b/contracts/evoting/controller/action.go @@ -164,6 +164,7 @@ func (a *RegisterAction) Execute(ctx node.Context) error { router.HandleFunc("/evoting/elections/{electionID}", ep.Election).Methods("GET") router.HandleFunc("/evoting/elections/{electionID}", ep.EditElection).Methods("PUT") router.HandleFunc("/evoting/elections/{electionID}", eproxy.AllowCORS).Methods("OPTIONS") + router.HandleFunc("/evoting/elections/{electionID}", ep.DeleteElection).Methods("DELETE") router.HandleFunc("/evoting/elections/{electionID}/vote", ep.NewElectionVote).Methods("POST") router.NotFoundHandler = http.HandlerFunc(eproxy.NotFoundHandler) diff --git a/contracts/evoting/evoting.go b/contracts/evoting/evoting.go index d62eabed1..cb082533b 100644 --- a/contracts/evoting/evoting.go +++ b/contracts/evoting/evoting.go @@ -115,6 +115,8 @@ func (e evotingCommand) createElection(snap store.Snapshot, step execution.Step) ShuffleThreshold: threshold.ByzantineThreshold(roster.Len()), } + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) + electionBuf, err := election.Serialize(e.context) if err != nil { return xerrors.Errorf("failed to marshal Election : %v", err) @@ -182,6 +184,7 @@ func (e evotingCommand) openElection(snap store.Snapshot, step execution.Step) e } election.Status = types.Open + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) if election.Pubkey != nil { return xerrors.Errorf("pubkey is already set: %s", election.Pubkey) @@ -251,6 +254,8 @@ func (e evotingCommand) castVote(snap store.Snapshot, step execution.Step) error return xerrors.Errorf("failed to set value: %v", err) } + PromElectionBallots.WithLabelValues(election.ElectionID).Set(float64(len(election.Suffragia.Ciphervotes))) + return nil } @@ -399,9 +404,12 @@ func (e evotingCommand) shuffleBallots(snap store.Snapshot, step execution.Step) election.ShuffleInstances = append(election.ShuffleInstances, currentShuffleInstance) + PromElectionShufflingInstances.WithLabelValues(election.ElectionID).Set(float64(len(election.ShuffleInstances))) + // in case we have enough shuffled ballots, we update the status if len(election.ShuffleInstances) >= election.ShuffleThreshold { election.Status = types.ShuffledBallots + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) } electionBuf, err := election.Serialize(e.context) @@ -477,6 +485,7 @@ func (e evotingCommand) closeElection(snap store.Snapshot, step execution.Step) } election.Status = types.Closed + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) electionBuf, err := election.Serialize(e.context) if err != nil { @@ -561,31 +570,36 @@ func (e evotingCommand) registerPubshares(snap store.Snapshot, step execution.St } } + units := &election.PubsharesUnits + // Check the node hasn't made any other submissions - for _, key := range election.PubsharesUnits.PubKeys { + for _, key := range units.PubKeys { if bytes.Equal(key, tx.PublicKey) { return xerrors.Errorf("'%x' already made a submission", key) } } - for _, index := range election.PubsharesUnits.Indexes { + for _, index := range units.Indexes { if index == tx.Index { return xerrors.Errorf("a submission has already been made for index %d", index) } } // Add the pubshares to the election - election.PubsharesUnits.Pubshares = append(election.PubsharesUnits.Pubshares, tx.Pubshares) - election.PubsharesUnits.PubKeys = append(election.PubsharesUnits.PubKeys, tx.PublicKey) - election.PubsharesUnits.Indexes = append(election.PubsharesUnits.Indexes, tx.Index) + units.Pubshares = append(units.Pubshares, tx.Pubshares) + units.PubKeys = append(units.PubKeys, tx.PublicKey) + units.Indexes = append(units.Indexes, tx.Index) - nbrSubmissions := len(election.PubsharesUnits.Pubshares) + nbrSubmissions := len(units.Pubshares) + + PromElectionPubShares.WithLabelValues(election.ElectionID).Set(float64(nbrSubmissions)) // For the Unikernel we must have as many shares as nodes. This is because // we are not storing the indexes of each node and the Unikernel is expected // all shares in order of indexes. if nbrSubmissions >= election.Roster.Len() { election.Status = types.PubSharesSubmitted + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) } electionBuf, err := election.Serialize(e.context) @@ -735,6 +749,7 @@ func (e evotingCommand) combineShares(snap store.Snapshot, step execution.Step) election.DecryptedBallots = ballots election.Status = types.ResultAvailable + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) electionBuf, err := election.Serialize(e.context) if err != nil { @@ -768,6 +783,7 @@ func (e evotingCommand) cancelElection(snap store.Snapshot, step execution.Step) } election.Status = types.Canceled + PromElectionStatus.WithLabelValues(election.ElectionID).Set(float64(election.Status)) electionBuf, err := election.Serialize(e.context) if err != nil { @@ -782,6 +798,62 @@ func (e evotingCommand) cancelElection(snap store.Snapshot, step execution.Step) return nil } +// deleteElection implements commands. It performs the DELETE_ELECTION command +func (e evotingCommand) deleteElection(snap store.Snapshot, step execution.Step) error { + + msg, err := e.getTransaction(step.Current) + if err != nil { + return xerrors.Errorf(errGetTransaction, err) + } + + tx, ok := msg.(types.DeleteElection) + if !ok { + return xerrors.Errorf(errWrongTx, msg) + } + + election, electionID, err := e.getElection(tx.ElectionID, snap) + if err != nil { + return xerrors.Errorf(errGetElection, err) + } + + err = snap.Delete(electionID) + if err != nil { + return xerrors.Errorf("failed to delete election: %v", err) + } + + // Update the election metadata store + + electionsMetadataBuf, err := snap.Get([]byte(ElectionsMetadataKey)) + if err != nil { + return xerrors.Errorf("failed to get key '%s': %v", electionsMetadataBuf, err) + } + + if len(electionsMetadataBuf) == 0 { + return nil + } + + var electionsMetadata types.ElectionsMetadata + + err = json.Unmarshal(electionsMetadataBuf, &electionsMetadata) + if err != nil { + return xerrors.Errorf("failed to unmarshal ElectionsMetadata: %v", err) + } + + electionsMetadata.ElectionsIDs.Remove(election.ElectionID) + + electionMetadataJSON, err := json.Marshal(electionsMetadata) + if err != nil { + return xerrors.Errorf("failed to marshal ElectionsMetadata: %v", err) + } + + err = snap.Set([]byte(ElectionsMetadataKey), electionMetadataJSON) + if err != nil { + return xerrors.Errorf("failed to set value: %v", err) + } + + return nil +} + // isMemberOf is a utility function to verify if a public key is associated to a // member of the roster or not. Returns nil if it's the case. func isMemberOf(roster authority.Authority, publicKey []byte) error { diff --git a/contracts/evoting/json/transaction.go b/contracts/evoting/json/transaction.go index f9b46e589..7737c6168 100644 --- a/contracts/evoting/json/transaction.go +++ b/contracts/evoting/json/transaction.go @@ -2,6 +2,7 @@ package json import ( "encoding/json" + "github.com/dedis/d-voting/contracts/evoting/types" "go.dedis.ch/dela/serde" "golang.org/x/xerrors" @@ -111,6 +112,12 @@ func (transactionFormat) Encode(ctx serde.Context, msg serde.Message) ([]byte, e } m = TransactionJSON{CancelElection: &ce} + case types.DeleteElection: + de := DeleteElectionJSON{ + ElectionID: t.ElectionID, + } + + m = TransactionJSON{DeleteElection: &de} default: return nil, xerrors.Errorf("unknown type: '%T", msg) } @@ -178,6 +185,10 @@ func (transactionFormat) Decode(ctx serde.Context, data []byte) (serde.Message, ElectionID: m.CancelElection.ElectionID, UserID: m.CancelElection.UserID, }, nil + case m.DeleteElection != nil: + return types.DeleteElection{ + ElectionID: m.DeleteElection.ElectionID, + }, nil } return nil, xerrors.Errorf("empty type: %s", data) @@ -194,6 +205,7 @@ type TransactionJSON struct { RegisterPubShares *RegisterPubSharesJSON `json:",omitempty"` CombineShares *CombineSharesJSON `json:",omitempty"` CancelElection *CancelElectionJSON `json:",omitempty"` + DeleteElection *DeleteElectionJSON `json:",omitempty"` } // CreateElectionJSON is the JSON representation of a CreateElection transaction @@ -251,6 +263,11 @@ type CancelElectionJSON struct { UserID string } +// DeleteElectionJSON is the JSON representation of a DeleteElection transaction +type DeleteElectionJSON struct { + ElectionID string +} + func decodeCastVote(ctx serde.Context, m CastVoteJSON) (serde.Message, error) { factory := ctx.GetFactory(types.CiphervoteKey{}) if factory == nil { diff --git a/contracts/evoting/mod.go b/contracts/evoting/mod.go index 820d8411a..1333c0e0f 100644 --- a/contracts/evoting/mod.go +++ b/contracts/evoting/mod.go @@ -1,8 +1,10 @@ package evoting import ( + dvoting "github.com/dedis/d-voting" "github.com/dedis/d-voting/contracts/evoting/types" "github.com/dedis/d-voting/services/dkg" + "github.com/prometheus/client_golang/prometheus" "go.dedis.ch/dela/core/access" "go.dedis.ch/dela/core/execution" "go.dedis.ch/dela/core/execution/native" @@ -19,6 +21,36 @@ import ( _ "github.com/dedis/d-voting/contracts/evoting/json" ) +var ( + PromElectionStatus = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "dvoting_status", + Help: "status of election", + }, + []string{"election"}, + ) + + PromElectionBallots = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "dvoting_ballots_total", + Help: "number of cast ballots", + }, + []string{"election"}, + ) + + PromElectionShufflingInstances = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "dvoting_shufflings_total", + Help: "number of shuffling instances", + }, + []string{"election"}, + ) + + PromElectionPubShares = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "dvoting_pubshares_total", + Help: "published public shares", + }, + []string{"election"}, + ) +) + const ( // ElectionsMetadataKey is the key at which election metadata are saved in // the storage. @@ -55,6 +87,7 @@ type commands interface { registerPubshares(snap store.Snapshot, step execution.Step) error combineShares(snap store.Snapshot, step execution.Step) error cancelElection(snap store.Snapshot, step execution.Step) error + deleteElection(snap store.Snapshot, step execution.Step) error } // Command defines a type of command for the value contract @@ -72,12 +105,16 @@ const ( // CmdShuffleBallots is the command to shuffle ballots CmdShuffleBallots Command = "SHUFFLE_BALLOTS" + // CmdRegisterPubShares is the command to register the pubshares CmdRegisterPubShares Command = "REGISTER_PUB_SHARES" // CmdCombineShares is the command to decrypt ballots CmdCombineShares Command = "COMBINE_SHARES" // CmdCancelElection is the command to cancel an election CmdCancelElection Command = "CANCEL_ELECTION" + + // CmdDeleteElection is the command to delete an election + CmdDeleteElection Command = "DELETE_ELECTION" ) // NewCreds creates new credentials for a evoting contract execution. We might @@ -209,9 +246,22 @@ func (c Contract) Execute(snap store.Snapshot, step execution.Step) error { if err != nil { return xerrors.Errorf("failed to cancel election: %v", err) } + case CmdDeleteElection: + err := c.cmd.deleteElection(snap, step) + if err != nil { + return xerrors.Errorf("failed to delete election: %v", err) + } default: return xerrors.Errorf("unknown command: %s", cmd) } return nil } + +func init() { + dvoting.PromCollectors = append(dvoting.PromCollectors, + PromElectionStatus, + PromElectionBallots, + PromElectionShufflingInstances, + PromElectionPubShares) +} diff --git a/contracts/evoting/mod_test.go b/contracts/evoting/mod_test.go index 0daa61cfe..77ff9f902 100644 --- a/contracts/evoting/mod_test.go +++ b/contracts/evoting/mod_test.go @@ -21,6 +21,7 @@ import ( "github.com/dedis/d-voting/contracts/evoting/types" "github.com/dedis/d-voting/internal/testing/fake" "github.com/dedis/d-voting/services/dkg" + "github.com/prometheus/client_golang/prometheus/testutil" "github.com/rs/zerolog" "github.com/stretchr/testify/require" "go.dedis.ch/dela" @@ -125,6 +126,8 @@ func TestExecute(t *testing.T) { } func TestCommand_CreateElection(t *testing.T) { + initMetrics() + fakeActor := fakeDkgActor{ publicKey: suite.Point(), err: nil, @@ -183,6 +186,7 @@ func TestCommand_CreateElection(t *testing.T) { require.True(t, ok) require.Equal(t, types.Initial, election.Status) + require.Equal(t, float64(types.Initial), testutil.ToFloat64(PromElectionStatus)) } func TestCommand_OpenElection(t *testing.T) { @@ -190,6 +194,8 @@ func TestCommand_OpenElection(t *testing.T) { } func TestCommand_CastVote(t *testing.T) { + initMetrics() + castVote := types.CastVote{ ElectionID: fakeElectionID, UserID: "dummyUserId", @@ -314,11 +320,13 @@ func TestCommand_CastVote(t *testing.T) { require.Len(t, election.Suffragia.Ciphervotes, 1) require.True(t, castVote.Ballot.Equal(election.Suffragia.Ciphervotes[0])) - require.Equal(t, castVote.UserID, - election.Suffragia.UserIDs[0]) + require.Equal(t, castVote.UserID, election.Suffragia.UserIDs[0]) + require.Equal(t, float64(len(election.Suffragia.Ciphervotes)), testutil.ToFloat64(PromElectionBallots)) } func TestCommand_CloseElection(t *testing.T) { + initMetrics() + closeElection := types.CloseElection{ ElectionID: fakeElectionID, UserID: "dummyUserId", @@ -365,6 +373,7 @@ func TestCommand_CloseElection(t *testing.T) { err = cmd.closeElection(snap, makeStep(t, ElectionArg, string(data))) require.EqualError(t, err, fmt.Sprintf("the election is not open, "+ "current status: %d", types.Initial)) + require.Equal(t, 0, testutil.CollectAndCount(PromElectionStatus)) dummyElection.Status = types.Open @@ -388,6 +397,7 @@ func TestCommand_CloseElection(t *testing.T) { err = cmd.closeElection(snap, makeStep(t, ElectionArg, string(data))) require.NoError(t, err) + require.Equal(t, float64(types.Closed), testutil.ToFloat64(PromElectionStatus)) res, err := snap.Get(dummyElectionIDBuff) require.NoError(t, err) @@ -399,6 +409,7 @@ func TestCommand_CloseElection(t *testing.T) { require.True(t, ok) require.Equal(t, types.Closed, election.Status) + require.Equal(t, float64(types.Closed), testutil.ToFloat64(PromElectionStatus)) } func TestCommand_ShuffleBallotsCannotShuffleTwice(t *testing.T) { @@ -445,6 +456,8 @@ func TestCommand_ShuffleBallotsCannotShuffleTwice(t *testing.T) { } func TestCommand_ShuffleBallotsValidScenarios(t *testing.T) { + initMetrics() + k := 3 // Simple Shuffle from round 0 : @@ -470,6 +483,7 @@ func TestCommand_ShuffleBallotsValidScenarios(t *testing.T) { err = cmd.shuffleBallots(snap, step) require.NoError(t, err) + require.Equal(t, float64(1), testutil.ToFloat64(PromElectionShufflingInstances)) // Valid Shuffle is over : shuffleBallots.Round = k @@ -499,6 +513,7 @@ func TestCommand_ShuffleBallotsValidScenarios(t *testing.T) { err = cmd.shuffleBallots(snap, makeStep(t, ElectionArg, string(data))) require.NoError(t, err) + require.Equal(t, float64(k+1), testutil.ToFloat64(PromElectionShufflingInstances)) // Check the shuffle is over: electionTxIDBuff, err := hex.DecodeString(election.ElectionID) @@ -513,7 +528,8 @@ func TestCommand_ShuffleBallotsValidScenarios(t *testing.T) { election, ok := message.(types.Election) require.True(t, ok) - require.Equal(t, election.Status, types.ShuffledBallots) + require.Equal(t, types.ShuffledBallots, election.Status) + require.Equal(t, float64(types.ShuffledBallots), testutil.ToFloat64(PromElectionStatus)) } func TestCommand_ShuffleBallotsFormatErrors(t *testing.T) { @@ -883,6 +899,7 @@ func TestCommand_RegisterPubShares(t *testing.T) { err = cmd.registerPubshares(snap, makeStep(t, ElectionArg, string(data))) require.NoError(t, err) + require.Equal(t, float64(1), testutil.ToFloat64(PromElectionPubShares)) // With the public key already used: election.PubsharesUnits.PubKeys = append(election.PubsharesUnits.PubKeys, @@ -923,6 +940,7 @@ func TestCommand_RegisterPubShares(t *testing.T) { err = cmd.registerPubshares(snap, makeStep(t, ElectionArg, string(data))) require.NoError(t, err) + require.Equal(t, float64(1), testutil.ToFloat64(PromElectionPubShares)) res, err := snap.Get(dummyElectionIDBuff) require.NoError(t, err) @@ -1352,6 +1370,7 @@ func Test_ImportBallots(t *testing.T) { require.Equal(t, expected0, string(res[0])) require.Equal(t, expected1, string(res[1])) + require.Equal(t, float64(types.ResultAvailable), testutil.ToFloat64(PromElectionStatus)) } func TestCommand_CancelElection(t *testing.T) { @@ -1411,7 +1430,7 @@ func TestCommand_CancelElection(t *testing.T) { require.True(t, ok) require.Equal(t, types.Canceled, election.Status) - + require.Equal(t, float64(types.Canceled), testutil.ToFloat64(PromElectionStatus)) } func TestRegisterContract(t *testing.T) { @@ -1481,6 +1500,13 @@ func Test_Unikernel_combine(t *testing.T) { // ----------------------------------------------------------------------------- // Utility functions +func initMetrics() { + PromElectionStatus.Reset() + PromElectionBallots.Reset() + PromElectionShufflingInstances.Reset() + PromElectionPubShares.Reset() +} + func initElectionAndContract() (types.Election, Contract) { fakeDkg := fakeDKG{ actor: fakeDkgActor{}, @@ -1738,6 +1764,10 @@ func (c fakeCmd) cancelElection(snap store.Snapshot, step execution.Step) error return c.err } +func (c fakeCmd) deleteElection(snap store.Snapshot, step execution.Step) error { + return c.err +} + func (c fakeCmd) registerPubshares(snap store.Snapshot, step execution.Step) error { return c.err } diff --git a/contracts/evoting/types/transactions.go b/contracts/evoting/types/transactions.go index 193c88cac..927680de0 100644 --- a/contracts/evoting/types/transactions.go +++ b/contracts/evoting/types/transactions.go @@ -29,20 +29,20 @@ type ElectionsMetadata struct { // ElectionIDs is a slice of hex-encoded election IDs type ElectionIDs []string -// Contains checks if el is present -func (e ElectionIDs) Contains(el string) bool { - for _, e1 := range e { +// Contains checks if el is present. Return < 0 if not. +func (e ElectionIDs) Contains(el string) int { + for i, e1 := range e { if e1 == el { - return true + return i } } - return false + return -1 } // Add adds an election ID or returns an error if already present func (e *ElectionIDs) Add(id string) error { - if e.Contains(id) { + if e.Contains(id) >= 0 { return xerrors.Errorf("id %q already exist", id) } @@ -51,6 +51,14 @@ func (e *ElectionIDs) Add(id string) error { return nil } +// Remove removes an election ID from the list, if it exists +func (e *ElectionIDs) Remove(id string) { + i := e.Contains(id) + if i >= 0 { + *e = append((*e)[:i], (*e)[i+1:]...) + } +} + // TransactionFactory provides the mean to deserialize a transaction. // // - implements serde.Factory @@ -267,6 +275,26 @@ func (ce CancelElection) Serialize(ctx serde.Context) ([]byte, error) { return data, nil } +// DeleteElection defines the transaction to delete the election +// +// - implements serde.Message +type DeleteElection struct { + // ElectionID is hex-encoded + ElectionID string +} + +// Serialize implements serde.Message +func (ce DeleteElection) Serialize(ctx serde.Context) ([]byte, error) { + format := transactionFormats.Get(ctx.GetFormat()) + + data, err := format.Encode(ctx, ce) + if err != nil { + return nil, xerrors.Errorf("failed to encode cancel election: %v", err) + } + + return data, nil +} + // RandomID returns the hex encoding of a randomly created 32 byte ID. func RandomID() (string, error) { buf := make([]byte, 32) diff --git a/docs/api.md b/docs/api.md index 0d0e4f958..3a1d5fd9d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -48,7 +48,7 @@ SC5:Close │ │ │ │ NS2:Shuffle │ │ │ ▼ - │ DK4:BeginDecryption + │ DK4:ComputePubshares │ ▼ SC6:CombineShares @@ -56,6 +56,8 @@ SC6:CombineShares ▼ SC2:ElectionGetInfo + + ``` In case of error: @@ -267,6 +269,30 @@ Return: ``` +# SC?: Election delete + +| | | +| ------- | --------------------------------- | +| URL | `/evoting/elections/{ElectionID}` | +| Method | `DELETE` | +| Input | | +| Headers | {Authorization: } | + +The value must be the hex-encoded signature of the hex-encoded +electionID: + +``` + = hex( sig( hex( electionID ) ) ) +``` + +Return: + +`200 OK` `text/plain` + +``` + +``` + # SC?: Election get all infos | | | @@ -370,7 +396,7 @@ Return: ```json { - "Action": "beginDecryption" + "Action": "computePubshares" } ``` diff --git a/integration/integration_test.go b/integration/integration_test.go index c4d4145ca..a23ebccee 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -111,7 +111,8 @@ func getIntegrationTest(numNodes, numVotes int) func(*testing.T) { err = closeElection(m, electionID, adminID) require.NoError(t, err) - time.Sleep(time.Second * 1) + waitForStatus(t, types.Closed, electionFac, electionID, nodes, numNodes, + 2*time.Second) // ##### SHUFFLE BALLOTS ##### t.Logf("initializing shuffle") @@ -124,7 +125,8 @@ func getIntegrationTest(numNodes, numVotes int) func(*testing.T) { err = sActor.Shuffle(electionID) require.NoError(t, err) - time.Sleep(time.Second * 1) + waitForStatus(t, types.ShuffledBallots, electionFac, electionID, nodes, + numNodes, 2*time.Second*time.Duration(numNodes)) // ##### SUBMIT PUBLIC SHARES ##### t.Logf("submitting public shares") @@ -134,9 +136,10 @@ func getIntegrationTest(numNodes, numVotes int) func(*testing.T) { err = actor.ComputePubshares() require.NoError(t, err) - // ##### DECRYPT BALLOTS ##### - time.Sleep(time.Millisecond * 5000 * time.Duration(numNodes)) + waitForStatus(t, types.PubSharesSubmitted, electionFac, electionID, nodes, + numNodes, 6*time.Second*time.Duration(numNodes)) + // ##### DECRYPT BALLOTS ##### t.Logf("decrypting") election, err = getElection(electionFac, electionID, nodes[0].GetOrdering()) @@ -145,7 +148,8 @@ func getIntegrationTest(numNodes, numVotes int) func(*testing.T) { err = decryptBallots(m, actor, election) require.NoError(t, err) - time.Sleep(time.Second * 1) + waitForStatus(t, types.ResultAvailable, electionFac, electionID, nodes, + numNodes, 500*time.Millisecond*time.Duration(numVotes)) t.Logf("get vote proof") election, err = getElection(electionFac, electionID, nodes[0].GetOrdering()) @@ -570,3 +574,34 @@ func closeNodes(t *testing.T, nodes []dVotingCosiDela) { func encodeID(ID string) types.ID { return types.ID(base64.StdEncoding.EncodeToString([]byte(ID))) } + +// waitForStatus polls the nodes until they all updated to the expected status +// for the given election. An error is raised if the timeout expires. +func waitForStatus(t *testing.T, status types.Status, electionFac types.ElectionFactory, + electionID []byte, nodes []dVotingCosiDela, numNodes int, timeOut time.Duration) { + // set up a timer to fail the test in case we never reach the status + timer := time.NewTimer(timeOut) + go func() { + <-timer.C + t.Errorf("timed out while waiting for status %d", status) + }() + + isOK := func() bool { + for _, node := range nodes { + election, err := getElection(electionFac, electionID, node.GetOrdering()) + require.NoError(t, err) + + if election.Status != status { + return false + } + } + + return true + } + + for !isOK() { + time.Sleep(time.Millisecond * 100) + } + + timer.Stop() +} diff --git a/integration/scenario_test.go b/integration/scenario_test.go new file mode 100644 index 000000000..9c72a80fb --- /dev/null +++ b/integration/scenario_test.go @@ -0,0 +1,538 @@ +package integration + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/dedis/d-voting/contracts/evoting/types" + "github.com/dedis/d-voting/internal/testing/fake" + ptypes "github.com/dedis/d-voting/proxy/types" + "github.com/stretchr/testify/require" + "go.dedis.ch/kyber/v3" + "go.dedis.ch/kyber/v3/sign/schnorr" + "go.dedis.ch/kyber/v3/suites" + "go.dedis.ch/kyber/v3/util/encoding" + "go.dedis.ch/kyber/v3/util/random" + "golang.org/x/xerrors" +) + +var suite = suites.MustFind("Ed25519") + +// Check the shuffled votes versus the cast votes on a few nodes +func TestScenario(t *testing.T) { + t.Run("Basic configuration", getScenarioTest(3, 3, 1)) +} + +func getScenarioTest(numNodes int, numVotes int, numElection int) func(*testing.T) { + return func(t *testing.T) { + + proxyList := make([]string, numNodes) + + for i := 0; i < numNodes; i++ { + proxyList[i] = fmt.Sprintf("http://localhost:90%02d", 80+i) + t.Log(proxyList[i]) + } + + var wg sync.WaitGroup + + for i := 0; i < numElection; i++ { + t.Log("Starting worker", i) + wg.Add(1) + + go startElectionProcess(&wg, numNodes, numVotes, proxyList, t, numElection) + time.Sleep(2 * time.Second) + + } + + t.Log("Waiting for workers to finish") + wg.Wait() + + } +} + +func startElectionProcess(wg *sync.WaitGroup, numNodes int, numVotes int, proxyArray []string, t *testing.T, numElection int) { + defer wg.Done() + rand.Seed(0) + + const contentType = "application/json" + secretkeyBuf, err := hex.DecodeString("28912721dfd507e198b31602fb67824856eb5a674c021d49fdccbe52f0234409") + require.NoError(t, err, "failed to decode key: %v", err) + + secret := suite.Scalar() + err = secret.UnmarshalBinary(secretkeyBuf) + require.NoError(t, err, "failed to Unmarshal key: %v", err) + + // ###################################### CREATE SIMPLE ELECTION ###### + + t.Log("Create election") + + configuration := fake.BasicConfiguration + + createSimpleElectionRequest := ptypes.CreateElectionRequest{ + Configuration: configuration, + AdminID: "adminId", + } + + signed, err := createSignedRequest(secret, createSimpleElectionRequest) + require.NoError(t, err, "failed to create signature") + + resp, err := http.Post(proxyArray[0]+"/evoting/elections", contentType, bytes.NewBuffer(signed)) + require.NoError(t, err, "failed retrieve the decryption from the server: %v", err) + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err, "failed to read the response body: %v", err) + + t.Log("response body:", string(body)) + resp.Body.Close() + + var createElectionResponse ptypes.CreateElectionResponse + + err = json.Unmarshal(body, &createElectionResponse) + require.NoError(t, err, "failed to parse the response body from js: %v", err) + + electionID := createElectionResponse.ElectionID + + t.Logf("ID of the election : " + electionID) + + // ##################################### SETUP DKG ######################### + + t.Log("Init DKG") + + for i := 0; i < numNodes; i++ { + t.Log("Node" + strconv.Itoa(i)) + t.Log(proxyArray[i]) + err = initDKG(secret, proxyArray[i], electionID, t) + require.NoError(t, err, "failed to init dkg: %v", err) + } + + t.Log("Setup DKG") + + msg := ptypes.UpdateDKG{ + Action: "setup", + } + signed, err = createSignedRequest(secret, msg) + require.NoError(t, err, "failed to sign: %v", err) + + req, err := http.NewRequest(http.MethodPut, proxyArray[0]+"/evoting/services/dkg/actors/"+electionID, bytes.NewBuffer(signed)) + require.NoError(t, err, "failed to create request: %v", err) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err, "failed to setup dkg on node 1: %v", err) + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + // ##################################### OPEN ELECTION ##################### + + randomproxy := proxyArray[rand.Intn(len(proxyArray))] + t.Logf("Open election send to proxy %v", randomproxy) + + _, err = updateElection(secret, randomproxy, electionID, "open", t) + require.NoError(t, err, "failed retrieve the decryption from the server: %v", err) + // ##################################### GET ELECTION INFO ################# + + proxyAddr1 := proxyArray[0] + time.Sleep(time.Second * 3) + + getElectionResponse := getElectionInfo(proxyAddr1, electionID, t) + electionpubkey := getElectionResponse.Pubkey + electionStatus := getElectionResponse.Status + BallotSize := getElectionResponse.BallotSize + Chunksperballot := chunksPerBallot(BallotSize) + + t.Logf("Publickey of the election : " + electionpubkey) + t.Logf("Status of the election : %v", electionStatus) + + require.NoError(t, err, "failed to unmarshal pubkey: %v", err) + t.Logf("BallotSize of the election : %v", BallotSize) + t.Logf("Chunksperballot of the election : %v", Chunksperballot) + + // Get election public key + pubKey, err := encoding.StringHexToPoint(suite, electionpubkey) + require.NoError(t, err, "failed to Unmarshal key: %v", err) + + // ##################################### CAST BALLOTS ###################### + t.Log("cast ballots") + + //make List of ballots + b1 := string("select:" + encodeIDBallot("bb") + ":0,0,1,0\n" + "text:" + encodeIDBallot("ee") + ":eWVz\n\n") //encoding of "yes" + + ballotList := make([]string, numVotes) + for i := 1; i <= numVotes; i++ { + ballotList[i-1] = b1 + } + + votesfrontend := make([]types.Ballot, numVotes) + + fakeConfiguration := fake.BasicConfiguration + t.Logf("configuration is: %v", fakeConfiguration) + + for i := 0; i < numVotes; i++ { + + var bMarshal types.Ballot + election := types.Election{ + Configuration: fakeConfiguration, + ElectionID: electionID, + BallotSize: BallotSize, + } + + err = bMarshal.Unmarshal(ballotList[i], election) + require.NoError(t, err, "failed to unmarshal ballot : %v", err) + + votesfrontend[i] = bMarshal + } + + for i := 0; i < numVotes; i++ { + t.Logf("ballot in str is: %v", ballotList[i]) + + ballot, err := marshallBallotManual(ballotList[i], pubKey, Chunksperballot) + require.NoError(t, err, "failed to encrypt ballot : %v", err) + + t.Logf("ballot is: %v", ballot) + + castVoteRequest := ptypes.CastVoteRequest{ + UserID: "user" + strconv.Itoa(i+1), + Ballot: ballot, + } + + randomproxy = proxyArray[rand.Intn(len(proxyArray))] + t.Logf("cast ballot to proxy %v", randomproxy) + + t.Logf("vote is: %v", castVoteRequest) + signed, err = createSignedRequest(secret, castVoteRequest) + require.NoError(t, err, "failed to sign: %v", err) + + resp, err = http.Post(randomproxy+"/evoting/elections/"+electionID+"/vote", contentType, bytes.NewBuffer(signed)) + require.NoError(t, err, "failed retrieve the decryption from the server: %v", err) + require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected status: %s", resp.Status) + + body, err = io.ReadAll(resp.Body) + require.NoError(t, err, "failed to read the response of castVoteRequest: %v", err) + + resp.Body.Close() + t.Log("Response body: " + string(body)) + + } + + // ############################# CLOSE ELECTION FOR REAL ################### + randomproxy = proxyArray[rand.Intn(len(proxyArray))] + + t.Logf("Close election (for real) send to proxy %v", randomproxy) + + _, err = updateElection(secret, randomproxy, electionID, "close", t) + require.NoError(t, err, "failed to set marshall types.CloseElectionRequest : %v", err) + + time.Sleep(time.Second * 3) + + getElectionResponse = getElectionInfo(proxyAddr1, electionID, t) + electionStatus = getElectionResponse.Status + + t.Logf("Status of the election : %v", electionStatus) + require.Equal(t, uint16(2), electionStatus) + + // ###################################### SHUFFLE BALLOTS ################## + + t.Log("shuffle ballots") + + shuffleBallotsRequest := ptypes.UpdateShuffle{ + Action: "shuffle", + } + + signed, err = createSignedRequest(secret, shuffleBallotsRequest) + require.NoError(t, err, "failed to set marshall types.SimpleElection : %v", err) + + randomproxy = proxyArray[rand.Intn(len(proxyArray))] + + timeTable := make([]float64, 3) + oldTime := time.Now() + + req, err = http.NewRequest(http.MethodPut, randomproxy+"/evoting/services/shuffle/"+electionID, bytes.NewBuffer(signed)) + require.NoError(t, err, "failed retrieve the decryption from the server: %v", err) + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err, "failed to execute the shuffle query: %v", err) + + currentTime := time.Now() + diff := currentTime.Sub(oldTime) + timeTable[0] = diff.Seconds() + t.Logf("Shuffle takes: %v sec", diff.Seconds()) + + body, err = io.ReadAll(resp.Body) + require.NoError(t, err, "failed to read the response body: %v", err) + + t.Log("Response body: " + string(body)) + resp.Body.Close() + + getElectionResponse = getElectionInfo(proxyAddr1, electionID, t) + electionStatus = getElectionResponse.Status + + t.Logf("Status of the election : %v", electionStatus) + require.Equal(t, uint16(3), electionStatus) + + // ###################################### REQUEST PUBLIC SHARES ############ + + t.Log("request public shares") + + randomproxy = proxyArray[rand.Intn(len(proxyArray))] + oldTime = time.Now() + + _, err = updateDKG(secret, randomproxy, electionID, "computePubshares", t) + require.NoError(t, err, "failed to set marshall types.SimpleElection : %v", err) + + currentTime = time.Now() + diff = currentTime.Sub(oldTime) + timeTable[1] = diff.Seconds() + + t.Logf("Request public share takes: %v sec", diff.Seconds()) + + time.Sleep(10 * time.Second) + + getElectionResponse = getElectionInfo(proxyAddr1, electionID, t) + electionStatus = getElectionResponse.Status + + t.Logf("Status of the election : %v", electionStatus) + require.Equal(t, uint16(4), electionStatus) + + // ###################################### DECRYPT BALLOTS ################## + + t.Log("decrypt ballots") + + randomproxy = proxyArray[rand.Intn(len(proxyArray))] + oldTime = time.Now() + + _, err = updateElection(secret, randomproxy, electionID, "combineShares", t) + require.NoError(t, err, "failed to combine shares: %v", err) + + currentTime = time.Now() + diff = currentTime.Sub(oldTime) + timeTable[2] = diff.Seconds() + + t.Logf("decryption takes: %v sec", diff.Seconds()) + + time.Sleep(time.Second * 3) + + getElectionResponse = getElectionInfo(proxyAddr1, electionID, t) + electionStatus = getElectionResponse.Status + + t.Logf("Status of the election : %v", electionStatus) + require.Equal(t, uint16(5), electionStatus) + + //#################################### VALIDATE ELECTION RESULT ############## + + tmpBallots := getElectionResponse.Result + var tmpCount bool + + for _, ballotIntem := range tmpBallots { + tmpComp := ballotIntem + tmpCount = false + for _, voteFront := range votesfrontend { + t.Logf("voteFront: %v", voteFront) + t.Logf("tmpComp: %v", tmpComp) + + tmpCount = reflect.DeepEqual(tmpComp, voteFront) + t.Logf("tmpCount: %v", tmpCount) + + if tmpCount { + break + } + } + } + + require.True(t, tmpCount, "front end votes are different from decrypted votes") + t.Logf("shuffle time : %v", timeTable[0]) + t.Logf("Public share time : %v", timeTable[1]) + t.Logf("decryption time : %v", timeTable[2]) +} + +// ----------------------------------------------------------------------------- +// Utility functions +func marshallBallotManual(voteStr string, pubkey kyber.Point, chunks int) (ptypes.CiphervoteJSON, error) { + + ballot := make(ptypes.CiphervoteJSON, chunks) + vote := strings.NewReader(voteStr) + fmt.Printf("votestr is: %v", voteStr) + + buf := make([]byte, 29) + + for i := 0; i < chunks; i++ { + var K, C kyber.Point + var err error + + n, err := vote.Read(buf) + if err != nil { + return nil, xerrors.Errorf("failed to read: %v", err) + } + + K, C, _, err = encryptManual(buf[:n], pubkey) + + if err != nil { + return ptypes.CiphervoteJSON{}, xerrors.Errorf("failed to encrypt the plaintext: %v", err) + } + + kbuff, err := K.MarshalBinary() + if err != nil { + return ptypes.CiphervoteJSON{}, xerrors.Errorf("failed to marshal K: %v", err) + } + + cbuff, err := C.MarshalBinary() + if err != nil { + return ptypes.CiphervoteJSON{}, xerrors.Errorf("failed to marshal C: %v", err) + } + + ballot[i] = ptypes.EGPairJSON{ + K: kbuff, + C: cbuff, + } + } + + return ballot, nil +} + +func encryptManual(message []byte, pubkey kyber.Point) (K, C kyber.Point, remainder []byte, err error) { + + // Embed the message (or as much of it as will fit) into a curve point. + M := suite.Point().Embed(message, random.New()) + max := suite.Point().EmbedLen() + if max > len(message) { + max = len(message) + } + remainder = message[max:] + // ElGamal-encrypt the point to produce ciphertext (K,C). + k := suite.Scalar().Pick(random.New()) // ephemeral private key + K = suite.Point().Mul(k, nil) // ephemeral DH public key + S := suite.Point().Mul(k, pubkey) // ephemeral DH shared secret + C = S.Add(S, M) // message blinded with secret + + return K, C, remainder, nil +} + +func chunksPerBallot(size int) int { return (size-1)/29 + 1 } + +func encodeIDBallot(ID string) types.ID { + return types.ID(base64.StdEncoding.EncodeToString([]byte(ID))) +} + +func createSignedRequest(secret kyber.Scalar, msg interface{}) ([]byte, error) { + jsonMsg, err := json.Marshal(msg) + if err != nil { + return nil, xerrors.Errorf("failed to marshal json: %v", err) + } + + payload := base64.URLEncoding.EncodeToString(jsonMsg) + + hash := sha256.New() + + hash.Write([]byte(payload)) + md := hash.Sum(nil) + + signature, err := schnorr.Sign(suite, secret, md) + if err != nil { + return nil, xerrors.Errorf("failed to sign: %v", err) + } + + signed := ptypes.SignedRequest{ + Payload: payload, + Signature: hex.EncodeToString(signature), + } + + signedJSON, err := json.Marshal(signed) + if err != nil { + return nil, xerrors.Errorf("failed to create json signed: %v", err) + } + + return signedJSON, nil +} + +func initDKG(secret kyber.Scalar, proxyAddr, electionIDHex string, t *testing.T) error { + setupDKG := ptypes.NewDKGRequest{ + ElectionID: electionIDHex, + } + + signed, err := createSignedRequest(secret, setupDKG) + require.NoError(t, err, "failed to create signature") + + resp, err := http.Post(proxyAddr+"/evoting/services/dkg/actors", "application/json", bytes.NewBuffer(signed)) + if err != nil { + return xerrors.Errorf("failed to post request: %v", err) + } + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + return nil +} + +func updateElection(secret kyber.Scalar, proxyAddr, electionIDHex, action string, t *testing.T) (int, error) { + msg := ptypes.UpdateElectionRequest{ + Action: action, + } + + signed, err := createSignedRequest(secret, msg) + require.NoError(t, err, "failed to create signature") + + req, err := http.NewRequest(http.MethodPut, proxyAddr+"/evoting/elections/"+electionIDHex, bytes.NewBuffer(signed)) + if err != nil { + return 0, xerrors.Errorf("failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, xerrors.Errorf("failed retrieve the decryption from the server: %v", err) + } + + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + return 0, nil +} + +func updateDKG(secret kyber.Scalar, proxyAddr, electionIDHex, action string, t *testing.T) (int, error) { + msg := ptypes.UpdateDKG{ + Action: action, + } + + signed, err := createSignedRequest(secret, msg) + require.NoError(t, err, "failed to create signature") + + req, err := http.NewRequest(http.MethodPut, proxyAddr+"/evoting/services/dkg/actors/"+electionIDHex, bytes.NewBuffer(signed)) + if err != nil { + return 0, xerrors.Errorf("failed to create request: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, xerrors.Errorf("failed to execute the query: %v", err) + } + + require.Equal(t, resp.StatusCode, http.StatusOK, "unexpected status: %s", resp.Status) + + return 0, nil +} + +func getElectionInfo(proxyAddr, electionID string, t *testing.T) ptypes.GetElectionResponse { + t.Log("Get election info") + + resp, err := http.Get(proxyAddr + "/evoting/elections" + "/" + electionID) + require.NoError(t, err, "failed to get the election: %v", err) + + var infoElection ptypes.GetElectionResponse + decoder := json.NewDecoder(resp.Body) + + err = decoder.Decode(&infoElection) + require.NoError(t, err, "failed to decode getInfoElection: %v", err) + + resp.Body.Close() + + return infoElection + +} diff --git a/proxy/election.go b/proxy/election.go index f3087055c..fcd231de8 100644 --- a/proxy/election.go +++ b/proxy/election.go @@ -22,6 +22,7 @@ import ( "go.dedis.ch/dela/core/txn/pool" "go.dedis.ch/dela/serde" "go.dedis.ch/kyber/v3" + "go.dedis.ch/kyber/v3/sign/schnorr" "golang.org/x/xerrors" ) @@ -147,7 +148,7 @@ func (h *election) NewElectionVote(w http.ResponseWriter, r *http.Request) { return } - if !elecMD.ElectionsIDs.Contains(electionID) { + if elecMD.ElectionsIDs.Contains(electionID) < 0 { http.Error(w, "the election does not exist", http.StatusNotFound) return } @@ -214,7 +215,7 @@ func (h *election) EditElection(w http.ResponseWriter, r *http.Request) { return } - if !elecMD.ElectionsIDs.Contains(electionID) { + if elecMD.ElectionsIDs.Contains(electionID) < 0 { http.Error(w, "the election does not exist", http.StatusNotFound) return } @@ -453,6 +454,61 @@ func (h *election) Elections(w http.ResponseWriter, r *http.Request) { } } +// DeleteElection implements proxy.Proxy +func (h *election) DeleteElection(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + + if vars == nil || vars["electionID"] == "" { + http.Error(w, fmt.Sprintf("electionID not found: %v", vars), http.StatusInternalServerError) + return + } + + electionID := vars["electionID"] + + elecMD, err := h.getElectionsMetadata() + if err != nil { + http.Error(w, "failed to get election metadata", http.StatusNotFound) + return + } + + if elecMD.ElectionsIDs.Contains(electionID) < 0 { + http.Error(w, "the election does not exist", http.StatusNotFound) + return + } + + // auth should contain the hex-encoded signature on the hex-encoded election + // ID + auth := r.Header.Get("Authorization") + + sig, err := hex.DecodeString(auth) + if err != nil { + BadRequestError(w, r, xerrors.Errorf("failed to decode auth: %v", err), nil) + return + } + + err = schnorr.Verify(suite, h.pk, []byte(electionID), sig) + if err != nil { + ForbiddenError(w, r, xerrors.Errorf("signature verification failed: %v", err), nil) + return + } + + deleteElection := types.DeleteElection{ + ElectionID: electionID, + } + + data, err := deleteElection.Serialize(h.context) + if err != nil { + InternalError(w, r, xerrors.Errorf("failed to marshal DeleteElection: %v", err), nil) + return + } + + _, err = h.submitAndWaitForTxn(r.Context(), evoting.CmdDeleteElection, evoting.ElectionArg, data) + if err != nil { + http.Error(w, "failed to submit txn: "+err.Error(), http.StatusInternalServerError) + return + } +} + // waitForTxnID blocks until `ID` is included or `events` is closed. func (h *election) waitForTxnID(events <-chan ordering.Event, ID []byte) error { for event := range events { @@ -483,6 +539,11 @@ func (h *election) getElectionsMetadata() (types.ElectionsMetadata, error) { return md, nil } + // if there is not election created yet the metadata will be empty + if len(proof.GetValue()) == 0 { + return types.ElectionsMetadata{}, nil + } + err = json.Unmarshal(proof.GetValue(), &md) if err != nil { return md, xerrors.Errorf("failed to unmarshal ElectionMetadata: %v", err) diff --git a/proxy/mod.go b/proxy/mod.go index 96f26f120..bde8c11b5 100644 --- a/proxy/mod.go +++ b/proxy/mod.go @@ -32,6 +32,8 @@ type Election interface { Elections(http.ResponseWriter, *http.Request) // GET /elections/{electionID} Election(http.ResponseWriter, *http.Request) + // DELETE /elections/{electionID} + DeleteElection(http.ResponseWriter, *http.Request) } // DKG defines the public HTTP API of the DKG service @@ -100,6 +102,11 @@ func BadRequestError(w http.ResponseWriter, r *http.Request, err error, args map httpErr(w, r, err, http.StatusBadRequest, "bad request", args) } +// ForbiddenError sets a forbidden error error +func ForbiddenError(w http.ResponseWriter, r *http.Request, err error, args map[string]interface{}) { + httpErr(w, r, err, http.StatusForbidden, "not authorized / forbidden", args) +} + func httpErr(w http.ResponseWriter, r *http.Request, err error, code uint, title string, args map[string]interface{}) { if args == nil { args = make(map[string]interface{}) diff --git a/runNode.sh b/runNode.sh new file mode 100755 index 000000000..7d1633d2b --- /dev/null +++ b/runNode.sh @@ -0,0 +1,34 @@ +#!/bin/bash + + +set -o errexit + +command -v tmux >/dev/null 2>&1 || { echo >&2 "tmux is not on your PATH!"; exit 1; } + + +pk=adbacd10fdb9822c71025d6d00092b8a4abb5ebcb673d28d863f7c7c5adaddf3 + + +# Launch session +s="d-voting-test" + +tmux list-sessions | rg "^$TMUX_SESSION_NAME:" >/dev/null 2>&1 && { echo >&2 "A session with the name $TMUX_SESSION_NAME already exists; kill it and try again"; exit 1; } + +tmux new-session -d -s $s + + +from=1 +to=$1 +while [ $from -le $to ] +do + +echo $from +tmux new-window -t $s +window=$from +tmux send-keys -t $s:$window "LLVL=info ./memcoin --config /tmp/node$from start --postinstall --promaddr :$((9099 + $from)) --proxyaddr :$((9079 + $from)) --proxykey $pk --listen tcp://0.0.0.0:$((2000 + $from)) --public //localhost:$((2000 + $from))" C-m +((from++)) + +done + + +tmux a diff --git a/services/dkg/pedersen/controller/action.go b/services/dkg/pedersen/controller/action.go index 4169549fc..15406279f 100644 --- a/services/dkg/pedersen/controller/action.go +++ b/services/dkg/pedersen/controller/action.go @@ -317,6 +317,7 @@ func (a *RegisterHandlersAction) Execute(ctx node.Context) error { ep := eproxy.NewDKG(mngr, dkg, proxykey) router.HandleFunc("/evoting/services/dkg/actors", ep.NewDKGActor).Methods("POST") + router.HandleFunc("/evoting/services/dkg/actors", eproxy.AllowCORS).Methods("OPTIONS") router.HandleFunc("/evoting/services/dkg/actors/{electionID}", ep.Actor).Methods("GET") router.HandleFunc("/evoting/services/dkg/actors/{electionID}", ep.EditDKGActor).Methods("PUT") router.HandleFunc("/evoting/services/dkg/actors/{electionID}", eproxy.AllowCORS).Methods("OPTIONS") diff --git a/setupnNode.sh b/setupnNode.sh new file mode 100755 index 000000000..72b6438b7 --- /dev/null +++ b/setupnNode.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +# This script is creating a new chain and setting up the services needed to run +# an evoting system. It ends by starting the http server needed by the frontend +# to communicate with the blockchain. This operation is blocking. It is expected +# that the "memcoin" binary is at the root. You can build it with: +# go build ./cli/memcoin + +set -e + +GREEN='\033[0;32m' +NC='\033[0m' # No Color + + +echo "${GREEN}[1/4]${NC} connect nodes" + +from=2 +to=$1 +while [ $from -le $to ] +do +./memcoin --config /tmp/node$from minogrpc join \ + --address //localhost:2001 $(./memcoin --config /tmp/node1 minogrpc token) + +((from++)) +done + +echo "${GREEN}[2/4]${NC} create a chain" + +ARRAY="" +from=1 +to=$1 +while [ $from -le $to ] +do + ARRAY+="--member " + ARRAY+="$(./memcoin --config /tmp/node$from ordering export) " + +((from++)) +done + +./memcoin --config /tmp/node1 ordering setup $ARRAY + + +echo "${GREEN}[3/4]${NC} setup access rights on each node" + +from=1 + +while [ $from -le $to ] +do +./memcoin --config /tmp/node$from access add \ + --identity $(crypto bls signer read --path private.key --format BASE64_PUBKEY) + +((from++)) +done + + +echo "${GREEN}[4/4]${NC} grant access on the chain" + +./memcoin --config /tmp/node1 pool add\ + --key private.key\ + --args go.dedis.ch/dela.ContractArg --args go.dedis.ch/dela.Access\ + --args access:grant_id --args 0300000000000000000000000000000000000000000000000000000000000000\ + --args access:grant_contract --args go.dedis.ch/dela.Evoting\ + --args access:grant_command --args all\ + --args access:identity --args $(crypto bls signer read --path private.key --format BASE64_PUBKEY)\ + --args access:command --args GRANT + + +from=1 + +while [ $from -le $to ] +do + +./memcoin --config /tmp/node1 pool add\ + --key private.key\ + --args go.dedis.ch/dela.ContractArg --args go.dedis.ch/dela.Access\ + --args access:grant_id --args 0300000000000000000000000000000000000000000000000000000000000000\ + --args access:grant_contract --args go.dedis.ch/dela.Evoting\ + --args access:grant_command --args all\ + --args access:identity --args $(crypto bls signer read --path /tmp/node$from/private.key --format BASE64_PUBKEY)\ + --args access:command --args GRANT + + +((from++)) +done diff --git a/web/backend/src/Server.ts b/web/backend/src/Server.ts index 291d987fb..b3de98d32 100644 --- a/web/backend/src/Server.ts +++ b/web/backend/src/Server.ts @@ -251,7 +251,7 @@ function sendToDela(dataStr: string, req: express.Request, res: express.Response }; // we strip the `/api` part: /api/election/xxx => /election/xxx - const uri = config.DELA_NODE_URL + req.baseUrl.slice(4); + const uri = config.DELA_NODE_URL + xss(req.baseUrl.slice(4)); console.log('sending payload:', JSON.stringify(payload), 'to', uri); @@ -287,12 +287,64 @@ app.use('/api/evoting/*', (req, res, next) => { } }); +// https://stackoverflow.com/a/1349426 +function makeid(length: number) { + let result = ''; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + for (let i = 0; i < length; i += 1) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +app.delete('/api/evoting/elections/:electionID', (req, res) => { + const { electionID } = req.params; + + const edCurve = kyber.curve.newCurve('edwards25519'); + + const priv = Buffer.from(config.PRIVATE_KEY, 'hex'); + const pub = Buffer.from(config.PUBLIC_KEY, 'hex'); + + const scalar = edCurve.scalar(); + scalar.unmarshalBinary(priv); + + const point = edCurve.point(); + point.unmarshalBinary(pub); + + const sign = kyber.sign.schnorr.sign(edCurve, scalar, Buffer.from(electionID)); + + // we strip the `/api` part: /api/election/xxx => /election/xxx + const uri = config.DELA_NODE_URL + xss(req.url.slice(4)); + + axios({ + method: req.method as Method, + url: uri, + headers: { + Authorization: sign.toString('hex'), + }, + }) + .then((resp) => { + res.status(200).send(resp.data); + }) + .catch((error: AxiosError) => { + let resp = ''; + if (error.response) { + resp = JSON.stringify(error.response.data); + } + + res + .status(500) + .send(`failed to proxy request: ${req.method} ${uri} - ${error.message} - ${resp}`); + }); +}); + // This API call is used redirect all the calls for DELA to the DELAs nodes. // During this process the data are processed : the user is authenticated and // controlled. Once this is done the data are signed before the are sent to the // DELA node To make this work, react has to redirect to this backend all the // request that needs to go the DELA nodes -app.use('/api/evoting/*', (req, res, next) => { +app.use('/api/evoting/*', (req, res) => { if (!req.session.userid) { res.status(400).send('Unauthorized'); return; @@ -300,26 +352,16 @@ app.use('/api/evoting/*', (req, res, next) => { const bodyData = req.body; - const dataStr = JSON.stringify(bodyData); - // special case for voting const regex = /\/api\/evoting\/elections\/.*\/vote/; if (req.baseUrl.match(regex)) { - // will be handled by the next matcher, just bellow - next(); - } else { - sendToDela(dataStr, req, res); + // We must set the UserID to know who this ballot is associated to. This is + // only needed to allow users to cast multiple ballots, where only the last + // ballot is taken into account. To preserve anonymity the web-backend could + // translate UserIDs to another random ID. + // bodyData.UserID = req.session.userid.toString(); + bodyData.UserID = makeid(10); } -}); - -app.post('/api/evoting/elections/:electionID/vote', (req, res) => { - const bodyData = req.body; - - // We must set the UserID to know who this ballot is associated to. This is - // only needed to allow users to cast multiple ballots, where only the last - // ballot is taken into account. To preserve anonymity the web-backend could - // translate UserIDs to another random ID. - bodyData.UserID = req.session.userid; const dataStr = JSON.stringify(bodyData); diff --git a/web/frontend/src/Routes.ts b/web/frontend/src/Routes.ts index 15de5a661..09b3f9e36 100644 --- a/web/frontend/src/Routes.ts +++ b/web/frontend/src/Routes.ts @@ -9,6 +9,5 @@ export const ROUTE_ELECTION_CREATE = '/election/create'; export const ROUTE_ELECTION_SHOW = '/elections/:electionID'; export const ROUTE_ELECTION_RESULT = '/elections/:electionID/result'; -export const ROUTE_BALLOT_INDEX = '/ballot/index'; export const ROUTE_BALLOT_CAST = '/ballot/cast'; export const ROUTE_BALLOT_SHOW = '/ballot/show'; diff --git a/web/frontend/src/components/modal/AddAdminUserModal.tsx b/web/frontend/src/components/modal/AddAdminUserModal.tsx index 5ad2891da..edc337f46 100644 --- a/web/frontend/src/components/modal/AddAdminUserModal.tsx +++ b/web/frontend/src/components/modal/AddAdminUserModal.tsx @@ -1,106 +1,186 @@ -import React, { FC, useState } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import Modal from '@mui/material/Modal'; -import Input from '@mui/material/Input'; -import InputLabel from '@mui/material/InputLabel'; -import MenuItem from '@mui/material/MenuItem'; -import FormControl from '@mui/material/FormControl'; -import Select from '@mui/material/Select'; +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 style = { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: 400, - bgcolor: 'background.paper', - border: '2px solid #000', - boxShadow: 24, - p: 4, -}; +const uid = new ShortUniqueId({ length: 8 }); type AddAdminUserModalProps = { open: boolean; setOpen(opened: boolean): void; + handleAddRoleUser(user: object): void; }; -const AddAdminUserModal: FC = ({ open, setOpen }) => { - const handleClose = () => setOpen(false); - const ariaLabel = { 'aria-label': 'description' }; +const roles = ['Admin', 'Operator']; - const [sciperValue, setSciperValue] = useState(''); +const AddAdminUserModal: FC = ({ open, setOpen, handleAddRoleUser }) => { + const { t } = useTranslation(); + const fctx = useContext(FlashContext); - const [roleValue, setRoleValue] = useState(''); + const [loading, setLoading] = useState(false); + const [sciperValue, setSciperValue] = useState(''); + const [selectedRole, setSelectedRole] = useState(roles[0]); - const handleChange = (event: any) => { - setRoleValue(event.target.value); - }; + const handleClose = () => setOpen(false); const handleUserInput = (e: any) => { setSciperValue(e.target.value); }; - const handleClick = () => { + const handleAddUser = async () => { + const userToAdd = { id: uid(), sciper: sciperValue, role: selectedRole }; const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sciper: sciperValue, role: roleValue }), + body: JSON.stringify(userToAdd), }; - fetch(ENDPOINT_ADD_ROLE, requestOptions).then((data) => { - if (data.status === 200) { - alert('User added successfully'); - setOpen(false); + + 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 { - alert('Error while adding the user'); + 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 ( -
- - - - Please give the sciper of the user - - -
-
- - - Role - - - -
- -
-
-
+ + +
+ + + + + {/* 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} + + )} + + ))} + + +
+
+
+
+
+
+ + +
+
+
+
+
+
); }; diff --git a/web/frontend/src/components/modal/ConfirmModal.tsx b/web/frontend/src/components/modal/ConfirmModal.tsx index c3795a266..df679ed5a 100644 --- a/web/frontend/src/components/modal/ConfirmModal.tsx +++ b/web/frontend/src/components/modal/ConfirmModal.tsx @@ -1,7 +1,10 @@ -import React, { FC } from 'react'; +import { FC, Fragment, useRef } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; +import { Dialog, Transition } from '@headlessui/react'; +import { ExclamationIcon } from '@heroicons/react/outline'; + type ConfirmModalProps = { showModal: boolean; setShowModal: (prev: boolean) => void; @@ -16,41 +19,85 @@ const ConfirmModal: FC = ({ setUserConfirmedAction, }) => { const { t } = useTranslation(); + const cancelButtonRef = useRef(null); const closeModal = () => { setShowModal(false); }; - const validateChoice = () => { + const confirmChoice = () => { setUserConfirmedAction(true); closeModal(); }; - const displayButtons = () => { + const Modal = () => { return ( -
- - -
+ + +
+ + + + + {/* This element is to trick the browser into centering the modal contents. */} + + +
+
+
+
+
+
+ + {t('actionChange')} + +
+

{textModal}

+
+
+
+
+
+ + +
+
+
+
+
+
); }; - return ( -
- {showModal ? ( -
-
-
{textModal}
-
{displayButtons()}
-
-
- ) : null} -
- ); + return
{showModal ? : null}
; }; ConfirmModal.propTypes = { diff --git a/web/frontend/src/components/modal/Modal.css b/web/frontend/src/components/modal/Modal.css deleted file mode 100644 index 72201f7db..000000000 --- a/web/frontend/src/components/modal/Modal.css +++ /dev/null @@ -1,41 +0,0 @@ -.modal-background { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.6); - z-index: 1; -} - -.modal-wrapper { - display: flex; - flex-direction: column; - justify-content: space-between; - position: fixed; - background: #f6f6fa; - max-width: 100%; - max-height: 100%; - min-height: 15%; - min-width: 10%; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - outline: #a9a9aa; - outline-style: solid; - outline-width: 1px; -} - -.text-container { - padding: 5%; -} - -.buttons-container { - overflow: hidden; - background-color: #177368; /* #177368 or #9bc0bc */ - padding: 3%; -} - -.btn-right { - float: right; -} diff --git a/web/frontend/src/components/modal/Modal.tsx b/web/frontend/src/components/modal/Modal.tsx index 49d2f5a31..5c7bb4a96 100644 --- a/web/frontend/src/components/modal/Modal.tsx +++ b/web/frontend/src/components/modal/Modal.tsx @@ -3,8 +3,6 @@ import PropTypes from 'prop-types'; import { Dialog, Transition } from '@headlessui/react'; -import './Modal.css'; - const Modal = ({ showModal, setShowModal, textModal, buttonRightText }) => { const closeModal = () => { setShowModal(false); diff --git a/web/frontend/src/components/modal/RedirectToModal.tsx b/web/frontend/src/components/modal/RedirectToModal.tsx index a42d9b118..88fca6b19 100644 --- a/web/frontend/src/components/modal/RedirectToModal.tsx +++ b/web/frontend/src/components/modal/RedirectToModal.tsx @@ -1,5 +1,5 @@ import { FC, Fragment } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { To, useNavigate } from 'react-router-dom'; import { Dialog, Transition } from '@headlessui/react'; type RedirectToModalProps = { @@ -8,7 +8,7 @@ type RedirectToModalProps = { title: string; children: string; buttonRightText: string; - navigateDestination?: string; + navigateDestination?: To | number; }; const RedirectToModal: FC = ({ @@ -23,7 +23,7 @@ const RedirectToModal: FC = ({ const closeModal = () => { if (navigateDestination) { - navigate(navigateDestination); + navigate(navigateDestination as To); } setShowModal(false); }; diff --git a/web/frontend/src/components/modal/RemoveAdminUserModal.tsx b/web/frontend/src/components/modal/RemoveAdminUserModal.tsx index 74f60dd58..0e64f2a01 100644 --- a/web/frontend/src/components/modal/RemoveAdminUserModal.tsx +++ b/web/frontend/src/components/modal/RemoveAdminUserModal.tsx @@ -1,71 +1,125 @@ -import React, { FC } from 'react'; -import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Typography from '@mui/material/Typography'; -import Modal from '@mui/material/Modal'; +import React, { FC, Fragment, useContext, useRef, useState } from 'react'; import { ENDPOINT_REMOVE_ROLE } from '../utils/Endpoints'; -import Stack from '@mui/material/Stack'; import PropTypes from 'prop-types'; - -const style = { - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: 400, - bgcolor: 'background.paper', - border: '2px solid #000', - boxShadow: 24, - p: 4, -}; +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 }) => { +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 = () => { + const handleDelete = async () => { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sciper: sciper }), }; - fetch(ENDPOINT_REMOVE_ROLE, requestOptions).then((data) => { - if (data.status === 200) { - alert('User removed successfully'); - setOpen(false); + + 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 { - alert('Error while adding the user'); + 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 (
- - - - Please confirm deletion for sciper {sciper} - + + +
+ + + - - - - - - + {/* This element is to trick the browser into centering the modal contents. */} + + +
+
+
+ + {t('confirmDeleteUserSciper')} {sciper} + +
+
+
+ + +
+
+
+
+
+
); }; diff --git a/web/frontend/src/components/utils/CancelButton.tsx b/web/frontend/src/components/utils/CancelButton.tsx index 1cdef1259..2b65e1e1d 100644 --- a/web/frontend/src/components/utils/CancelButton.tsx +++ b/web/frontend/src/components/utils/CancelButton.tsx @@ -1,3 +1,4 @@ +import { XIcon } from '@heroicons/react/outline'; import { AuthContext } from 'index'; import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,7 +11,15 @@ const CancelButton = ({ status, handleCancel }) => { const isAuthorized = authCtx.role === 'admin' || authCtx.role === 'operator'; return ( - isAuthorized && status === STATUS.Open && + isAuthorized && + status === STATUS.Open && ( + + ) ); }; export default CancelButton; diff --git a/web/frontend/src/components/utils/CloseButton.tsx b/web/frontend/src/components/utils/CloseButton.tsx index 7e3130185..c432cf6bd 100644 --- a/web/frontend/src/components/utils/CloseButton.tsx +++ b/web/frontend/src/components/utils/CloseButton.tsx @@ -1,3 +1,4 @@ +import { LockClosedIcon } from '@heroicons/react/outline'; import { AuthContext } from 'index'; import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,7 +12,15 @@ const CloseButton = ({ status, handleClose }) => { const isAuthorized = authCtx.role === ROLE.Admin || authCtx.role === ROLE.Operator; return ( - isAuthorized && status === STATUS.Open && + isAuthorized && + status === STATUS.Open && ( + + ) ); }; export default CloseButton; diff --git a/web/frontend/src/components/utils/CombineButton.tsx b/web/frontend/src/components/utils/CombineButton.tsx new file mode 100644 index 000000000..476171acc --- /dev/null +++ b/web/frontend/src/components/utils/CombineButton.tsx @@ -0,0 +1,29 @@ +import { ShieldCheckIcon } from '@heroicons/react/outline'; +import { AuthContext } from 'index'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { STATUS } from 'types/election'; +import { ROLE } from 'types/userRole'; + +const CombineButton = ({ status, handleCombine }) => { + const authCtx = useContext(AuthContext); + const { t } = useTranslation(); + + const isAuthorized = authCtx.role === ROLE.Admin || authCtx.role === ROLE.Operator; + + return ( + isAuthorized && + status === STATUS.DecryptedBallots && ( + + + + ) + ); +}; + +export default CombineButton; diff --git a/web/frontend/src/components/utils/DecryptButton.tsx b/web/frontend/src/components/utils/DecryptButton.tsx index 14866cbea..6dd3c7314 100644 --- a/web/frontend/src/components/utils/DecryptButton.tsx +++ b/web/frontend/src/components/utils/DecryptButton.tsx @@ -1,3 +1,4 @@ +import { KeyIcon } from '@heroicons/react/outline'; import { AuthContext } from 'index'; import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,7 +18,12 @@ const DecryptButton = ({ status, isDecrypting, handleDecrypt }) => {

{t('statusOnGoingDecryption')}

) : ( - + )) ); diff --git a/web/frontend/src/components/utils/DeleteButton.tsx b/web/frontend/src/components/utils/DeleteButton.tsx new file mode 100644 index 000000000..5bdb94892 --- /dev/null +++ b/web/frontend/src/components/utils/DeleteButton.tsx @@ -0,0 +1,24 @@ +import { TrashIcon } from '@heroicons/react/outline'; +import { AuthContext } from 'index'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ROLE } from 'types/userRole'; + +const DeleteButton = ({ status, handleDelete }) => { + const authCtx = useContext(AuthContext); + const { t } = useTranslation(); + + const isAuthorized = authCtx.role === ROLE.Admin || authCtx.role === ROLE.Operator; + + return isAuthorized ? ( + + ) : ( + <> + ); +}; +export default DeleteButton; diff --git a/web/frontend/src/components/utils/Endpoints.tsx b/web/frontend/src/components/utils/Endpoints.tsx index c28d97a73..24c052ba6 100644 --- a/web/frontend/src/components/utils/Endpoints.tsx +++ b/web/frontend/src/components/utils/Endpoints.tsx @@ -9,9 +9,10 @@ export const ENDPOINT_REMOVE_ROLE = '/api/remove_role'; export const newElection = '/api/evoting/elections'; export const editElection = (ElectionID: string) => `/api/evoting/elections/${ElectionID}`; export const newElectionVote = (ElectionID: string) => `/api/evoting/elections/${ElectionID}/vote`; -export const editShuffle = (ElectionID: string) => `/evoting/services/shuffle/${ElectionID}`; +export const editShuffle = (ElectionID: string) => `/api/evoting/services/shuffle/${ElectionID}`; // Decrypt -export const editDKGActors = (ElectionID: string) => `/evoting/services/dkg/actors/${ElectionID}`; +export const editDKGActors = (ElectionID: string) => + `/api/evoting/services/dkg/actors/${ElectionID}`; // public information can be directly fetched from dela nodes export const election = (ElectionID: string) => diff --git a/web/frontend/src/components/utils/ResultButton.tsx b/web/frontend/src/components/utils/ResultButton.tsx index 7104dfe31..f230548ac 100644 --- a/web/frontend/src/components/utils/ResultButton.tsx +++ b/web/frontend/src/components/utils/ResultButton.tsx @@ -1,13 +1,17 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { STATUS } from 'types/election'; +import { ChartSquareBarIcon } from '@heroicons/react/outline'; const ResultButton = ({ status, electionID }) => { const { t } = useTranslation(); return ( status === STATUS.ResultAvailable && ( - +
+
) ); diff --git a/web/frontend/src/components/utils/ShuffleButton.tsx b/web/frontend/src/components/utils/ShuffleButton.tsx index 66797dc49..5581b319e 100644 --- a/web/frontend/src/components/utils/ShuffleButton.tsx +++ b/web/frontend/src/components/utils/ShuffleButton.tsx @@ -1,3 +1,4 @@ +import { EyeOffIcon } from '@heroicons/react/outline'; import { AuthContext } from 'index'; import { useContext } from 'react'; import { useTranslation } from 'react-i18next'; @@ -17,7 +18,12 @@ const ShuffleButton = ({ status, isShuffling, handleShuffle }) => {

{t('statusOnGoingShuffle')}

) : ( - + )) ); diff --git a/web/frontend/src/components/utils/SpinnerIcon.tsx b/web/frontend/src/components/utils/SpinnerIcon.tsx new file mode 100644 index 000000000..2a235169a --- /dev/null +++ b/web/frontend/src/components/utils/SpinnerIcon.tsx @@ -0,0 +1,23 @@ +const SpinnerIcon = () => { + return ( + + + + + ); +}; + +export default SpinnerIcon; diff --git a/web/frontend/src/components/utils/VoteButton.tsx b/web/frontend/src/components/utils/VoteButton.tsx new file mode 100644 index 000000000..af0597966 --- /dev/null +++ b/web/frontend/src/components/utils/VoteButton.tsx @@ -0,0 +1,32 @@ +import { PencilAltIcon } from '@heroicons/react/outline'; +import { AuthContext } from 'index'; +import { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { ROUTE_BALLOT_SHOW } from 'Routes'; +import { STATUS } from 'types/election'; +import { ROLE } from 'types/userRole'; + +const VoteButton = ({ status, electionID }) => { + const authCtx = useContext(AuthContext); + const { t } = useTranslation(); + + const isAuthorized = + authCtx.role === ROLE.Admin || authCtx.role === ROLE.Operator || authCtx.role === ROLE.Voter; + + return ( + isAuthorized && + status === STATUS.Open && + authCtx.isLogged && ( + + + + ) + ); +}; +export default VoteButton; diff --git a/web/frontend/src/components/utils/useChangeAction.tsx b/web/frontend/src/components/utils/useChangeAction.tsx index 0033306c2..29222e06b 100644 --- a/web/frontend/src/components/utils/useChangeAction.tsx +++ b/web/frontend/src/components/utils/useChangeAction.tsx @@ -11,6 +11,8 @@ import CancelButton from './CancelButton'; import OpenButton from './OpenButton'; import DecryptButton from './DecryptButton'; import ResultButton from './ResultButton'; +import VoteButton from './VoteButton'; +import CombineButton from './CombineButton'; const useChangeAction = ( status: STATUS, @@ -158,15 +160,15 @@ const useChangeAction = ( const handleDecrypt = async () => { const decryptSuccess = await electionUpdate( - 'beginDecryption', + 'computePubshares', endpoints.editDKGActors(electionID.toString()) ); if (decryptSuccess && postError === null) { // TODO : setResultAvailable is undefined when the decryption is clicked - if (setResultAvailable !== null && setResultAvailable !== undefined) { - setResultAvailable(true); - } - setStatus(STATUS.ResultAvailable); + // if (setResultAvailable !== null && setResultAvailable !== undefined) { + // setResultAvailable(true); + // } + setStatus(STATUS.DecryptedBallots); } else { setShowModalError(true); setIsDecrypting(false); @@ -174,6 +176,20 @@ const useChangeAction = ( setPostError(null); }; + const handleCombine = async () => { + const combineSuccess = await electionUpdate( + 'combineShares', + endpoints.editElection(electionID.toString()) + ); + if (combineSuccess && postError === null) { + setStatus(STATUS.ResultAvailable); + } else { + setShowModalError(true); + setIsOpening(false); + } + setPostError(null); + }; + const getAction = () => { switch (status) { case STATUS.Initial: @@ -188,6 +204,7 @@ const useChangeAction = ( + ); case STATUS.Closed: @@ -210,6 +227,12 @@ const useChangeAction = ( /> ); + case STATUS.DecryptedBallots: + return ( + + + + ); case STATUS.ResultAvailable: return ( @@ -217,7 +240,7 @@ const useChangeAction = ( ); case STATUS.Canceled: - return --- ; + return ; default: return --- ; } diff --git a/web/frontend/src/components/utils/useChangeStatus.tsx b/web/frontend/src/components/utils/useChangeStatus.tsx index a153320cd..1e803a082 100644 --- a/web/frontend/src/components/utils/useChangeStatus.tsx +++ b/web/frontend/src/components/utils/useChangeStatus.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { useTranslation } from 'react-i18next'; import { STATUS } from 'types/election'; @@ -11,87 +10,87 @@ const useChangeStatus = (status: STATUS) => { switch (status) { case STATUS.Initial: return ( - - - {t('statusInitial')} - +
+
+
{t('statusInitial')}
+
); case STATUS.InitializedNodes: return ( - - - {t('statusInitializedNodes')} - +
+
+
{t('statusInitializedNodes')}
+
); case STATUS.OnGoingSetup: return ( - - - {t('statusOnGoingSetup')} - +
+
+
{t('statusOnGoingSetup')}
+
); case STATUS.Setup: return ( - - - {t('statusSetup')} - +
+
+
{t('statusSetup')}
+
); case STATUS.Open: return ( - - - {t('statusOpen')} - +
+
+
{t('statusOpen')}
+
); case STATUS.Closed: return ( - - - {t('statusClose')} - +
+
+
{t('statusClose')}
+
); case STATUS.OnGoingShuffle: return ( - - - {t('statusOnGoingShuffle')} - +
+
+
{t('statusOnGoingShuffle')}
+
); case STATUS.ShuffledBallots: return ( - - - {t('statusShuffle')} - +
+
+
{t('statusShuffle')}
+
); case STATUS.OnGoingDecryption: return ( - - - {t('statusOnGoingDecryption')} - +
+
+
{t('statusOnGoingDecryption')}
+
); case STATUS.DecryptedBallots: return ( - - - {t('statusDecrypted')} - +
+
+
{t('statusDecrypted')}
+
); case STATUS.ResultAvailable: return ( - - - {t('statusResultAvailable')} - +
+
+
{t('statusResultAvailable')}
+
); case STATUS.Canceled: return ( - - - {t('statusCancel')} - +
+
+
{t('statusCancel')}
+
); default: return null; diff --git a/web/frontend/src/index.tsx b/web/frontend/src/index.tsx index 3730970c7..9e9ddc552 100644 --- a/web/frontend/src/index.tsx +++ b/web/frontend/src/index.tsx @@ -73,7 +73,30 @@ export const FlashContext = createContext(undefined); // A small elements to display that the page is loading, should be something // more elegant in the future and be its own component. -const Loading: FC = () =>

App is loading...

; +const Loading: FC = () => ( +
+
+
+ + + + +
+

App is loading...

+
+
+); const Failed: FC = ({ children }) => (
diff --git a/web/frontend/src/language/en.json b/web/frontend/src/language/en.json index 525288150..88fe1e9b9 100644 --- a/web/frontend/src/language/en.json +++ b/web/frontend/src/language/en.json @@ -4,10 +4,22 @@ "navBarHome": "Home", "navBarStatus": "Elections", "navBarCreate": "Create", - "navBarVote": "Vote", + "vote": "Vote", + "elections": "Elections", "navBarResult": "Results", "navBarAbout": "About", "navBarAdmin": "Admin", + "admin": "Admin", + "previous": "Previous", + "next": "Next", + "confirmDeleteUserSciper": "Do confirm deleting the role for the user sciper", + "notFound": "Page not found", + "notFoundDescription": "The page you are looking for does not exist.", + "notFoundLink": "Go to home page", + "results": "results", + "showing": "Showing", + "enterSciper": "Please give the sciper of the user", + "adminDetails": "Add or remove roles of users from the admin table", "navBarCreateElection": "Create election", "homeTitle": "Welcome to our e-voting platform!", "homeText": "Use the navigation bar above to reach the the page you want.", @@ -17,12 +29,16 @@ "elecName": "Election title", "namePlaceHolder": "Enter the name", "addCandidate": "Add a candidate", + "addUser": "Add user", + "role": "Role", + "edit": "Edit", "addCandPlaceHolder": "candidate's name", "nothingToAdd": "There is nothing to add.", "duplicateCandidate": "This candidate has already been added.", "add": "Add", "exportElecJSON": "Export as JSON", "delete": "Delete", + "combine": "Combine shares", "createElec": "Create election", "clearElec": "Clear election", "upload": "Choose a json file from your computer:", @@ -77,6 +93,15 @@ "voteSuccess": "Your vote was successfully submitted!", "voteSuccessful": "Vote successful", "errorTitle": "Error", + "actionChange": "Action Change", + "notification": "Notification", + "successCreateElection": "Election successfully created! ElectionID: ", + "errorIncorrectConfSchema": "Incorrect election configuration, please fill it completely: ", + "successAddUser": "User successfully added!", + "errorAddUser": "Error while adding the user", + "successRemoveUser": "User successfully removed!", + "errorRemoveUser": "Error while removing the user", + "errorFetchingUsers": "Error while fetching the users", "voteFailure": "Your ballot hasn't been taken into account. It might be that the election has been closed or cancelled. Try refreshing the page.", "ballotFailure": "An error seemed to have occurred while sending your ballot. Please contact the administrator of this website", "incompleteBallot": "Some answers are not complete.", @@ -105,7 +130,9 @@ "notEnoughBallot": "The operation failed because less than two ballots have been casted.", "operationFailure": "The operation failed. Try refreshing the page.", "shuffleFail": "The shuffle operation failed.", - "voteImpossible": "The election is not open for voting anymore.", + "voteImpossible": "Vote Impossible", + "notFoundVoteImpossible": "Go back to election table", + "voteImpossibleDescription": "The election is not open for voting anymore.", "yes": "Yes", "no": "No", "download": "Export results in JSON format", diff --git a/web/frontend/src/layout/App.tsx b/web/frontend/src/layout/App.tsx index 9af2a99c7..da20c5b69 100644 --- a/web/frontend/src/layout/App.tsx +++ b/web/frontend/src/layout/App.tsx @@ -4,7 +4,6 @@ import { Navigate, Route, BrowserRouter as Router, Routes, useLocation } from 'r import { ROUTE_ABOUT, ROUTE_ADMIN, - ROUTE_BALLOT_INDEX, ROUTE_BALLOT_SHOW, ROUTE_ELECTION_CREATE, ROUTE_ELECTION_INDEX, @@ -19,7 +18,6 @@ import ElectionIndex from '../pages/election/Index'; import ElectionCreate from '../pages/election/New'; import ElectionResult from '../pages/election/Result'; import ElectionShow from '../pages/election/Show'; -import BallotIndex from '../pages/ballot/Index'; import BallotShow from '../pages/ballot/Show'; import NavBar from './NavBar'; import Footer from './Footer'; @@ -28,8 +26,7 @@ import './App.css'; import { AuthContext } from '..'; import Logged from 'pages/session/Logged'; import Flash from './Flash'; - -const NotFound = () =>
404 not found
; +import NotFound from './NotFound'; const App = () => { const RequireAuth = ({ children }) => { @@ -48,12 +45,12 @@ const App = () => {
-
+
+ className=" mb-auto max-w-[80rem] mx-auto flex flex-row justify-center items-center w-full"> { /> } /> } /> - } /> ( -
-
- - © 2021 DEDIS LAB -{' '} - https://github.com/dedis/dela - {' '} -
+
+
); diff --git a/web/frontend/src/layout/NavBar.tsx b/web/frontend/src/layout/NavBar.tsx index a22c9779b..f990e7a98 100644 --- a/web/frontend/src/layout/NavBar.tsx +++ b/web/frontend/src/layout/NavBar.tsx @@ -7,7 +7,6 @@ import { ENDPOINT_LOGOUT } from '../components/utils/Endpoints'; import { ROUTE_ABOUT, ROUTE_ADMIN, - ROUTE_BALLOT_INDEX, ROUTE_ELECTION_CREATE, ROUTE_ELECTION_INDEX, ROUTE_HOME, @@ -68,15 +67,6 @@ const MobileMenu = ({ authCtx, handleLogout, fctx, t }) => ( } - {authCtx.isLogged && ( - - - - {t('navBarVote')} - - - - )} {authCtx.isLogged && (authCtx.role === 'admin' || authCtx.role === 'operator') && ( @@ -187,24 +177,14 @@ const LeftSideNavBar = ({ authCtx, t }) => ( className={'text-black text-lg hover:text-indigo-700'}> {t('navBarStatus')} - {authCtx.isLogged && (authCtx.role === 'admin' || authCtx.role === 'operator') && ( - - {t('navBarVote')} - - )} {authCtx.role === 'admin' && authCtx.isLogged && ( Admin )} - {!authCtx.isLogged && ( - - {t('navBarAbout')} - - )} + + {t('navBarAbout')} +
diff --git a/web/frontend/src/layout/NotFound.tsx b/web/frontend/src/layout/NotFound.tsx new file mode 100644 index 000000000..e37b34913 --- /dev/null +++ b/web/frontend/src/layout/NotFound.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { ROUTE_HOME } from 'Routes'; + +export default function NotFound() { + const { t } = useTranslation(); + + return ( +
+
+
+
+

404

+
+
+

+ {t('notFound')} +

+

{t('notFoundDescription')}

+
+
+ + {t('notFoundLink')} + +
+
+
+
+
+
+ ); +} diff --git a/web/frontend/src/mocks/handlers.ts b/web/frontend/src/mocks/handlers.ts index 6b6454fae..269851f06 100644 --- a/web/frontend/src/mocks/handlers.ts +++ b/web/frontend/src/mocks/handlers.ts @@ -31,7 +31,7 @@ const { mockElections, mockResults } = setupMockElection(); var mockUserDB = setupMockUserDB(); export const handlers = [ - rest.get(ENDPOINT_PERSONAL_INFO, (req, res, ctx) => { + rest.get(ENDPOINT_PERSONAL_INFO, async (req, res, ctx) => { const isLogged = sessionStorage.getItem('is-authenticated') === 'true'; const userId = isLogged ? mockUserID : 0; const userInfos = isLogged @@ -42,6 +42,7 @@ export const handlers = [ sciper: userId, } : {}; + await new Promise((r) => setTimeout(r, 1000)); return res( ctx.status(200), @@ -52,10 +53,12 @@ export const handlers = [ ); }), - rest.get(ENDPOINT_GET_TEQ_KEY, (req, res, ctx) => { + rest.get(ENDPOINT_GET_TEQ_KEY, async (req, res, ctx) => { const url = ROUTE_LOGGED; sessionStorage.setItem('is-authenticated', 'true'); sessionStorage.setItem('id', '283205'); + await new Promise((r) => setTimeout(r, 1000)); + return res(ctx.status(200), ctx.json({ url: url })); }), @@ -64,7 +67,9 @@ export const handlers = [ return res(ctx.status(200)); }), - rest.get(endpoints.elections, (req, res, ctx) => { + rest.get(endpoints.elections, async (req, res, ctx) => { + await new Promise((r) => setTimeout(r, 1000)); + return res( ctx.status(200), ctx.json({ @@ -75,15 +80,18 @@ export const handlers = [ ); }), - rest.get(endpoints.election(':ElectionID'), (req, res, ctx) => { + rest.get(endpoints.election(':ElectionID'), async (req, res, ctx) => { const { ElectionID } = req.params; + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200), ctx.json(mockElections.get(ElectionID as ID))); }), - rest.post(endpoints.newElection, (req, res, ctx) => { + rest.post(endpoints.newElection, async (req, res, ctx) => { const body = req.body as NewElectionBody; + await new Promise((r) => setTimeout(r, 1000)); + const createElection = (configuration: any) => { const newElectionID = uid(); @@ -108,8 +116,9 @@ export const handlers = [ ); }), - rest.post(endpoints.newElectionVote(':ElectionID'), (req, res, ctx) => { - const { Ballot }: NewElectionVoteBody = JSON.parse(req.body.toString()); + rest.post(endpoints.newElectionVote(':ElectionID'), async (req, res, ctx) => { + const { Ballot }: NewElectionVoteBody = req.body as NewElectionVoteBody; + await new Promise((r) => setTimeout(r, 1000)); return res( ctx.status(200), @@ -119,7 +128,7 @@ export const handlers = [ ); }), - rest.put(endpoints.editElection(':ElectionID'), (req, res, ctx) => { + rest.put(endpoints.editElection(':ElectionID'), async (req, res, ctx) => { const body = req.body as EditElectionBody; const { ElectionID } = req.params; var Status = STATUS.Initial; @@ -144,45 +153,60 @@ export const handlers = [ ...mockElections.get(ElectionID as string), Status, }); + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200), ctx.text('Action successfully done')); }), - rest.put(endpoints.editShuffle(':ElectionID'), (req, res, ctx) => { + rest.delete(endpoints.editElection(':ElectionID'), async (req, res, ctx) => { + const { ElectionID } = req.params; + mockElections.delete(ElectionID as string); + await new Promise((r) => setTimeout(r, 1000)); + + return res(ctx.status(200), ctx.text('Election deleted')); + }), + + rest.put(endpoints.editShuffle(':ElectionID'), async (req, res, ctx) => { const { ElectionID } = req.params; mockElections.set(ElectionID as string, { ...mockElections.get(ElectionID as string), Status: STATUS.ShuffledBallots, }); + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200), ctx.text('Action successfully done')); }), - rest.put(endpoints.editDKGActors(':ElectionID'), (req, res, ctx) => { + rest.put(endpoints.editDKGActors(':ElectionID'), async (req, res, ctx) => { const { ElectionID } = req.params; mockElections.set(ElectionID as string, { ...mockElections.get(ElectionID as string), Result: mockResults.get(ElectionID as string), Status: STATUS.ResultAvailable, }); + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200), ctx.text('Action successfully done')); }), - rest.get(endpoints.ENDPOINT_USER_RIGHTS, (req, res, ctx) => { + rest.get(endpoints.ENDPOINT_USER_RIGHTS, async (req, res, ctx) => { + await new Promise((r) => setTimeout(r, 1000)); + return res(ctx.status(200), ctx.json(mockUserDB.filter((user) => user.role !== 'voter'))); }), - rest.post(endpoints.ENDPOINT_ADD_ROLE, (req, res, ctx) => { + rest.post(endpoints.ENDPOINT_ADD_ROLE, async (req, res, ctx) => { const body = req.body as NewUserRole; mockUserDB.push({ id: uid(), ...body }); + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200)); }), - rest.post(endpoints.ENDPOINT_REMOVE_ROLE, (req, res, ctx) => { + rest.post(endpoints.ENDPOINT_REMOVE_ROLE, async (req, res, ctx) => { const body = req.body as RemoveUserRole; mockUserDB = mockUserDB.filter((user) => user.sciper !== body.sciper); + await new Promise((r) => setTimeout(r, 1000)); return res(ctx.status(200)); }), diff --git a/web/frontend/src/mocks/mockData.ts b/web/frontend/src/mocks/mockData.ts index 885d5748c..b2897e12f 100644 --- a/web/frontend/src/mocks/mockData.ts +++ b/web/frontend/src/mocks/mockData.ts @@ -151,10 +151,7 @@ const mockElection2: any = { ID: (0xbeef).toString(), MaxN: 2, MinN: 1, - Choices: [ - 'INNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN', - 'SC', - ], + Choices: ['IN', 'SC'], }, ], Texts: [], diff --git a/web/frontend/src/mocks/setupMockUserDB.ts b/web/frontend/src/mocks/setupMockUserDB.ts index 04383377c..9ea62fe9f 100644 --- a/web/frontend/src/mocks/setupMockUserDB.ts +++ b/web/frontend/src/mocks/setupMockUserDB.ts @@ -26,7 +26,6 @@ const setupMockUserDB = () => { userDB.push(mockUser1); userDB.push(mockUser2); userDB.push(mockUser3); - return userDB; }; diff --git a/web/frontend/src/old/UploadFile.tsx.old b/web/frontend/src/old/UploadFile.tsx.old deleted file mode 100644 index 16639b286..000000000 --- a/web/frontend/src/old/UploadFile.tsx.old +++ /dev/null @@ -1,131 +0,0 @@ -import React, { FC, useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { useTranslation } from 'react-i18next'; - -import { ENDPOINT_POST_EVOTING_CREATE } from '../utils/Endpoints'; -import usePostCall from '../utils/usePostCall'; - -type UploadFileProps = { - setShowModal(shown: boolean): void; - setTextModal(text: string): void; -}; - -const UploadFile: FC = ({ setShowModal, setTextModal }) => { - const { t } = useTranslation(); - const [file, setFile] = useState(null); - const [fileExt, setFileExt] = useState(null); - const [errors, setErrors] = useState({ nothing: '', extension: '' }); - const [name, setName] = useState(''); - const [, setIsSubmitting] = useState(false); - const [postError, setPostError] = useState(null); - const { postData } = usePostCall(setPostError); - - useEffect(() => { - if (postError === null) { - setTextModal(t('electionSuccess')); - } else { - if (postError.includes('ECONNREFUSED')) { - setTextModal(t('errorServerDown')); - } else { - setTextModal(t('electionFail')); - } - } - }, [postError, setTextModal, t]); - - const validateJSONFields = () => { - var data = JSON.parse(file); - var candidates = JSON.parse(data.Format).Candidates; - if (data.Title === '') { - return false; - } - if (!Array.isArray(candidates)) { - return false; - } else { - /*check if the elements of the array are string*/ - for (var i = 0; i < candidates.length; i++) { - if (typeof candidates[i] !== 'string') { - return false; - } - } - } - return true; - }; - - const sendElection = async (data) => { - let postRequest = { - method: 'POST', - body: JSON.stringify(data), - }; - setPostError(null); - postData(ENDPOINT_POST_EVOTING_CREATE, postRequest, setIsSubmitting); - }; - - /*Check that the filename has indeed the extension .json - Important: User can bypass this test by renaming the extension - -> backend needs to perform other verification! */ - const validateFileExtension = () => { - if (fileExt === null) { - errors.nothing = t('noFile'); - setErrors(errors); - return false; - } else { - let fileName = fileExt.name; - if (fileName.substring(fileName.length - 5, fileName.length) !== '.json') { - errors.extension = t('notJson'); - setErrors(errors); - return false; - } - return validateJSONFields(); - } - }; - - const uploadJSON = async () => { - if (validateFileExtension()) { - sendElection(JSON.parse(file)); - setName(''); - setShowModal(true); - } - }; - - const handleChange = (event) => { - setFileExt(event.target.files[0]); - var newUpload = event.target.files[0]; - setName(event.target.value); - var reader = new FileReader(); - reader.onload = function (param) { - setFile(param.target.result); - }; - reader.readAsText(newUpload); - }; - - return ( -
-
Option 2
- {t('upload')} - - - {errors.nothing} - {errors.extension} - -
- ); -}; - -UploadFile.propTypes = { - setShowModal: PropTypes.func.isRequired, - setTextModal: PropTypes.func.isRequired, -}; - -export default UploadFile; diff --git a/web/frontend/src/old/VoteEncrypt.js.old b/web/frontend/src/old/VoteEncrypt.js.old deleted file mode 100644 index f8d7ff2ba..000000000 --- a/web/frontend/src/old/VoteEncrypt.js.old +++ /dev/null @@ -1,24 +0,0 @@ -export function encryptVote(vote, dkgKey, edCurve){ - - //embed the vote into a curve point - const enc = new TextEncoder(); - const voteByte = enc.encode(vote); //vote as []byte - const voteBuff = Buffer.from(voteByte.buffer); - const M = edCurve.point().embed(voteBuff); - - - //dkg public key as a point on the EC - const keyBuff = dkgKey; - const p = edCurve.point(); - p.unmarshalBinary(keyBuff); //unmarshall dkg public key - const pubKeyPoint = p.clone(); //get the point corresponding to the dkg public key - - const k = edCurve.scalar().pick(); //ephemeral private key - const K = edCurve.point().mul(k, null); // ephemeral DH public key - - const S = edCurve.point().mul(k, pubKeyPoint); //ephemeral DH shared secret - const C = S.add(S,M); //message blinded with secret - - //(K,C) are what we'll send to the backend - return [K.marshalBinary(),C.marshalBinary()]; -} \ No newline at end of file diff --git a/web/frontend/src/pages/About.tsx b/web/frontend/src/pages/About.tsx index d92ef0d33..2bb2e317e 100644 --- a/web/frontend/src/pages/About.tsx +++ b/web/frontend/src/pages/About.tsx @@ -6,8 +6,8 @@ const About: FC = () => { return ( -
-
+
+

{t('about1')}
diff --git a/web/frontend/src/pages/Admin.css b/web/frontend/src/pages/Admin.css deleted file mode 100644 index 9aa24e855..000000000 --- a/web/frontend/src/pages/Admin.css +++ /dev/null @@ -1,3 +0,0 @@ -.admin-grid { - height: 400px; -} diff --git a/web/frontend/src/pages/Admin.tsx b/web/frontend/src/pages/Admin.tsx index 107b5359e..4be564d59 100644 --- a/web/frontend/src/pages/Admin.tsx +++ b/web/frontend/src/pages/Admin.tsx @@ -1,84 +1,193 @@ -import React, { useEffect, useState } from 'react'; -import Button from '@mui/material/Button'; -import { DataGrid } from '@mui/x-data-grid'; +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 './Admin.css'; +import Loading from './Loading'; +import { FlashContext, FlashLevel } from 'index'; + +const SCIPERS_PER_PAGE = 10; const Admin = () => { - const [rows, setRows] = useState([]); - const [newusrOpen, setNewusrOpen] = useState(false); + const { t } = useTranslation(); + const fctx = useContext(FlashContext); - const [sciperToDelete, setSciperToDelete] = useState(0); + 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 = () => setNewusrOpen(true); + const openModal = () => setNewUserOpen(true); useEffect(() => { - if (newusrOpen || showDeleteModal) { - return; - } - + setLoading(true); fetch(ENDPOINT_USER_RIGHTS) .then((resp) => { - const jsonData = resp.json(); - jsonData.then((result) => { - console.log(result); - setRows(result); - }); + 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) => { - console.log(error); + setLoading(false); + fctx.addMessage(`${t('errorFetchingUsers')}: ${error.message}`, FlashLevel.Error); }); - }, [newusrOpen, showDeleteModal]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - const columns = [ - { - field: 'sciper', - headerName: 'sciper', - width: 150, - }, - { - field: 'role', - headerName: 'role', - width: 150, - }, - { - field: 'action', - headerName: 'Action', - width: 150, - renderCell: function (params: any) { - function handledClick() { - setSciperToDelete(params.row.sciper); - setShowDeleteModal(true); - } - return ( - - ); - }, - }, - ]; + const partitionArray = (array: any[], size: number) => + array.map((v, i) => (i % size === 0 ? array.slice(i, i + size) : null)).filter((v) => v); - return ( -
-
- - - - + 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/Home.css b/web/frontend/src/pages/Home.css deleted file mode 100644 index 76fc3469b..000000000 --- a/web/frontend/src/pages/Home.css +++ /dev/null @@ -1,28 +0,0 @@ -.home { - padding-bottom: 5%; -} - -h1 { - text-align: center; -} -.home-txt { - text-align: left; -} - -.signin-admin-btn { - display: block; - margin-top: 10px; - border-radius: 8px; - cursor: pointer; - width: 200px; - height: 30px; -} - -.signin-voter-btn { - display: block; - margin-top: 10px; - border-radius: 8px; - cursor: pointer; - width: 200px; - height: 30px; -} diff --git a/web/frontend/src/pages/Home.tsx b/web/frontend/src/pages/Home.tsx index 630e1e683..74c53e942 100644 --- a/web/frontend/src/pages/Home.tsx +++ b/web/frontend/src/pages/Home.tsx @@ -1,43 +1,13 @@ -import { FlashContext, FlashLevel } from 'index'; -import React, { FC, useContext } from 'react'; +import { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import './Home.css'; - const Home: FC = () => { const { t } = useTranslation(); - const fctx = useContext(FlashContext); return ( -
+

{t('homeTitle')}

-
{t('homeText')}
-
- - - -
+
{t('homeText')}
); }; diff --git a/web/frontend/src/pages/Loading.tsx b/web/frontend/src/pages/Loading.tsx new file mode 100644 index 000000000..7d7894b60 --- /dev/null +++ b/web/frontend/src/pages/Loading.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +const Loading: FC = () => { + const { t } = useTranslation(); + return ( +
+
+ + + + +
+

{t('loading')}

+
+ ); +}; + +export default Loading; diff --git a/web/frontend/src/pages/ballot/Index.css b/web/frontend/src/pages/ballot/Index.css deleted file mode 100644 index 765b28283..000000000 --- a/web/frontend/src/pages/ballot/Index.css +++ /dev/null @@ -1,30 +0,0 @@ -.vote-allowed { - padding-bottom: 3%; -} -.cast-ballot { - padding: 3%; - background-color: #f6f6fa; -} - -.ballot-indication { - padding-bottom: 2%; -} - -.cast-ballot-card { - padding: 0.5%; - background-color: #f6f6fa; - outline-style: solid; - outline-width: 1px; - outline-color: #ececf0; -} - -.cast-ballot-btn { - align-content: center; - border-radius: 8px; - cursor: pointer; -} - -.past-vote { - padding-top: 1%; - font-size: small; -} diff --git a/web/frontend/src/pages/ballot/Index.tsx b/web/frontend/src/pages/ballot/Index.tsx deleted file mode 100644 index 082afede8..000000000 --- a/web/frontend/src/pages/ballot/Index.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React, { FC } from 'react'; -import { useTranslation } from 'react-i18next'; - -import SimpleTable from 'pages/election/components/SimpleTable'; -import './Index.css'; -import { ROUTE_BALLOT_SHOW } from 'Routes'; -import { STATUS } from 'types/election'; - -const BallotIndex: FC = () => { - const { t } = useTranslation(); - - return ( -
- -
- ); -}; - -export default BallotIndex; diff --git a/web/frontend/src/pages/ballot/Show.tsx b/web/frontend/src/pages/ballot/Show.tsx index 8bf5f1008..c5f8f7ed8 100644 --- a/web/frontend/src/pages/ballot/Show.tsx +++ b/web/frontend/src/pages/ballot/Show.tsx @@ -1,12 +1,10 @@ import { FC, useEffect, useState } from 'react'; -import { CloudUploadIcon } from '@heroicons/react/outline'; import { useTranslation } from 'react-i18next'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import kyber from '@dedis/kyber'; import PropTypes from 'prop-types'; import { Buffer } from 'buffer'; -import { ROUTE_BALLOT_INDEX } from '../../Routes'; import useElection from 'components/utils/useElection'; import usePostCall from 'components/utils/usePostCall'; import * as endpoints from 'components/utils/Endpoints'; @@ -22,9 +20,14 @@ import Rank, { handleOnDragEnd } from './components/Rank'; import Text from './components/Text'; import { ballotIsValid } from './components/ValidateAnswers'; import { STATUS } from 'types/election'; +import ElectionClosed from './components/ElectionClosed'; +import Loading from 'pages/Loading'; +import { CloudUploadIcon } from '@heroicons/react/solid'; +import SpinnerIcon from 'components/utils/SpinnerIcon'; const Ballot: FC = () => { const { t } = useTranslation(); + const { electionId } = useParams(); const UserID = sessionStorage.getItem('id'); const { loading, configObj, electionID, status, pubKey, ballotSize, chunksPerBallot } = @@ -32,20 +35,13 @@ const Ballot: FC = () => { const { configuration, answers, setAnswers } = useConfiguration(configObj); const [userErrors, setUserErrors] = useState(''); const edCurve = kyber.curve.newCurve('edwards25519'); - const [postRequest, setPostRequest] = useState(null); const [postError, setPostError] = useState(''); const [showModal, setShowModal] = useState(false); const [modalText, setModalText] = useState(t('voteSuccess') as string); const [modalTitle, setModalTitle] = useState(''); - const [navigateDest, setNavigateDest] = useState(null); + const [castVoteLoading, setCastVoteLoading] = useState(false); const sendFetchRequest = usePostCall(setPostError); - useEffect(() => { - if (postRequest !== null) { - sendFetchRequest(endpoints.newElectionVote(electionID.toString()), postRequest, setShowModal); - } - }, [postRequest]); - useEffect(() => { if (postError !== null) { if (postError.includes('ECONNREFUSED')) { @@ -55,7 +51,6 @@ const Ballot: FC = () => { } setModalTitle(t('errorTitle')); } else { - setNavigateDest('/'); setModalText(t('voteSuccess')); setModalTitle(t('voteSuccessful')); } @@ -64,7 +59,7 @@ const Ballot: FC = () => { const hexToBytes = (hex: string) => { const bytes: number[] = []; for (let c = 0; c < hex.length; c += 2) { - bytes.push(parseInt(hex.substr(c, 2), 16)); + bytes.push(parseInt(hex.substring(c, c + 2), 16)); } return new Uint8Array(bytes); }; @@ -90,8 +85,15 @@ const Ballot: FC = () => { const newRequest = { method: 'POST', body: JSON.stringify(ballot), + headers: { + 'Content-Type': 'Application/json', + }, }; - setPostRequest(newRequest); + await sendFetchRequest( + endpoints.newElectionVote(electionID.toString()), + newRequest, + setShowModal + ); } catch (e) { console.log(e); setModalText(t('ballotFailure')); @@ -99,6 +101,7 @@ const Ballot: FC = () => { setShowModal(true); } + setCastVoteLoading(false); }; const handleClick = () => { @@ -106,6 +109,7 @@ const Ballot: FC = () => { setUserErrors(t('incompleteBallot')); return; } + setCastVoteLoading(true); setUserErrors(''); sendBallot(); @@ -165,7 +169,11 @@ const Ballot: FC = () => { type="button" className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-500 hover:bg-indigo-600" onClick={handleClick}> -
@@ -175,21 +183,6 @@ const Ballot: FC = () => { ); }; - const electionClosedDisplay = () => { - return ( -
-
{t('voteImpossible')}
- - - -
- ); - }; - return (
{ setShowModal={setShowModal} title={modalTitle} buttonRightText={t('close')} - navigateDestination={navigateDest}> + navigateDestination={-1}> {modalText} {loading ? ( -

{t('loading')}

+ ) : ( -
{status === STATUS.Open ? ballotDisplay() : electionClosedDisplay()}
+
{status === STATUS.Open ? ballotDisplay() : }
)}
); diff --git a/web/frontend/src/pages/ballot/components/ElectionClosed.tsx b/web/frontend/src/pages/ballot/components/ElectionClosed.tsx new file mode 100644 index 000000000..bb6c25ee0 --- /dev/null +++ b/web/frontend/src/pages/ballot/components/ElectionClosed.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { ROUTE_ELECTION_INDEX } from 'Routes'; + +export default function ElectionClosed() { + const { t } = useTranslation(); + + return ( +
+
+
+
+
+
+

+ {t('voteImpossible')} +

+

{t('voteImpossibleDescription')}

+
+
+ + {t('notFoundVoteImpossible')} + +
+
+
+
+
+
+ ); +} diff --git a/web/frontend/src/pages/election/Index.css b/web/frontend/src/pages/election/Index.css deleted file mode 100644 index abed946f0..000000000 --- a/web/frontend/src/pages/election/Index.css +++ /dev/null @@ -1,69 +0,0 @@ -.election-wrapper { - padding-top: 3%; - background-color: #f6f6fa; - padding-right: 3%; - padding-left: 3%; -} - -.election-box { - padding-bottom: 5%; -} -.click-info { - padding-bottom: 3%; -} -.election-btn { - border-radius: 8px; - margin: 4px 2px; - cursor: pointer; -} - -.election-status { - display: inline-flex; - margin: 20px 20px; - margin-left: 3%; -} - -.election-status-text { - display: inline-flex; - margin: 0px 5px; -} - -.election-status-on { - display: inline-block; - height: 15px; - width: 15px; - margin-top: 1.5%; - background-color: green; - border-radius: 50%; -} - -.election-status-closed { - display: inline-block; - height: 15px; - width: 15px; - margin-top: 1.5%; - background-color: grey; - border-radius: 50%; -} - -.election-status-cancelled { - display: inline-block; - height: 15px; - width: 15px; - margin-top: 1.5%; - background-color: red; - border-radius: 50%; -} - -.loading { - background-color: #9bc0bc; -} - -.error-retrieving { - padding-top: 10%; - padding-bottom: 10%; -} - -.no-election { - padding-bottom: 3%; -} diff --git a/web/frontend/src/pages/election/Index.tsx b/web/frontend/src/pages/election/Index.tsx index 9822f5ccc..9eaf04dce 100644 --- a/web/frontend/src/pages/election/Index.tsx +++ b/web/frontend/src/pages/election/Index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import ElectionTable from './components/ElectionTable'; import useFetchCall from 'components/utils/useFetchCall'; import * as endpoints from 'components/utils/Endpoints'; -import './Index.css'; +import Loading from 'pages/Loading'; const ElectionIndex: FC = () => { const { t } = useTranslation(); @@ -19,31 +19,32 @@ const ElectionIndex: FC = () => { /*Show all the elections retrieved if any */ const showElection = () => { - return ( -
- {data.Elections.length > 0 ? ( -
-
{t('clickElection')}
-
- -
-
- ) : ( -
{t('noElection')}
- )} -
+ return data.Elections.length > 0 ? ( + <> +
+

+ {t('elections')} +

+
{t('listElection')}
+
{t('clickElection')}
+
+
+ +
+ + ) : ( +
{t('noElection')}
); }; return ( -
- {t('listElection')} +
{!loading ? ( showElection() ) : error === null ? ( -

{t('loading')}

+ ) : ( -
+
{t('errorRetrievingElection')} - {error.toString()}
)} diff --git a/web/frontend/src/pages/election/New.tsx b/web/frontend/src/pages/election/New.tsx index 58fa6d050..696af017c 100644 --- a/web/frontend/src/pages/election/New.tsx +++ b/web/frontend/src/pages/election/New.tsx @@ -1,28 +1,20 @@ -import React, { FC, useState } from 'react'; +import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import ElectionForm from './components/ElectionForm'; -import Modal from 'components/modal/Modal'; const ElectionCreate: FC = () => { const { t } = useTranslation(); - const [showModal, setShowModal] = useState(false); - const [textModal, setTextModal] = useState(''); return ( -
- -

- {t('navBarCreate')} -

-

{t('create')}

- - +
+
+

+ {t('navBarCreate')} +

+
{t('create')}
+
+
); }; diff --git a/web/frontend/src/pages/election/Result.tsx b/web/frontend/src/pages/election/Result.tsx index bb63e6ac7..b8bb40589 100644 --- a/web/frontend/src/pages/election/Result.tsx +++ b/web/frontend/src/pages/election/Result.tsx @@ -18,7 +18,8 @@ import { import DownloadButton from 'components/buttons/DownloadButton'; import { useTranslation } from 'react-i18next'; import saveAs from 'file-saver'; -import { Link, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import TextButton from '../../components/buttons/TextButton'; import useElection from 'components/utils/useElection'; import { useConfigurationOnly } from 'components/utils/useConfiguration'; @@ -27,10 +28,12 @@ import { countSelectResult, countTextResult, } from './components/utils/countResult'; +import Loading from 'pages/Loading'; // Functional component that displays the result of the votes const ElectionResult: FC = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const { electionId } = useParams(); const { loading, result, configObj } = useElection(electionId); @@ -88,6 +91,7 @@ const ElectionResult: FC = () => { setSelectResult(selectRes); setTextResult(textRes); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [result]); const SubjectElementResultDisplay = (element: SubjectElement) => { @@ -202,14 +206,14 @@ const ElectionResult: FC = () => { {configuration.Scaffold.map((subject: Subject) => displayResults(subject))}
- +
navigate(-1)}> {t('back')} - +
{t('exportResJSON')}
) : ( -

{t('loading')}

+ )}
); diff --git a/web/frontend/src/pages/election/Show.css b/web/frontend/src/pages/election/Show.css deleted file mode 100644 index aab3861b4..000000000 --- a/web/frontend/src/pages/election/Show.css +++ /dev/null @@ -1,27 +0,0 @@ -.election-candidates { - list-style: none; - display: flexbox; - padding-bottom: 3%; -} - -.election-candidate { - list-style: disc; - margin-left: 4%; -} -.election-details-box { - background-color: #f6f6fa; - padding-left: 2%; - padding-bottom: 2%; -} - -.election-action { - margin-left: 5%; -} -.back-btn { - margin-top: 3%; -} -h1 { - padding-top: 2%; - text-align: left; - color: #177368; -} diff --git a/web/frontend/src/pages/election/Show.tsx b/web/frontend/src/pages/election/Show.tsx index 914cb88f7..7826a9d8d 100644 --- a/web/frontend/src/pages/election/Show.tsx +++ b/web/frontend/src/pages/election/Show.tsx @@ -1,23 +1,18 @@ -import React, { FC, useContext, useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; +import React, { FC, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import useElection from 'components/utils/useElection'; -import './Show.css'; import useGetResults from 'components/utils/useGetResults'; import { STATUS } from 'types/election'; -import Status from './components/Status'; import Action from './components/Action'; -import { ROUTE_BALLOT_SHOW, ROUTE_ELECTION_INDEX } from 'Routes'; -import TextButton from 'components/buttons/TextButton'; -import { AuthContext } from 'index'; -import { ROLE } from 'types/userRole'; +import StatusTimeline from './components/StatusTimeline'; +import Loading from 'pages/Loading'; const ElectionShow: FC = () => { const { t } = useTranslation(); const { electionId } = useParams(); - const authCtx = useContext(AuthContext); const { loading, electionID, status, setStatus, setResult, configObj, setIsResultSet } = useElection(electionId); @@ -31,19 +26,28 @@ const ElectionShow: FC = () => { if (status === STATUS.ResultAvailable && isResultAvailable) { getResults(electionID, setError, setResult, setIsResultSet); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isResultAvailable, status]); return ( -
+
{!loading ? ( -
-
-

- {configObj.MainTitle} -

-
- {t('status')}: - {t('action')}: + <> +

+ {configObj.MainTitle} +

+ +

Election ID : {electionId}

+
+
{t('status')}
+ +
+ +
+
+
+
{t('action')}
+
{ />
-
- {status === STATUS.Open && - authCtx.isLogged && - (authCtx.role === ROLE.Admin || - authCtx.role === ROLE.Operator || - authCtx.role === ROLE.Voter) ? ( - - {t('navBarVote')} - - ) : null} - - {t('back')} - -
-
+ ) : ( -

{t('loading')}

+ )}
); diff --git a/web/frontend/src/pages/election/components/Action.tsx b/web/frontend/src/pages/election/components/Action.tsx index 3113cddbb..ee50a1551 100644 --- a/web/frontend/src/pages/election/components/Action.tsx +++ b/web/frontend/src/pages/election/components/Action.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState } from 'react'; +import React, { FC, useContext, useState } from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; @@ -6,6 +6,9 @@ import Modal from 'components/modal/Modal'; import { ID } from 'types/configuration'; import useChangeAction from 'components/utils/useChangeAction'; import { STATUS } from 'types/election'; +import DeleteButton from 'components/utils/DeleteButton'; +import { FlashContext, FlashLevel } from 'index'; +import { useNavigate } from 'react-router-dom'; type ActionProps = { status: STATUS; @@ -16,6 +19,8 @@ type ActionProps = { const Action: FC = ({ status, electionID, setStatus, setResultAvailable }) => { const { t } = useTranslation(); + const fctx = useContext(FlashContext); + const navigate = useNavigate(); const [textModalError, setTextModalError] = useState(null); const [showModalError, setShowModalError] = useState(false); @@ -28,11 +33,28 @@ const Action: FC = ({ status, electionID, setStatus, setResultAvail setShowModalError ); + const deleteElection = async () => { + const request = { + method: 'DELETE', + }; + + const res = await fetch(`/api/evoting/elections/${electionID}`, request); + if (!res.ok) { + const txt = await res.text(); + fctx.addMessage(`failed to send delete request: ${txt}`, FlashLevel.Error); + return; + } + + fctx.addMessage('election deleted', FlashLevel.Info); + navigate('/'); + }; + return ( {getAction()} {modalClose} {modalCancel} + { = ({ setShowModal, setTextModal }) => { +const ElectionForm: FC = () => { // conf is the configuration object containing MainTitle and Scaffold which // contains an array of subject. const { t } = useTranslation(); const emptyConf: Configuration = emptyConfiguration(); const [conf, setConf] = useState(emptyConf); + const [loading, setLoading] = useState(false); + const [showModal, setShowModal] = useState(false); + const [textModal, setTextModal] = useState(''); + const [navigateDestination, setNavigateDestination] = useState(null); const { MainTitle, Scaffold } = conf; async function createHandler() { @@ -47,14 +50,13 @@ const ElectionForm: FC = ({ setShowModal, setTextModal }) => try { await configurationSchema.validate(data.Configuration); } catch (err) { - setTextModal( - 'Incorrect election configuration, please fill it completely: ' + err.errors.join(',') - ); + setTextModal(t('errorIncorrectConfSchema') + err.errors.join(',')); setShowModal(true); return; } try { + setLoading(true); const res = await fetch(newElection, req); if (res.status !== 200) { const response = await res.text(); @@ -62,13 +64,16 @@ const ElectionForm: FC = ({ setShowModal, setTextModal }) => setShowModal(true); } else { const response = await res.json(); - setTextModal(`Success creating an election ! ElectionID : ${response.ElectionID}`); + setNavigateDestination('/elections/' + response.ElectionID); + setTextModal(`${t('successCreateElection')} ${response.ElectionID}`); setShowModal(true); setConf(emptyConf); } + setLoading(false); } catch (error) { setTextModal(error.message); setShowModal(true); + setLoading(false); } } @@ -79,9 +84,7 @@ const ElectionForm: FC = ({ setShowModal, setTextModal }) => try { await configurationSchema.validate(data); } catch (err) { - setTextModal( - 'Incorrect election configuration, please fill it completely: ' + err.errors.join(',') - ); + setTextModal(t('errorIncorrectConfSchema') + err.errors.join(',')); setShowModal(true); return; } @@ -113,53 +116,67 @@ const ElectionForm: FC = ({ setShowModal, setTextModal }) => }; return ( -
-
- -
-
-
+ <> + + {textModal} + +
+
+ +
+
+
+
-
- setConf({ ...conf, MainTitle: e.target.value })} - name="MainTitle" - type="text" - placeholder="Enter the Main title" - className="ml-3 mt-4 w-60 mb-2 text-lg border rounded-md" - /> - {Scaffold.map((subject) => ( - setConf({ ...conf, MainTitle: e.target.value })} + name="MainTitle" + type="text" + placeholder="Enter the Main title" + className="ml-3 mt-4 w-60 mb-2 text-lg border rounded-md" /> - ))} -
- Subject + {Scaffold.map((subject) => ( + + ))} +
+ Subject +
+
+
+ + + {t('exportElecJSON')}
-
- - - {t('exportElecJSON')} -
-
+ ); }; diff --git a/web/frontend/src/pages/election/components/SimpleTable.tsx b/web/frontend/src/pages/election/components/SimpleTable.tsx deleted file mode 100644 index c9cde7e6e..000000000 --- a/web/frontend/src/pages/election/components/SimpleTable.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { FC } from 'react'; -import { Link } from 'react-router-dom'; -import PropTypes from 'prop-types'; -import { useTranslation } from 'react-i18next'; - -import useFetchCall from '../../../components/utils/useFetchCall'; -import * as endpoints from '../../../components/utils/Endpoints'; -import { LightElectionInfo, STATUS } from 'types/election'; - -type SimpleTableProps = { - statusToKeep: STATUS; - pathLink: string; - textWhenData: string; - textWhenNoData: string; -}; - -// Functional component that fetches all the elections, only keeps the elections -// whose status = statusToKeep and display them in a table with a single title -// column. It adds a link to '/pathLink/:id' when the title is clicked -// If table is empty, it display textWhenNoData instead -const SimpleTable: FC = ({ - statusToKeep, - pathLink, - textWhenData, - textWhenNoData, -}) => { - const { t } = useTranslation(); - const request = { - method: 'GET', - }; - - const [fetchedData, loading, error] = useFetchCall(endpoints.elections, request); - - const ballotsToDisplay = (elections: LightElectionInfo[]) => { - return elections.filter((election) => election.Status === statusToKeep); - }; - - const displayBallotTable = (data: LightElectionInfo[]) => { - if (data.length > 0) { - return ( -
-
{textWhenData}
-
- - - - - - - - {data.map((election) => { - return ( - - - - ); - })} - -
- {t('elecName')} -
- - {election.Title} - -
-
-
- ); - } else { - return
{textWhenNoData}
; - } - }; - - const showBallots = (elections: LightElectionInfo[]) => { - return displayBallotTable(ballotsToDisplay(elections)); - }; - - return ( -
- {!loading ? ( - showBallots(fetchedData.Elections) - ) : error === null ? ( -

{t('loading')}

- ) : ( -
{t('errorRetrievingElection')}
- )} -
- ); -}; - -SimpleTable.propTypes = { - statusToKeep: PropTypes.number.isRequired, - pathLink: PropTypes.string.isRequired, - textWhenData: PropTypes.string.isRequired, - textWhenNoData: PropTypes.string.isRequired, -}; - -export default SimpleTable; diff --git a/web/frontend/src/pages/election/components/StatusTimeline.tsx b/web/frontend/src/pages/election/components/StatusTimeline.tsx new file mode 100644 index 000000000..92d967231 --- /dev/null +++ b/web/frontend/src/pages/election/components/StatusTimeline.tsx @@ -0,0 +1,114 @@ +import { AuthContext } from 'index'; +import { FC, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { STATUS } from 'types/election'; +import { ROLE } from 'types/userRole'; + +type StatusTimelineProps = { + status: STATUS; +}; + +const CanceledStep = { name: 'Canceled', status: STATUS.Canceled }; + +const StatusTimeline: FC = ({ status }) => { + const authCtx = useContext(AuthContext); + const { t } = useTranslation(); + + const completeSteps = [ + { name: 'statusInitial', status: STATUS.Initial }, + { name: 'statusInitializedNodes', status: STATUS.InitializedNodes }, + { name: 'statusOnGoingSetup', status: STATUS.Setup }, + { name: 'statusSetup', status: STATUS.Setup }, + { name: 'statusOpen', status: STATUS.Open }, + { name: 'statusClose', status: STATUS.Closed }, + { name: 'statusOnGoingShuffle', status: STATUS.OnGoingShuffle }, + { name: 'statusShuffle', status: STATUS.ShuffledBallots }, + { name: 'statusOnGoingDecryption', status: STATUS.OnGoingDecryption }, + { name: 'statusDecrypted', status: STATUS.DecryptedBallots }, + { name: 'statusResultAvailable', status: STATUS.ResultAvailable }, + ]; + + const simpleSteps = [ + { name: 'statusInitial', status: STATUS.Initial }, + { name: 'statusOpen', status: STATUS.Open }, + { name: 'statusClose', status: STATUS.Closed }, + { name: 'statusShuffle', status: STATUS.ShuffledBallots }, + { name: 'statusDecrypted', status: STATUS.DecryptedBallots }, + { name: 'statusResultAvailable', status: STATUS.ResultAvailable }, + ]; + + const steps = + authCtx.role === ROLE.Admin || authCtx.role === ROLE.Operator ? completeSteps : simpleSteps; + + // If the status is Canceled we need to add the Canceled step to the steps + // array at the correct position in the workflow (before the Closed step) + if (status === STATUS.Canceled) { + steps.splice( + steps.findIndex((step) => step.status === STATUS.Closed), + 0, + CanceledStep + ); + } + + // Find the current step in the steps array (the status) + const currentStep = steps.findIndex((step) => step.status === status); + + const DisplayStatus = ({ state, name }) => { + switch (state) { + case 'complete': + return ( +
+ + {t(name)} + +
+ ); + case 'current': + return ( +
+ + {t(name)} + +
+ ); + default: + return ( +
+ + {t(name)} + +
+ ); + } + }; + + return ( +
    + {steps.map((step, index) => { + if (index < currentStep) { + return ( +
  1. + +
  2. + ); + } + if (index === currentStep) { + return ( +
  3. + +
  4. + ); + } + return ( +
  5. + +
  6. + ); + })} +
+ ); +}; + +export default StatusTimeline;