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