feat(admin-console): server-rendered operator console at /_gm #87
@@ -377,6 +377,7 @@ func run(ctx context.Context) (err error) {
|
||||
CSRF: consoleCSRF,
|
||||
Monitor: opsstatus.NewStore(db),
|
||||
Ready: ready,
|
||||
Users: userSvc,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{with .Data}}
|
||||
<p class="{{.Class}}">{{.Message}}</p>
|
||||
{{if .BackHref}}<p><a href="{{.BackHref}}">« back</a></p>{{end}}
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/users">« all users</a></p>
|
||||
<h1>{{.Email}}</h1>
|
||||
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li>User ID: <code>{{.UserID}}</code></li>
|
||||
<li>User name: {{.UserName}}</li>
|
||||
<li>Display name: {{.DisplayName}}</li>
|
||||
<li>Preferred language: {{.PreferredLanguage}}</li>
|
||||
<li>Time zone: {{.TimeZone}}</li>
|
||||
<li>Declared country: {{.DeclaredCountry}}</li>
|
||||
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
|
||||
<li>Created: {{.CreatedAt}}</li>
|
||||
<li>Updated: {{.UpdatedAt}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Entitlement</h2>
|
||||
<ul class="kv">
|
||||
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
|
||||
<li>Source: {{.EntitlementSource}}</li>
|
||||
<li>Reason: {{.EntitlementReason}}</li>
|
||||
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Tier
|
||||
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
|
||||
</label>
|
||||
<label>Source <input type="text" name="source" value="admin"></label>
|
||||
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
|
||||
<button type="submit">Update entitlement</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Active sanctions</h2>
|
||||
{{if .Sanctions}}
|
||||
<table class="counts"><tbody>
|
||||
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
|
||||
</tbody></table>
|
||||
{{else}}<p class="note">none</p>{{end}}
|
||||
{{if .Blocked}}
|
||||
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
|
||||
{{else}}
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Reason <input type="text" name="reason_code" required></label>
|
||||
<button type="submit" class="danger">Permanently block</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Danger zone</h2>
|
||||
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<button type="submit" class="danger">Soft-delete account</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
|
||||
<td>{{.UserName}}</td>
|
||||
<td>{{.DisplayName}}</td>
|
||||
<td>{{.Tier}}</td>
|
||||
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user