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)
}
+42 -7
View File
@@ -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,
// 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 username`)
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)
}
File diff suppressed because one or more lines are too long
+11 -3
View File
@@ -328,12 +328,20 @@
text-transform: uppercase; letter-spacing: 0.08em;
}
.audit-row.head:hover { background: transparent; }
.audit-row.head .sort-header {
/* Sort-header link styling shared by .audit-row and .user-row
(and any other future sortable table headers). The selectors
scope to .head rows so hover and accent-glyph treatment only
apply to the header, not data rows that happen to contain a
<a class="sort-header">. */
.audit-row.head .sort-header,
.user-row.head .sort-header {
color: inherit; text-decoration: none; cursor: pointer;
display: inline-flex; align-items: baseline; gap: 4px;
}
.audit-row.head .sort-header:hover { color: var(--ink); }
.audit-row.head .sort-glyph {
.audit-row.head .sort-header:hover,
.user-row.head .sort-header:hover { color: var(--ink); }
.audit-row.head .sort-glyph,
.user-row.head .sort-glyph {
font-size: 9px; color: var(--accent);
/* keep the row height stable when the glyph appears/disappears */
min-width: 8px; display: inline-block;
+20 -4
View File
@@ -29,11 +29,27 @@
</form>
<div class="panel mt-4 rounded-[7px] overflow-hidden">
{{/* Header — Username/Email/Role/Last login are clickable sort
links. Hrefs are pre-built server-side ($page.SortHrefs) so
html/template's URL-attribute escaping doesn't trip on the
'=' chars. Same pattern as the audit log. */}}
<div class="user-row head">
<div>Username</div>
<div>Email</div>
<div>Role</div>
<div>Last login</div>
<div>
<a href="{{index $page.SortHrefs "username"}}"
class="sort-header">Username <span class="sort-glyph">{{sortGlyph "username" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "email"}}"
class="sort-header">Email <span class="sort-glyph">{{sortGlyph "email" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "role"}}"
class="sort-header">Role <span class="sort-glyph">{{sortGlyph "role" $page.Sort $page.Dir}}</span></a>
</div>
<div>
<a href="{{index $page.SortHrefs "last_login_at"}}"
class="sort-header">Last login <span class="sort-glyph">{{sortGlyph "last_login_at" $page.Sort $page.Dir}}</span></a>
</div>
<div>Status</div>
<div></div>
</div>