From 2ed77926e2dc13a42f227730363e1c2e635d8894 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sun, 5 Jul 2026 14:14:10 -0400 Subject: [PATCH] Refactor admin users page --- internal/core/admin.go | 94 ++++++++++++- internal/core/endpoints_admin_test.go | 79 +++++++++++ .../core/templates/admin_user_history.html | 63 ++++++--- .../templates/admin_user_permissions.html | 30 ++++ internal/core/templates/admin_users.html | 131 ++++++++++++++---- internal/core/web.go | 1 + internal/e2e/admin_users_test.go | 39 ++++++ internal/store/users.go | 129 +++++++++++++++-- internal/store/users_list_test.go | 95 +++++++++++++ internal/store/users_paging_test.go | 9 +- 10 files changed, 598 insertions(+), 72 deletions(-) create mode 100644 internal/core/endpoints_admin_test.go create mode 100644 internal/core/templates/admin_user_permissions.html create mode 100644 internal/e2e/admin_users_test.go create mode 100644 internal/store/users_list_test.go 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}} +
+ + + + + + {{range .Changes}} + + + + + + + {{end}} + +
WhenChangeByIP
{{.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}}
+
+{{else}} +

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"}} + + + +{{end}} + {{define "content"}}
@@ -15,28 +55,7 @@ (user #{{.Account.ID}}). This record is visible to user managers only — old usernames are never shown on public or member-facing pages.

- - {{if .Changes}} -
- - - - - - {{range .Changes}} - - - - - - - {{end}} - -
WhenChangeByIP
{{.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}}
-
- {{else}} -

This user has never changed their username.

- {{end}} + {{template "history-body" .}}
{{template "icon-arrow-left" "me-1"}}Users diff --git a/internal/core/templates/admin_user_permissions.html b/internal/core/templates/admin_user_permissions.html new file mode 100644 index 0000000..33b195e --- /dev/null +++ b/internal/core/templates/admin_user_permissions.html @@ -0,0 +1,30 @@ +{{/* permissions-modal is the htmx fragment swapped into the shared user modal. + The form posts normally (full navigation back to /admin/users), which also + closes the modal — no extra client JS needed. */}} +{{define "permissions-modal"}} + +
+ + +
+{{end}} diff --git a/internal/core/templates/admin_users.html b/internal/core/templates/admin_users.html index 9c3b173..5acc25b 100644 --- a/internal/core/templates/admin_users.html +++ b/internal/core/templates/admin_users.html @@ -7,40 +7,111 @@ {{end}} -{{define "content"}} -
-
- {{if .Error}}
{{.Error}}
{{end}} -

- 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. -

-
- {{range .Users}} -
- {{.Name}} @{{.Username}}{{if eq .ID $.Self}} you{{end}} - Last login: {{if .LastLoginAt}}{{.LastLoginAt.Format "Jan 2, 2006 15:04"}}{{else}}never{{end}} - - - History - -
- {{end}} +{{/* user-rows renders just the list rows; shared by the full page and the htmx + "load more" fragment so appended pages match the first. The Actions dropdown + loads the Permissions/History fragments into the shared #user-modal. */}} +{{define "user-rows"}} +{{range .Users}} +
+
+
+ {{.Name}} @{{.Username}} + {{if eq .ID $.Self}}you{{end}} + {{if .CapManageUsers}}manage users{{end}} + {{if .CapManageCatalog}}manage catalog{{end}}
- {{if .NextAfter}} -
- Show more users +
+ Joined {{.CreatedAt.Format "Jan 2, 2006"}} · Last login: {{if .LastLoginAt}}{{.LastLoginAt.Format "Jan 2, 2006 15:04"}}{{else}}never{{end}} +
+
+
+{{end}} +{{end}} + +{{/* user-more is the "load more" control; it replaces itself with the next page's + rows + a fresh control. The cursor already carries sort, search, and filter. */}} +{{define "user-more"}} +
+ +
+{{end}} + +{{/* users-frag is the htmx response: rows + next control, no page chrome. */}} +{{define "users-frag"}}{{template "user-rows" .}}{{if .NextCursor}}{{template "user-more" .}}{{end}}{{end}} + +{{define "content"}} +{{if .Error}}
{{.Error}}
{{end}} +
+ +
+
+ {{if .Users}} +
+ {{template "user-rows" .}} + {{if .NextCursor}}{{template "user-more" .}}{{end}} +
+ {{else}} +

{{if .Query}}No users match “{{.Query}}”.{{else}}No users match this filter.{{end}}

+ {{end}} +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+ + + + {{template "icon-arrow-left" "me-1"}}Admin {{end}} diff --git a/internal/core/web.go b/internal/core/web.go index 698e4ed..40239e8 100644 --- a/internal/core/web.go +++ b/internal/core/web.go @@ -190,6 +190,7 @@ func (s *Handlers) appRouter() chi.Router { r.With(s.requireCap(capCatalog)).Get("/catalog", s.pageCatalog) r.With(s.requireCap(capCatalog)).Post("/catalog/{id}", s.handleUpdateCommand) r.With(s.requireCap(capUsers)).Get("/users", s.pageUsers) + r.With(s.requireCap(capUsers)).Get("/users/{id}/permissions", s.pageUserPermissions) r.With(s.requireCap(capUsers)).Get("/users/{id}/history", s.pageUserHistory) r.With(s.requireCap(capUsers)).Post("/users/{id}", s.handleSetUserCaps) }) diff --git a/internal/e2e/admin_users_test.go b/internal/e2e/admin_users_test.go new file mode 100644 index 0000000..aef4d20 --- /dev/null +++ b/internal/e2e/admin_users_test.go @@ -0,0 +1,39 @@ +//go:build browser + +package e2e + +import ( + "testing" + + "github.com/chromedp/chromedp" +) + +// TestAdminUsersActionsModal drives the reworked admin users page in a real +// browser: open a row's Actions dropdown, pick Permissions, and confirm the +// modal loads the form via htmx — exercising the Bootstrap dropdown + modal + +// htmx-fragment interaction and asserting the strict CSP isn't tripped (the one +// thing Go handler tests can't see). +func TestAdminUsersActionsModal(t *testing.T) { + srv := newE2EServer(t) + // login()'s first account bootstraps the manage-users capability, so it can + // reach /admin/users. + _, cookie := srv.login(t, "e2eadmin") + + ctx, cancel, watch := startBrowser(t) + defer cancel() + + if err := chromedp.Run(ctx, + setSessionCookie(cookie), + chromedp.Navigate(srv.appURL+"/admin/users"), + chromedp.WaitVisible(`[data-testid="user-row"]`, chromedp.ByQuery), + chromedp.Click(`[data-testid="user-actions"]`, chromedp.ByQuery), // open the dropdown + chromedp.WaitVisible(`[data-testid="user-permissions"]`, chromedp.ByQuery), + chromedp.Click(`[data-testid="user-permissions"]`, chromedp.ByQuery), // open the modal + htmx load + // The permissions form arrives in the modal via htmx. + chromedp.WaitVisible(`#user-modal-content input[name="manage_users"]`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("drive admin users modal: %v", err) + } + + watch.assertClean(t) +} diff --git a/internal/store/users.go b/internal/store/users.go index afe28fb..15deabc 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "time" "github.com/jackc/pgx/v5" @@ -35,6 +36,7 @@ type User struct { // LastLoginAt is the most recent successful sign-in, or nil if the account has // never logged in since the column was added. LastLoginAt *time.Time + CreatedAt time.Time } // Name returns the display name if set, else the username. @@ -45,12 +47,12 @@ func (u *User) Name() string { return u.Username } -const userCols = `id, username, display_name, password_hash, bio, location, callsign, cap_manage_users, cap_manage_catalog, last_login_at` +const userCols = `id, username, display_name, password_hash, bio, location, callsign, cap_manage_users, cap_manage_catalog, last_login_at, created_at` func scanUser(row pgx.Row) (*User, error) { var u User if err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, - &u.Bio, &u.Location, &u.Callsign, &u.CapManageUsers, &u.CapManageCatalog, &u.LastLoginAt); err != nil { + &u.Bio, &u.Location, &u.Callsign, &u.CapManageUsers, &u.CapManageCatalog, &u.LastLoginAt, &u.CreatedAt); err != nil { return nil, err } return &u, nil @@ -165,16 +167,121 @@ func (s *Store) CountManageUsers(ctx context.Context) (int, error) { // UsersPageSize is the number of users returned per admin-page request. const UsersPageSize = 50 -// ListUsersPage returns one keyset page of users for the admin page, ordered by -// username, starting strictly after afterUsername (empty starts at the -// beginning). It returns the page (capped at UsersPageSize) and whether more -// rows follow. username is UNIQUE, so it's a stable single-column cursor and -// the seek rides the existing unique index. -func (s *Store) ListUsersPage(ctx context.Context, afterUsername string) ([]*User, bool, error) { +// UserSort names the orderings the admin user list can be browsed in. +type UserSort string + +const ( + UserSortName UserSort = "name" // username A–Z (default) + UserSortLastLogin UserSort = "last_login" // most recent sign-in first + UserSortNewest UserSort = "newest" // most recently created first +) + +// NormalizeUserSort coerces an untrusted sort key to a known value. +func NormalizeUserSort(s string) UserSort { + switch UserSort(s) { + case UserSortLastLogin, UserSortNewest: + return UserSort(s) + default: + return UserSortName + } +} + +// UserCapFilter narrows the list to accounts with a given capability. +type UserCapFilter string + +const ( + UserCapAny UserCapFilter = "" // no filter + UserCapManagers UserCapFilter = "managers" // cap_manage_users + UserCapCatalog UserCapFilter = "catalog" // cap_manage_catalog + UserCapNone UserCapFilter = "none" // no capabilities +) + +// NormalizeUserCapFilter coerces an untrusted capability filter to a known value. +func NormalizeUserCapFilter(s string) UserCapFilter { + switch UserCapFilter(s) { + case UserCapManagers, UserCapCatalog, UserCapNone: + return UserCapFilter(s) + default: + return UserCapAny + } +} + +// UserListParams is one page request for the admin user list: an optional search +// over username/display name, a sort, a capability filter, and a keyset cursor. +type UserListParams struct { + Query string + Sort UserSort + Cap UserCapFilter + HasCursor bool + AfterName string // username-sort cursor + AfterID int64 // tiebreaker + AfterTime time.Time // last-login / newest cursor (for last-login, the coalesced value) +} + +// nullLoginEpoch is the sort key for accounts that have never logged in, so the +// last-login ordering can keyset over a non-null column (NULLs sort last under +// DESC). It matches Postgres' 'epoch' timestamptz used in the query. +var nullLoginEpoch = time.Unix(0, 0).UTC() + +// LastLoginKey returns the row's last-login sort key (its login time, or the +// epoch sentinel when it has never logged in) for building the next cursor. +func (u *User) LastLoginKey() time.Time { + if u.LastLoginAt != nil { + return *u.LastLoginAt + } + return nullLoginEpoch +} + +// ListUsersPage returns one keyset page of the admin user list in the requested +// order, optionally searched and capability-filtered, seeking strictly past the +// cursor. It returns the page (capped at UsersPageSize) and whether more follow. +func (s *Store) ListUsersPage(ctx context.Context, p UserListParams) ([]*User, bool, error) { + var args []any + add := func(v any) string { args = append(args, v); return fmt.Sprintf("$%d", len(args)) } + + var where []string + if q := strings.TrimSpace(p.Query); q != "" { + ph := add("%" + escapeLikePattern(q) + "%") + where = append(where, fmt.Sprintf("(username ILIKE %[1]s OR coalesce(display_name, '') ILIKE %[1]s)", ph)) + } + switch p.Cap { + case UserCapManagers: + where = append(where, "cap_manage_users") + case UserCapCatalog: + where = append(where, "cap_manage_catalog") + case UserCapNone: + where = append(where, "NOT cap_manage_users AND NOT cap_manage_catalog") + } + + // login_key coalesces last_login_at so the last-login sort can keyset over a + // never-null expression (never-logged-in rows sort last under DESC). + var order string + switch p.Sort { + case UserSortLastLogin: + order = "coalesce(last_login_at, 'epoch') DESC, id DESC" + if p.HasCursor { + where = append(where, fmt.Sprintf("(coalesce(last_login_at, 'epoch'), id) < (%s, %s)", add(p.AfterTime), add(p.AfterID))) + } + case UserSortNewest: + order = "created_at DESC, id DESC" + if p.HasCursor { + where = append(where, fmt.Sprintf("(created_at, id) < (%s, %s)", add(p.AfterTime), add(p.AfterID))) + } + default: // UserSortName + order = "username ASC, id ASC" + if p.HasCursor { + where = append(where, fmt.Sprintf("(username, id) > (%s, %s)", add(p.AfterName), add(p.AfterID))) + } + } + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + // Fetch one extra row to detect whether a further page exists. - rows, err := s.pool.Query(ctx, - `SELECT `+userCols+` FROM users WHERE username > $1 ORDER BY username LIMIT $2`, - afterUsername, UsersPageSize+1) + limit := add(UsersPageSize + 1) + query := fmt.Sprintf(`SELECT %s FROM users %s ORDER BY %s LIMIT %s`, userCols, whereClause, order, limit) + rows, err := s.pool.Query(ctx, query, args...) if err != nil { return nil, false, fmt.Errorf("list users: %w", err) } diff --git a/internal/store/users_list_test.go b/internal/store/users_list_test.go new file mode 100644 index 0000000..869e90a --- /dev/null +++ b/internal/store/users_list_test.go @@ -0,0 +1,95 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// collectUsers walks every page for the given params and returns the flattened +// list (exercising the keyset cursor across pages). +func collectUsers(t *testing.T, st *Store, ctx context.Context, p UserListParams) []*User { + t.Helper() + var all []*User + for { + page, more, err := st.ListUsersPage(ctx, p) + if err != nil { + t.Fatalf("list users: %v", err) + } + all = append(all, page...) + if !more { + return all + } + last := page[len(page)-1] + p.HasCursor = true + p.AfterName = last.Username + p.AfterID = last.ID + switch p.Sort { + case UserSortLastLogin: + p.AfterTime = last.LastLoginKey() + case UserSortNewest: + p.AfterTime = last.CreatedAt + } + } +} + +func TestListUsersFilterSearchSort(t *testing.T) { + t.Parallel() + st, ctx := orgTestStore(t) + + // The first account created bootstraps both manage caps; make it a throwaway + // so the accounts under test have exactly the caps we set. + if _, err := st.CreateUser(ctx, "zzz-root", ""); err != nil { + t.Fatalf("bootstrap user: %v", err) + } + mk := func(name string, manageUsers, manageCatalog bool) *User { + u, err := st.CreateUser(ctx, name, name+" display") + if err != nil { + t.Fatalf("create %s: %v", name, err) + } + if manageUsers || manageCatalog { + if err := st.SetCapabilities(ctx, u.ID, manageUsers, manageCatalog); err != nil { + t.Fatalf("caps %s: %v", name, err) + } + } + return u + } + alice := mk("alice", true, false) + bob := mk("bob", false, true) + carol := mk("carol", false, false) + + // Capability filter: zzz-root has both caps (bootstrap), so it counts under + // both managers and catalog. + if got := collectUsers(t, st, ctx, UserListParams{Cap: UserCapManagers}); len(got) != 2 { + t.Fatalf("managers filter = %d users, want 2 (zzz-root, alice)", len(got)) + } + if got := collectUsers(t, st, ctx, UserListParams{Cap: UserCapCatalog}); len(got) != 2 { + t.Fatalf("catalog filter = %d users, want 2 (zzz-root, bob)", len(got)) + } + none := collectUsers(t, st, ctx, UserListParams{Cap: UserCapNone}) + if len(none) != 1 || none[0].ID != carol.ID { + t.Fatalf("none filter = %v, want just carol", none) + } + + // Search matches username or display name, case-insensitively. + if got := collectUsers(t, st, ctx, UserListParams{Query: "ALI"}); len(got) != 1 || got[0].ID != alice.ID { + t.Fatalf("search 'ALI' = %v, want just alice", got) + } + + // Last-login sort: set explicit times; a never-logged-in account sorts last. + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + if _, err := st.pool.Exec(ctx, `UPDATE users SET last_login_at=$2 WHERE id=$1`, bob.ID, base); err != nil { + t.Fatalf("set bob login: %v", err) + } + if _, err := st.pool.Exec(ctx, `UPDATE users SET last_login_at=$2 WHERE id=$1`, alice.ID, base.Add(time.Hour)); err != nil { + t.Fatalf("set alice login: %v", err) + } + // carol and zzz-root never logged in (NULL) → sort after the two that did. + byLogin := collectUsers(t, st, ctx, UserListParams{Sort: UserSortLastLogin}) + if byLogin[0].ID != alice.ID || byLogin[1].ID != bob.ID { + t.Fatalf("last-login order front = %q, %q; want alice, bob", byLogin[0].Username, byLogin[1].Username) + } + if byLogin[len(byLogin)-1].LastLoginAt != nil { + t.Fatalf("last-login order should end on a never-logged-in account, got %q", byLogin[len(byLogin)-1].Username) + } +} diff --git a/internal/store/users_paging_test.go b/internal/store/users_paging_test.go index 525e55c..016838f 100644 --- a/internal/store/users_paging_test.go +++ b/internal/store/users_paging_test.go @@ -22,12 +22,12 @@ func TestListUsersPage(t *testing.T) { var ( seen = map[int64]bool{} count int - after string + p = UserListParams{Sort: UserSortName} prevName string pages int ) for { - page, hasMore, err := st.ListUsersPage(ctx, after) + page, hasMore, err := st.ListUsersPage(ctx, p) if err != nil { t.Fatalf("page: %v", err) } @@ -49,7 +49,10 @@ func TestListUsersPage(t *testing.T) { if !hasMore { break } - after = page[len(page)-1].Username + last := page[len(page)-1] + p.HasCursor = true + p.AfterName = last.Username + p.AfterID = last.ID } if count != total {