Refactor admin users page

This commit is contained in:
Jonathon Leight committed 2026-07-05 14:14:10 -04:00
1 parent 5296c663c4
commit 2ed77926e2
10 files changed
+598 -72

No files matched your search

+88 -6
View File
@@ -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)
}
+79
View File
@@ -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
}
+41 -22
View File
@@ -7,6 +7,46 @@
</div>
</div>
{{end}}
{{/* history-body is the rename table, shared by the full page and the modal. */}}
{{define "history-body"}}
{{if .Changes}}
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr><th>When</th><th>Change</th><th>By</th><th>IP</th></tr>
</thead>
<tbody>
{{range .Changes}}
<tr>
<td class="text-nowrap">{{.ChangedAt.Format "Jan 2, 2006 15:04"}}</td>
<td><span class="text-secondary">@{{.OldUsername}}</span> → <span class="font-monospace">@{{.NewUsername}}</span></td>
<td>{{if .BySelf}}self{{else if .ByActor}}admin (@{{.ByActor}}){{else}}admin{{end}}</td>
<td class="font-monospace text-secondary">{{if .IP}}{{.IP}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-secondary mb-0">This user has never changed their username.</p>
{{end}}
{{end}}
{{/* history-modal is the htmx fragment swapped into the shared modal's content. */}}
{{define "history-modal"}}
<div class="modal-header">
<h5 class="modal-title">Username history — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-secondary">Visible to user managers only — old usernames are never shown on public or member-facing pages.</p>
{{template "history-body" .}}
</div>
<div class="modal-footer">
<button type="button" class="btn" data-bs-dismiss="modal">Close</button>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
@@ -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.
</p>
{{if .Changes}}
<div class="table-responsive">
<table class="table table-vcenter">
<thead>
<tr><th>When</th><th>Change</th><th>By</th><th>IP</th></tr>
</thead>
<tbody>
{{range .Changes}}
<tr>
<td class="text-nowrap">{{.ChangedAt.Format "Jan 2, 2006 15:04"}}</td>
<td><span class="text-secondary">@{{.OldUsername}}</span> → <span class="font-monospace">@{{.NewUsername}}</span></td>
<td>{{if .BySelf}}self{{else if .ByActor}}admin (@{{.ByActor}}){{else}}admin{{end}}</td>
<td class="font-monospace text-secondary">{{if .IP}}{{.IP}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-secondary">This user has never changed their username.</p>
{{end}}
{{template "history-body" .}}
</div>
</div>
<a class="back-link mt-3" href="/admin/users">{{template "icon-arrow-left" "me-1"}}Users</a>
@@ -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"}}
<div class="modal-header">
<h5 class="modal-title">Permissions — {{.Account.Name}} <span class="text-secondary">@{{.Account.Username}}</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/admin/users/{{.Account.ID}}">
<div class="modal-body">
<p class="text-secondary">
<strong>Manage users</strong> = grant/revoke these capabilities (superadmin).
<strong>Manage catalog</strong> = edit the command catalog. Neither grants access to any
repeater — that's still per-repeater sharing.
</p>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="manage_users" {{if .Account.CapManageUsers}}checked{{end}}>
<span class="form-check-label">Manage users</span>
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="manage_catalog" {{if .Account.CapManageCatalog}}checked{{end}}>
<span class="form-check-label">Manage catalog</span>
</label>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary me-auto" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
{{end}}
+101 -30
View File
@@ -7,40 +7,111 @@
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary">
<strong>Manage users</strong> = can grant/revoke these capabilities (superadmin).
<strong>Manage catalog</strong> = can edit the command catalog. Neither grants access to any
repeater — that's still per-repeater sharing.
</p>
<div class="list-group list-group-flush">
{{range .Users}}
<form method="post" action="/admin/users/{{.ID}}" class="list-group-item d-flex align-items-center flex-wrap gap-3 px-0">
<span class="flex-fill">{{.Name}} <span class="text-secondary">@{{.Username}}</span>{{if eq .ID $.Self}} <span class="badge bg-azure-lt">you</span>{{end}}
<span class="d-block text-secondary small">Last login: {{if .LastLoginAt}}{{.LastLoginAt.Format "Jan 2, 2006 15:04"}}{{else}}never{{end}}</span></span>
<label class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" name="manage_users" {{if .CapManageUsers}}checked{{end}}>
<span class="form-check-label">manage users</span>
</label>
<label class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" name="manage_catalog" {{if .CapManageCatalog}}checked{{end}}>
<span class="form-check-label">manage catalog</span>
</label>
<a href="/admin/users/{{.ID}}/history" class="btn btn-sm btn-ghost-secondary">History</a>
<button type="submit" class="btn btn-sm">Save</button>
</form>
{{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}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2" data-testid="user-row">
<div class="flex-fill">
<div>
<span class="fw-bold">{{.Name}}</span> <span class="text-secondary">@{{.Username}}</span>
{{if eq .ID $.Self}}<span class="badge bg-azure-lt">you</span>{{end}}
{{if .CapManageUsers}}<span class="badge bg-purple-lt">manage users</span>{{end}}
{{if .CapManageCatalog}}<span class="badge bg-teal-lt">manage catalog</span>{{end}}
</div>
{{if .NextAfter}}
<div class="text-center mt-3">
<a class="btn btn-outline-secondary" href="/admin/users?after={{.NextAfter | urlquery}}">Show more users</a>
<div class="text-secondary small">
Joined {{.CreatedAt.Format "Jan 2, 2006"}} · Last login: {{if .LastLoginAt}}{{.LastLoginAt.Format "Jan 2, 2006 15:04"}}{{else}}never{{end}}
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" data-testid="user-actions">Actions</button>
<div class="dropdown-menu dropdown-menu-end">
<button type="button" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#user-modal" data-testid="user-permissions"
hx-get="/admin/users/{{.ID}}/permissions" hx-target="#user-modal-content" hx-swap="innerHTML">
Permissions
</button>
<button type="button" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#user-modal" data-testid="user-history"
hx-get="/admin/users/{{.ID}}/history" hx-target="#user-modal-content" hx-swap="innerHTML">
History
</button>
</div>
{{end}}
</div>
</div>
{{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"}}
<div class="list-group-item text-center" id="user-more">
<button class="btn btn-outline-secondary" hx-get="/admin/users?cursor={{.NextCursor}}" hx-target="#user-more" hx-swap="outerHTML">
Show more users
</button>
</div>
{{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}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="row row-cards">
<!-- Left: the user list -->
<div class="col-lg-9">
<div class="card">
{{if .Users}}
<div class="list-group list-group-flush" id="user-list">
{{template "user-rows" .}}
{{if .NextCursor}}{{template "user-more" .}}{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">{{if .Query}}No users match “{{.Query}}”.{{else}}No users match this filter.{{end}}</p></div>
{{end}}
</div>
</div>
<!-- Right: search, sort, filter -->
<div class="col-lg-3">
<div class="card">
<div class="card-body">
<form method="get" action="/admin/users">
<div class="mb-3">
<label class="form-label" for="user-q">Search</label>
<input type="search" id="user-q" class="form-control" name="q" value="{{.Query}}"
placeholder="Username or display name" aria-label="Search users">
</div>
<div class="mb-3">
<label class="form-label" for="user-sort">Sort by</label>
<select id="user-sort" class="form-select" name="sort" aria-label="Sort users" data-autosubmit>
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Username (A–Z)</option>
<option value="last_login"{{if eq .Sort "last_login"}} selected{{end}}>Last login</option>
<option value="newest"{{if eq .Sort "newest"}} selected{{end}}>Newest</option>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="user-cap">Capability</label>
<select id="user-cap" class="form-select" name="cap" aria-label="Filter by capability" data-autosubmit>
<option value=""{{if eq .Cap ""}} selected{{end}}>All users</option>
<option value="managers"{{if eq .Cap "managers"}} selected{{end}}>User managers</option>
<option value="catalog"{{if eq .Cap "catalog"}} selected{{end}}>Catalog managers</option>
<option value="none"{{if eq .Cap "none"}} selected{{end}}>No capabilities</option>
</select>
</div>
<button class="btn btn-primary w-100" type="submit">{{template "icon-search" "me-1"}}Search</button>
</form>
</div>
</div>
</div>
</div>
<!-- Shared modal; the Actions dropdown loads Permissions/History into it via htmx. -->
<div class="modal modal-blur fade" id="user-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content" id="user-modal-content"></div>
</div>
</div>
<a class="back-link mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Admin</a>
{{end}}
+1
View File
@@ -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)
})
+39
View File
@@ -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)
}
+118 -11
View File
@@ -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)
}
+95
View File
@@ -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)
}
}
+6 -3
View File
@@ -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 {