From 254573316a5f344e41c8fbad1f72ef7f7cb2c0a1 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 7 Apr 2026 10:18:00 +0200 Subject: [PATCH] feat: add admin role management and user deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins can now change user roles and permanently delete user accounts. - New SetRole store method with validation (user/admin only) - New Delete store method — cascades via foreign keys to sessions, faves, and fave_tags - handleAdminSetRole: change role with self-modification prevention - handleAdminDeleteUser: permanent deletion with image cleanup from disk before cascade delete, self-deletion prevention - admin_users.html: role dropdown with save button per user row, delete button with hx-confirm for safety - Routes: POST /admin/users/{id}/role, POST /admin/users/{id}/delete Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handler/admin.go | 83 ++++++++++++++++++++++++++++ internal/store/user.go | 28 ++++++++++ internal/store/user_test.go | 68 +++++++++++++++++++++++ web/templates/pages/admin_users.html | 16 ++++++ 4 files changed, 195 insertions(+) diff --git a/internal/handler/admin.go b/internal/handler/admin.go index ea3b0d2..1c8c2e5 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "kode.naiv.no/olemd/favoritter/internal/image" "kode.naiv.no/olemd/favoritter/internal/middleware" "kode.naiv.no/olemd/favoritter/internal/render" "kode.naiv.no/olemd/favoritter/internal/store" @@ -158,6 +159,88 @@ func (h *Handler) handleAdminToggleDisabled(w http.ResponseWriter, r *http.Reque h.adminUsersFlash(w, r, "Bruker "+user.Username+" er "+action+".", "success") } +// handleAdminSetRole changes a user's role. +func (h *Handler) handleAdminSetRole(w http.ResponseWriter, r *http.Request) { + admin := middleware.UserFromContext(r.Context()) + + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + + if id == admin.ID { + h.adminUsersFlash(w, r, "Du kan ikke endre din egen rolle.", "error") + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + role := r.FormValue("role") + if role != "user" && role != "admin" { + h.adminUsersFlash(w, r, "Ugyldig rolle.", "error") + return + } + + if err := h.deps.Users.SetRole(id, role); err != nil { + slog.Error("set role error", "error", err) + h.adminUsersFlash(w, r, "Noe gikk galt.", "error") + return + } + + user, _ := h.deps.Users.GetByID(id) + h.adminUsersFlash(w, r, "Rollen til "+user.Username+" er endret til "+role+".", "success") +} + +// handleAdminDeleteUser permanently deletes a user and all their data. +func (h *Handler) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) { + admin := middleware.UserFromContext(r.Context()) + + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + + if id == admin.ID { + h.adminUsersFlash(w, r, "Du kan ikke slette din egen konto.", "error") + return + } + + user, err := h.deps.Users.GetByID(id) + if err != nil { + slog.Error("get user error", "error", err) + h.adminUsersFlash(w, r, "Noe gikk galt.", "error") + return + } + + // Delete user's images from disk before database deletion. + faves, _, _ := h.deps.Faves.ListByUser(id, 100000, 0) + for _, f := range faves { + if f.ImagePath != "" { + if delErr := image.Delete(h.deps.Config.UploadDir, f.ImagePath); delErr != nil { + slog.Error("image delete error", "fave_id", f.ID, "error", delErr) + } + } + } + if user.AvatarPath != "" { + if delErr := image.Delete(h.deps.Config.UploadDir, user.AvatarPath); delErr != nil { + slog.Error("avatar delete error", "user_id", id, "error", delErr) + } + } + + if err := h.deps.Users.Delete(id); err != nil { + slog.Error("delete user error", "error", err) + h.adminUsersFlash(w, r, "Noe gikk galt.", "error") + return + } + + h.adminUsersFlash(w, r, "Bruker "+user.Username+" er permanent slettet.", "success") +} + // handleAdminTags lists all tags. func (h *Handler) handleAdminTags(w http.ResponseWriter, r *http.Request) { tags, err := h.deps.Tags.ListAll() diff --git a/internal/store/user.go b/internal/store/user.go index 22b766a..2035c34 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -198,6 +198,34 @@ func (s *UserStore) SetDisabled(userID int64, disabled bool) error { return err } +// SetRole changes a user's role (user/admin). +func (s *UserStore) SetRole(userID int64, role string) error { + if role != "user" && role != "admin" { + return fmt.Errorf("invalid role: %s", role) + } + _, err := s.db.Exec( + `UPDATE users SET role = ?, + updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + WHERE id = ?`, + role, userID, + ) + return err +} + +// Delete permanently removes a user. Cascading foreign keys handle +// sessions, faves, and fave_tags. Image cleanup must be done by the caller. +func (s *UserStore) Delete(userID int64) error { + result, err := s.db.Exec("DELETE FROM users WHERE id = ?", userID) + if err != nil { + return fmt.Errorf("delete user: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return ErrUserNotFound + } + return nil +} + // ListAll returns all users, ordered by username. func (s *UserStore) ListAll() ([]*model.User, error) { rows, err := s.db.Query( diff --git a/internal/store/user_test.go b/internal/store/user_test.go index ec5f5f0..3902fd9 100644 --- a/internal/store/user_test.go +++ b/internal/store/user_test.go @@ -203,3 +203,71 @@ func TestDisabledUser(t *testing.T) { t.Errorf("disabled user error = %v, want ErrUserDisabled", err) } } + +func TestSetRole(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("testuser", "password123", "user") + if user.Role != "user" { + t.Fatalf("initial role = %q", user.Role) + } + + // Promote to admin. + err := users.SetRole(user.ID, "admin") + if err != nil { + t.Fatalf("set role: %v", err) + } + + updated, _ := users.GetByID(user.ID) + if updated.Role != "admin" { + t.Errorf("role = %q, want admin", updated.Role) + } + + // Invalid role should error. + err = users.SetRole(user.ID, "superadmin") + if err == nil { + t.Error("invalid role should return error") + } +} + +func TestDeleteUser(t *testing.T) { + db := testDB(t) + users := NewUserStore(db) + faves := NewFaveStore(db) + + Argon2Memory = 1024 + Argon2Time = 1 + defer func() { Argon2Memory = 65536; Argon2Time = 3 }() + + user, _ := users.Create("deleteme", "password123", "user") + faves.Create(user.ID, "Fave 1", "", "", "", "public") + faves.Create(user.ID, "Fave 2", "", "", "", "public") + + err := users.Delete(user.ID) + if err != nil { + t.Fatalf("delete user: %v", err) + } + + // User should be gone. + _, err = users.GetByUsername("deleteme") + if err != ErrUserNotFound { + t.Errorf("expected ErrUserNotFound, got %v", err) + } + + // Faves should be cascade-deleted. + list, total, _ := faves.ListByUser(user.ID, 10, 0) + if total != 0 || len(list) != 0 { + t.Errorf("faves should be cascade-deleted: total=%d, len=%d", total, len(list)) + } + + // Deleting non-existent user should return error. + err = users.Delete(99999) + if err != ErrUserNotFound { + t.Errorf("delete nonexistent: %v, want ErrUserNotFound", err) + } +} diff --git a/web/templates/pages/admin_users.html b/web/templates/pages/admin_users.html index bedcd70..d61a3f5 100644 --- a/web/templates/pages/admin_users.html +++ b/web/templates/pages/admin_users.html @@ -54,6 +54,14 @@ {{.CreatedAt.Format "02.01.2006"}} +
+ + + +
@@ -64,6 +72,14 @@ {{if .Disabled}}Aktiver{{else}}Deaktiver{{end}}
+ {{end}}