From a5475ec16e1b92ee146b09b2786c59a19e07e0be Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Thu, 2 Jun 2022 23:14:38 +0200 Subject: [PATCH] Implement user editing --- cmd/boardvoting/handlers.go | 147 +++++++++++++-- cmd/boardvoting/main.go | 2 +- cmd/boardvoting/notifications.go | 18 +- cmd/boardvoting/routes.go | 6 + internal/forms/forms.go | 61 +++++++ internal/models/users.go | 300 +++++++++++++++++++++++++++++-- internal/validator/validator.go | 29 +++ ui/html/pages/delete_user.html | 4 +- ui/html/pages/edit_user.html | 79 ++++++++ 9 files changed, 614 insertions(+), 32 deletions(-) create mode 100644 ui/html/pages/edit_user.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 6a6cbdf..54fa893 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -612,8 +612,8 @@ func (app *application) userList(w http.ResponseWriter, r *http.Request) { users, err := app.users.List( r.Context(), - app.users.WithRolesOption(), - app.users.CanDeleteOption(), + app.users.WithRoles(), + app.users.CanDelete(), ) if err != nil { app.serverError(w, err) @@ -631,14 +631,121 @@ func (app *application) submitUserRoles(_ http.ResponseWriter, _ *http.Request) panic("not implemented") } -func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement editUserForm - panic("not implemented") +func (app *application) editUserForm(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses()) + if userToEdit == nil { + return + } + + roles, err := userToEdit.Roles() + if err != nil { + app.serverError(w, err) + + return + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + app.serverError(w, err) + + return + } + + roleNames := make([]string, len(roles)) + for i := range roles { + roleNames[i] = roles[i].Name + } + + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + data.Form = &forms.EditUserForm{ + User: userToEdit, + Name: userToEdit.Name, + MailAddresses: emailAddresses, + AllRoles: models.AllRoles, + ReminderMail: userToEdit.Reminder.String, + Roles: roleNames, + } + + app.render(w, http.StatusOK, "edit_user.html", data) } -func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement editUserSubmit - panic("not implemented") +func (app *application) editUserSubmit(w http.ResponseWriter, r *http.Request) { + params := httprouter.ParamsFromContext(r.Context()) + + userToEdit := app.userFromRequestParam(w, r, params, app.users.WithRoles(), app.users.WithEmailAddresses()) + if userToEdit == nil { + return + } + + var form forms.EditUserForm + + err := app.decodePostForm(r, &form) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + form.User = userToEdit + form.AllRoles = models.AllRoles + + if err := form.Validate(); err != nil { + app.serverError(w, err) + + return + } + + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + if !form.Valid() { + roles, err := userToEdit.Roles() + if err != nil { + app.serverError(w, err) + + return + } + + emailAddresses, err := userToEdit.EmailAddresses() + if err != nil { + app.serverError(w, err) + + return + } + + roleNames := make([]string, len(roles)) + for i := range roles { + roleNames[i] = roles[i].Name + } + + form.MailAddresses = emailAddresses + form.Roles = roleNames + + data.Form = &form + + app.render(w, http.StatusUnprocessableEntity, "edit_user.html", data) + + return + } + + form.Normalize() + form.UpdateUser(userToEdit) + + if err = app.users.EditUser(r.Context(), userToEdit, data.User, form.Roles, form.Reasoning); err != nil { + app.serverError(w, err) + + return + } + + app.sessionManager.Put( + r.Context(), + "flash", + fmt.Sprintf("User %s has been modified.", userToEdit.Name), + ) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) } func (app *application) userAddEmailForm(_ http.ResponseWriter, _ *http.Request) { @@ -651,8 +758,28 @@ func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Reques panic("not implemented") } +func (app *application) userDeleteEmailForm(_ http.ResponseWriter, _ *http.Request) { + // TODO: implement userDeleteEmailForm + panic("not implemented") +} + +func (app *application) userDeleteEmailSubmit(_ http.ResponseWriter, _ *http.Request) { + // TODO: implement userDeleteEmailSubmit + panic("not implemented") +} + +func (app *application) newUserForm(_ http.ResponseWriter, _ *http.Request) { + // TODO: implement newUserForm + panic("not implemented") +} + +func (app *application) newUserSubmit(_ http.ResponseWriter, _ *http.Request) { + // TODO: implement userDeleteEmailSubmit + panic("not implemented") +} + func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) { - userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption()) + userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete()) if userToDelete == nil { return } @@ -673,7 +800,7 @@ func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) { } func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) { - userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption()) + userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDelete()) if userToDelete == nil { return } diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index cc4aa61..10dbb65 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -210,7 +210,7 @@ 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.WithRolesOption()) + voter, err := app.users.ByID(r.Context(), voterID, app.users.WithRoles()) if err != nil { app.serverError(w, err) diff --git a/cmd/boardvoting/notifications.go b/cmd/boardvoting/notifications.go index 6ac7751..9554d2a 100644 --- a/cmd/boardvoting/notifications.go +++ b/cmd/boardvoting/notifications.go @@ -188,18 +188,24 @@ type RemindVoterNotification struct { } func (r RemindVoterNotification) GetNotificationContent(*mailConfig) *NotificationContent { + recipientAddress := make([]recipientData, 0) + + if r.voter.Reminder.Valid { + recipientAddress = append(recipientAddress, recipientData{ + field: "To", + address: r.voter.Reminder.String, + name: r.voter.Name, + }) + } + return &NotificationContent{ template: "remind_voter_mail.txt", data: struct { Decisions []*models.Motion Name string }{Decisions: r.decisions, Name: r.voter.Name}, - subject: "Outstanding CAcert board votes", - recipients: []recipientData{{ - field: "To", - address: r.voter.Reminder, - name: r.voter.Name, - }}, + subject: "Outstanding CAcert board votes", + recipients: recipientAddress, } } diff --git a/cmd/boardvoting/routes.go b/cmd/boardvoting/routes.go index a912e6c..55cc490 100644 --- a/cmd/boardvoting/routes.go +++ b/cmd/boardvoting/routes.go @@ -84,10 +84,16 @@ func (app *application) routes() http.Handler { 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)) router.Handler(http.MethodPost, "/users/:id/", canManageUsers.ThenFunc(app.editUserSubmit)) router.Handler(http.MethodGet, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailForm)) router.Handler(http.MethodPost, "/users/:id/add-mail", canManageUsers.ThenFunc(app.userAddEmailSubmit)) + router.Handler(http.MethodGet, "/users/:id/mail/:address/delete", + canManageUsers.ThenFunc(app.userDeleteEmailForm)) + router.Handler(http.MethodPost, "/users/:id/mail/:address/delete", + canManageUsers.ThenFunc(app.userDeleteEmailSubmit)) router.Handler(http.MethodGet, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserForm)) router.Handler(http.MethodPost, "/users/:id/delete", canManageUsers.ThenFunc(app.deleteUserSubmit)) diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 3c02aab..e4a08a5 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -120,6 +120,67 @@ func (f *ProxyVoteForm) Normalize() { f.Justification = strings.TrimSpace(f.Justification) } +type EditUserForm struct { + User *models.User `form:"-"` + MailAddresses []string `form:"-"` + AllRoles []*models.Role `form:"-"` + Name string `form:"name"` + Roles []string `form:"roles"` + ReminderMail string `form:"reminder_mail"` + Reasoning string `form:"reasoning"` + validator.Validator `form:"-"` +} + +func (f *EditUserForm) Validate() error { + addresses, err := f.User.EmailAddresses() + if err != nil { + return fmt.Errorf("error while validating form: %w", err) + } + + f.CheckField(validator.NotBlank(f.Name), "name", "This field cannot be blank") + + f.CheckField( + validator.NotBlank(f.ReminderMail), + "reminder_mail", + "Must choose a reminder mail address", + ) + f.CheckField( + validator.PermittedString(f.ReminderMail, addresses...), + "reminder_mail", + "Reminder mail must be one of the user's email addresses", + ) + + allRoleNames := make([]string, len(f.AllRoles)) + for i := range f.AllRoles { + allRoleNames[i] = f.AllRoles[i].Name + } + + f.CheckField( + validator.PermittedStringSet(f.Roles, allRoleNames), + "roles", + 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), + ) + + return nil +} + +func (f *EditUserForm) Normalize() { + f.Name = strings.TrimSpace(f.Name) + f.Reasoning = strings.TrimSpace(f.Reasoning) +} + +func (f *EditUserForm) UpdateUser(edit *models.User) { + edit.Reminder.String = f.ReminderMail + edit.Name = f.Name +} + type DeleteUserForm struct { User *models.User `form:"user"` Reasoning string `form:"reasoning"` diff --git a/internal/models/users.go b/internal/models/users.go index 8e876ee..747c545 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -19,9 +19,13 @@ package models import ( "context" + "database/sql" + "database/sql/driver" "errors" "fmt" + "sort" "strings" + "time" "github.com/jmoiron/sqlx" "golang.org/x/text/cases" @@ -47,6 +51,10 @@ func (r *Role) Scan(src any) error { return nil } +func (r *Role) Value() (driver.Value, error) { + return r.Name, nil +} + type RoleName string const ( @@ -55,6 +63,12 @@ const ( RoleVoter RoleName = "VOTER" ) +var AllRoles = []*Role{ + {Name: string(RoleVoter)}, + {Name: string(RoleSecretary)}, + {Name: string(RoleAdmin)}, +} + // The User type is used for mapping users from the voters table. The table // has two columns that are obsolete: // @@ -64,11 +78,12 @@ const ( // The columns cannot be dropped in SQLite without dropping and recreating // the voters table and all foreign key indices pointing to that table. type User struct { - ID int64 `db:"id"` - Name string `db:"name"` - Reminder string `db:"reminder"` // reminder email address - roles []*Role `db:"-"` - canDelete bool + ID int64 `db:"id"` + Name string `db:"name"` + Reminder sql.NullString `db:"reminder"` // reminder email address + roles []*Role `db:"-"` + emailAddresses []string `db:"-"` + canDelete bool `db:"-"` } func (u *User) Roles() ([]*Role, error) { @@ -76,7 +91,15 @@ func (u *User) Roles() ([]*Role, error) { return u.roles, nil } - return nil, errors.New("call to Roles required") + return nil, errors.New("WithRoles required") +} + +func (u *User) EmailAddresses() ([]string, error) { + if u.emailAddresses != nil { + return u.emailAddresses, nil + } + + return nil, errors.New("WithEmailAddressesOption required") } func (u *User) HasRole(roles ...RoleName) (bool, error) { @@ -105,6 +128,40 @@ func (u *User) CanDelete() bool { return u.canDelete } +func (u *User) rolesAndAddresses() ([]string, []string) { + roleNames := make([]string, len(u.roles)) + + for i := range u.roles { + roleNames[i] = u.roles[i].Name + } + + return roleNames, u.emailAddresses +} + +func (u *User) rolesEqual(other *User) bool { + roles1 := u.roles + roles2 := other.roles + + if len(roles1) != len(roles2) { + return false + } + + sort.SliceStable(roles1, func(i, j int) bool { return roles1[i].Name < roles1[j].Name }) + sort.SliceStable(roles2, func(i, j int) bool { return roles2[i].Name < roles2[j].Name }) + + for i, v := range roles1 { + if v.Name != roles2[i].Name { + return false + } + } + + return true +} + +func (u *User) reminderEqual(other *User) bool { + return u.Reminder.Valid == other.Reminder.Valid && u.Reminder.String == other.Reminder.String +} + type UserModel struct { DB *sqlx.DB } @@ -325,9 +382,8 @@ func (m *UserModel) ByID(ctx context.Context, voterID int64, options ...UserList ctx, `SELECT DISTINCT v.id, v.name, e.address AS reminder FROM voters v - LEFT OUTER JOIN emails e ON e.voter = v.id + LEFT OUTER JOIN emails e ON e.voter = v.id AND e.reminder = TRUE WHERE v.id = ? - AND e.reminder = TRUE `, voterID, ) @@ -387,17 +443,85 @@ func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*Use return users, nil } -func (m *UserModel) WithRolesOption() UserListOption { +func (m *UserModel) WithEmailAddresses() UserListOption { return func(ctx context.Context, users []*User) error { if len(users) == 0 { return nil } - userIDs := make([]int64, len(users)) + userIDs := userIDsFromUsers(users) + + query, args, err := sqlx.In( + `SELECT voter, address FROM emails WHERE voter IN (?)`, + userIDs, + ) + if err != nil { + return errCouldNotCreateInQuery(err) + } + + rows, err := m.DB.QueryxContext(ctx, query, args...) + if err != nil { + return errCouldNotExecuteQuery(err) + } + + defer func(rows *sqlx.Rows) { + _ = rows.Close() + }(rows) + + addressMap, err := buildAddressMap(rows, len(users)) + if err != nil { + return err + } + for idx := range users { - userIDs[idx] = users[idx].ID + if addresses, ok := addressMap[users[idx].ID]; ok { + users[idx].emailAddresses = addresses + + continue + } + + users[idx].emailAddresses = make([]string, 0) } + return nil + } +} + +func buildAddressMap(rows *sqlx.Rows, userCount int) (map[int64][]string, error) { + addressMap := make(map[int64][]string, userCount) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, errCouldNotFetchRow(err) + } + + var ( + userID int64 + address string + ) + + if err := rows.Scan(&userID, &address); err != nil { + return nil, errCouldNotScanResult(err) + } + + if _, ok := addressMap[userID]; !ok { + addressMap[userID] = []string{address} + } else { + addressMap[userID] = append(addressMap[userID], address) + } + } + + return addressMap, nil +} + +func (m *UserModel) WithRoles() UserListOption { + return func(ctx context.Context, users []*User) error { + if len(users) == 0 { + return nil + } + + userIDs := userIDsFromUsers(users) + query, args, err := sqlx.In( `SELECT voter_id, role FROM user_roles WHERE voter_id IN (?)`, userIDs, @@ -434,6 +558,14 @@ func (m *UserModel) WithRolesOption() UserListOption { } } +func userIDsFromUsers(users []*User) []int64 { + userIDs := make([]int64, len(users)) + for idx := range users { + userIDs[idx] = users[idx].ID + } + return userIDs +} + func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) { roleMap := make(map[int64][]*Role, userCount) @@ -461,7 +593,7 @@ func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) { return roleMap, nil } -func (m *UserModel) CanDeleteOption() UserListOption { +func (m *UserModel) CanDelete() UserListOption { return func(ctx context.Context, users []*User) error { if len(users) == 0 { return nil @@ -520,7 +652,7 @@ func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, r userInfo := struct { Name string `json:"user"` Address string `json:"address"` - }{Name: userToDelete.Name, Address: userToDelete.Reminder} + }{Name: userToDelete.Name, Address: userToDelete.Reminder.String} details := struct { User any `json:"user"` }{User: userInfo} @@ -552,3 +684,145 @@ func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, r return nil } + +func (m *UserModel) EditUser(ctx context.Context, edit *User, admin *User, roles []string, reasoning string) error { + newRoles := make([]*Role, len(roles)) + for i := range roles { + newRoles[i] = &Role{Name: roles[i]} + } + + edit.roles = newRoles + + userBefore, err := m.ByID(ctx, edit.ID, m.WithRoles(), 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 edit.Name != userBefore.Name { + if _, err = tx.NamedExecContext(ctx, `UPDATE voters SET name=:name WHERE id=:id`, edit); err != nil { + return errCouldNotExecuteQuery(err) + } + } + + if !userBefore.rolesEqual(edit) { + if err = updateRoles(ctx, tx, edit); err != nil { + return err + } + } + + if !userBefore.reminderEqual(edit) { + if err = updateReminder(ctx, tx, edit); err != nil { + return err + } + } + + 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 updateReminder(ctx context.Context, tx *sqlx.Tx, edit *User) error { + if _, err := tx.ExecContext( + ctx, + `UPDATE emails SET reminder=TRUE WHERE address=? AND voter=?`, + edit.Reminder.String, + edit.ID, + ); err != nil { + return errCouldNotExecuteQuery(err) + } + + if _, err := tx.ExecContext( + ctx, + `UPDATE emails SET reminder=FALSE WHERE address<>? AND voter=?`, + edit.Reminder.String, + edit.ID, + ); err != nil { + return errCouldNotExecuteQuery(err) + } + + return nil +} + +func updateRoles(ctx context.Context, tx *sqlx.Tx, user *User) error { + if len(user.roles) == 0 { + if _, err := tx.ExecContext(ctx, `DELETE FROM user_roles WHERE voter_id=?`, user.ID); err != nil { + return errCouldNotExecuteQuery(err) + } + + return nil + } + + query, args, err := sqlx.In( + `DELETE FROM user_roles WHERE role NOT IN (?) AND voter_id=?`, + user.roles, + user.ID, + ) + if err != nil { + return errCouldNotCreateInQuery(err) + } + + if _, err = tx.ExecContext(ctx, query, args...); err != nil { + return errCouldNotExecuteQuery(err) + } + + for idx := range user.roles { + if _, err = tx.ExecContext( + ctx, + `INSERT INTO user_roles (voter_id, role, created) +SELECT ?, ?, ? +WHERE NOT EXISTS(SELECT * FROM user_roles WHERE voter_id = ? AND role = ?)`, + user.ID, + user.roles[idx], + time.Now().UTC(), + user.ID, + user.roles[idx], + ); err != nil { + return errCouldNotExecuteQuery(err) + } + } + + return nil +} + +func userChangeInfo(before *User, 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"` + } + 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 +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 07f8b32..f3a185b 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -74,3 +74,32 @@ func PermittedInt(value int, permittedValues ...int) bool { return false } + +func PermittedString(value string, permittedValues ...string) bool { + for i := range permittedValues { + if value == permittedValues[i] { + return true + } + } + + return false +} + +func PermittedStringSet(value []string, permittedValues []string) bool { + if value == nil || len(value) == 0 { + return true + } + + valueMap := make(map[string]struct{}, len(permittedValues)) + for _, v := range permittedValues { + valueMap[v] = struct{}{} + } + + for j := range value { + if _, ok := valueMap[value[j]]; !ok { + return false + } + } + + return true +} diff --git a/ui/html/pages/delete_user.html b/ui/html/pages/delete_user.html index 25f0e62..83c30fa 100644 --- a/ui/html/pages/delete_user.html +++ b/ui/html/pages/delete_user.html @@ -6,7 +6,7 @@
- Withdraw motion? + Delete user?

Do you want to delete user {{ .Form.User.Name }}?

@@ -23,7 +23,7 @@ - Cancel + Cancel
diff --git a/ui/html/pages/edit_user.html b/ui/html/pages/edit_user.html new file mode 100644 index 0000000..a1daa64 --- /dev/null +++ b/ui/html/pages/edit_user.html @@ -0,0 +1,79 @@ +{{ define "title" }}Edit User {{ .Form.User.Name }}{{ end }} + +{{ define "main" }} + {{ $form := .Form }} + {{ $user := .User }} +
+
+ +
+
+ + + {{ if .Form.FieldErrors.name }} + {{ .Form.FieldErrors.name }} + {{ end }} +
+
+ + + + {{ range .Form.MailAddresses }} + + + + + {{ end }} + +
+
+
+ + +
+
+
+ {{ if not (eq $form.ReminderMail .) }} {{ end }} +
+ {{ if .Form.FieldErrors.reminder_mail }} + {{ .Form.FieldErrors.reminder_mail }} + {{ end }} + Add new email address +
+
+ + {{ range .Form.AllRoles }} + {{ $currentRole := . }} +
+
+ + +
+
+ {{ end }} + {{ if .Form.FieldErrors.roles }} + {{ .Form.FieldErrors.roles }} + {{ end }} +
+
+ + + {{ if .Form.FieldErrors.reasoning }} + {{ .Form.FieldErrors.reasoning }} + {{ end }} +
+ + Cancel +
+
+
+{{ end }} \ No newline at end of file