Implement add email address

This commit is contained in:
Jan Dittberner 2022-06-03 20:57:20 +02:00
parent a5475ec16e
commit be14a37b4d
6 changed files with 251 additions and 18 deletions

View file

@ -626,11 +626,6 @@ func (app *application) userList(w http.ResponseWriter, r *http.Request) {
app.render(w, http.StatusOK, "users.html", data) 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) { func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) {
params := httprouter.ParamsFromContext(r.Context()) 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()) userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses())
if userToEdit == nil { if userToEdit == nil {
app.notFound(w)
return return
} }
var form forms.EditUserForm var form forms.EditUserForm
err := app.decodePostForm(r, &form) if err := app.decodePostForm(r, &form); err != nil {
if err != nil {
app.clientError(w, http.StatusBadRequest) app.clientError(w, http.StatusBadRequest)
return return
@ -733,7 +729,7 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) {
form.Normalize() form.Normalize()
form.UpdateUser(userToEdit) 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) app.serverError(w, err)
return return
@ -748,14 +744,104 @@ func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/users/", http.StatusSeeOther) http.Redirect(w, r, "/users/", http.StatusSeeOther)
} }
func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) { func (app *application) userAddEmailForm(w http.ResponseWriter, r *http.Request) {
// TODO: implement userAddEmailForm params := httprouter.ParamsFromContext(r.Context())
panic("not implemented")
userToEdit := app.userFromRequestParam(w, r, params, app.users.WithEmailAddresses())
if userToEdit == nil {
app.notFound(w)
return
} }
func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Request) { emailAddresses, err := userToEdit.EmailAddresses()
// TODO: implement userAddEmailSubmit if err != nil {
panic("not implemented") 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(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) { func (app *application) userDeleteEmailForm(_ http.ResponseWriter, _ *http.Request) {

View file

@ -83,7 +83,6 @@ func (app *application) routes() http.Handler {
router.Handler(http.MethodPost, "/newmotion/", canEditVote.ThenFunc(app.newMotionSubmit)) router.Handler(http.MethodPost, "/newmotion/", canEditVote.ThenFunc(app.newMotionSubmit))
router.Handler(http.MethodGet, "/users/", canManageUsers.ThenFunc(app.userList)) 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.MethodGet, "/new-user/", canManageUsers.ThenFunc(app.newUserForm))
router.Handler(http.MethodPost, "/new-user/", canManageUsers.ThenFunc(app.newUserSubmit)) router.Handler(http.MethodPost, "/new-user/", canManageUsers.ThenFunc(app.newUserSubmit))
router.Handler(http.MethodGet, "/users/:id/", canManageUsers.ThenFunc(app.editUserForm)) router.Handler(http.MethodGet, "/users/:id/", canManageUsers.ThenFunc(app.editUserForm))

View file

@ -199,3 +199,27 @@ func (f *DeleteUserForm) Validate() {
func (f *DeleteUserForm) Normalize() { func (f *DeleteUserForm) Normalize() {
f.Reasoning = strings.TrimSpace(f.Reasoning) 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)
}

View file

@ -452,7 +452,7 @@ func (m *UserModel) WithEmailAddresses() UserListOption {
userIDs := userIDsFromUsers(users) userIDs := userIDsFromUsers(users)
query, args, err := sqlx.In( 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, userIDs,
) )
if err != nil { 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 return err
} }
@ -799,7 +852,7 @@ WHERE NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = ? AND role = ?)`,
return nil return nil
} }
func userChangeInfo(before *User, after *User) any { func userChangeInfo(before, after *User) any {
beforeRoleNames, beforeAddresses := before.rolesAndAddresses() beforeRoleNames, beforeAddresses := before.rolesAndAddresses()
afterRoleNames, afterAddresses := after.rolesAndAddresses() afterRoleNames, afterAddresses := after.rolesAndAddresses()
@ -826,3 +879,10 @@ func userChangeInfo(before *User, after *User) any {
return details return details
} }
func emailChangeInfo(before, after []string) any {
return struct {
Before []string `json:"before"`
After []string `json:"after"`
}{Before: before, After: after}
}

View file

@ -18,15 +18,24 @@ limitations under the License.
package validator package validator
import ( import (
"net"
"net/mail"
"reflect" "reflect"
"regexp"
"strings" "strings"
"unicode/utf8" "unicode/utf8"
) )
type Validator struct { type Validator struct {
FieldErrors map[string]string 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 { func (v *Validator) Valid() bool {
return len(v.FieldErrors) == 0 return len(v.FieldErrors) == 0
} }
@ -103,3 +112,18 @@ func PermittedStringSet(value []string, permittedValues []string) bool {
return true 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
}

View file

@ -0,0 +1,40 @@
{{ define "title" }}Add email address for {{ .Form.User.Name }}{{ end }}
{{ define "main" }}
<form action="/users/{{ .Form.User.ID }}/add-mail" method="post">
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="ui form segment{{ if .Form.FieldErrors }} error{{ end }}">
<div class="ui message">
<div class="header">
Add email address for {{ .Form.User.Name }}
</div>
<p>The following addresses are registered for {{ .Form.User.Name }}:</p>
<div class="ui list">
{{ range .Form.EmailAddresses }}
<div class="item">
<i class="mail icon"></i>
<div class="content">{{ . }}</div>
</div>
{{ end }}
</div>
</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="text" 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="add icon"></i> Add email address
</button>
<a href="/users/{{ .Form.User.ID }}/" class="ui button">Cancel</a>
</div>
</form>
{{ end }}