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) {
|
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 {
|
if err != nil {
|
||||||
slog.Error("api users: list", "err", err)
|
slog.Error("api users: list", "err", err)
|
||||||
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
|
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,
|
Secure: s.deps.Cfg.CookieSecure,
|
||||||
Expires: now.Add(8 * time.Hour),
|
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{
|
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||||
ID: ulid.Make().String(),
|
ID: ulid.Make().String(),
|
||||||
UserID: &u.ID,
|
UserID: &u.ID,
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||||||
SortHrefs: hrefs,
|
SortHrefs: hrefs,
|
||||||
CSVHref: csvHref,
|
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 {
|
for _, ux := range users {
|
||||||
page.UserNames[ux.ID] = ux.Username
|
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
|
// Resolve user_id → username and host_id → name once for the
|
||||||
// human-friendly columns.
|
// human-friendly columns.
|
||||||
userNames := map[string]string{}
|
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 {
|
for _, ux := range users {
|
||||||
userNames[ux.ID] = ux.Username
|
userNames[ux.ID] = ux.Username
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
stdhttp "net/http"
|
stdhttp "net/http"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +32,15 @@ import (
|
|||||||
type usersPage struct {
|
type usersPage struct {
|
||||||
Users []userRow
|
Users []userRow
|
||||||
ShowDisabled bool
|
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 {
|
type userRow struct {
|
||||||
@@ -48,8 +58,29 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
|||||||
if u == nil {
|
if u == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showDisabled := r.URL.Query().Get("show_disabled") == "1"
|
q := r.URL.Query()
|
||||||
users, err := s.deps.Store.ListUsers(r.Context())
|
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 {
|
if err != nil {
|
||||||
slog.Error("ui users: list", "err", err)
|
slog.Error("ui users: list", "err", err)
|
||||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||||
@@ -75,10 +106,37 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request)
|
|||||||
MustChangePassword: ux.MustChangePassword,
|
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 := s.baseView(r, u)
|
||||||
view.Title = "Users · restic-manager"
|
view.Title = "Users · restic-manager"
|
||||||
view.Active = "settings"
|
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 {
|
if err := s.deps.UI.Render(w, "users", view); err != nil {
|
||||||
slog.Error("ui users: render", "err", err)
|
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)
|
return scanUser(row.Scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers returns every user, sorted by username. Used by surfaces
|
// UserSort selects the column ListUsers orders by. OrderBy is
|
||||||
// that need to render a user-id → username map (audit log filter,
|
// allowlisted in usersOrderColumn so callers can't inject SQL via
|
||||||
// "ack'd by" projections) and the user-management page.
|
// this field. Empty / unknown OrderBy falls back to "username".
|
||||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
type UserSort struct {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
OrderBy string // "username" | "email" | "role" | "last_login_at"
|
||||||
`SELECT id, username, password_hash, role, email, disabled_at,
|
OrderAsc bool // false = DESC; true = ASC
|
||||||
must_change_password, created_at, last_login_at
|
}
|
||||||
FROM users ORDER BY username`)
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list users: %w", err)
|
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;
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.audit-row.head:hover { background: transparent; }
|
.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;
|
color: inherit; text-decoration: none; cursor: pointer;
|
||||||
display: inline-flex; align-items: baseline; gap: 4px;
|
display: inline-flex; align-items: baseline; gap: 4px;
|
||||||
}
|
}
|
||||||
.audit-row.head .sort-header:hover { color: var(--ink); }
|
.audit-row.head .sort-header:hover,
|
||||||
.audit-row.head .sort-glyph {
|
.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);
|
font-size: 9px; color: var(--accent);
|
||||||
/* keep the row height stable when the glyph appears/disappears */
|
/* keep the row height stable when the glyph appears/disappears */
|
||||||
min-width: 8px; display: inline-block;
|
min-width: 8px; display: inline-block;
|
||||||
|
|||||||
@@ -29,11 +29,27 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="panel mt-4 rounded-[7px] overflow-hidden">
|
<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 class="user-row head">
|
||||||
<div>Username</div>
|
<div>
|
||||||
<div>Email</div>
|
<a href="{{index $page.SortHrefs "username"}}"
|
||||||
<div>Role</div>
|
class="sort-header">Username <span class="sort-glyph">{{sortGlyph "username" $page.Sort $page.Dir}}</span></a>
|
||||||
<div>Last login</div>
|
</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>Status</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user