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}}
{{end}}
+
{{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 @@
+
+
+