Implement user deletion
- add audit logging for user changes - refactor model errors into functions - implement user delete form and submit handlers
This commit is contained in:
parent
db52f88e25
commit
5efc57d2c3
11 changed files with 385 additions and 70 deletions
|
@ -579,10 +579,8 @@ func (app *application) proxyVoteSubmit(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
voter, err := app.users.ByID(r.Context(), form.Voter.ID)
|
voter := app.getVoter(w, r, form.Voter.ID)
|
||||||
if err != nil {
|
if voter == nil {
|
||||||
app.serverError(w, err)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -637,6 +635,7 @@ func (app *application) editUserForm(_ http.ResponseWriter, _ *http.Request) {
|
||||||
// TODO: implement editUserForm
|
// TODO: implement editUserForm
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) {
|
func (app *application) editUserSubmit(_ http.ResponseWriter, _ *http.Request) {
|
||||||
// TODO: implement editUserSubmit
|
// TODO: implement editUserSubmit
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
|
@ -652,14 +651,77 @@ func (app *application) userAddEmailSubmit(_ http.ResponseWriter, _ *http.Reques
|
||||||
panic("not implemented")
|
panic("not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *application) deleteUserForm(_ http.ResponseWriter, _ *http.Request) {
|
func (app *application) deleteUserForm(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: implement deleteUserForm
|
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
|
||||||
panic("not implemented")
|
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) {
|
func (app *application) deleteUserSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
// TODO: implement deleteUserSubmit
|
userToDelete := app.userFromRequestParam(w, r, httprouter.ParamsFromContext(r.Context()), app.users.CanDeleteOption())
|
||||||
panic("not implemented")
|
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) {
|
func (app *application) healthCheck(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/sprig/v3"
|
"github.com/Masterminds/sprig/v3"
|
||||||
|
@ -196,6 +197,32 @@ func (app *application) motionFromRequestParam(
|
||||||
return motion
|
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 {
|
func (app *application) choiceFromRequestParam(w http.ResponseWriter, params httprouter.Params) *models.VoteChoice {
|
||||||
choiceParam := params.ByName("choice")
|
choiceParam := params.ByName("choice")
|
||||||
|
|
||||||
|
|
|
@ -209,6 +209,31 @@ func (app *application) startHTTPSServer(config *Config) error {
|
||||||
return nil
|
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) {
|
func setupHTTPRedirect(config *Config, errChan chan error) {
|
||||||
redirect := &http.Server{
|
redirect := &http.Server{
|
||||||
Addr: config.HTTPAddress,
|
Addr: config.HTTPAddress,
|
||||||
|
|
|
@ -119,3 +119,22 @@ func (f *ProxyVoteForm) Validate() {
|
||||||
func (f *ProxyVoteForm) Normalize() {
|
func (f *ProxyVoteForm) Normalize() {
|
||||||
f.Justification = strings.TrimSpace(f.Justification)
|
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)
|
||||||
|
}
|
||||||
|
|
3
internal/migrations/2022060101_add_audit_table.down.sql
Normal file
3
internal/migrations/2022060101_add_audit_table.down.sql
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
-- add an audit table to track changes to users
|
||||||
|
DROP INDEX audit_change_idx;
|
||||||
|
DROP TABLE audit;
|
18
internal/migrations/2022060101_add_audit_table.up.sql
Normal file
18
internal/migrations/2022060101_add_audit_table.up.sql
Normal file
|
@ -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);
|
67
internal/models/audit.go
Normal file
67
internal/models/audit.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -49,11 +49,21 @@ func parseSqlite3TimeStamp(timeStamp string) (*time.Time, error) {
|
||||||
return nil, fmt.Errorf("could not parse timestamp: %w", err)
|
return nil, fmt.Errorf("could not parse timestamp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
func errCouldNotExecuteQuery(err error) error {
|
||||||
errCouldNotExecuteQuery = "could not execute query: %w"
|
return fmt.Errorf("could not execute query: %w", err)
|
||||||
errCouldNotFetchRow = "could not fetch row: %w"
|
}
|
||||||
errCouldNotScanResult = "could not scan result: %w"
|
func errCouldNotFetchRow(err error) error {
|
||||||
errCouldNotStartTransaction = "could not start transaction: %w"
|
return fmt.Errorf("could not fetch row: %w", err)
|
||||||
errCouldNotCommitTransaction = "could not commit transaction: %w"
|
}
|
||||||
errCouldNotCreateInQuery = "could not create query with IN clause: %w"
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -302,7 +302,7 @@ VALUES (:proposed, :proponent, :title, :content, :votetype, :status, :due, :prop
|
||||||
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) {
|
func (m *MotionModel) CloseDecisions(ctx context.Context) ([]*Motion, error) {
|
||||||
tx, err := m.DB.BeginTxx(ctx, nil)
|
tx, err := m.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotStartTransaction, err)
|
return nil, errCouldNotStartTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(tx *sqlx.Tx) {
|
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() {
|
for rows.Next() {
|
||||||
decision := &Motion{}
|
decision := &Motion{}
|
||||||
if err = rows.StructScan(decision); err != nil {
|
if err = rows.StructScan(decision); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotScanResult, err)
|
return nil, errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows.Err() != nil {
|
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 {
|
if err = tx.Commit(); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotCommitTransaction, err)
|
return nil, errCouldNotCommitTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
|
@ -379,7 +379,7 @@ func closeDecision(ctx context.Context, tx *sqlx.Tx, d *Motion) (*Motion, error)
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
|
return nil, errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
affectedRows, err := result.RowsAffected()
|
affectedRows, err := result.RowsAffected()
|
||||||
|
@ -408,7 +408,7 @@ WHERE due < ? AND status=? AND NOT EXISTS(SELECT * FROM votes WHERE decision = d
|
||||||
voteStatusPending,
|
voteStatusPending,
|
||||||
voter.ID)
|
voter.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
|
return nil, errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(rows *sqlx.Rows) {
|
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() {
|
for rows.Next() {
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotFetchRow, err)
|
return nil, errCouldNotFetchRow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var motion Motion
|
var motion Motion
|
||||||
|
|
||||||
if err := rows.StructScan(&motion); err != nil {
|
if err := rows.StructScan(&motion); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotScanResult, err)
|
return nil, errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result = append(result, &motion)
|
result = append(result, &motion)
|
||||||
|
@ -590,11 +590,11 @@ func (m *MotionModel) List(ctx context.Context, options *MotionListOptions) ([]*
|
||||||
var decision Motion
|
var decision Motion
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotFetchRow, err)
|
return nil, errCouldNotFetchRow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = rows.StructScan(&decision); err != nil {
|
if err = rows.StructScan(&decision); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotScanResult, err)
|
return nil, errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
motions = append(motions, &decision)
|
motions = append(motions, &decision)
|
||||||
|
@ -633,7 +633,7 @@ GROUP BY v.decision, v.vote`,
|
||||||
|
|
||||||
rows, err := m.DB.QueryContext(ctx, query, args...)
|
rows, err := m.DB.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotExecuteQuery, err)
|
return errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(rows *sql.Rows) {
|
defer func(rows *sql.Rows) {
|
||||||
|
@ -642,7 +642,7 @@ GROUP BY v.decision, v.vote`,
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return fmt.Errorf(errCouldNotFetchRow, err)
|
return errCouldNotFetchRow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -653,7 +653,7 @@ GROUP BY v.decision, v.vote`,
|
||||||
|
|
||||||
err = rows.Scan(&decisionID, &vote, &count)
|
err = rows.Scan(&decisionID, &vote, &count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotScanResult, err)
|
return errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
@ -861,7 +861,7 @@ ORDER BY voters.name`,
|
||||||
var vote Vote
|
var vote Vote
|
||||||
|
|
||||||
if err := rows.StructScan(&vote); err != nil {
|
if err := rows.StructScan(&vote); err != nil {
|
||||||
return fmt.Errorf(errCouldNotScanResult, err)
|
return errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
md.Votes = append(md.Votes, &vote)
|
md.Votes = append(md.Votes, &vote)
|
||||||
|
@ -893,7 +893,7 @@ func (m *MotionModel) Update(
|
||||||
) error {
|
) error {
|
||||||
tx, err := m.DB.BeginTxx(ctx, nil)
|
tx, err := m.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotStartTransaction, err)
|
return errCouldNotStartTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(tx *sqlx.Tx) {
|
defer func(tx *sqlx.Tx) {
|
||||||
|
@ -908,7 +908,7 @@ func (m *MotionModel) Update(
|
||||||
var motion Motion
|
var motion Motion
|
||||||
|
|
||||||
if err := row.StructScan(&motion); err != nil {
|
if err := row.StructScan(&motion); err != nil {
|
||||||
return fmt.Errorf(errCouldNotScanResult, err)
|
return errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFn(&motion)
|
updateFn(&motion)
|
||||||
|
@ -932,7 +932,7 @@ WHERE id = :id`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf(errCouldNotCommitTransaction, err)
|
return errCouldNotCommitTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
@ -950,7 +950,7 @@ type Vote struct {
|
||||||
func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error {
|
func (m *MotionModel) UpdateVote(ctx context.Context, userID, motionID int64, performVoteFn func(v *Vote)) error {
|
||||||
tx, err := m.DB.BeginTxx(ctx, nil)
|
tx, err := m.DB.BeginTxx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotStartTransaction, err)
|
return errCouldNotStartTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(tx *sqlx.Tx) {
|
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)
|
row := tx.QueryRowxContext(ctx, `SELECT * FROM votes WHERE voter=? AND decision=?`, userID, motionID)
|
||||||
if err := row.Err(); err != nil {
|
if err := row.Err(); err != nil {
|
||||||
return fmt.Errorf(errCouldNotExecuteQuery, err)
|
return errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vote := Vote{UserID: userID, MotionID: motionID}
|
vote := Vote{UserID: userID, MotionID: motionID}
|
||||||
|
@ -986,7 +986,7 @@ WHERE decision = :decision
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf(errCouldNotCommitTransaction, err)
|
return errCouldNotCommitTransaction(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -320,16 +320,16 @@ func (m *UserModel) Voters(ctx context.Context) ([]*User, error) {
|
||||||
return m.InRole(ctx, RoleVoter)
|
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(
|
row := m.DB.QueryRowxContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT DISTINCT v.id, v.name
|
`SELECT DISTINCT v.id, v.name, e.address AS reminder
|
||||||
FROM voters v
|
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 = ?
|
WHERE v.id = ?
|
||||||
AND ur.role = ?`,
|
AND e.reminder = TRUE
|
||||||
|
`,
|
||||||
voterID,
|
voterID,
|
||||||
RoleVoter,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if err := row.Err(); err != nil {
|
if err := row.Err(); err != nil {
|
||||||
|
@ -342,6 +342,12 @@ WHERE v.id = ?
|
||||||
return nil, fmt.Errorf("could not scan row: %w", err)
|
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
|
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) {
|
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`)
|
rows, err := m.DB.QueryxContext(ctx, `SELECT id, name FROM voters ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotExecuteQuery, err)
|
return nil, errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(rows *sqlx.Rows) {
|
defer func(rows *sqlx.Rows) {
|
||||||
|
@ -361,12 +367,12 @@ func (m *UserModel) List(ctx context.Context, options ...UserListOption) ([]*Use
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
if err = rows.Err(); err != nil {
|
if err = rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotFetchRow, err)
|
return nil, errCouldNotFetchRow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var user User
|
var user User
|
||||||
if err = rows.StructScan(&user); err != nil {
|
if err = rows.StructScan(&user); err != nil {
|
||||||
return nil, fmt.Errorf(errCouldNotScanResult, err)
|
return nil, errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
users = append(users, &user)
|
users = append(users, &user)
|
||||||
|
@ -397,37 +403,21 @@ func (m *UserModel) WithRolesOption() UserListOption {
|
||||||
userIDs,
|
userIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotCreateInQuery, err)
|
return errCouldNotCreateInQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := m.DB.QueryxContext(ctx, query, args...)
|
rows, err := m.DB.QueryxContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotExecuteQuery, err)
|
return errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(rows *sqlx.Rows) {
|
defer func(rows *sqlx.Rows) {
|
||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
}(rows)
|
}(rows)
|
||||||
|
|
||||||
roleMap := make(map[int64][]*Role, len(users))
|
roleMap, err := buildRoleMap(rows, len(users))
|
||||||
|
if err != nil {
|
||||||
for rows.Next() {
|
return err
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx := range users {
|
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 {
|
func (m *UserModel) CanDeleteOption() UserListOption {
|
||||||
return func(ctx context.Context, users []*User) error {
|
return func(ctx context.Context, users []*User) error {
|
||||||
if len(users) == 0 {
|
if len(users) == 0 {
|
||||||
|
@ -465,12 +482,12 @@ WHERE id IN (?)
|
||||||
userIDs,
|
userIDs,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotCreateInQuery, err)
|
return errCouldNotCreateInQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := m.DB.QueryxContext(ctx, query, args...)
|
rows, err := m.DB.QueryxContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(errCouldNotExecuteQuery, err)
|
return errCouldNotExecuteQuery(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func(rows *sqlx.Rows) {
|
defer func(rows *sqlx.Rows) {
|
||||||
|
@ -479,13 +496,13 @@ WHERE id IN (?)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return fmt.Errorf(errCouldNotFetchRow, err)
|
return errCouldNotFetchRow(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var userID int64
|
var userID int64
|
||||||
|
|
||||||
if err := rows.Scan(&userID); err != nil {
|
if err := rows.Scan(&userID); err != nil {
|
||||||
return fmt.Errorf(errCouldNotScanResult, err)
|
return errCouldNotScanResult(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx := range users {
|
for idx := range users {
|
||||||
|
@ -498,3 +515,40 @@ WHERE id IN (?)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
30
ui/html/pages/delete_user.html
Normal file
30
ui/html/pages/delete_user.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
{{ define "title" }}Delete User {{ .Form.User.Name }}{{ end }}
|
||||||
|
|
||||||
|
{{ define "main" }}
|
||||||
|
{{ $form := .Form }}
|
||||||
|
{{ $user := .User }}
|
||||||
|
<div class="ui form segment">
|
||||||
|
<div class="ui negative message">
|
||||||
|
<div class="header">
|
||||||
|
Withdraw motion?
|
||||||
|
</div>
|
||||||
|
<p>Do you want to delete user <strong>{{ .Form.User.Name }}</strong>?</p>
|
||||||
|
</div>
|
||||||
|
<form action="/users/{{ .Form.User.ID }}/delete" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||||
|
<div class="ui form{{ if .Form.FieldErrors }} error{{ end }}">
|
||||||
|
<div class="required field{{ if .Form.FieldErrors.reasoning }} error{{ end }}">
|
||||||
|
<label for="reasoning">Reasoning for the deletion</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 negative labeled icon button" type="submit">
|
||||||
|
<i class="trash icon"></i> Delete user
|
||||||
|
</button>
|
||||||
|
<a href="/motions/" class="ui button">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
Loading…
Reference in a new issue