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
+
+- User ID:
{{.UserID}}
+- User name: {{.UserName}}
+- Display name: {{.DisplayName}}
+- Preferred language: {{.PreferredLanguage}}
+- Time zone: {{.TimeZone}}
+- Declared country: {{.DeclaredCountry}}
+- Status: {{if .Blocked}}blocked{{else}}active{{end}}
+- Created: {{.CreatedAt}}
+- Updated: {{.UpdatedAt}}
+
+
+
+
+Entitlement
+
+- Tier: {{.Tier}} ({{if .IsPaid}}paid{{else}}free{{end}})
+- Source: {{.EntitlementSource}}
+- Reason: {{.EntitlementReason}}
+- Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}โ{{end}}
+
+
+
+
+
+Active sanctions
+{{if .Sanctions}}
+
+{{range .Sanctions}}| {{.SanctionCode}} | {{.Scope}} | {{.ReasonCode}} | {{.AppliedAt}} |
{{end}}
+
+{{else}}none
{{end}}
+{{if .Blocked}}
+User is permanently blocked. Unblock is not available in the current admin API.
+{{else}}
+
+{{end}}
+
+
+
+{{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}}
+
+| Email | User name | Display | Tier | Status | Created |
+
+{{range .Items}}
+
+| {{.Email}} |
+{{.UserName}} |
+{{.DisplayName}} |
+{{.Tier}} |
+{{if .Deleted}}deleted{{else if .Blocked}}blocked{{else}}active{{end}} |
+{{.CreatedAt}} |
+
+{{else}}
+| no users |
+{{end}}
+
+
+
+{{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