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:
@@ -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}}
|
||||
Reference in New Issue
Block a user