mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-22 21:22:04 +00:00
Refactor admin users page
This commit is contained in:
1 parent
5296c663c4
commit
2ed77926e2
10 files changed
+598
-72
No files matched your search
+88
-6
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user