diff --git a/internal/core/admin.go b/internal/core/admin.go index b35de8d..40dbfbc 100644 --- a/internal/core/admin.go +++ b/internal/core/admin.go @@ -3,6 +3,8 @@ package core import ( "net/http" "strconv" + "strings" + "time" "github.com/go-chi/chi/v5" @@ -103,9 +105,57 @@ func (s *Handlers) handleUpdateCommand(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/catalog?saved=1", http.StatusSeeOther) } +// userCursor carries the sort, search, capability filter, and keyset position of +// an admin user-list page, so "load more" stays consistent without re-sending +// the form; the sort/q/cap params only matter on the first (cursorless) page. +type userCursor struct { + Sort string `json:"s,omitempty"` + Query string `json:"q,omitempty"` + Cap string `json:"c,omitempty"` + Name string `json:"n,omitempty"` // username-sort cursor + Time string `json:"t,omitempty"` // RFC3339Nano, for last-login / newest sorts + ID int64 `json:"i"` +} + +func nextUserCursor(p store.UserListParams, last *store.User) userCursor { + c := userCursor{Sort: string(p.Sort), Query: p.Query, Cap: string(p.Cap), ID: last.ID} + switch p.Sort { + case store.UserSortLastLogin: + c.Time = last.LastLoginKey().Format(time.RFC3339Nano) + case store.UserSortNewest: + c.Time = last.CreatedAt.Format(time.RFC3339Nano) + default: // UserSortName + c.Name = last.Username + } + return c +} + func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) { - after := r.URL.Query().Get("after") - users, hasMore, err := s.Store.ListUsersPage(r.Context(), after) + q := r.URL.Query() + sortKey := store.NormalizeUserSort(q.Get("sort")) + capFilter := store.NormalizeUserCapFilter(q.Get("cap")) + query := strings.TrimSpace(q.Get("q")) + if len(query) > 100 { + query = query[:100] + } + + var p store.UserListParams + if c, ok := web.DecodeCursor[userCursor](q.Get("cursor")); ok { + sortKey = store.NormalizeUserSort(c.Sort) + capFilter = store.NormalizeUserCapFilter(c.Cap) + query = c.Query + p.HasCursor = true + p.AfterName = c.Name + p.AfterID = c.ID + if c.Time != "" { + p.AfterTime, _ = time.Parse(time.RFC3339Nano, c.Time) + } + } + p.Sort = sortKey + p.Cap = capFilter + p.Query = query + + users, hasMore, err := s.Store.ListUsersPage(r.Context(), p) if err != nil { http.Error(w, "could not load users", http.StatusInternalServerError) return @@ -113,14 +163,41 @@ func (s *Handlers) pageUsers(w http.ResponseWriter, r *http.Request) { data := map[string]any{ "Users": users, "Self": s.Auth.CurrentUserID(r.Context()), - "Error": r.URL.Query().Get("error"), + "Error": q.Get("error"), + "Sort": string(sortKey), + "Cap": string(capFilter), + "Query": query, } if hasMore && len(users) > 0 { - data["NextAfter"] = users[len(users)-1].Username + data["NextCursor"] = web.EncodeCursor(nextUserCursor(p, users[len(users)-1])) + } + // htmx "load more": return just the rows + next control to append in place. + if r.Header.Get("HX-Request") != "" { + data["Layout"] = "users-frag" } s.Render(w, r, "admin_users.html", data) } +// pageUserPermissions renders the per-user permissions modal fragment (the +// manage-users/manage-catalog form), loaded via htmx into the shared modal. +func (s *Handlers) pageUserPermissions(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + u, err := s.Store.GetUserByID(r.Context(), id) + if err != nil { + http.NotFound(w, r) + return + } + s.Render(w, r, "admin_user_permissions.html", map[string]any{ + "Account": u, + "Self": s.Auth.CurrentUserID(r.Context()), + "Layout": "permissions-modal", + }) +} + func (s *Handlers) handleSetUserCaps(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { @@ -177,8 +254,13 @@ func (s *Handlers) pageUserHistory(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load history", http.StatusInternalServerError) return } - s.Render(w, r, "admin_user_history.html", map[string]any{ + data := map[string]any{ "Account": u, "Changes": changes, - }) + } + // Loaded into the shared modal via htmx; render just the modal body then. + if r.Header.Get("HX-Request") != "" { + data["Layout"] = "history-modal" + } + s.Render(w, r, "admin_user_history.html", data) } diff --git a/internal/core/endpoints_admin_test.go b/internal/core/endpoints_admin_test.go new file mode 100644 index 0000000..8daf1b1 --- /dev/null +++ b/internal/core/endpoints_admin_test.go @@ -0,0 +1,79 @@ +package core + +import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// TestAdminUsersFilterSearchAndModals covers the reworked admin users page: +// capability filter + search on the list, and the Permissions/History modal +// fragments the Actions dropdown loads via htmx. +func TestAdminUsersFilterSearchAndModals(t *testing.T) { + t.Parallel() + st, ctx, ts, h := splitServer(t) + admin, sess := appLogin(t, ts, st, ctx, h.app, "adminz") + if err := st.SetCapabilities(ctx, admin.ID, true, true); err != nil { + t.Fatalf("set caps: %v", err) + } + mgr, err := st.CreateUser(ctx, "mgruser", "") + if err != nil { + t.Fatalf("create mgr: %v", err) + } + if err := st.SetCapabilities(ctx, mgr.ID, true, false); err != nil { + t.Fatalf("caps: %v", err) + } + if _, err := st.CreateUser(ctx, "plainuser", ""); err != nil { + t.Fatalf("create plain: %v", err) + } + mgrID := strconv.FormatInt(mgr.ID, 10) + + // Capability filter: managers only. + if body := readBody(t, do(t, ts, h.app, "/admin/users?cap=managers", sess)); !strings.Contains(body, "mgruser") || strings.Contains(body, "plainuser") { + t.Fatal("cap=managers should list managers and exclude non-managers") + } + // Search: username substring. + if body := readBody(t, do(t, ts, h.app, "/admin/users?q=plainuser", sess)); !strings.Contains(body, "plainuser") || strings.Contains(body, "mgruser") { + t.Fatal("search should show only matching users") + } + + // Permissions fragment: a save form scoped to the target, prefilled. + perm := readBody(t, do(t, ts, h.app, "/admin/users/"+mgrID+"/permissions", sess)) + if !strings.Contains(perm, `action="/admin/users/`+mgrID+`"`) { + t.Fatalf("permissions fragment missing the save form:\n%s", perm) + } + if !strings.Contains(perm, `name="manage_users"`) || !strings.Contains(perm, "Permissions —") { + t.Fatal("permissions fragment missing expected fields") + } + + // History as an htmx fragment vs. the full page. + frag := readBody(t, doHX(t, ts, h.app, "/admin/users/"+mgrID+"/history", sess)) + if !strings.Contains(frag, "modal-header") || strings.Contains(frag, "back-link") { + t.Fatalf("history HX fragment should be modal chrome, not a full page:\n%s", frag) + } + full := readBody(t, do(t, ts, h.app, "/admin/users/"+mgrID+"/history", sess)) + if !strings.Contains(full, "back-link") { + t.Fatal("history full page should render the back-link") + } +} + +// doHX issues a GET with the HX-Request header (an htmx fragment request). +func doHX(t *testing.T, ts *httptest.Server, host, path string, cookies ...*http.Cookie) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, ts.URL+path, nil) + if err != nil { + t.Fatalf("request: %v", err) + } + req.Host = host + req.Header.Set("HX-Request", "true") + for _, c := range cookies { + req.AddCookie(c) + } + resp, err := noRedirect().Do(req) + if err != nil { + t.Fatalf("hx get %s%s: %v", host, path, err) + } + return resp +} diff --git a/internal/core/templates/admin_user_history.html b/internal/core/templates/admin_user_history.html index ceeee27..e642583 100644 --- a/internal/core/templates/admin_user_history.html +++ b/internal/core/templates/admin_user_history.html @@ -7,6 +7,46 @@ {{end}} +{{/* history-body is the rename table, shared by the full page and the modal. */}} +{{define "history-body"}} +{{if .Changes}} +
| When | Change | By | IP |
|---|---|---|---|
| {{.ChangedAt.Format "Jan 2, 2006 15:04"}} | +@{{.OldUsername}} → @{{.NewUsername}} | +{{if .BySelf}}self{{else if .ByActor}}admin (@{{.ByActor}}){{else}}admin{{end}} | +{{if .IP}}{{.IP}}{{else}}—{{end}} | +
This user has never changed their username.
+{{end}} +{{end}} + +{{/* history-modal is the htmx fragment swapped into the shared modal's content. */}} +{{define "history-modal"}} +Visible to user managers only — old usernames are never shown on public or member-facing pages.
+ {{template "history-body" .}} +| When | Change | By | IP |
|---|---|---|---|
| {{.ChangedAt.Format "Jan 2, 2006 15:04"}} | -@{{.OldUsername}} → @{{.NewUsername}} | -{{if .BySelf}}self{{else if .ByActor}}admin (@{{.ByActor}}){{else}}admin{{end}} | -{{if .IP}}{{.IP}}{{else}}—{{end}} | -
This user has never changed their username.
- {{end}} + {{template "history-body" .}}- Manage users = can grant/revoke these capabilities (superadmin). - Manage catalog = can edit the command catalog. Neither grants access to any - repeater — that's still per-repeater sharing. -
-{{if .Query}}No users match “{{.Query}}”.{{else}}No users match this filter.{{end}}