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
+43 -8
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,
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)
}