From cf34710b4f589a186c929f1c338c01ce6cec61b0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:15:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin-console):=20Stage=203=20=E2=80=94=20?= =?UTF-8?q?users=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the operator console's user-administration pages over the existing *user.Service (no new business logic). - GET /_gm/users paginated account list - GET /_gm/users/{id} account detail: profile, entitlement, sanctions - POST /_gm/users/{id}/block apply permanent_block (reason required) - POST /_gm/users/{id}/entitlement set the entitlement tier - POST /_gm/users/{id}/soft-delete soft-delete the account (cascades) The console depends on a UserAdmin interface (satisfied by *user.Service) so the pages render in tests without a database. All writes flow through the CSRF guard, carry the operator as the audit actor, and answer with a 303 redirect; a generic message page handles not-found, validation, and failure notices. Unblock is intentionally absent — the admin API exposes no remove-sanction endpoint. Tests: list/detail render, not-found, block (with actor/scope/reason assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete redirect, and the service-unavailable path. Docs: backend/docs/admin-console.md gains the page inventory. --- backend/cmd/backend/main.go | 1 + backend/docs/admin-console.md | 16 + .../internal/adminconsole/assets/console.css | 29 ++ backend/internal/adminconsole/message.go | 11 + .../templates/pages/message.gohtml | 7 + .../templates/pages/user_detail.gohtml | 68 +++++ .../adminconsole/templates/pages/users.gohtml | 27 ++ backend/internal/adminconsole/users.go | 61 ++++ .../internal/server/handlers_admin_console.go | 14 + .../server/handlers_admin_console_users.go | 252 +++++++++++++++ .../handlers_admin_console_users_test.go | 288 ++++++++++++++++++ backend/internal/server/router.go | 6 + 12 files changed, 780 insertions(+) create mode 100644 backend/internal/adminconsole/message.go create mode 100644 backend/internal/adminconsole/templates/pages/message.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/user_detail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/users.gohtml create mode 100644 backend/internal/adminconsole/users.go create mode 100644 backend/internal/server/handlers_admin_console_users.go create mode 100644 backend/internal/server/handlers_admin_console_users_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index a042347..07435f9 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -377,6 +377,7 @@ func run(ctx context.Context) (err error) { CSRF: consoleCSRF, Monitor: opsstatus.NewStore(db), Ready: ready, + Users: userSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 801fa84..929234f 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -80,6 +80,22 @@ exporters on `backend` and `gateway` are wired and enabled in the dev deployment so a future Prometheus + Grafana stack can scrape them without code changes. +## Pages + +| Path | Method | Purpose | +| --------------------------------- | -------- | -------------------------------------------------------------- | +| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. | +| `/_gm/assets/*` | GET | Embedded stylesheet. | +| `/_gm/users` | GET | Paginated account list. | +| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. | +| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). | +| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. | +| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). | + +Each page reuses the same service layer as the corresponding `/api/v1/admin/*` +JSON endpoint; the console adds no business logic. Unblocking a user is not yet +available because the JSON admin API exposes no remove-sanction endpoint. + ## Configuration | Variable | Where | Notes | diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 1fcec80..a34bc8d 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -69,3 +69,32 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); } .ok { color: var(--ok); } .bad { color: var(--danger); } + +.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; } +.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); } +.list th { color: var(--ink-dim); font-weight: 600; } +.list tr:hover td { background: var(--panel-hi); } +.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); } +.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; } +.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } +.form input, .form select { + background: var(--bg); + color: var(--ink); + border: 1px solid var(--line); + border-radius: 6px; + padding: 0.35rem 0.5rem; + font: inherit; +} +button { + background: var(--accent); + color: #06121f; + border: 0; + border-radius: 6px; + padding: 0.4rem 0.9rem; + font: inherit; + font-weight: 600; + cursor: pointer; +} +button:hover { filter: brightness(1.1); } +button.danger { background: var(--danger); color: #1a0606; } +code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } diff --git a/backend/internal/adminconsole/message.go b/backend/internal/adminconsole/message.go new file mode 100644 index 0000000..2da0461 --- /dev/null +++ b/backend/internal/adminconsole/message.go @@ -0,0 +1,11 @@ +package adminconsole + +// MessageData is the view model for the generic message page used to render +// not-found, validation, and operation-failure notices. Class selects the CSS +// styling (for example "bad" for errors); BackHref, when set, renders a link +// back to a relevant page. +type MessageData struct { + Message string + Class string + BackHref string +} diff --git a/backend/internal/adminconsole/templates/pages/message.gohtml b/backend/internal/adminconsole/templates/pages/message.gohtml new file mode 100644 index 0000000..8f30abc --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/message.gohtml @@ -0,0 +1,7 @@ +{{define "content" -}} +

