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:
Ole-Morten Duesund 2026-04-07 10:18:00 +02:00
commit 254573316a
4 changed files with 195 additions and 0 deletions

View file

@ -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(

View file

@ -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)
}
}