Implement user creation and voter management

This commit is contained in:
Jan Dittberner 2022-06-04 19:00:57 +02:00
parent 39bd724381
commit f966cbd62f
14 changed files with 599 additions and 263 deletions

View file

@ -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"

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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)
})
})

View file

@ -39,6 +39,68 @@ const (
threeWeeks = 28
)
type Form interface {
Validate() error
Normalize()
}
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",
)
v.CheckField(
validator.MinChars(title, minimumTitleLength),
field,
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
v.CheckField(
validator.MaxChars(title, maximumTitleLength),
field,
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
}
func validateMotionContent(v *validator.Validator, content string, field string) {
v.CheckField(
validator.NotBlank(content),
field,
"This field cannot be blank",
)
v.CheckField(
validator.MinChars(content, minimumContentLength),
field,
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
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"`
@ -47,42 +109,16 @@ type EditMotionForm struct {
validator.Validator `form:"-"`
}
func (f *EditMotionForm) Validate() {
f.CheckField(
validator.NotBlank(f.Title),
"title",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Title, minimumTitleLength),
"title",
fmt.Sprintf("This field must be at least %d characters long", minimumTitleLength),
)
f.CheckField(
validator.MaxChars(f.Title, maximumTitleLength),
"title",
fmt.Sprintf("This field must be at most %d characters long", maximumTitleLength),
)
f.CheckField(
validator.NotBlank(f.Content),
"content",
"This field cannot be blank",
)
f.CheckField(
validator.MinChars(f.Content, minimumContentLength),
"content",
fmt.Sprintf("This field must be at least %d characters long", minimumContentLength),
)
f.CheckField(
validator.MaxChars(f.Content, maximumContentLength),
"content",
fmt.Sprintf("This field must be at most %d characters long", maximumContentLength),
)
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() {
}

View file

@ -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
}

View file

@ -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()
type userInfo struct {
Name string `json:"user"`
Roles []string `json:"roles"`
Addresses []string `json:"addresses"`
Reminder string `json:"reminder"`
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)
}
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
defer tx.Rollback()
votersBefore, err := allVoterNames(ctx, tx)
if err != nil {
return err
}
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 emailChangeInfo(before, after []string) any {
return struct {
Before []string `json:"before"`
After []string `json:"after"`
}{Before: before, After: after}
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
}

View file

@ -0,0 +1,34 @@
{{ define "title" }}Choose voters{{ end }}
{{ define "main" }}
{{ $voterIDs := .Form.VoterIDs }}
<div class="ui info message">
<div class="header">Edit voter list</div>
<p>Use the lists below to add or remove voters.</p>
</div>
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<form action="/voters/" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<label class="hidden" aria-label="voters" for="voters">Voters</label>
<div class="field">
<select id="voters" name="voters" class="ui fluid search dropdown" multiple aria-multiselectable="true">
{{ range .Form.Users }}
{{ $userID := .ID }}
<option value="{{ .ID }}" {{ range $voterIDs }}{{ if eq $userID . }} selected{{ end }}{{ end }}>{{ .Name }}</option>
{{ end }}
</select>
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="edit icon"></i>
Change voters
</button>
</form>
</div>
{{ end }}

View file

@ -0,0 +1,34 @@
{{ define "title" }}Add user{{ end }}
{{ define "main" }}
<form action="/new-user/" method="post">
<input type="hidden" name="csrf_token" , value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="required field{{ if .Form.FieldErrors.name }} error{{ end }}">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ .Form.Name }}">
{{ if .Form.FieldErrors.name }}
<span class="ui small error text">{{ .Form.FieldErrors.name }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.email_address }} error{{ end}}">
<label for="email_address">Email address</label>
<input id="email_address" name="email_address" type="email" value="{{ .Form.EmailAddress }}">
{{ if .Form.FieldErrors.email_address }}
<span class="ui small error text">{{ .Form.FieldErrors.email_address }}</span>
{{ end }}
</div>
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
<label for="reasoning">Reasoning for the change</label>
<textarea id="reasoning" name="reasoning" rows="2">{{ .Form.Reasoning }}</textarea>
{{ if .Form.FieldErrors.reasoning }}
<span class="ui small error text">{{ .Form.FieldErrors.reasoning }}</span>
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="magic icon"></i> Add user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>
</form>
{{ end }}

View file

@ -70,7 +70,7 @@
{{ end }}
</div>
<button class="ui primary labeled icon button" type="submit">
<i class="trash icon"></i> Edit user
<i class="edit icon"></i> Edit user
</button>
<a href="/users/" class="ui button">Cancel</a>
</div>

View file

@ -33,6 +33,11 @@
{{ if canManageUsers $user }}
<nav class="ui secondary pointing menu">
<a href="/users/" class="{{ if eq .ActiveSubNav "users" }}active {{ end }}item">Manage users</a>
<a href="/voters/" class="{{ if eq .ActiveSubNav "manage-voters" }}active {{ end }}item">Manage voters</a>
<div class="right item">
<a class="ui primary labeled icon button" href="/new-user/">
<i class="magic icon"></i> Add User</a>
</div>
</nav>
{{ end }}
{{ end }}

View file

@ -2,4 +2,6 @@ $(document).ready(function () {
$('.message .close').on('click', function () {
$(this).closest('.message').transition('fade');
});
$('.ui.dropdown').dropdown();
});