mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 17:38:45 +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>
138 lines
4.4 KiB
Go
138 lines
4.4 KiB
Go
package users
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func mustCreate(t *testing.T, st *Store, email, name string) *User {
|
|
t.Helper()
|
|
u, err := st.CreatePending(email, name, "$argon2id$placeholder")
|
|
if err != nil {
|
|
t.Fatalf("CreatePending(%s): %v", email, err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestCreatePendingAndDuplicate(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
u := mustCreate(t, st, "alice@example.org", "Alice")
|
|
if u.ID == 0 || u.Status != StatusPending || u.Role != RoleUser || !u.CreatedAt.Equal(clk.Now()) {
|
|
t.Fatalf("unexpected user: %+v", u)
|
|
}
|
|
if _, err := st.CreatePending("alice@example.org", "Other", "x"); !errors.Is(err, ErrEmailTaken) {
|
|
t.Fatalf("duplicate err = %v; want ErrEmailTaken", err)
|
|
}
|
|
got, err := st.GetByEmail("alice@example.org")
|
|
if err != nil || got.ID != u.ID {
|
|
t.Fatalf("GetByEmail = %+v, %v", got, err)
|
|
}
|
|
if _, err := st.GetByID(9999); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("GetByID(missing) err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestActivateOnlyPending(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
admin := mustCreate(t, st, "admin@example.org", "Admin")
|
|
u := mustCreate(t, st, "bob@example.org", "Bob")
|
|
by := admin.ID
|
|
if err := st.Activate(u.ID, RoleAdmin, &by); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := st.GetByID(u.ID)
|
|
if got.Status != StatusActive || got.Role != RoleAdmin || got.ActivatedAt == nil || got.ActivatedBy == nil || *got.ActivatedBy != admin.ID {
|
|
t.Fatalf("after Activate: %+v", got)
|
|
}
|
|
if err := st.Activate(u.ID, RoleUser, nil); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("second Activate err = %v; want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestSettersAndEmailUniqueness(t *testing.T) {
|
|
st, clk := newTestStore(t)
|
|
a := mustCreate(t, st, "a@example.org", "Aa")
|
|
mustCreate(t, st, "b@example.org", "Bb")
|
|
if err := st.SetEmail(a.ID, "b@example.org"); !errors.Is(err, ErrEmailTaken) {
|
|
t.Fatalf("SetEmail to taken = %v", err)
|
|
}
|
|
if err := st.SetEmail(a.ID, "c@example.org"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetDisplayName(a.ID, "New name"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetPassword(a.ID, "newhash"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetStatus(a.ID, StatusDisabled); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetRole(a.ID, RoleAdmin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetEmailBouncing(a.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.TouchLogin(a.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, _ := st.GetByID(a.ID)
|
|
if got.Email != "c@example.org" || got.DisplayName != "New name" || got.PasswordHash != "newhash" ||
|
|
got.Status != StatusDisabled || got.Role != RoleAdmin || !got.EmailBouncing ||
|
|
got.LastLoginAt == nil || !got.LastLoginAt.Equal(clk.Now()) {
|
|
t.Fatalf("after setters: %+v", got)
|
|
}
|
|
if err := st.SetStatus(9999, StatusActive); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("SetStatus(missing) = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListFiltersAndAdminCount(t *testing.T) {
|
|
st, _ := newTestStore(t)
|
|
a := mustCreate(t, st, "alice@example.org", "Alice")
|
|
b := mustCreate(t, st, "bob@example.org", "Bob_1")
|
|
mustCreate(t, st, "carol@example.org", "Carol")
|
|
st.Activate(a.ID, RoleAdmin, nil)
|
|
st.Activate(b.ID, RoleUser, nil)
|
|
|
|
all, err := st.List(ListFilter{})
|
|
if err != nil || len(all) != 3 {
|
|
t.Fatalf("List all = %d, %v", len(all), err)
|
|
}
|
|
pending, _ := st.List(ListFilter{Status: StatusPending})
|
|
if len(pending) != 1 || pending[0].Email != "carol@example.org" {
|
|
t.Fatalf("pending = %+v", pending)
|
|
}
|
|
admins, _ := st.List(ListFilter{Role: RoleAdmin})
|
|
if len(admins) != 1 || admins[0].ID != a.ID {
|
|
t.Fatalf("admins = %+v", admins)
|
|
}
|
|
q, _ := st.List(ListFilter{Query: "BOB"})
|
|
if len(q) != 1 || q[0].ID != b.ID {
|
|
t.Fatalf("query BOB = %+v", q)
|
|
}
|
|
// LIKE wildcards in the query are literal.
|
|
if w, _ := st.List(ListFilter{Query: "_"}); len(w) != 1 || w[0].ID != b.ID {
|
|
t.Fatalf("query _ = %+v", w)
|
|
}
|
|
// Bouncing narrows on the server, combined with the other filters.
|
|
if err := st.SetEmailBouncing(b.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bo, err := st.List(ListFilter{Bouncing: true}); err != nil || len(bo) != 1 || bo[0].ID != b.ID {
|
|
t.Fatalf("bouncing = %+v, %v", bo, err)
|
|
}
|
|
if bo, _ := st.List(ListFilter{Bouncing: true, Role: RoleAdmin}); len(bo) != 0 {
|
|
t.Fatalf("bouncing admins = %+v", bo)
|
|
}
|
|
n, err := st.CountActiveAdmins()
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("CountActiveAdmins = %d, %v", n, err)
|
|
}
|
|
st.SetStatus(a.ID, StatusDisabled)
|
|
if n, _ := st.CountActiveAdmins(); n != 0 {
|
|
t.Fatalf("disabled admin still counted: %d", n)
|
|
}
|
|
}
|