{{.Title}}

+{{with .Data}} +

{{.Message}}

+{{if .BackHref}}

« back

{{end}} +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml new file mode 100644 index 0000000..b01d4fb --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -0,0 +1,68 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« all users

+

{{.Email}}

+{{if .Deleted}}

This account is soft-deleted.

{{end}} + +
+

Account

+ +
+ +
+

Entitlement

+ +
+ + + + + +
+
+ +
+

Active sanctions

+{{if .Sanctions}} + +{{range .Sanctions}}{{end}} +
{{.SanctionCode}}{{.Scope}}{{.ReasonCode}}{{.AppliedAt}}
+{{else}}

none

{{end}} +{{if .Blocked}} +

User is permanently blocked. Unblock is not available in the current admin API.

+{{else}} +
+ + + +
+{{end}} +
+ +
+

Danger zone

+
+ + +
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml new file mode 100644 index 0000000..0cc371c --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/users.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Users

+{{with .Data}} + + + +{{range .Items}} + + + + + + + + +{{else}} + +{{end}} + +
EmailUser nameDisplayTierStatusCreated
{{.Email}}{{.UserName}}{{.DisplayName}}{{.Tier}}{{if .Deleted}}deleted{{else if .Blocked}}blocked{{else}}active{{end}}{{.CreatedAt}}
no users
+ +{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/users.go b/backend/internal/adminconsole/users.go new file mode 100644 index 0000000..dc2b210 --- /dev/null +++ b/backend/internal/adminconsole/users.go @@ -0,0 +1,61 @@ +package adminconsole + +// UserRow is one line in the users list table. +type UserRow struct { + UserID string + Email string + UserName string + DisplayName string + Tier string + Blocked bool + Deleted bool + CreatedAt string +} + +// UsersListData is the view model for the paginated users list. +type UsersListData struct { + Items []UserRow + Page int + PageSize int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// SanctionView is one active sanction shown on the user detail page. +type SanctionView struct { + SanctionCode string + Scope string + ReasonCode string + AppliedAt string + ExpiresAt string +} + +// UserDetailData is the view model for a single user's detail page, +// combining the account aggregate with the form option lists. +type UserDetailData struct { + UserID string + Email string + UserName string + DisplayName string + PreferredLanguage string + TimeZone string + DeclaredCountry string + Blocked bool + Deleted bool + CreatedAt string + UpdatedAt string + + Tier string + IsPaid bool + EntitlementSource string + EntitlementReason string + EntitlementEnds string + + Sanctions []SanctionView + + // Tiers lists the selectable entitlement tiers for the form. + Tiers []string +} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 1846eb8..6821728 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -28,6 +28,7 @@ type AdminConsoleHandlers struct { assets http.Handler monitor opsstatus.Reader ready func() bool + users UserAdmin logger *zap.Logger } @@ -41,6 +42,7 @@ type AdminConsoleDeps struct { CSRF *adminconsole.CSRF Monitor opsstatus.Reader Ready func() bool + Users UserAdmin Logger *zap.Logger } @@ -77,6 +79,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), monitor: deps.Monitor, ready: deps.Ready, + users: deps.Users, logger: logger.Named("http.admin.console"), } } @@ -168,6 +171,17 @@ func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNa c.Data(status, "text/html; charset=utf-8", buf.Bytes()) } +// renderMessage renders the generic message page (not-found, validation, or +// operation-failure notices). class selects the CSS styling and backHref, when +// non-empty, adds a back link. +func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) { + h.render(c, status, "message", activeNav, title, adminconsole.MessageData{ + Message: message, + Class: class, + BackHref: backHref, + }) +} + // isSafeHTTPMethod reports whether method is a read-only HTTP method that the // CSRF guard may let through without a token. func isSafeHTTPMethod(method string) bool { diff --git a/backend/internal/server/handlers_admin_console_users.go b/backend/internal/server/handlers_admin_console_users.go new file mode 100644 index 0000000..2ddf065 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_users.go @@ -0,0 +1,252 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + "galaxy/backend/internal/user" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// UserAdmin is the subset of the user service the operator console depends on. +// *user.Service satisfies it; tests supply a fake so the console pages render +// without a database. +type UserAdmin interface { + ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error) + GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error) + ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error) + ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error) + SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error +} + +// consoleTiers lists the selectable entitlement tiers in display order. +var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent} + +// UsersList renders GET /_gm/users — the paginated account list. +func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + + result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize) + if err != nil { + h.logger.Error("admin console: list users", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result)) + } +} + +// UserDetail renders GET /_gm/users/:user_id. +func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + account, err := h.users.GetAccount(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, user.ErrAccountNotFound) { + h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users") + return + } + h.logger.Error("admin console: get user", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users") + return + } + h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account)) + } +} + +// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block. +func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + back := "/_gm/users/" + userID.String() + reason := strings.TrimSpace(c.PostForm("reason_code")) + if reason == "" { + h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back) + return + } + _, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{ + UserID: userID, + SanctionCode: user.SanctionCodePermanentBlock, + Scope: "account", + ReasonCode: reason, + Actor: actorFromContext(c), + }) + if err != nil { + h.logger.Error("admin console: block user", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// UserEntitlement handles POST /_gm/users/:user_id/entitlement. +func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + back := "/_gm/users/" + userID.String() + tier := strings.TrimSpace(c.PostForm("tier")) + source := strings.TrimSpace(c.PostForm("source")) + if source == "" { + source = "admin" + } + _, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{ + UserID: userID, + Tier: tier, + Source: source, + Actor: actorFromContext(c), + ReasonCode: strings.TrimSpace(c.PostForm("reason_code")), + }) + if err != nil { + if errors.Is(err, user.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back) + return + } + h.logger.Error("admin console: apply entitlement", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete. +func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc { + return func(c *gin.Context) { + if h.users == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/") + return + } + userID, ok := parseUserIDParam(c) + if !ok { + return + } + if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil { + if errors.Is(err, user.ErrAccountNotFound) { + h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users") + return + } + // A cascade error does not undo the soft delete; log and proceed. + h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err)) + } + c.Redirect(http.StatusSeeOther, "/_gm/users") + } +} + +// actorFromContext builds the admin ActorRef for audit trails from the +// authenticated operator username stored by the Basic Auth middleware. +func actorFromContext(c *gin.Context) user.ActorRef { + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + return user.ActorRef{Type: "admin", ID: username} +} + +// toUsersListData maps an account page into the users list view model. +func toUsersListData(page user.AccountPage) adminconsole.UsersListData { + data := adminconsole.UsersListData{ + Items: make([]adminconsole.UserRow, 0, len(page.Items)), + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + PrevPage: page.Page - 1, + NextPage: page.Page + 1, + HasPrev: page.Page > 1, + HasNext: page.Page*page.PageSize < page.Total, + } + for _, account := range page.Items { + data.Items = append(data.Items, adminconsole.UserRow{ + UserID: account.UserID.String(), + Email: account.Email, + UserName: account.UserName, + DisplayName: account.DisplayName, + Tier: account.Entitlement.Tier, + Blocked: account.PermanentBlock, + Deleted: account.DeletedAt != nil, + CreatedAt: fmtConsoleTime(account.CreatedAt), + }) + } + return data +} + +// toUserDetailData maps an account aggregate into the detail view model. +func toUserDetailData(account user.Account) adminconsole.UserDetailData { + data := adminconsole.UserDetailData{ + UserID: account.UserID.String(), + Email: account.Email, + UserName: account.UserName, + DisplayName: account.DisplayName, + PreferredLanguage: account.PreferredLanguage, + TimeZone: account.TimeZone, + DeclaredCountry: account.DeclaredCountry, + Blocked: account.PermanentBlock, + Deleted: account.DeletedAt != nil, + CreatedAt: fmtConsoleTime(account.CreatedAt), + UpdatedAt: fmtConsoleTime(account.UpdatedAt), + Tier: account.Entitlement.Tier, + IsPaid: account.Entitlement.IsPaid, + EntitlementSource: account.Entitlement.Source, + EntitlementReason: account.Entitlement.ReasonCode, + EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt), + Tiers: consoleTiers, + } + for _, sanction := range account.ActiveSanctions { + data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{ + SanctionCode: sanction.SanctionCode, + Scope: sanction.Scope, + ReasonCode: sanction.ReasonCode, + AppliedAt: fmtConsoleTime(sanction.AppliedAt), + ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt), + }) + } + return data +} + +// fmtConsoleTime renders a timestamp for display in the console. +func fmtConsoleTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02 15:04 UTC") +} + +// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil. +func fmtConsoleTimePtr(t *time.Time) string { + if t == nil { + return "" + } + return fmtConsoleTime(*t) +} diff --git a/backend/internal/server/handlers_admin_console_users_test.go b/backend/internal/server/handlers_admin_console_users_test.go new file mode 100644 index 0000000..ffe5b74 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_users_test.go @@ -0,0 +1,288 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/server/middleware/basicauth" + "galaxy/backend/internal/user" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// fakeUserAdmin records calls so the console handlers can be exercised without +// a database. +type fakeUserAdmin struct { + page user.AccountPage + account user.Account + getErr error + + sanctionCalls int + lastSanction user.ApplySanctionInput + entitlementCall int + lastEntitlement user.ApplyEntitlementInput + softDeleteCalls int + lastSoftActor user.ActorRef +} + +func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) { + return f.page, nil +} + +func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) { + return f.account, f.getErr +} + +func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) { + f.sanctionCalls++ + f.lastSanction = in + return f.account, nil +} + +func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) { + f.entitlementCall++ + f.lastEntitlement = in + return f.account, nil +} + +func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error { + f.softDeleteCalls++ + f.lastSoftActor = actor + return nil +} + +func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func TestConsoleUsersList(t *testing.T) { + fake := &fakeUserAdmin{page: user.AccountPage{ + Items: []user.Account{ + {UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"}, + {UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true}, + }, + Page: 1, PageSize: 50, Total: 2, + }} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} { + if !strings.Contains(body, want) { + t.Errorf("users list missing %q", want) + } + } +} + +func TestConsoleUserDetailRendersForms(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{ + UserID: id, Email: "alice@example.test", UserName: "alice", + Entitlement: user.EntitlementSnapshot{Tier: user.TierFree}, + }} + router, csrf := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, want := range []string{ + "alice@example.test", + "Permanently block", + "Update entitlement", + "Soft-delete account", + csrf.Token("ops"), + } { + if !strings.Contains(body, want) { + t.Errorf("user detail missing %q", want) + } + } +} + +func TestConsoleUserDetailNotFound(t *testing.T) { + fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + if !strings.Contains(rec.Body.String(), "not found") { + t.Error("expected a not-found message") + } +} + +func TestConsoleUserBlock(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam" + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if fake.sanctionCalls != 1 { + t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls) + } + if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock { + t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode) + } + if fake.lastSanction.Scope != "account" { + t.Errorf("scope = %q, want account", fake.lastSanction.Scope) + } + if fake.lastSanction.ReasonCode != "spam" { + t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode) + } + if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" { + t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor) + } + if fake.lastSanction.UserID != id { + t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id) + } +} + +func TestConsoleUserBlockMissingReason(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if fake.sanctionCalls != 0 { + t.Errorf("ApplySanction must not be called without a reason") + } +} + +func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, _ := newUsersConsoleRouter(t, fake) + + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if fake.sanctionCalls != 0 { + t.Errorf("ApplySanction must not run when the CSRF token is missing") + } +} + +func TestConsoleUserEntitlement(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo" + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if fake.entitlementCall != 1 { + t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall) + } + if fake.lastEntitlement.Tier != user.TierMonthly { + t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier) + } + if fake.lastEntitlement.Actor.ID != "ops" { + t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID) + } +} + +func TestConsoleUserSoftDelete(t *testing.T) { + id := uuid.New() + fake := &fakeUserAdmin{account: user.Account{UserID: id}} + router, csrf := newUsersConsoleRouter(t, fake) + + form := "_csrf=" + csrf.Token("ops") + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if got := rec.Header().Get("Location"); got != "/_gm/users" { + t.Errorf("redirect Location = %q, want /_gm/users", got) + } + if fake.softDeleteCalls != 1 { + t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls) + } + if fake.lastSoftActor.ID != "ops" { + t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID) + } +} + +func TestConsoleUsersUnavailable(t *testing.T) { + router, _ := newUsersConsoleRouter(t, nil) // no user service wired + + req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 960e859..6568617 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -388,6 +388,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.GET("/assets/*filepath", deps.AdminConsole.Asset()) group.GET("", deps.AdminConsole.Dashboard()) group.GET("/", deps.AdminConsole.Dashboard()) + + group.GET("/users", deps.AdminConsole.UsersList()) + group.GET("/users/:user_id", deps.AdminConsole.UserDetail()) + group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock()) + group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement()) + group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete()) } // allowedMethodsForPath returns the comma-separated list of methods