feat: add admin role management and user deletion
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) <noreply@anthropic.com>
This commit is contained in:
parent
b186fb4bc5
commit
254573316a
4 changed files with 195 additions and 0 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue