From be14a37b4d6d3a4f94a7a7b867ef263d261d9de8 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 3 Jun 2022 20:57:20 +0200 Subject: [PATCH] Implement add email address --- cmd/boardvoting/handlers.go | 114 ++++++++++++++++++++++++++++---- cmd/boardvoting/routes.go | 1 - internal/forms/forms.go | 24 +++++++ internal/models/users.go | 66 +++++++++++++++++- internal/validator/validator.go | 24 +++++++ ui/html/pages/add_email.html | 40 +++++++++++ 6 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 ui/html/pages/add_email.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 54fa893..c62ed97 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -626,11 +626,6 @@ func (app *application) userList(w http.ResponseWriter, r *http.Request) { app.render(w, http.StatusOK, "users.html", data) } -func (app *application) submitUserRoles(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement submitUserRoles - panic("not implemented") -} - func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) { params := httprouter.ParamsFromContext(r.Context()) @@ -677,13 +672,14 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses()) if userToEdit == nil { + app.notFound(w) + return } var form forms.EditUserForm - err := app.decodePostForm(r, &form) - if err != nil { + if err := app.decodePostForm(r, &form); err != nil { app.clientError(w, http.StatusBadRequest) return @@ -733,7 +729,7 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { form.Normalize() form.UpdateUser(userToEdit) - if err = app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { + if err := app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { app.serverError(w, err) return @@ -748,14 +744,104 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/users/", http.StatusSeeOther) } -func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement userAddEmailForm - panic("not implemented") +func (app *application) userAddEmailForm(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + userToEdit := app.userFromRequestParam(w, r, params, app.users.WithEmailAddresses()) + if userToEdit == nil { + app.notFound(w) + + return + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + app.serverError(w, err) + + return + } + + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + data.Form = &forms.AddEmailForm{ + User: userToEdit, + EmailAddresses: emailAddresses, + } + + app.render(w, http.StatusOK, "add_email.html", data) } -func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement userAddEmailSubmit - panic("not implemented") +func (app *application) userAddEmailSubmit(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + userToEdit := app.userFromRequestParam(w, r, params, app.users.WithEmailAddresses()) + if userToEdit == nil { + app.notFound(w) + + return + } + + var form forms.AddEmailForm + + if err := app.decodePostForm(r, &form); err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + form.User = userToEdit + + form.Validate() + + 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 + } + + if emailExists { + form.FieldErrors = map[string]string{ + "email_address": "Email address must be unique", + } + } + } + + if !form.Valid() { + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + app.serverError(w, err) + + return + } + + form.EmailAddresses = emailAddresses + + data.Form = &form + + app.render(w, http.StatusUnprocessableEntity, "add_email.html", data) + + return + } + + form.Normalize() + + if err := app.users.AddEmail(r.Context(), userToEdit, data.User, form.EmailAddress, form.Reasoning); err != nil { + app.serverError(w, err) + + return + } + + app.sessionManager.Put( + r.Context(), + "flash", + fmt.Sprintf("Added email address %s for user %s", form.EmailAddress, userToEdit.Name), + ) + + http.Redirect(w, r, fmt.Sprintf("/users/%d/", userToEdit.ID), http.StatusSeeOther) } func (app *application) userDeleteEmailForm(_ http.ResponseWriter, _ *http.Request) { diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index 55cc490..ad905c0 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -83,7 +83,6 @@ func (app *application) routes() http.Handler { router.Handler(http.MethodPost, "/newmotion/", canEditVote.ThenFunc(app.newMotionSubmit)) router.Handler(http.MethodGet, "/users/", canManageUsers.ThenFunc(app.userList)) - router.Handler(http.MethodPost, "/users/", canManageUsers.ThenFunc(app.submitUserRoles)) router.Handler(http.MethodGet, "/new-user/", canManageUsers.ThenFunc(app.newUserForm)) router.Handler(http.MethodPost, "/new-user/", canManageUsers.ThenFunc(app.newUserSubmit)) router.Handler(http.MethodGet, "/users/:id/", canManageUsers.ThenFunc(app.editUserForm)) diff --git a/internal/forms/forms.go b/internal/forms/forms.go index e4a08a5..5435f85 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -199,3 +199,27 @@ func (f *DeleteUserForm) Validate() { func (f *DeleteUserForm) Normalize() { f.Reasoning = strings.TrimSpace(f.Reasoning) } + +type AddEmailForm struct { + EmailAddress string `form:"email_address"` + Reasoning string `form:"reasoning"` + 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) Normalize() { + f.EmailAddress = strings.TrimSpace(f.EmailAddress) + f.Reasoning = strings.TrimSpace(f.Reasoning) +} diff --git a/internal/models/users.go b/internal/models/users.go index 747c545..bb089b4 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -452,7 +452,7 @@ func (m *UserModel) WithEmailAddresses() UserListOption { userIDs := userIDsFromUsers(users) query, args, err := sqlx.In( - `SELECT voter, address FROM emails WHERE voter IN (?)`, + `SELECT voter, address FROM emails WHERE voter IN (?) ORDER BY address`, userIDs, ) if err != nil { @@ -725,7 +725,60 @@ func (m *UserModel) EditUser(ctx context.Context, edit *User, admin *User, roles } } - if err = AuditLog(ctx, tx, admin, AuditEditUser, reasoning, userChangeInfo(userBefore, edit)); err != nil { + if err = AuditLog( + ctx, tx, admin, AuditEditUser, reasoning, + userChangeInfo(userBefore, edit), + ); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return errCouldNotCommitTransaction(err) + } + + return nil +} + +func (m *UserModel) EmailExists(ctx context.Context, address string) (bool, error) { + row := m.DB.QueryRowxContext(ctx, `SELECT EXISTS(SELECT * FROM emails WHERE address=?)`, address) + + if err := row.Err(); err != nil { + return false, errCouldNotExecuteQuery(err) + } + + var exists bool + + if err := row.Scan(&exists); err != nil { + return false, errCouldNotScanResult(err) + } + + return exists, nil +} + +func (m *UserModel) AddEmail(ctx context.Context, user *User, admin *User, emailAddress string, reasoning string) error { + userBefore, err := m.ByID(ctx, user.ID, m.WithEmailAddresses()) + if err != nil { + return err + } + + tx, err := m.DB.BeginTxx(ctx, nil) + if err != nil { + return errCouldNotStartTransaction(err) + } + + defer func(tx *sqlx.Tx) { + _ = tx.Rollback() + }(tx) + + if _, err := tx.ExecContext(ctx, `INSERT INTO emails (voter, address) VALUES (?, ?)`, + user.ID, emailAddress); err != nil { + return errCouldNotExecuteQuery(err) + } + + if err = AuditLog( + ctx, tx, admin, AuditAddEmail, reasoning, + emailChangeInfo(userBefore.emailAddresses, append(userBefore.emailAddresses, emailAddress)), + ); err != nil { return err } @@ -799,7 +852,7 @@ WHERE NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = ? AND role = ?)`, return nil } -func userChangeInfo(before *User, after *User) any { +func userChangeInfo(before, after *User) any { beforeRoleNames, beforeAddresses := before.rolesAndAddresses() afterRoleNames, afterAddresses := after.rolesAndAddresses() @@ -826,3 +879,10 @@ func userChangeInfo(before *User, after *User) any { return details } + +func emailChangeInfo(before, after []string) any { + return struct { + Before []string `json:"before"` + After []string `json:"after"` + }{Before: before, After: after} +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index f3a185b..d9d054d 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -18,15 +18,24 @@ limitations under the License. package validator import ( + "net" + "net/mail" "reflect" + "regexp" "strings" "unicode/utf8" ) type Validator struct { FieldErrors map[string]string + rxEmail *regexp.Regexp } +var rxEmail = regexp.MustCompile( + "^[a-zA-Z0-9.!#$%&'*+\\\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", +) + func (v *Validator) Valid() bool { return len(v.FieldErrors) == 0 } @@ -103,3 +112,18 @@ func PermittedStringSet(value []string, permittedValues []string) bool { return true } + +func IsEmail(value string) bool { + addr, err := mail.ParseAddress(value) + if err != nil { + return false + } + + parts := strings.SplitN(addr.Address, "@", 2) + mxs, err := net.LookupMX(parts[1]) + if err != nil || len(mxs) < 1 { + return false + } + + return true +} diff --git a/ui/html/pages/add_email.html b/ui/html/pages/add_email.html new file mode 100644 index 0000000..b1b048b --- /dev/null +++ b/ui/html/pages/add_email.html @@ -0,0 +1,40 @@ +{{ define "title" }}Add email address for {{ .Form.User.Name }}{{ end }} + +{{ define "main" }} +
+ +
+
+
+ Add email address for {{ .Form.User.Name }} +
+

The following addresses are registered for {{ .Form.User.Name }}:

+
+ {{ range .Form.EmailAddresses }} +
+ +
{{ . }}
+
+ {{ 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