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)
|
||||
}
|
||||
|
||||
+42
-7
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user