Compare commits
No commits in common. "254573316a5f344e41c8fbad1f72ef7f7cb2c0a1" and "1260cfd18fa341973910e9badc5b435a41c042f8" have entirely different histories.
254573316a
...
1260cfd18f
13 changed files with 3 additions and 555 deletions
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"kode.naiv.no/olemd/favoritter/internal/image"
|
|
||||||
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
"kode.naiv.no/olemd/favoritter/internal/middleware"
|
||||||
"kode.naiv.no/olemd/favoritter/internal/render"
|
"kode.naiv.no/olemd/favoritter/internal/render"
|
||||||
"kode.naiv.no/olemd/favoritter/internal/store"
|
"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")
|
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.
|
// handleAdminTags lists all tags.
|
||||||
func (h *Handler) handleAdminTags(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleAdminTags(w http.ResponseWriter, r *http.Request) {
|
||||||
tags, err := h.deps.Tags.ListAll()
|
tags, err := h.deps.Tags.ListAll()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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.
|
// handleTagSearch handles tag autocomplete HTMX requests.
|
||||||
func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query().Get("q")
|
q := r.URL.Query().Get("q")
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,6 @@ func (h *Handler) Routes() *http.ServeMux {
|
||||||
mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit)))
|
mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit)))
|
||||||
mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate)))
|
mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate)))
|
||||||
mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete)))
|
mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete)))
|
||||||
mux.Handle("POST /faves/{id}/privacy", requireLogin(http.HandlerFunc(h.handleFaveTogglePrivacy)))
|
|
||||||
|
|
||||||
// Tags.
|
// Tags.
|
||||||
mux.HandleFunc("GET /tags/search", h.handleTagSearch)
|
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", admin(h.handleAdminCreateUser))
|
||||||
mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword))
|
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}/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("GET /admin/tags", admin(h.handleAdminTags))
|
||||||
mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag))
|
mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag))
|
||||||
mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag))
|
mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag))
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
// --- Export page ---
|
||||||
|
|
||||||
func TestExportPageRendering(t *testing.T) {
|
func TestExportPageRendering(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -73,17 +73,6 @@ func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy
|
||||||
return err
|
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.
|
// Delete removes a fave by its ID. The cascade will clean up fave_tags.
|
||||||
func (s *FaveStore) Delete(id int64) error {
|
func (s *FaveStore) Delete(id int64) error {
|
||||||
result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id)
|
result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestListByTag(t *testing.T) {
|
||||||
db := testDB(t)
|
db := testDB(t)
|
||||||
users := NewUserStore(db)
|
users := NewUserStore(db)
|
||||||
|
|
|
||||||
|
|
@ -198,34 +198,6 @@ func (s *UserStore) SetDisabled(userID int64, disabled bool) error {
|
||||||
return err
|
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.
|
// ListAll returns all users, ordered by username.
|
||||||
func (s *UserStore) ListAll() ([]*model.User, error) {
|
func (s *UserStore) ListAll() ([]*model.User, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
|
|
|
||||||
|
|
@ -203,71 +203,3 @@ func TestDisabledUser(t *testing.T) {
|
||||||
t.Errorf("disabled user error = %v, want ErrUserDisabled", err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -92,40 +92,6 @@
|
||||||
padding: 0 1rem 0.5rem;
|
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 */
|
/* Privacy badge */
|
||||||
.badge-private {
|
.badge-private {
|
||||||
background: var(--pico-muted-border-color);
|
background: var(--pico-muted-border-color);
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,6 @@
|
||||||
</td>
|
</td>
|
||||||
<td>{{.CreatedAt.Format "02.01.2006"}}</td>
|
<td>{{.CreatedAt.Format "02.01.2006"}}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/role" class="inline-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
|
||||||
<select name="role" class="inline-input">
|
|
||||||
<option value="user" {{if eq .Role "user"}}selected{{end}}>Bruker</option>
|
|
||||||
<option value="admin" {{if eq .Role "admin"}}selected{{end}}>Admin</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="outline nav-button">Lagre</button>
|
|
||||||
</form>
|
|
||||||
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/reset-password" class="inline-form">
|
<form method="POST" action="{{basePath}}/admin/users/{{.ID}}/reset-password" class="inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
|
||||||
<button type="submit" class="outline secondary nav-button">Tilbakestill passord</button>
|
<button type="submit" class="outline secondary nav-button">Tilbakestill passord</button>
|
||||||
|
|
@ -72,14 +64,6 @@
|
||||||
{{if .Disabled}}Aktiver{{else}}Deaktiver{{end}}
|
{{if .Disabled}}Aktiver{{else}}Deaktiver{{end}}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button
|
|
||||||
hx-post="{{basePath}}/admin/users/{{.ID}}/delete"
|
|
||||||
hx-confirm="Er du HELT sikker? Dette sletter brukeren og alle favorittene permanent."
|
|
||||||
hx-target="closest tr"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="outline secondary nav-button"
|
|
||||||
style="color: var(--pico-del-color);"
|
|
||||||
>Slett</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
<a href="{{basePath}}/faves/{{.ID}}">
|
<a href="{{basePath}}/faves/{{.ID}}">
|
||||||
<strong>{{.Description}}</strong>
|
<strong>{{.Description}}</strong>
|
||||||
</a>
|
</a>
|
||||||
|
{{if eq .Privacy "private"}}
|
||||||
|
<small class="badge-private" aria-label="Privat">Privat</small>
|
||||||
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
{{if .Tags}}
|
{{if .Tags}}
|
||||||
<footer>
|
<footer>
|
||||||
|
|
@ -29,27 +32,6 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</footer>
|
</footer>
|
||||||
{{end}}
|
{{end}}
|
||||||
<footer class="fave-card-actions">
|
|
||||||
<span class="privacy-toggle" id="privacy-{{.ID}}">
|
|
||||||
<button
|
|
||||||
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
|
|
||||||
hx-target="#privacy-{{.ID}}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
|
|
||||||
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
|
|
||||||
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
|
|
||||||
</span>
|
|
||||||
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
|
|
||||||
aria-label="Rediger {{.Description}}">Rediger</a>
|
|
||||||
<button
|
|
||||||
hx-delete="{{basePath}}/faves/{{.ID}}"
|
|
||||||
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
|
|
||||||
hx-target="closest article"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="fave-action-btn secondary"
|
|
||||||
aria-label="Slett {{.Description}}"
|
|
||||||
>Slett</button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -75,20 +75,6 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</footer>
|
</footer>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if $d.IsOwner}}
|
|
||||||
<footer class="fave-card-actions">
|
|
||||||
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
|
|
||||||
aria-label="Rediger {{.Description}}">Rediger</a>
|
|
||||||
<button
|
|
||||||
hx-delete="{{basePath}}/faves/{{.ID}}"
|
|
||||||
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
|
|
||||||
hx-target="closest article"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="fave-action-btn secondary"
|
|
||||||
aria-label="Slett {{.Description}}"
|
|
||||||
>Slett</button>
|
|
||||||
</footer>
|
|
||||||
{{end}}
|
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
<span class="privacy-toggle" id="privacy-{{.ID}}">
|
|
||||||
<button
|
|
||||||
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
|
|
||||||
hx-target="#privacy-{{.ID}}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
|
|
||||||
aria-label="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
|
|
||||||
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
|
|
||||||
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
|
|
||||||
</span>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue