From 5efc57d2c31b53a62e3e1fd567c8033a81dfd36a Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Wed, 1 Jun 2022 18:57:38 +0200 Subject: [PATCH] Implement user deletion - add audit logging for user changes - refactor model errors into functions - implement user delete form and submit handlers --- cmd/boardvoting/handlers.go | 82 ++++++++++-- cmd/boardvoting/helpers.go | 27 ++++ cmd/boardvoting/main.go | 25 ++++ internal/forms/forms.go | 19 +++ .../2022060101_add_audit_table.down.sql | 3 + .../2022060101_add_audit_table.up.sql | 18 +++ internal/models/audit.go | 67 ++++++++++ internal/models/models.go | 26 ++-- internal/models/motions.go | 38 +++--- internal/models/users.go | 120 +++++++++++++----- ui/html/pages/delete_user.html | 30 +++++ 11 files changed, 385 insertions(+), 70 deletions(-) create mode 100644 internal/migrations/2022060101_add_audit_table.down.sql create mode 100644 internal/migrations/2022060101_add_audit_table.up.sql create mode 100644 internal/models/audit.go create mode 100644 ui/html/pages/delete_user.html diff --git a/cmd/boardvoting/handlers.go b/cmd/boardvoting/handlers.go index 816c375..6a6cbdf 100644 --- a/cmd/boardvoting/handlers.go +++ b/cmd/boardvoting/handlers.go @@ -579,10 +579,8 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request) return } - voter, err := app.users.ByID(r.Context(), form.Voter.ID) - if err != nil { - app.serverError(w, err) - + voter := app.getVoter(w, r, form.Voter.ID) + if voter == nil { return } @@ -637,6 +635,7 @@ func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) { // TODO: implement editUserForm panic("not implemented") } + func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) { // TODO: implement editUserSubmit panic("not implemented") @@ -652,14 +651,77 @@ func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Reques panic("not implemented") } -func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement deleteUserForm - 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()) + if userToDelete == nil { + return + } + + if !userToDelete.CanDelete() { + app.notFound(w) + + return + } + + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + data.Form = &forms.DeleteUserForm{ + User: userToDelete, + } + + app.render(w, http.StatusOK, "delete_user.html", data) } -func (app *application) deleteUserSubmit(_ http.ResponseWriter, _ *http.Request) { - // TODO: implement deleteUserSubmit - panic("not implemented") +func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) { + userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption()) + if userToDelete == nil { + return + } + + if !userToDelete.CanDelete() { + app.notFound(w) + + return + } + + var form forms.DeleteUserForm + + err := app.decodePostForm(r, &form) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return + } + + form.User = userToDelete + + form.Validate() + + data := app.newTemplateData(r, topLevelNavUsers, subLevelNavUsers) + + if !form.Valid() { + data.Form = &form + + app.render(w, http.StatusUnprocessableEntity, "delete_user.html", data) + + return + } + + form.Normalize() + + if err = app.users.DeleteUser(r.Context(), userToDelete, data.User, form.Reasoning); err != nil { + app.serverError(w, err) + + return + } + + app.sessionManager.Put( + r.Context(), + "flash", + fmt.Sprintf("User %s has been deleted.", userToDelete.Name), + ) + + http.Redirect(w, r, "/users/", http.StatusSeeOther) } func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) { diff --git a/cmd/boardvoting/helpers.go b/cmd/boardvoting/helpers.go index 697e90b..f41b295 100644 --- a/cmd/boardvoting/helpers.go +++ b/cmd/boardvoting/helpers.go @@ -28,6 +28,7 @@ import ( "net/http" "path/filepath" "runtime/debug" + "strconv" "strings" "github.com/Masterminds/sprig/v3" @@ -196,6 +197,32 @@ func (app *application) motionFromRequestParam( return motion } +func (app *application) userFromRequestParam( + w http.ResponseWriter, r *http.Request, params httprouter.Params, options ...models.UserListOption, +) *models.User { + userID, err := strconv.Atoi(params.ByName("id")) + if err != nil { + app.clientError(w, http.StatusBadRequest) + + return nil + } + + user, err := app.users.ByID(r.Context(), int64(userID), options...) + if err != nil { + app.serverError(w, err) + + return nil + } + + if user == nil { + app.notFound(w) + + return nil + } + + return user +} + func (app *application) choiceFromRequestParam(w http.ResponseWriter, params httprouter.Params) *models.VoteChoice { choiceParam := params.ByName("choice") diff --git a/cmd/boardvoting/main.go b/cmd/boardvoting/main.go index 63693c2..cc4aa61 100644 --- a/cmd/boardvoting/main.go +++ b/cmd/boardvoting/main.go @@ -209,6 +209,31 @@ func (app *application) startHTTPSServer(config *Config) error { return nil } +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()) + if err != nil { + app.serverError(w, err) + + return nil + } + + var isVoter bool + + if isVoter, err = voter.HasRole(models.RoleVoter); err != nil { + app.serverError(w, err) + + return nil + } + + if !isVoter { + app.clientError(w, http.StatusBadRequest) + + return nil + } + + return voter +} + func setupHTTPRedirect(config *Config, errChan chan error) { redirect := &http.Server{ Addr: config.HTTPAddress, diff --git a/internal/forms/forms.go b/internal/forms/forms.go index 7a70e8c..3c02aab 100644 --- a/internal/forms/forms.go +++ b/internal/forms/forms.go @@ -119,3 +119,22 @@ func (f *ProxyVoteForm) Validate() { func (f *ProxyVoteForm) Normalize() { f.Justification = strings.TrimSpace(f.Justification) } + +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) Normalize() { + f.Reasoning = strings.TrimSpace(f.Reasoning) +} diff --git a/internal/migrations/2022060101_add_audit_table.down.sql b/internal/migrations/2022060101_add_audit_table.down.sql new file mode 100644 index 0000000..b8b431d --- /dev/null +++ b/internal/migrations/2022060101_add_audit_table.down.sql @@ -0,0 +1,3 @@ +-- add an audit table to track changes to users +DROP INDEX audit_change_idx; +DROP TABLE audit; \ No newline at end of file diff --git a/internal/migrations/2022060101_add_audit_table.up.sql b/internal/migrations/2022060101_add_audit_table.up.sql new file mode 100644 index 0000000..729b3ec --- /dev/null +++ b/internal/migrations/2022060101_add_audit_table.up.sql @@ -0,0 +1,18 @@ +-- add an audit table to track changes to users +CREATE TABLE audit +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- copied authenticated user name to keep the information when the admin is locked/deleted later + user_name VARCHAR(255) NOT NULL, + -- copied authenticated user email address to keep the information when the admin is locked/deleted later + user_address VARCHAR(255) NOT NULL, + created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- an enum (in code) value to specify the action. Stored as text to be flexible for future changes + change TEXT NOT NULL, + -- reasoning for the change specified by the user + reasoning TEXT NOT NULL, + -- additional information about the change in an application specific json format + details TEXT +); + +CREATE INDEX audit_change_idx ON audit (change); \ No newline at end of file diff --git a/internal/models/audit.go b/internal/models/audit.go new file mode 100644 index 0000000..f0141e2 --- /dev/null +++ b/internal/models/audit.go @@ -0,0 +1,67 @@ +/* +Copyright 2022 CAcert Inc. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package models + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jmoiron/sqlx" +) + +type AuditChange string + +const ( + AuditCreateUser AuditChange = "CREATE_USER" + AuditDeleteUser AuditChange = "DELETE_USER" + AuditEditUser AuditChange = "EDIT_USER" + AuditAddEmail AuditChange = "ADD_EMAIL" + AuditRemoveEmail AuditChange = "REMOVE_EMAIL" + AuditAddRole AuditChange = "ADD_ROLE" + AuditRemoveRole AuditChange = "REMOVE_ROLE" +) + +type Audit struct { + ID int64 `db:"id"` + UserName string `db:"user_name"` + UserAddress string `db:"user_address"` + Created time.Time `db:"created"` + Change *AuditChange `db:"change"` + Reasoning string `db:"reasoning"` + Details string `db:"details"` +} + +func AuditLog(ctx context.Context, tx *sqlx.Tx, user *User, change AuditChange, reasoning string, details any) error { + jsonDetails, err := json.Marshal(details) + if err != nil { + return fmt.Errorf("could not transform details to JSON: %w", err) + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO audit (user_name, user_address, created, change, reasoning, details) VALUES (?, ?, ?, ?, ?, ?)`, + user.Name, user.Reminder, time.Now().UTC(), change, reasoning, string(jsonDetails), + ) + if err != nil { + return errCouldNotExecuteQuery(err) + } + + return nil +} diff --git a/internal/models/models.go b/internal/models/models.go index f5c02f3..1c1150b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -49,11 +49,21 @@ func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) { return nil, fmt.Errorf("could not parse timestamp: %w", err) } -const ( - errCouldNotExecuteQuery = "could not execute query: %w" - errCouldNotFetchRow = "could not fetch row: %w" - errCouldNotScanResult = "could not scan result: %w" - errCouldNotStartTransaction = "could not start transaction: %w" - errCouldNotCommitTransaction = "could not commit transaction: %w" - errCouldNotCreateInQuery = "could not create query with IN clause: %w" -) +func errCouldNotExecuteQuery(err error) error { + return fmt.Errorf("could not execute query: %w", err) +} +func errCouldNotFetchRow(err error) error { + return fmt.Errorf("could not fetch row: %w", err) +} +func errCouldNotScanResult(err error) error { + return fmt.Errorf("could not scan result: %w", err) +} +func errCouldNotStartTransaction(err error) error { + return fmt.Errorf("could not start transaction: %w", err) +} +func errCouldNotCommitTransaction(err error) error { + return fmt.Errorf("could not commit transaction: %w", err) +} +func errCouldNotCreateInQuery(err error) error { + return fmt.Errorf("could not create query with IN clause: %w", err) +} diff --git a/internal/models/motions.go b/internal/models/motions.go index e1231de..524b2ad 100644 --- a/internal/models/motions.go +++ b/internal/models/motions.go @@ -302,7 +302,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { - return nil, fmt.Errorf(errCouldNotStartTransaction, err) + return nil, errCouldNotStartTransaction(err) } defer func(tx *sqlx.Tx) { @@ -327,7 +327,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now for rows.Next() { decision := &Motion{} if err = rows.StructScan(decision); err != nil { - return nil, fmt.Errorf(errCouldNotScanResult, err) + return nil, errCouldNotScanResult(err) } if rows.Err() != nil { @@ -350,7 +350,7 @@ WHERE decisions.status=0 AND :now > due`, struct{ Now time.Time }{Now: time.Now } if err = tx.Commit(); err != nil { - return nil, fmt.Errorf(errCouldNotCommitTransaction, err) + return nil, errCouldNotCommitTransaction(err) } return results, nil @@ -379,7 +379,7 @@ func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error) ) if err != nil { - return nil, fmt.Errorf(errCouldNotExecuteQuery, err) + return nil, errCouldNotExecuteQuery(err) } affectedRows, err := result.RowsAffected() @@ -408,7 +408,7 @@ WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = d voteStatusPending, voter.ID) if err != nil { - return nil, fmt.Errorf(errCouldNotExecuteQuery, err) + return nil, errCouldNotExecuteQuery(err) } defer func(rows *sqlx.Rows) { @@ -419,13 +419,13 @@ WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = d for rows.Next() { if err := rows.Err(); err != nil { - return nil, fmt.Errorf(errCouldNotFetchRow, err) + return nil, errCouldNotFetchRow(err) } var motion Motion if err := rows.StructScan(&motion); err != nil { - return nil, fmt.Errorf(errCouldNotScanResult, err) + return nil, errCouldNotScanResult(err) } result = append(result, &motion) @@ -590,11 +590,11 @@ func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]* var decision Motion if err = rows.Err(); err != nil { - return nil, fmt.Errorf(errCouldNotFetchRow, err) + return nil, errCouldNotFetchRow(err) } if err = rows.StructScan(&decision); err != nil { - return nil, fmt.Errorf(errCouldNotScanResult, err) + return nil, errCouldNotScanResult(err) } motions = append(motions, &decision) @@ -633,7 +633,7 @@ GROUP BY v.decision, v.vote`, rows, err := m.DB.QueryContext(ctx, query, args...) if err != nil { - return fmt.Errorf(errCouldNotExecuteQuery, err) + return errCouldNotExecuteQuery(err) } defer func(rows *sql.Rows) { @@ -642,7 +642,7 @@ GROUP BY v.decision, v.vote`, for rows.Next() { if err = rows.Err(); err != nil { - return fmt.Errorf(errCouldNotFetchRow, err) + return errCouldNotFetchRow(err) } var ( @@ -653,7 +653,7 @@ GROUP BY v.decision, v.vote`, err = rows.Scan(&decisionID, &vote, &count) if err != nil { - return fmt.Errorf(errCouldNotScanResult, err) + return errCouldNotScanResult(err) } switch { @@ -861,7 +861,7 @@ ORDER BY voters.name`, var vote Vote if err := rows.StructScan(&vote); err != nil { - return fmt.Errorf(errCouldNotScanResult, err) + return errCouldNotScanResult(err) } md.Votes = append(md.Votes, &vote) @@ -893,7 +893,7 @@ func (m *MotionModel) Update( ) error { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { - return fmt.Errorf(errCouldNotStartTransaction, err) + return errCouldNotStartTransaction(err) } defer func(tx *sqlx.Tx) { @@ -908,7 +908,7 @@ func (m *MotionModel) Update( var motion Motion if err := row.StructScan(&motion); err != nil { - return fmt.Errorf(errCouldNotScanResult, err) + return errCouldNotScanResult(err) } updateFn(&motion) @@ -932,7 +932,7 @@ WHERE id = :id`, } if err := tx.Commit(); err != nil { - return fmt.Errorf(errCouldNotCommitTransaction, err) + return errCouldNotCommitTransaction(err) } return nil @@ -950,7 +950,7 @@ type Vote struct { func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error { tx, err := m.DB.BeginTxx(ctx, nil) if err != nil { - return fmt.Errorf(errCouldNotStartTransaction, err) + return errCouldNotStartTransaction(err) } defer func(tx *sqlx.Tx) { @@ -959,7 +959,7 @@ func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, pe row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID) if err := row.Err(); err != nil { - return fmt.Errorf(errCouldNotExecuteQuery, err) + return errCouldNotExecuteQuery(err) } vote := Vote{UserID: userID, MotionID: motionID} @@ -986,7 +986,7 @@ WHERE decision = :decision } if err := tx.Commit(); err != nil { - return fmt.Errorf(errCouldNotCommitTransaction, err) + return errCouldNotCommitTransaction(err) } return nil diff --git a/internal/models/users.go b/internal/models/users.go index 9cf7529..8e876ee 100644 --- a/internal/models/users.go +++ b/internal/models/users.go @@ -320,16 +320,16 @@ func (m *UserModel) Voters(ctx context.Context) ([]*User, error) { return m.InRole(ctx, RoleVoter) } -func (m *UserModel) ByID(ctx context.Context, voterID int64) (*User, error) { +func (m *UserModel) ByID(ctx context.Context, voterID int64, options ...UserListOption) (*User, error) { row := m.DB.QueryRowxContext( ctx, - `SELECT DISTINCT v.id, v.name + `SELECT DISTINCT v.id, v.name, e.address AS reminder FROM voters v - JOIN user_roles ur ON v.id = ur.voter_id + LEFT OUTER JOIN emails e ON e.voter = v.id WHERE v.id = ? - AND ur.role = ?`, + AND e.reminder = TRUE +`, voterID, - RoleVoter, ) if err := row.Err(); err != nil { @@ -342,6 +342,12 @@ WHERE v.id = ? return nil, fmt.Errorf("could not scan row: %w", err) } + for _, option := range options { + if err := option(ctx, []*User{&user}); err != nil { + return nil, err + } + } + return &user, nil } @@ -350,7 +356,7 @@ type UserListOption func(context.Context, []*User) error func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*User, error) { rows, err := m.DB.QueryxContext(ctx, `SELECT id, name FROM voters ORDER BY name`) if err != nil { - return nil, fmt.Errorf(errCouldNotExecuteQuery, err) + return nil, errCouldNotExecuteQuery(err) } defer func(rows *sqlx.Rows) { @@ -361,12 +367,12 @@ func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*Use for rows.Next() { if err = rows.Err(); err != nil { - return nil, fmt.Errorf(errCouldNotFetchRow, err) + return nil, errCouldNotFetchRow(err) } var user User if err = rows.StructScan(&user); err != nil { - return nil, fmt.Errorf(errCouldNotScanResult, err) + return nil, errCouldNotScanResult(err) } users = append(users, &user) @@ -397,37 +403,21 @@ func (m *UserModel) WithRolesOption() UserListOption { userIDs, ) if err != nil { - return fmt.Errorf(errCouldNotCreateInQuery, err) + return errCouldNotCreateInQuery(err) } rows, err := m.DB.QueryxContext(ctx, query, args...) if err != nil { - return fmt.Errorf(errCouldNotExecuteQuery, err) + return errCouldNotExecuteQuery(err) } defer func(rows *sqlx.Rows) { _ = rows.Close() }(rows) - roleMap := make(map[int64][]*Role, len(users)) - - for rows.Next() { - if err := rows.Err(); err != nil { - return fmt.Errorf(errCouldNotFetchRow, err) - } - - var userID int64 - var role Role - - if err := rows.Scan(&userID, &role); err != nil { - return fmt.Errorf(errCouldNotScanResult, err) - } - - if _, ok := roleMap[userID]; !ok { - roleMap[userID] = []*Role{&role} - } else { - roleMap[userID] = append(roleMap[userID], &role) - } + roleMap, err := buildRoleMap(rows, len(users)) + if err != nil { + return err } for idx := range users { @@ -444,6 +434,33 @@ func (m *UserModel) WithRolesOption() UserListOption { } } +func buildRoleMap(rows *sqlx.Rows, userCount int) (map[int64][]*Role, error) { + roleMap := make(map[int64][]*Role, userCount) + + for rows.Next() { + if err := rows.Err(); err != nil { + return nil, errCouldNotFetchRow(err) + } + + var ( + userID int64 + role Role + ) + + if err := rows.Scan(&userID, &role); err != nil { + return nil, errCouldNotScanResult(err) + } + + if _, ok := roleMap[userID]; !ok { + roleMap[userID] = []*Role{&role} + } else { + roleMap[userID] = append(roleMap[userID], &role) + } + } + + return roleMap, nil +} + func (m *UserModel) CanDeleteOption() UserListOption { return func(ctx context.Context, users []*User) error { if len(users) == 0 { @@ -465,12 +482,12 @@ WHERE id IN (?) userIDs, ) if err != nil { - return fmt.Errorf(errCouldNotCreateInQuery, err) + return errCouldNotCreateInQuery(err) } rows, err := m.DB.QueryxContext(ctx, query, args...) if err != nil { - return fmt.Errorf(errCouldNotExecuteQuery, err) + return errCouldNotExecuteQuery(err) } defer func(rows *sqlx.Rows) { @@ -479,13 +496,13 @@ WHERE id IN (?) for rows.Next() { if err := rows.Err(); err != nil { - return fmt.Errorf(errCouldNotFetchRow, err) + return errCouldNotFetchRow(err) } var userID int64 if err := rows.Scan(&userID); err != nil { - return fmt.Errorf(errCouldNotScanResult, err) + return errCouldNotScanResult(err) } for idx := range users { @@ -498,3 +515,40 @@ WHERE id IN (?) return nil } } + +func (m *UserModel) DeleteUser(ctx context.Context, userToDelete, admin *User, reasoning string) error { + userInfo := struct { + Name string `json:"user"` + Address string `json:"address"` + }{Name: userToDelete.Name, Address: userToDelete.Reminder} + details := struct { + User any `json:"user"` + }{User: userInfo} + + 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, `DELETE FROM emails WHERE voter=?`, userToDelete.ID); err != nil { + return errCouldNotExecuteQuery(err) + } + + if _, err = tx.ExecContext(ctx, `DELETE FROM voters WHERE id=?`, userToDelete.ID); err != nil { + return errCouldNotExecuteQuery(err) + } + + if err = AuditLog(ctx, tx, admin, AuditDeleteUser, reasoning, details); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return errCouldNotCommitTransaction(err) + } + + return nil +} diff --git a/ui/html/pages/delete_user.html b/ui/html/pages/delete_user.html new file mode 100644 index 0000000..25f0e62 --- /dev/null +++ b/ui/html/pages/delete_user.html @@ -0,0 +1,30 @@ +{{ define "title" }}Delete User {{ .Form.User.Name }}{{ end }} + +{{ define "main" }} + {{ $form := .Form }} + {{ $user := .User }} +
+
+
+ Withdraw motion? +
+

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

+
+
+ +
+
+ + + {{ if .Form.FieldErrors.reasoning }} + {{ .Form.FieldErrors.reasoning }} + {{ end }} +
+ + Cancel +
+
+
+{{ end }} \ No newline at end of file