ui(users): record last_login on /setup + sortable headers

This commit is contained in:
2026-05-05 10:31:28 +01:00
parent 0521a2169f
commit 2d9e53b025
8 changed files with 143 additions and 22 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ type apiUser struct {
}
func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
users, err := s.deps.Store.ListUsers(r.Context())
users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{})
if err != nil {
slog.Error("api users: list", "err", err)
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+4
View File
@@ -144,6 +144,10 @@ func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request)
Secure: s.deps.Cfg.CookieSecure,
Expires: now.Add(8 * time.Hour),
})
// Record the login so the users-list "Last login" column shows
// the moment they completed setup (the regular /login path does
// the same; we'd otherwise leave the row showing "never").
_ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now)
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
+2 -2
View File
@@ -159,7 +159,7 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
SortHrefs: hrefs,
CSVHref: csvHref,
}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
for _, ux := range users {
page.UserNames[ux.ID] = ux.Username
}
@@ -220,7 +220,7 @@ func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request)
// Resolve user_id → username and host_id → name once for the
// human-friendly columns.
userNames := map[string]string{}
if users, err := s.deps.Store.ListUsers(r.Context()); err == nil {
if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil {
for _, ux := range users {
userNames[ux.ID] = ux.Username
}
+61 -3
View File
@@ -19,6 +19,7 @@ import (
"log/slog"
stdhttp "net/http"
"net/mail"
"net/url"
"strings"
"time"
@@ -31,6 +32,15 @@ import (
type usersPage struct {
Users []userRow
ShowDisabled bool
Sort string // "username" | "email" | "role" | "last_login_at"
Dir string // "asc" | "desc"
// SortHrefs is a fully-encoded /settings/users?…&sort=COL&dir=…
// for each sortable column. Built server-side because constructing
// the querystring inside <a href="…"> in html/template applies
// URL-attribute escaping to '=' (turning 'show_disabled=1' into
// 'show_disabled%3D1'), which silently drops every filter on click.
// Same shape as the audit page's SortHrefs.
SortHrefs map[string]string
}
type userRow struct {
@@ -48,8 +58,29 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
if u == nil {
return
}
showDisabled := r.URL.Query().Get("show_disabled") == "1"
users, err := s.deps.Store.ListUsers(r.Context())
q := r.URL.Query()
showDisabled := q.Get("show_disabled") == "1"
// Resolve sort against the allowlist. Default: username ASC.
resolvedSort := "username"
switch q.Get("sort") {
case "username", "email", "role", "last_login_at":
resolvedSort = q.Get("sort")
}
asc := q.Get("dir") != "desc"
if q.Get("sort") == "" {
// No explicit sort param → default ASC even though dir
// querystring might be missing (fresh page load).
asc = true
}
dirStr := "desc"
if asc {
dirStr = "asc"
}
users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{
OrderBy: resolvedSort, OrderAsc: asc,
})
if err != nil {
slog.Error("ui users: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
@@ -75,10 +106,37 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
MustChangePassword: ux.MustChangePassword,
})
}
// Pre-build per-column hrefs so the template just emits them.
// Same pattern as ui_audit's SortHrefs — sidesteps html/template
// URL-attribute escaping turning '=' into '%3D'.
base := url.Values{}
if showDisabled {
base.Set("show_disabled", "1")
}
hrefs := make(map[string]string, 4)
for _, col := range []string{"username", "email", "role", "last_login_at"} {
v := url.Values{}
for k, vs := range base {
v[k] = vs
}
v.Set("sort", col)
newDir := "asc" // sensible default for unactive columns
if col == resolvedSort && asc {
newDir = "desc"
}
v.Set("dir", newDir)
hrefs[col] = "/settings/users?" + v.Encode()
}
view := s.baseView(r, u)
view.Title = "Users · restic-manager"
view.Active = "settings"
view.Page = usersPage{Users: rows, ShowDisabled: showDisabled}
view.Page = usersPage{
Users: rows, ShowDisabled: showDisabled,
Sort: resolvedSort, Dir: dirStr,
SortHrefs: hrefs,
}
if err := s.deps.UI.Render(w, "users", view); err != nil {
slog.Error("ui users: render", "err", err)
}