From b186fb4bc53ccb6006254fbe6971e0c1eb3679d6 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 7 Apr 2026 10:17:46 +0200 Subject: [PATCH 1/2] feat: add edit/delete buttons to list views and inline privacy toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fave cards in the list and profile views now show edit, delete, and privacy toggle buttons directly — no need to open the detail page first. - New POST /faves/{id}/privacy route with HTMX privacy toggle partial - New UpdatePrivacy store method for single-column update - fave_list.html: edit link, HTMX delete, privacy toggle on every card - profile.html: edit/delete for owner's own cards - privacy_toggle.html: new HTMX partial that swaps inline on toggle - CSS: compact .fave-card-actions styles The existing handleFaveDelete already returns empty 200 for HTMX requests, so hx-target="closest article" hx-swap="outerHTML" removes the card from DOM seamlessly. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handler/fave.go | 45 +++++ internal/handler/handler.go | 3 + internal/handler/web_test.go | 191 +++++++++++++++++++++ internal/store/fave.go | 11 ++ internal/store/fave_test.go | 31 ++++ web/static/css/style.css | 34 ++++ web/templates/pages/fave_list.html | 24 ++- web/templates/pages/profile.html | 14 ++ web/templates/partials/privacy_toggle.html | 10 ++ 9 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 web/templates/partials/privacy_toggle.html diff --git a/internal/handler/fave.go b/internal/handler/fave.go index fb75784..5960762 100644 --- a/internal/handler/fave.go +++ b/internal/handler/fave.go @@ -335,6 +335,51 @@ 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 c2f3a80..d0c550f 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -103,6 +103,7 @@ 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) @@ -138,6 +139,8 @@ 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 aa53c2e..9070357 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -1282,6 +1282,197 @@ 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 5888490..62f6fc3 100644 --- a/internal/store/fave.go +++ b/internal/store/fave.go @@ -73,6 +73,17 @@ 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 f96ce8f..c870080 100644 --- a/internal/store/fave_test.go +++ b/internal/store/fave_test.go @@ -150,6 +150,37 @@ 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/web/static/css/style.css b/web/static/css/style.css index 56780dd..4710dea 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -92,6 +92,40 @@ 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/fave_list.html b/web/templates/pages/fave_list.html index 155445f..77f8874 100644 --- a/web/templates/pages/fave_list.html +++ b/web/templates/pages/fave_list.html @@ -21,9 +21,6 @@ {{.Description}} - {{if eq .Privacy "private"}} - Privat - {{end}} {{if .Tags}}
@@ -32,6 +29,27 @@ {{end}}
{{end}} +
+ + + + Rediger + +
{{end}} diff --git a/web/templates/pages/profile.html b/web/templates/pages/profile.html index 0cf2f4b..21371dd 100644 --- a/web/templates/pages/profile.html +++ b/web/templates/pages/profile.html @@ -75,6 +75,20 @@ {{end}} {{end}} + {{if $d.IsOwner}} + + {{end}} {{end}} diff --git a/web/templates/partials/privacy_toggle.html b/web/templates/partials/privacy_toggle.html new file mode 100644 index 0000000..90a0b9f --- /dev/null +++ b/web/templates/partials/privacy_toggle.html @@ -0,0 +1,10 @@ + + + From 254573316a5f344e41c8fbad1f72ef7f7cb2c0a1 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 7 Apr 2026 10:18:00 +0200 Subject: [PATCH 2/2] 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}}