mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 04:40:05 +00:00
Part C of #2128: an admin area with an overview, the user table and a global audit log. Off by default like the rest of user management: with `userManagement` off nothing changes, and non-admins get no new UI. This is the first of 4 stacked PRs (C, D, E, then a small export/backup follow-up). Each later one contains this branch; review them in order. ## The situation - An admin could manage users one by one in `#/admin/users`, but nothing showed whether the instance needs attention: accounts stuck in activation, bouncing mail, someone guessing a password, an MQTT source that dropped. - The audit log existed (`internal/users/audit.go`) but could only be read per user, and logins were not recorded. ## What this PR adds **Store (`internal/users`)** - Schema v3: an index on `audit_log(at)`. - `AuditList` with filters (action or group prefix like `user.login.*`, user as actor or target, period) and keyset pagination; `PruneAudit`; `Stats` for the user figures. **Server** - `GET /api/admin/stats` (typed struct): accounts by status, admins, registrations and active users over 7 and 30 days, logins and failed logins in 24 hours, mail by final status, and the attention items computed server-side. - `GET /api/admin/audit`: filtered, newest first, `next` cursor. - `GET /api/admin/users` gains `bouncing=1`. - Logins are audited as `user.login` and `user.login.failed` (reason `wrong_password`, `pending` or `disabled`). An unknown address writes no row. The writes are asynchronous, so the login response does not wait on `users.db`. Login rows are pruned after 90 days. **Frontend** - `#/admin?tab=overview|users|audit`: `admin.js` (tab shell), `admin-overview.js` ("Needs attention", Users card, System card from the existing health, MQTT and observer endpoints), `admin-audit.js` (filters in the URL, "Load more"). `admin-users.js` becomes the Users tab; the old `#/admin/users` link rewrites to it. - `/api/healthz` is read on open and on Refresh only, not on the 60-second timer, because it walks every packet under a read lock. Spec: [`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md). ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally, 26 new Go tests. One upstream test, `TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain `master` (file modes) and is unrelated. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E with the `e2etest` build: 13 of 13 steps locally. - Running on our staging and production instance since 7 October 2026. ## Not in this PR - Restricting existing pages (perf, MQTT status) to logged-in users or admins. The admin area adds no access mechanism of its own, so that stays a later router rule plus endpoint check. - Server-enforced customizer tab restrictions (#1508). - The 24-hour, 5-attempt and 10-minute attention thresholds are constants for now (AGENTS.md rule 8: customizer later). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
6.1 KiB
Go
186 lines
6.1 KiB
Go
package users
|
|
|
|
import "time"
|
|
|
|
// Admin overview thresholds (docs/specs/2026-10-07-admin-dashboard-design.md).
|
|
// Constants in this version; they belong in config later (AGENTS.md rule 8).
|
|
const (
|
|
StuckPendingAfter = 24 * time.Hour // a pending account this old counts as stuck
|
|
GuessingWindow = 24 * time.Hour
|
|
GuessingThreshold = 5 // failed logins on one account inside GuessingWindow
|
|
|
|
statsPerDayDays = 30
|
|
statsGuessingLimit = 50
|
|
)
|
|
|
|
// DayCount is the count for one UTC day; Day is YYYY-MM-DD.
|
|
type DayCount struct {
|
|
Day string
|
|
Count int
|
|
}
|
|
|
|
// MailCounts buckets mails by their latest delivery event.
|
|
type MailCounts struct {
|
|
Delivered int // delivered, opened, clicked, unsubscribed
|
|
Bounced int // hard_bounce, soft_bounce, invalid_email
|
|
Blocked int
|
|
Spam int
|
|
Pending int // sent or deferred: no final event yet
|
|
Other int // error, or an event this version does not know
|
|
}
|
|
|
|
// GuessedAccount is an account with at least GuessingThreshold failed
|
|
// logins inside GuessingWindow.
|
|
type GuessedAccount struct {
|
|
UserID int64
|
|
DisplayName string // empty when the account no longer exists
|
|
Failed int
|
|
}
|
|
|
|
// Stats holds the figures of the admin overview's Users card and its
|
|
// attention rules.
|
|
type Stats struct {
|
|
Total, Active, Pending, Disabled int
|
|
Admins int // active admins
|
|
StuckPending int // pending for longer than StuckPendingAfter
|
|
Bouncing int
|
|
New7d, New30d int // user.register audit rows
|
|
NewPerDay []DayCount // statsPerDayDays entries, oldest first, the last is today (UTC)
|
|
Active7d, Active30d int // last login or session activity inside the window
|
|
Logins24h, FailedLogins24h int
|
|
Mail7d MailCounts
|
|
Guessing []GuessedAccount // most failures first, never nil
|
|
}
|
|
|
|
func secs(d time.Duration) int64 { return int64(d / time.Second) }
|
|
|
|
// Stats computes the overview figures at now: a handful of COUNT queries
|
|
// on users.db.
|
|
func (s *Store) Stats(now time.Time) (Stats, error) {
|
|
const day = 24 * time.Hour
|
|
t := unix(now)
|
|
var st Stats
|
|
if err := s.db.QueryRow(`SELECT COUNT(*),
|
|
COALESCE(SUM(status = 'active'), 0),
|
|
COALESCE(SUM(status = 'pending'), 0),
|
|
COALESCE(SUM(status = 'disabled'), 0),
|
|
COALESCE(SUM(role = 'admin' AND status = 'active'), 0),
|
|
COALESCE(SUM(status = 'pending' AND created_at < ?), 0),
|
|
COALESCE(SUM(email_bouncing != 0), 0)
|
|
FROM users`, t-secs(StuckPendingAfter)).
|
|
Scan(&st.Total, &st.Active, &st.Pending, &st.Disabled, &st.Admins, &st.StuckPending, &st.Bouncing); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
if err := s.db.QueryRow(`SELECT COALESCE(SUM(at >= ?), 0), COUNT(*) FROM audit_log
|
|
WHERE action = 'user.register' AND at >= ? AND at <= ?`, t-secs(7*day), t-secs(30*day), t).
|
|
Scan(&st.New7d, &st.New30d); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
perDay, err := s.registrationsPerDay(now)
|
|
if err != nil {
|
|
return Stats{}, err
|
|
}
|
|
st.NewPerDay = perDay
|
|
if err := s.db.QueryRow(`SELECT COALESCE(SUM(last >= ?), 0), COUNT(*) FROM (
|
|
SELECT MAX(COALESCE(u.last_login_at, 0),
|
|
COALESCE((SELECT MAX(last_seen_at) FROM sessions s WHERE s.user_id = u.id), 0)) AS last
|
|
FROM users u
|
|
) WHERE last >= ?`, t-secs(7*day), t-secs(30*day)).
|
|
Scan(&st.Active7d, &st.Active30d); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
if err := s.db.QueryRow(`SELECT COALESCE(SUM(action = 'user.login'), 0), COALESCE(SUM(action = 'user.login.failed'), 0)
|
|
FROM audit_log WHERE action IN ('user.login', 'user.login.failed') AND at >= ?`, t-secs(day)).
|
|
Scan(&st.Logins24h, &st.FailedLogins24h); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
if st.Mail7d, err = s.mailCounts(t - secs(7*day)); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
if st.Guessing, err = s.guessedAccounts(t - secs(GuessingWindow)); err != nil {
|
|
return Stats{}, err
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// registrationsPerDay counts user.register rows per UTC day for the last
|
|
// statsPerDayDays days, today included.
|
|
func (s *Store) registrationsPerDay(now time.Time) ([]DayCount, error) {
|
|
first := now.UTC().Truncate(24*time.Hour).AddDate(0, 0, -(statsPerDayDays - 1))
|
|
rows, err := s.db.Query(`SELECT (at - ?) / 86400, COUNT(*) FROM audit_log
|
|
WHERE action = 'user.register' AND at >= ? AND at <= ? GROUP BY 1`, unix(first), unix(first), unix(now))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
counts := make([]int, statsPerDayDays)
|
|
for rows.Next() {
|
|
var d int64
|
|
var n int
|
|
if err := rows.Scan(&d, &n); err != nil {
|
|
return nil, err
|
|
}
|
|
counts[d] = n
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]DayCount, statsPerDayDays)
|
|
for i, n := range counts {
|
|
out[i] = DayCount{Day: first.AddDate(0, 0, i).Format("2006-01-02"), Count: n}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Store) mailCounts(since int64) (MailCounts, error) {
|
|
var mc MailCounts
|
|
rows, err := s.db.Query(`SELECT last_event, COUNT(*) FROM mail_log WHERE sent_at >= ? GROUP BY last_event`, since)
|
|
if err != nil {
|
|
return mc, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var ev string
|
|
var n int
|
|
if err := rows.Scan(&ev, &n); err != nil {
|
|
return mc, err
|
|
}
|
|
switch ev {
|
|
case "delivered", "opened", "clicked", "unsubscribed":
|
|
mc.Delivered += n
|
|
case "hard_bounce", "soft_bounce", "invalid_email":
|
|
mc.Bounced += n
|
|
case "blocked":
|
|
mc.Blocked += n
|
|
case "spam":
|
|
mc.Spam += n
|
|
case "sent", "deferred":
|
|
mc.Pending += n
|
|
default:
|
|
mc.Other += n
|
|
}
|
|
}
|
|
return mc, rows.Err()
|
|
}
|
|
|
|
func (s *Store) guessedAccounts(since int64) ([]GuessedAccount, error) {
|
|
rows, err := s.db.Query(`SELECT a.target_user_id, COALESCE(u.display_name, ''), COUNT(*) AS n
|
|
FROM audit_log a LEFT JOIN users u ON u.id = a.target_user_id
|
|
WHERE a.action = 'user.login.failed' AND a.at >= ? AND a.target_user_id IS NOT NULL
|
|
GROUP BY a.target_user_id HAVING n >= ? ORDER BY n DESC, a.target_user_id LIMIT ?`,
|
|
since, GuessingThreshold, statsGuessingLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []GuessedAccount{}
|
|
for rows.Next() {
|
|
var g GuessedAccount
|
|
if err := rows.Scan(&g.UserID, &g.DisplayName, &g.Failed); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out, rows.Err()
|
|
}
|