diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 1c8c2e5..ea3b0d2 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -11,7 +11,6 @@ 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" @@ -159,88 +158,6 @@ 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/handler/fave.go b/internal/handler/fave.go index 5960762..fb75784 100644 --- a/internal/handler/fave.go +++ b/internal/handler/fave.go @@ -335,51 +335,6 @@ func (h *Handler) handleFaveDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther) } -// handleFaveTogglePrivacy toggles a fave's privacy and returns the updated toggle partial. -func (h *Handler) handleFaveTogglePrivacy(w http.ResponseWriter, r *http.Request) { - user := middleware.UserFromContext(r.Context()) - - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - h.notFound(w, r) - return - } - - fave, err := h.deps.Faves.GetByID(id) - if err != nil { - if errors.Is(err, store.ErrFaveNotFound) { - h.notFound(w, r) - return - } - slog.Error("get fave error", "error", err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - - if user.ID != fave.UserID { - h.forbidden(w, r) - return - } - - newPrivacy := "private" - if fave.Privacy == "private" { - newPrivacy = "public" - } - - if err := h.deps.Faves.UpdatePrivacy(id, newPrivacy); err != nil { - slog.Error("toggle privacy error", "error", err) - http.Error(w, "Internal error", http.StatusInternalServerError) - return - } - - fave.Privacy = newPrivacy - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.deps.Renderer.Partial(w, "privacy_toggle", fave); err != nil { - slog.Error("render privacy toggle error", "error", err) - } -} - // handleTagSearch handles tag autocomplete HTMX requests. func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") diff --git a/internal/handler/handler.go b/internal/handler/handler.go index d0c550f..c2f3a80 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -103,7 +103,6 @@ func (h *Handler) Routes() *http.ServeMux { mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit))) mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate))) mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete))) - mux.Handle("POST /faves/{id}/privacy", requireLogin(http.HandlerFunc(h.handleFaveTogglePrivacy))) // Tags. mux.HandleFunc("GET /tags/search", h.handleTagSearch) @@ -139,8 +138,6 @@ func (h *Handler) Routes() *http.ServeMux { mux.Handle("POST /admin/users", admin(h.handleAdminCreateUser)) mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword)) mux.Handle("POST /admin/users/{id}/toggle-disabled", admin(h.handleAdminToggleDisabled)) - mux.Handle("POST /admin/users/{id}/role", admin(h.handleAdminSetRole)) - mux.Handle("POST /admin/users/{id}/delete", admin(h.handleAdminDeleteUser)) mux.Handle("GET /admin/tags", admin(h.handleAdminTags)) mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag)) mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag)) diff --git a/internal/handler/web_test.go b/internal/handler/web_test.go index 9070357..aa53c2e 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -1282,197 +1282,6 @@ func TestFaveNewPreFill(t *testing.T) { } } -// --- Privacy toggle --- - -func TestTogglePrivacyOwner(t *testing.T) { - h, mux := testServer(t) - - user, _ := h.deps.Users.Create("testuser", "pass123", "user") - fave, _ := h.deps.Faves.Create(user.ID, "Toggle me", "", "", "", "public") - token, _ := h.deps.Sessions.Create(user.ID) - cookie := &http.Cookie{Name: "session", Value: token} - - getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) - getReq.AddCookie(cookie) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if rr.Code != http.StatusOK { - t.Fatalf("toggle privacy: got %d, want 200\nbody: %s", rr.Code, rr.Body.String()) - } - - // Should now be private. - updated, _ := h.deps.Faves.GetByID(fave.ID) - if updated.Privacy != "private" { - t.Errorf("privacy = %q, want private after toggle from public", updated.Privacy) - } - - // Response should contain the toggle partial with "Privat". - if !strings.Contains(rr.Body.String(), "Privat") { - t.Error("toggle response should show new privacy state") - } -} - -func TestTogglePrivacyNotOwner(t *testing.T) { - h, mux := testServer(t) - - userA, _ := h.deps.Users.Create("usera", "pass123", "user") - fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public") - - cookieB := loginUser(t, h, "userb", "pass123", "user") - - getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil) - getReq.AddCookie(cookieB) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookieB) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if rr.Code != http.StatusForbidden { - t.Errorf("toggle by non-owner: got %d, want 403", rr.Code) - } -} - -func TestFaveListShowsEditButton(t *testing.T) { - h, mux := testServer(t) - cookie := loginUser(t, h, "testuser", "pass123", "user") - - user, _ := h.deps.Users.GetByUsername("testuser") - h.deps.Faves.Create(user.ID, "Editable fave", "", "", "", "public") - - req := httptest.NewRequest("GET", "/faves", nil) - req.AddCookie(cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - body := rr.Body.String() - if !strings.Contains(body, "Rediger") { - t.Error("fave list should show edit link") - } - if !strings.Contains(body, "Slett") { - t.Error("fave list should show delete button") - } - if !strings.Contains(body, "Offentlig") { - t.Error("fave list should show privacy toggle") - } -} - -// --- Admin: role + delete --- - -func TestAdminSetRoleSuccess(t *testing.T) { - h, mux := testServer(t) - cookie := loginUser(t, h, "admin", "pass123", "admin") - - user, _ := h.deps.Users.Create("target", "pass123", "user") - - getReq := httptest.NewRequest("GET", "/admin/users", nil) - getReq.AddCookie(cookie) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - form := url.Values{"role": {"admin"}} - req := postForm("/admin/users/"+faveIDStr(user.ID)+"/role", csrf, form, cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if !strings.Contains(rr.Body.String(), "endret til admin") { - t.Errorf("should show role changed: %s", rr.Body.String()) - } - - updated, _ := h.deps.Users.GetByID(user.ID) - if updated.Role != "admin" { - t.Errorf("role = %q, want admin", updated.Role) - } -} - -func TestAdminSetRoleSelf(t *testing.T) { - h, mux := testServer(t) - - admin, _ := h.deps.Users.Create("admin", "pass123", "admin") - token, _ := h.deps.Sessions.Create(admin.ID) - cookie := &http.Cookie{Name: "session", Value: token} - - getReq := httptest.NewRequest("GET", "/admin/users", nil) - getReq.AddCookie(cookie) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - form := url.Values{"role": {"user"}} - req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/role", csrf, form, cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if !strings.Contains(rr.Body.String(), "din egen rolle") { - t.Error("should prevent changing own role") - } -} - -func TestAdminDeleteUserSuccess(t *testing.T) { - h, mux := testServer(t) - cookie := loginUser(t, h, "admin", "pass123", "admin") - - user, _ := h.deps.Users.Create("deleteme", "pass123", "user") - h.deps.Faves.Create(user.ID, "Will be deleted", "", "", "", "public") - - getReq := httptest.NewRequest("GET", "/admin/users", nil) - getReq.AddCookie(cookie) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - req := postForm("/admin/users/"+faveIDStr(user.ID)+"/delete", csrf, url.Values{}, cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if !strings.Contains(rr.Body.String(), "permanent slettet") { - t.Errorf("should show deleted: %s", rr.Body.String()) - } - - // User should be gone. - _, err := h.deps.Users.GetByUsername("deleteme") - if err == nil { - t.Error("deleted user should not exist") - } - - // Faves should be cascade-deleted. - faves, total, _ := h.deps.Faves.ListByUser(user.ID, 10, 0) - if total != 0 || len(faves) != 0 { - t.Error("faves should be cascade-deleted with user") - } -} - -func TestAdminDeleteUserSelf(t *testing.T) { - h, mux := testServer(t) - - admin, _ := h.deps.Users.Create("admin", "pass123", "admin") - token, _ := h.deps.Sessions.Create(admin.ID) - cookie := &http.Cookie{Name: "session", Value: token} - - getReq := httptest.NewRequest("GET", "/admin/users", nil) - getReq.AddCookie(cookie) - getRR := httptest.NewRecorder() - mux.ServeHTTP(getRR, getReq) - csrf := extractCookie(getRR, "csrf_token") - - req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/delete", csrf, url.Values{}, cookie) - rr := httptest.NewRecorder() - mux.ServeHTTP(rr, req) - - if !strings.Contains(rr.Body.String(), "din egen konto") { - t.Error("should prevent self-deletion") - } -} - // --- Export page --- func TestExportPageRendering(t *testing.T) { diff --git a/internal/store/fave.go b/internal/store/fave.go index 62f6fc3..5888490 100644 --- a/internal/store/fave.go +++ b/internal/store/fave.go @@ -73,17 +73,6 @@ func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy return err } -// UpdatePrivacy toggles a fave's privacy setting. -func (s *FaveStore) UpdatePrivacy(id int64, privacy string) error { - _, err := s.db.Exec( - `UPDATE faves SET privacy = ?, - updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - WHERE id = ?`, - privacy, id, - ) - return err -} - // Delete removes a fave by its ID. The cascade will clean up fave_tags. func (s *FaveStore) Delete(id int64) error { result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id) diff --git a/internal/store/fave_test.go b/internal/store/fave_test.go index c870080..f96ce8f 100644 --- a/internal/store/fave_test.go +++ b/internal/store/fave_test.go @@ -150,37 +150,6 @@ func TestFaveNotes(t *testing.T) { } } -func TestUpdatePrivacy(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("testuser", "password123", "user") - fave, _ := faves.Create(user.ID, "Toggle me", "", "", "", "public") - - // Toggle to private. - err := faves.UpdatePrivacy(fave.ID, "private") - if err != nil { - t.Fatalf("update privacy: %v", err) - } - - got, _ := faves.GetByID(fave.ID) - if got.Privacy != "private" { - t.Errorf("privacy = %q, want private", got.Privacy) - } - - // Toggle back to public. - faves.UpdatePrivacy(fave.ID, "public") - got, _ = faves.GetByID(fave.ID) - if got.Privacy != "public" { - t.Errorf("privacy = %q, want public", got.Privacy) - } -} - func TestListByTag(t *testing.T) { db := testDB(t) users := NewUserStore(db) diff --git a/internal/store/user.go b/internal/store/user.go index 2035c34..22b766a 100644 --- a/internal/store/user.go +++ b/internal/store/user.go @@ -198,34 +198,6 @@ 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 3902fd9..ec5f5f0 100644 --- a/internal/store/user_test.go +++ b/internal/store/user_test.go @@ -203,71 +203,3 @@ 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/static/css/style.css b/web/static/css/style.css index 4710dea..56780dd 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -92,40 +92,6 @@ padding: 0 1rem 0.5rem; } -/* Card action buttons (edit/delete/privacy toggle) */ -.fave-card-actions { - display: flex; - gap: 0.5rem; - padding: 0 1rem 0.5rem; - align-items: center; -} - -.fave-action-link { - font-size: 0.8rem; - text-decoration: none; -} - -.fave-action-btn { - font-size: 0.75rem; - padding: 0.15rem 0.5rem; - margin: 0; - border: 1px solid var(--pico-muted-border-color); - background: transparent; - cursor: pointer; - border-radius: var(--pico-border-radius); - color: inherit; -} - -.fave-action-btn:hover { - border-color: var(--pico-primary); - color: var(--pico-primary); -} - -.fave-action-btn.secondary:hover { - border-color: var(--pico-del-color); - color: var(--pico-del-color); -} - /* Privacy badge */ .badge-private { background: var(--pico-muted-border-color); diff --git a/web/templates/pages/admin_users.html b/web/templates/pages/admin_users.html index d61a3f5..bedcd70 100644 --- a/web/templates/pages/admin_users.html +++ b/web/templates/pages/admin_users.html @@ -54,14 +54,6 @@