From f966cbd62f2fc6c436c1b98f45fd8dca8ab03ea4 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Sat, 4 Jun 2022 19:00:57 +0200 Subject: [PATCH] Implement user creation and voter management --- cmd/boardvoting/handlers.go | 304 ++++++++++++++++++----------- cmd/boardvoting/helpers.go | 31 +-- cmd/boardvoting/main.go | 8 +- cmd/boardvoting/middleware.go | 8 +- cmd/boardvoting/middleware_test.go | 14 +- cmd/boardvoting/routes.go | 3 + internal/forms/forms.go | 192 +++++++++++------- internal/models/audit.go | 77 +++++++- internal/models/users.go | 140 +++++++++---- ui/html/pages/choose_voters.html | 34 ++++ ui/html/pages/create_user.html | 34 ++++ ui/html/pages/edit_user.html | 2 +- ui/html/partials/nav.html | 5 + ui/static/handlers.js | 2 + 14 files changed, 595 insertions(+), 259 deletions(-) create mode 100644 ui/html/pages/choose_voters.html create mode 100644 ui/html/pages/create_user.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 393b32c..d4e20b9 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -48,6 +48,7 @@ const ( subLevelNavMotionsAll subLevelNavItem = "all-motions" subLevelNavMotionsUnvoted subLevelNavItem = "unvoted-motions" subLevelNavUsers subLevelNavItem = "users" + subLevelNavVoters subLevelNavItem = "manage-voters" ) func (m *templateData) motionPaginationOptions(limit int, first, last *time.Time) error { @@ -75,12 +76,6 @@ func (m *templateData) motionPaginationOptions(limit int, first, last *time.Time } func (app *application) motionList(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/motions/" { - app.notFound(w) - - return - } - var ( listOptions *models.MotionListOptions err error @@ -97,16 +92,12 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { motions, err := app.motions.List(ctx, listOptions) if err != nil { - app.serverError(w, err) - - return + panic(err) } first, last, err := app.motions.TimestampRange(ctx, listOptions) if err != nil { - app.serverError(w, err) - - return + panic(err) } templateData := app.newTemplateData(r, "motions", "all-motions") @@ -119,9 +110,7 @@ func (app *application) motionList(w http.ResponseWriter, r *http.Request) { err = templateData.motionPaginationOptions(listOptions.Limit, first, last) if err != nil { - app.serverError(w, err) - - return + panic(err) } app.render(w, http.StatusOK, "motions.html", templateData) @@ -207,17 +196,21 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) return } - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = form + data.Form = &form app.render(w, http.StatusUnprocessableEntity, "create_motion.html", data) return } + form.Normalize() + user, err := app.GetUser(r) if err != nil { app.clientError(w, http.StatusUnauthorized) @@ -238,16 +231,12 @@ func (app *application) newMotionSubmit(w http.ResponseWriter, r *http.Request) now.Add(dueDuration), ) if err != nil { - app.serverError(w, err) - - return + panic(err) } decision, err := app.motions.ByID(r.Context(), decisionID) if err != nil { - app.serverError(w, err) - - return + panic(err) } app.mailNotifier.Notify(&NewDecisionNotification{ @@ -300,11 +289,13 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) return } - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) - data.Form = form + data.Form = &form app.render(w, http.StatusUnprocessableEntity, "edit_motion.html", data) @@ -334,16 +325,12 @@ func (app *application) editMotionSubmit(w http.ResponseWriter, r *http.Request) }, ) if err != nil { - app.serverError(w, err) - - return + panic(err) } decision, err := app.motions.ByID(r.Context(), motion.ID) if err != nil { - app.serverError(w, err) - - return + panic(err) } app.mailNotifier.Notify(&UpdateDecisionNotification{ @@ -395,9 +382,7 @@ func (app *application) withdrawMotionSubmit(w http.ResponseWriter, r *http.Requ err = app.motions.Withdraw(r.Context(), motion.ID) if err != nil { - app.serverError(w, err) - - return + panic(err) } app.mailNotifier.Notify(&WithDrawMotionNotification{motion, user}) @@ -459,9 +444,7 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { clientCert, err := getPEMClientCert(r) if err != nil { - app.serverError(w, err) - - return + panic(err) } if err := app.motions.UpdateVote(r.Context(), user.ID, motion.ID, func(v *models.Vote) { @@ -469,9 +452,7 @@ func (app *application) voteSubmit(w http.ResponseWriter, r *http.Request) { v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Direct Vote\n\n%s", clientCert) }); err != nil { - app.serverError(w, err) - - return + panic(err) } app.mailNotifier.Notify(&DirectVoteNotification{ @@ -499,9 +480,7 @@ func (app *application) proxyVoteForm(w http.ResponseWriter, r *http.Request) { potentialVoters, err := app.users.Voters(r.Context()) if err != nil { - app.serverError(w, err) - - return + panic(err) } data.Form = &forms.ProxyVoteForm{ @@ -526,7 +505,9 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) return } - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } if !form.Valid() { data := app.newTemplateData(r, topLevelNavMotions, subLevelNavMotionsAll) @@ -534,13 +515,11 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) potentialVoters, err := app.users.Voters(r.Context()) if err != nil { - app.serverError(w, err) - - return + panic(err) } form.Voters = potentialVoters - data.Form = form + data.Form = &form app.render(w, http.StatusUnprocessableEntity, "proxy_vote.html", data) @@ -558,9 +537,7 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) clientCert, err := getPEMClientCert(r) if err != nil { - app.serverError(w, err) - - return + panic(err) } voter := app.getVoter(w, r, form.Voter.ID) @@ -573,9 +550,7 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) v.Voted = time.Now().UTC() v.Notes = fmt.Sprintf("Proxy-Vote by %s\n\n%s\n\n%s", user.Name, form.Justification, clientCert) }); err != nil { - app.serverError(w, err) - - return + panic(err) } app.mailNotifier.Notify(&ProxyVoteNotification{ @@ -604,9 +579,7 @@ func (app *application) userList(w http.ResponseWriter, r *http.Request) { app.users.CanDelete(), ) if err != nil { - app.serverError(w, err) - - return + panic(err) } data.Users = users @@ -622,16 +595,12 @@ func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) { roles, err := userToEdit.Roles() if err != nil { - app.serverError(w, err) - - return + panic(err) } emailAddresses, err := userToEdit.EmailAddresses() if err != nil { - app.serverError(w, err) - - return + panic(err) } roleNames := make([]string, len(roles)) @@ -673,9 +642,7 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { form.AllRoles = models.AllRoles if err := form.Validate(); err != nil { - app.serverError(w, err) - - return + panic(err) } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) @@ -683,16 +650,12 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { if !form.Valid() { roles, err := userToEdit.Roles() if err != nil { - app.serverError(w, err) - - return + panic(err) } emailAddresses, err := userToEdit.EmailAddresses() if err != nil { - app.serverError(w, err) - - return + panic(err) } roleNames := make([]string, len(roles)) @@ -714,9 +677,7 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { form.UpdateUser(userToEdit) if err := app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { - app.serverError(w, err) - - return + panic(err) } app.addFlash(r, &FlashMessage{ @@ -738,9 +699,7 @@ func (app *application) userAddEmailForm(w http.ResponseWriter, r *http.Request) emailAddresses, err := userToEdit.EmailAddresses() if err != nil { - app.serverError(w, err) - - return + panic(err) } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) @@ -771,16 +730,16 @@ func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Reques form.User = userToEdit - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) if form.Valid() { emailExists, err := app.users.EmailExists(r.Context(), form.EmailAddress) if err != nil { - app.serverError(w, err) - - return + panic(err) } if emailExists { @@ -793,9 +752,7 @@ func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Reques if !form.Valid() { emailAddresses, err := userToEdit.EmailAddresses() if err != nil { - app.serverError(w, err) - - return + panic(err) } form.EmailAddresses = emailAddresses @@ -810,9 +767,7 @@ func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Reques form.Normalize() if err := app.users.AddEmail(r.Context(), userToEdit, data.User, form.EmailAddress, form.Reasoning); err != nil { - app.serverError(w, err) - - return + panic(err) } app.addFlash(r, &FlashMessage{ @@ -831,9 +786,7 @@ func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Reques func (app *application) userDeleteEmailForm(w http.ResponseWriter, r *http.Request) { userToEdit, emailAddress, err := app.deleteEmailParams(w, r) if err != nil { - app.serverError(w, err) - - return + panic(err) } if userToEdit == nil || emailAddress == "" { @@ -862,9 +815,7 @@ func (app *application) userDeleteEmailForm(w http.ResponseWriter, r *http.Reque func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Request) { userToEdit, emailAddress, err := app.deleteEmailParams(w, r) if err != nil { - app.serverError(w, err) - - return + panic(err) } if userToEdit == nil || emailAddress == "" { @@ -895,7 +846,9 @@ func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Req return } - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } if !form.Valid() { form.EmailAddress = emailAddress @@ -903,7 +856,7 @@ func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Req data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) - data.Form = form + data.Form = &form app.render(w, http.StatusUnprocessableEntity, "delete_email.html", data) @@ -913,9 +866,7 @@ func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Req form.Normalize() if err := app.users.DeleteEmail(r.Context(), userToEdit, admin, emailAddress, form.Reasoning); err != nil { - app.serverError(w, err) - - return + panic(err) } app.addFlash(r, &FlashMessage{ @@ -931,14 +882,66 @@ func (app *application) userDeleteEmailSubmit(w http.ResponseWriter, r *http.Req http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) } -func (app *application) newUserForm(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement newUserForm - panic("not implemented") +func (app *application) newUserForm(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + data.Form = &forms.NewUserForm{} + + app.render(w, http.StatusOK, "create_user.html", data) } -func (app *application) newUserSubmit(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement newUserSubmit - panic("not implemented") +func (app *application) newUserSubmit(w http.ResponseWriter, r *http.Request) { + admin, err := app.GetUser(r) + if err != nil { + app.clientError(w, http.StatusUnauthorized) + + return + } + + var form forms.NewUserForm + + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + if err := form.Validate(); err != nil { + panic(err) + } + + if !form.Valid() { + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + data.Form = &form + + app.render(w, http.StatusUnprocessableEntity, "create_user.html", data) + + return + } + + form.Normalize() + + createUserParams := &models.CreateUserParams{ + Admin: admin, + Name: form.Name, + Reminder: form.EmailAddress, + Emails: []string{form.EmailAddress}, + Reasoning: form.Reasoning, + } + + userID, err := app.users.Create(r.Context(), createUserParams) + if err != nil { + panic(err) + } + + app.addFlash(r, &FlashMessage{ + Variant: flashSuccess, + Title: "User created", + Message: fmt.Sprintf("Created new user %s", form.Name), + }) + + http.Redirect(w, r, fmt.Sprintf("/users/%d", userID), http.StatusSeeOther) } func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) { @@ -985,7 +988,9 @@ func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) form.User = userToDelete - form.Validate() + if err := form.Validate(); err != nil { + panic(err) + } data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) @@ -1000,9 +1005,7 @@ func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) form.Normalize() if err = app.users.DeleteUser(r.Context(), userToDelete, data.User, form.Reasoning); err != nil { - app.serverError(w, err) - - return + panic(err) } app.addFlash(r, &FlashMessage{ @@ -1014,6 +1017,89 @@ func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, "/users/", http.StatusSeeOther) } +func (app *application) chooseVotersForm(w http.ResponseWriter, r *http.Request) { + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavVoters) + + allUsers, err := app.users.List(r.Context(), app.users.WithRoles()) + if err != nil { + panic(err) + } + + voterIDs := make([]int64, 0) + + for _, user := range allUsers { + isVoter, err := user.HasRole(models.RoleVoter) + if err != nil { + panic(err) + } + + if isVoter { + voterIDs = append(voterIDs, user.ID) + + continue + } + } + + data.Form = &forms.ChooseVoterForm{ + Users: allUsers, + VoterIDs: voterIDs, + } + + app.render(w, http.StatusOK, "choose_voters.html", data) +} + +func (app *application) chooseVotersSubmit(w http.ResponseWriter, r *http.Request) { + admin, err := app.GetUser(r) + if err != nil { + app.clientError(w, http.StatusUnauthorized) + + return + } + + var form forms.ChooseVoterForm + + if err = app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + if err = form.Validate(); err != nil { + panic(err) + } + + if !form.Valid() { + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavVoters) + + allUsers, err := app.users.List(r.Context(), app.users.WithRoles()) + if err != nil { + panic(err) + } + + form.Users = allUsers + + data.Form = &form + + app.render(w, http.StatusUnprocessableEntity, "choose_voters.html", data) + + return + } + + form.Normalize() + + if err = app.users.ChooseVoters(r.Context(), admin, form.VoterIDs, form.Reasoning); err != nil { + panic(err) + } + + app.addFlash(r, &FlashMessage{ + Variant: flashSuccess, + Title: "Voters selected", + Message: "A new list of voters has been selected", + }) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) +} + func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { const ( ok = "OK" diff --git a/cmd/boardvoting/helpers.go b/cmd/boardvoting/helpers.go index 60d816c..f832f80 100644 --- a/cmd/boardvoting/helpers.go +++ b/cmd/boardvoting/helpers.go @@ -27,10 +27,10 @@ import ( "io/fs" "net/http" "path/filepath" - "runtime/debug" "strconv" "strings" + "git.cacert.org/cacert-boardvoting/internal/forms" "github.com/Masterminds/sprig/v3" "github.com/go-chi/chi/v5" "github.com/go-playground/form/v4" @@ -40,14 +40,6 @@ import ( "git.cacert.org/cacert-boardvoting/ui" ) -func (app *application) serverError(w http.ResponseWriter, err error) { - trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack()) - - _ = app.errorLog.Output(2, trace) - - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) -} - func (app *application) clientError(w http.ResponseWriter, status int) { http.Error(w, http.StatusText(status), status) } @@ -56,7 +48,7 @@ func (app *application) notFound(w http.ResponseWriter) { app.clientError(w, http.StatusNotFound) } -func (app *application) decodePostForm(r *http.Request, dst any) error { +func (app *application) decodePostForm(r *http.Request, dst forms.Form) error { err := r.ParseForm() if err != nil { return fmt.Errorf("could not parse HTML form: %w", err) @@ -127,7 +119,7 @@ type templateData struct { Users []*models.User Request *http.Request Flashes []FlashMessage - Form any + Form forms.Form ActiveNav topLevelNavItem ActiveSubNav subLevelNavItem CSRFToken string @@ -153,18 +145,14 @@ func (app *application) newTemplateData( func (app *application) render(w http.ResponseWriter, status int, page string, data *templateData) { ts, ok := app.templateCache[page] if !ok { - app.serverError(w, fmt.Errorf("the template %s does not exist", page)) - - return + panic(fmt.Sprintf("the template %s does not exist", page)) } buf := new(bytes.Buffer) err := ts.ExecuteTemplate(buf, "base", data) if err != nil { - app.serverError(w, err) - - return + panic(err) } w.WriteHeader(status) @@ -180,9 +168,7 @@ func (app *application) motionFromRequestParam( motion, err := app.motions.ByTag(r.Context(), chi.URLParam(r, "tag"), withVotes) if err != nil { - app.serverError(w, err) - - return nil + panic(err) } if motion.ID == 0 { @@ -206,9 +192,7 @@ func (app *application) userFromRequestParam( user, err := app.users.ByID(r.Context(), int64(userID), options...) if err != nil { - app.serverError(w, err) - - return nil + panic(err) } if user == nil { @@ -286,7 +270,6 @@ const ( flashWarning FlashVariant = "warning" flashInfo FlashVariant = "info" flashSuccess FlashVariant = "success" - flashError FlashVariant = "error" ) type FlashMessage struct { diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 1f229c0..8f67f10 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -215,17 +215,13 @@ func (app *application) startHTTPSServer(config *Config) error { func (app *application) getVoter(w http.ResponseWriter, r *http.Request, voterID int64) *models.User { voter, err := app.users.ByID(r.Context(), voterID, app.users.WithRoles()) if err != nil { - app.serverError(w, err) - - return nil + panic(err) } var isVoter bool if isVoter, err = voter.HasRole(models.RoleVoter); err != nil { - app.serverError(w, err) - - return nil + panic(err) } if !isVoter { diff --git a/cmd/boardvoting/middleware.go b/cmd/boardvoting/middleware.go index 8bcc894..7361785 100644 --- a/cmd/boardvoting/middleware.go +++ b/cmd/boardvoting/middleware.go @@ -90,9 +90,7 @@ func (app *application) tryAuthenticate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, cert, err := app.authenticateRequest(r) if err != nil { - app.serverError(w, err) - - return + panic(err) } if user == nil { @@ -161,9 +159,7 @@ func (app *application) requireRole(next http.Handler, roles ...models.RoleName) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hasRole, hasUser, err := app.HasRole(r, roles...) if err != nil { - app.serverError(w, err) - - return + panic(err) } if !hasUser { diff --git a/cmd/boardvoting/middleware_test.go b/cmd/boardvoting/middleware_test.go index ddfca55..ae83085 100644 --- a/cmd/boardvoting/middleware_test.go +++ b/cmd/boardvoting/middleware_test.go @@ -21,6 +21,7 @@ import ( "context" "crypto/tls" "crypto/x509" + "database/sql" "log" "net/http" "net/http/httptest" @@ -67,9 +68,16 @@ func TestApplication_tryAuthenticate(t *testing.T) { _, err = users.Create( context.Background(), - "Test User", - "test@example.org", - []string{"test@example.org"}, + &models.CreateUserParams{ + Admin: &models.User{ + Name: "Admin", + Reminder: sql.NullString{String: "admin@example.org", Valid: true}, + }, + Name: "Test User", + Reminder: "test@example.org", + Emails: []string{"test@example.org"}, + Reasoning: "Test data", + }, ) var nextCtx context.Context diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index 3e3aa8d..3af2325 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -104,6 +104,9 @@ func (app *application) routes() http.Handler { r.Get("/delete", app.deleteUserForm) r.Post("/delete", app.deleteUserSubmit) }) + + r.Get("/voters/", app.chooseVotersForm) + r.Post("/voters/", app.chooseVotersSubmit) }) }) diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 6596dba..ca9204b 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -39,50 +39,86 @@ const ( threeWeeks = 28 ) -type EditMotionForm struct { - Title string `form:"title"` - Content string `form:"content"` - Type *models.VoteType `form:"type"` - Due int `form:"due"` - validator.Validator `form:"-"` +type Form interface { + Validate() error + Normalize() } -func (f *EditMotionForm) Validate() { - f.CheckField( - validator.NotBlank(f.Title), - "title", +func validateUserName(v *validator.Validator, name string, field string) { + v.CheckField(validator.NotBlank(name), field, "This field cannot be blank") +} + +func validateEmailAddress(v *validator.Validator, address string, field string) { + v.CheckField(validator.NotBlank(address), field, "This field cannot be blank") + v.CheckField(validator.IsEmail(address), field, "This field must be an email address") +} + +func validateReasoning(v *validator.Validator, reasoning string, field string) { + v.CheckField(validator.NotBlank(reasoning), field, "This field cannot be blank") + v.CheckField( + validator.MinChars(reasoning, minimumJustificationLen), + field, + fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), + ) + +} + +func validateMotionTitle(v *validator.Validator, title string, field string) { + v.CheckField( + validator.NotBlank(title), + field, "This field cannot be blank", ) - f.CheckField( - validator.MinChars(f.Title, minimumTitleLength), - "title", + v.CheckField( + validator.MinChars(title, minimumTitleLength), + field, fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength), ) - f.CheckField( - validator.MaxChars(f.Title, maximumTitleLength), - "title", + v.CheckField( + validator.MaxChars(title, maximumTitleLength), + field, fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength), ) - f.CheckField( - validator.NotBlank(f.Content), - "content", + +} + +func validateMotionContent(v *validator.Validator, content string, field string) { + v.CheckField( + validator.NotBlank(content), + field, "This field cannot be blank", ) - f.CheckField( - validator.MinChars(f.Content, minimumContentLength), - "content", + v.CheckField( + validator.MinChars(content, minimumContentLength), + field, fmt.Sprintf("This field must be at least %d characters long", minimumContentLength), ) - f.CheckField( - validator.MaxChars(f.Content, maximumContentLength), - "content", + v.CheckField( + validator.MaxChars(content, maximumContentLength), + field, fmt.Sprintf("This field must be at most %d characters long", maximumContentLength), ) + +} + +type EditMotionForm struct { + Title string `form:"title"` + Content string `form:"content"` + Type *models.VoteType `form:"type"` + Due int `form:"due"` + validator.Validator `form:"-"` +} + +func (f *EditMotionForm) Validate() error { + validateMotionTitle(&f.Validator, f.Title, "title") + validateMotionContent(&f.Validator, f.Content, "content") f.CheckField(validator.NotNil(f.Type), "type", "You must choose a valid vote type") f.CheckField(validator.PermittedInt( f.Due, threeDays, oneWeek, twoWeeks, threeWeeks), "due", "invalid duration choice", ) + + return nil } func (f *EditMotionForm) Normalize() { @@ -94,6 +130,10 @@ type DirectVoteForm struct { Choice *models.VoteChoice } +func (f *DirectVoteForm) Validate() error { return nil } + +func (f *DirectVoteForm) Normalize() {} + type ProxyVoteForm struct { Voter *models.User `form:"voter"` Choice *models.VoteChoice `form:"choice"` @@ -102,18 +142,12 @@ type ProxyVoteForm struct { validator.Validator `form:"-"` } -func (f *ProxyVoteForm) Validate() { +func (f *ProxyVoteForm) Validate() error { f.CheckField(validator.NotNil(f.Voter), "voter", "Please choose a valid voter") - f.CheckField(validator.NotBlank(f.Justification), "justification", "This field cannot be blank") - f.CheckField( - validator.MinChars( - f.Justification, - minimumJustificationLen, - ), - "justification", - fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), - ) + validateReasoning(&f.Validator, f.Justification, "justification") f.CheckField(validator.NotNil(f.Choice), "choice", "A choice has to be made") + + return nil } func (f *ProxyVoteForm) Normalize() { @@ -137,7 +171,7 @@ func (f *EditUserForm) Validate() error { return fmt.Errorf("error while validating form: %w", err) } - f.CheckField(validator.NotBlank(f.Name), "name", "This field cannot be blank") + validateUserName(&f.Validator, f.Name, "name") f.CheckField( validator.NotBlank(f.ReminderMail), @@ -161,12 +195,7 @@ func (f *EditUserForm) Validate() error { fmt.Sprintf("Roles must only contain values from %s", strings.Join(allRoleNames, ", ")), ) - f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank") - f.CheckField( - validator.MinChars(f.Reasoning, minimumJustificationLen), - "reasoning", - fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), - ) + validateReasoning(&f.Validator, f.Reasoning, "reasoning") return nil } @@ -181,19 +210,41 @@ func (f *EditUserForm) UpdateUser(edit *models.User) { edit.Name = f.Name } +type NewUserForm struct { + Name string `form:"name"` + EmailAddress string `form:"email_address"` + Reasoning string `form:"reasoning"` + validator.Validator `form:"-"` +} + +func (f *NewUserForm) Validate() error { + validateUserName(&f.Validator, f.Name, "name") + validateEmailAddress(&f.Validator, f.EmailAddress, "email_address") + validateReasoning(&f.Validator, f.Reasoning, "reasoning") + + return nil +} + +func (f *NewUserForm) Normalize() { + f.Name = strings.TrimSpace(f.Name) + f.EmailAddress = strings.TrimSpace(f.EmailAddress) + f.Reasoning = strings.TrimSpace(f.Reasoning) +} + +func (f *NewUserForm) FillUser() *models.User { + return &models.User{Name: f.Name} +} + type DeleteUserForm struct { User *models.User `form:"user"` Reasoning string `form:"reasoning"` validator.Validator `form:"-"` } -func (f *DeleteUserForm) Validate() { - f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank") - f.CheckField( - validator.MinChars(f.Reasoning, minimumJustificationLen), - "reasoning", - fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), - ) +func (f *DeleteUserForm) Validate() error { + validateReasoning(&f.Validator, f.Reasoning, "reasoning") + + return nil } func (f *DeleteUserForm) Normalize() { @@ -203,20 +254,16 @@ func (f *DeleteUserForm) Normalize() { type AddEmailForm struct { EmailAddress string `form:"email_address"` Reasoning string `form:"reasoning"` - User *models.User `form:"-""` + User *models.User `form:"-"` EmailAddresses []string `form:"-"` validator.Validator `form:"-"` } -func (f *AddEmailForm) Validate() { - f.CheckField(validator.NotBlank(f.EmailAddress), "email_address", "This field cannot be blank") - f.CheckField(validator.IsEmail(f.EmailAddress), "email_address", "This field must be an email address") - f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank") - f.CheckField( - validator.MinChars(f.Reasoning, minimumJustificationLen), - "reasoning", - fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), - ) +func (f *AddEmailForm) Validate() error { + validateEmailAddress(&f.Validator, f.EmailAddress, "email_address") + validateReasoning(&f.Validator, f.Reasoning, "reasoning") + + return nil } func (f *AddEmailForm) Normalize() { @@ -231,15 +278,28 @@ type DeleteEmailForm struct { validator.Validator `form:"-"` } -func (f *DeleteEmailForm) Validate() { - f.CheckField(validator.NotBlank(f.Reasoning), "reasoning", "This field cannot be blank") - f.CheckField( - validator.MinChars(f.Reasoning, minimumJustificationLen), - "reasoning", - fmt.Sprintf("This field must be at least %d characters long", minimumJustificationLen), - ) +func (f *DeleteEmailForm) Validate() error { + validateReasoning(&f.Validator, f.Reasoning, "reasoning") + + return nil } func (f *DeleteEmailForm) Normalize() { f.Reasoning = strings.TrimSpace(f.Reasoning) } + +type ChooseVoterForm struct { + Reasoning string `form:"reasoning"` + VoterIDs []int64 `form:"voters"` + Users []*models.User + validator.Validator `form:"-"` +} + +func (f *ChooseVoterForm) Validate() error { + validateReasoning(&f.Validator, f.Reasoning, "reasoning") + + return nil +} + +func (f *ChooseVoterForm) Normalize() { +} diff --git a/internal/models/audit.go b/internal/models/audit.go index a686bcd..5fc311f 100644 --- a/internal/models/audit.go +++ b/internal/models/audit.go @@ -29,13 +29,12 @@ import ( type AuditChange string const ( - AuditCreateUser AuditChange = "CREATE_USER" - AuditDeleteUser AuditChange = "DELETE_USER" - AuditEditUser AuditChange = "EDIT_USER" - AuditAddEmail AuditChange = "ADD_EMAIL" - AuditDeleteEmail AuditChange = "DELETE_EMAIL" - AuditAddRole AuditChange = "ADD_ROLE" - AuditRemoveRole AuditChange = "REMOVE_ROLE" + AuditCreateUser AuditChange = "CREATE_USER" + AuditDeleteUser AuditChange = "DELETE_USER" + AuditEditUser AuditChange = "EDIT_USER" + AuditAddEmail AuditChange = "ADD_EMAIL" + AuditDeleteEmail AuditChange = "DELETE_EMAIL" + AuditChangeVoters AuditChange = "CHANGE_VOTERS" ) type Audit struct { @@ -65,3 +64,67 @@ func AuditLog(ctx context.Context, tx *sqlx.Tx, user *User, change AuditChange, return nil } + +func emailChangeInfo(before, after []string) any { + return struct { + Before []string `json:"before"` + After []string `json:"after"` + }{Before: before, After: after} +} + +func voterChangeInfo(before []string, after []string) any { + return struct { + Before []string `json:"before"` + After []string `json:"after"` + }{Before: before, After: after} +} + +func userChangeInfo(before, after *User) any { + beforeRoleNames, beforeAddresses := before.rolesAndAddresses() + afterRoleNames, afterAddresses := after.rolesAndAddresses() + + type userInfo struct { + Name string `json:"name"` + Roles []string `json:"roles"` + Addresses []string `json:"addresses"` + Reminder string `json:"reminder"` + } + details := struct { + Before userInfo `json:"before"` + After userInfo `json:"after"` + }{userInfo{ + Name: before.Name, + Roles: beforeRoleNames, + Addresses: beforeAddresses, + Reminder: before.Reminder.String, + }, userInfo{ + Name: after.Name, + Roles: afterRoleNames, + Addresses: afterAddresses, + Reminder: after.Reminder.String, + }} + + return details +} + +func userCreateInfo(u *User) any { + roleNames, addresses := u.rolesAndAddresses() + + type userInfo struct { + Name string `json:"name"` + Roles []string `json:"roles"` + Addresses []string `json:"addresses"` + Reminder string `json:"reminder"` + } + + details := struct { + Created userInfo `json:"created"` + }{Created: userInfo{ + Name: u.Name, + Roles: roleNames, + Addresses: addresses, + Reminder: u.Reminder.String, + }} + + return details +} diff --git a/internal/models/users.go b/internal/models/users.go index 6bf7743..6288f31 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -291,7 +291,15 @@ func (m *UserModel) Roles(ctx context.Context, user *User) ([]*Role, error) { return result, nil } -func (m *UserModel) Create(ctx context.Context, name string, reminder string, emails []string) (int64, error) { +type CreateUserParams struct { + Admin *User + Name string + Reminder string + Emails []string + Reasoning string +} + +func (m *UserModel) Create(ctx context.Context, params *CreateUserParams) (int64, error) { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { return 0, fmt.Errorf("could not start transaction: %w", err) @@ -304,7 +312,7 @@ func (m *UserModel) Create(ctx context.Context, name string, reminder string, em res, err := tx.ExecContext( ctx, `INSERT INTO voters (name, enabled) VALUES (?, 0)`, - name) + params.Name) if err != nil { return 0, fmt.Errorf("could not insert user: %w", err) } @@ -314,20 +322,32 @@ func (m *UserModel) Create(ctx context.Context, name string, reminder string, em return 0, fmt.Errorf("could not get user id: %w", err) } - for i := range emails { + for i := range params.Emails { _, err := tx.ExecContext( ctx, `INSERT INTO emails (voter, address, reminder) VALUES (?, ?, ?)`, userID, - emails[i], - emails[i] == reminder, + params.Emails[i], + params.Emails[i] == params.Reminder, ) if err != nil { - return 0, fmt.Errorf("could not insert email %s for voter %s: %w", emails[i], name, err) + return 0, fmt.Errorf("could not insert email %s for voter %s: %w", params.Emails[i], params.Name, err) } } + if err := AuditLog(ctx, tx, params.Admin, AuditCreateUser, params.Reasoning, userCreateInfo(&User{ + Name: params.Name, + Reminder: sql.NullString{ + String: params.Reminder, + Valid: true, + }, + roles: []*Role{}, + emailAddresses: params.Emails, + })); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { return 0, fmt.Errorf("could not commit user transaction: %w", err) } @@ -908,37 +928,83 @@ WHERE NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = ? AND role = ?)`, return nil } -func userChangeInfo(before, after *User) any { - beforeRoleNames, beforeAddresses := before.rolesAndAddresses() - afterRoleNames, afterAddresses := after.rolesAndAddresses() +func (m *UserModel) ChooseVoters(ctx context.Context, admin *User, voterIDs []int64, reasoning string) error { + tx, err := m.DB.BeginTxx(ctx, nil) + if err != nil { + return errCouldNotStartTransaction(err) + } + + defer tx.Rollback() - type userInfo struct { - Name string `json:"user"` - Roles []string `json:"roles"` - Addresses []string `json:"addresses"` - Reminder string `json:"reminder"` + votersBefore, err := allVoterNames(ctx, tx) + if err != nil { + return err } - details := struct { - Before userInfo `json:"before"` - After userInfo `json:"after"` - }{userInfo{ - Name: before.Name, - Roles: beforeRoleNames, - Addresses: beforeAddresses, - Reminder: before.Reminder.String, - }, userInfo{ - Name: after.Name, - Roles: afterRoleNames, - Addresses: afterAddresses, - Reminder: after.Reminder.String, - }} - - return details -} - -func emailChangeInfo(before, after []string) any { - return struct { - Before []string `json:"before"` - After []string `json:"after"` - }{Before: before, After: after} + + query, args, err := sqlx.In( + `DELETE FROM user_roles WHERE voter_id NOT IN (?) AND role=?`, + voterIDs, RoleVoter, + ) + if err != nil { + return errCouldNotCreateInQuery(err) + } + + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return errCouldNotExecuteQuery(err) + } + + query, args, err = sqlx.In(`INSERT INTO user_roles (voter_id, role, created) +SELECT v.id, ?, ? +FROM voters v +WHERE id IN (?) AND NOT EXISTS(SELECT * FROM user_roles WHERE role = ? AND voter_id = v.id)`, + RoleVoter, time.Now().UTC(), voterIDs, RoleVoter) + if err != nil { + return errCouldNotCreateInQuery(err) + } + + if _, err := tx.ExecContext(ctx, query, args...); err != nil { + return errCouldNotExecuteQuery(err) + } + + votersAfter, err := allVoterNames(ctx, tx) + if err != nil { + return err + } + + if err := AuditLog(ctx, tx, admin, AuditChangeVoters, reasoning, voterChangeInfo(votersBefore, votersAfter)); err != nil { + return err + } + + if err := tx.Commit(); err != nil { + return errCouldNotCommitTransaction(err) + } + + return nil +} + +func allVoterNames(ctx context.Context, tx *sqlx.Tx) ([]string, error) { + rows, err := tx.QueryxContext(ctx, `SELECT name FROM voters JOIN user_roles ur ON voters.id = ur.voter_id WHERE role=? ORDER BY name`, RoleVoter) + if err != nil { + return nil, errCouldNotExecuteQuery(err) + } + + defer rows.Close() + + names := make([]string, 0) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, errCouldNotFetchRow(err) + } + + var name string + + if err := rows.Scan(&name); err != nil { + return nil, errCouldNotScanResult(err) + } + + names = append(names, name) + } + + return names, nil } diff --git a/ui/html/pages/choose_voters.html b/ui/html/pages/choose_voters.html new file mode 100644 index 0000000..9d3aa9f --- /dev/null +++ b/ui/html/pages/choose_voters.html @@ -0,0 +1,34 @@ +{{ define "title" }}Choose voters{{ end }} + +{{ define "main" }} + {{ $voterIDs := .Form.VoterIDs }} +
+
Edit voter list
+

Use the lists below to add or remove voters.

+
+
+
+ + +
+ +
+
+ + + {{ if .Form.FieldErrors.reasoning }} + {{ .Form.FieldErrors.reasoning }} + {{ end }} +
+ +
+
+{{ end }} \ No newline at end of file diff --git a/ui/html/pages/create_user.html b/ui/html/pages/create_user.html new file mode 100644 index 0000000..3ab60a4 --- /dev/null +++ b/ui/html/pages/create_user.html @@ -0,0 +1,34 @@ +{{ define "title" }}Add user{{ end }} + +{{ define "main" }} +
+ +
+
+ + + {{ if .Form.FieldErrors.name }} + {{ .Form.FieldErrors.name }} + {{ end }} +
+
+ + + {{ if .Form.FieldErrors.email_address }} + {{ .Form.FieldErrors.email_address }} + {{ end }} +
+
+ + + {{ if .Form.FieldErrors.reasoning }} + {{ .Form.FieldErrors.reasoning }} + {{ end }} +
+ + Cancel +
+
+{{ end }} \ No newline at end of file diff --git a/ui/html/pages/edit_user.html b/ui/html/pages/edit_user.html index a1daa64..523c2c4 100644 --- a/ui/html/pages/edit_user.html +++ b/ui/html/pages/edit_user.html @@ -70,7 +70,7 @@ {{ end }} Cancel diff --git a/ui/html/partials/nav.html b/ui/html/partials/nav.html index 8edf31c..dba70d7 100644 --- a/ui/html/partials/nav.html +++ b/ui/html/partials/nav.html @@ -33,6 +33,11 @@ {{ if canManageUsers $user }} {{ end }} {{ end }} diff --git a/ui/static/handlers.js b/ui/static/handlers.js index 684c5ba..2e6b5e1 100644 --- a/ui/static/handlers.js +++ b/ui/static/handlers.js @@ -2,4 +2,6 @@ $(document).ready(function () { $('.message .close').on('click', function () { $(this).closest('.message').transition('fade'); }); + + $('.ui.dropdown').dropdown(); });