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.
This commit is contained in:
@@ -377,6 +377,7 @@ func run(ctx context.Context) (err error) {
|
|||||||
CSRF: consoleCSRF,
|
CSRF: consoleCSRF,
|
||||||
Monitor: opsstatus.NewStore(db),
|
Monitor: opsstatus.NewStore(db),
|
||||||
Ready: ready,
|
Ready: ready,
|
||||||
|
Users: userSvc,
|
||||||
Logger: logger,
|
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
|
deployment so a future Prometheus + Grafana stack can scrape them without code
|
||||||
changes.
|
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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Where | Notes |
|
| 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); }
|
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
|
||||||
.ok { color: var(--ok); }
|
.ok { color: var(--ok); }
|
||||||
.bad { color: var(--danger); }
|
.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
|
assets http.Handler
|
||||||
monitor opsstatus.Reader
|
monitor opsstatus.Reader
|
||||||
ready func() bool
|
ready func() bool
|
||||||
|
users UserAdmin
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type AdminConsoleDeps struct {
|
|||||||
CSRF *adminconsole.CSRF
|
CSRF *adminconsole.CSRF
|
||||||
Monitor opsstatus.Reader
|
Monitor opsstatus.Reader
|
||||||
Ready func() bool
|
Ready func() bool
|
||||||
|
Users UserAdmin
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +79,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
|||||||
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
||||||
monitor: deps.Monitor,
|
monitor: deps.Monitor,
|
||||||
ready: deps.Ready,
|
ready: deps.Ready,
|
||||||
|
users: deps.Users,
|
||||||
logger: logger.Named("http.admin.console"),
|
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())
|
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
|
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
|
||||||
// CSRF guard may let through without a token.
|
// CSRF guard may let through without a token.
|
||||||
func isSafeHTTPMethod(method string) bool {
|
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("/assets/*filepath", deps.AdminConsole.Asset())
|
||||||
group.GET("", deps.AdminConsole.Dashboard())
|
group.GET("", deps.AdminConsole.Dashboard())
|
||||||
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
|
// allowedMethodsForPath returns the comma-separated list of methods
|
||||||
|
|||||||
Reference in New Issue
Block a user