ui(users): record last_login on /setup + sortable headers
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+43
-8
@@ -49,14 +49,49 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||
return scanUser(row.Scan)
|
||||
}
|
||||
|
||||
// ListUsers returns every user, sorted by username. Used by surfaces
|
||||
// that need to render a user-id → username map (audit log filter,
|
||||
// "ack'd by" projections) and the user-management page.
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, email, disabled_at,
|
||||
must_change_password, created_at, last_login_at
|
||||
FROM users ORDER BY username`)
|
||||
// UserSort selects the column ListUsers orders by. OrderBy is
|
||||
// allowlisted in usersOrderColumn so callers can't inject SQL via
|
||||
// this field. Empty / unknown OrderBy falls back to "username".
|
||||
type UserSort struct {
|
||||
OrderBy string // "username" | "email" | "role" | "last_login_at"
|
||||
OrderAsc bool // false = DESC; true = ASC
|
||||
}
|
||||
|
||||
// usersOrderColumn validates s.OrderBy and returns the SQL fragment.
|
||||
// last_login_at gets a NULL-tail trick so users who've never logged
|
||||
// in sort to the bottom regardless of asc/desc — matches operator
|
||||
// intuition ("show me real activity" not "show me NULLs first").
|
||||
func usersOrderColumn(col string, asc bool) string {
|
||||
dir := "DESC"
|
||||
if asc {
|
||||
dir = "ASC"
|
||||
}
|
||||
switch col {
|
||||
case "email":
|
||||
return fmt.Sprintf("email IS NULL, email %s, username", dir)
|
||||
case "role":
|
||||
return fmt.Sprintf("role %s, username", dir)
|
||||
case "last_login_at":
|
||||
return fmt.Sprintf("last_login_at IS NULL, last_login_at %s, username", dir)
|
||||
default: // username (and unknown)
|
||||
return fmt.Sprintf("username %s", dir)
|
||||
}
|
||||
}
|
||||
|
||||
// ListUsers returns users sorted per UserSort. Default (zero value)
|
||||
// is username ASC. Used by the user-management page (sort headers)
|
||||
// and by surfaces that need a user-id → username map (audit log
|
||||
// filter, "ack'd by" projections) — those callers pass UserSort{}.
|
||||
func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) {
|
||||
asc := sort.OrderAsc
|
||||
if sort.OrderBy == "" {
|
||||
// Default: username ASC (alphabetical), matching pre-sort behaviour.
|
||||
asc = true
|
||||
}
|
||||
q := `SELECT id, username, password_hash, role, email, disabled_at,
|
||||
must_change_password, created_at, last_login_at
|
||||
FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc)
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list users: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user