3b744b7d2f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
There was no way to see the money as a whole: the ledger was only ever rendered inside one account's card, so "what came in last month" meant exporting the entire CSV and reading it elsewhere. /_gm/ledger lists every operation, newest first, filtered by date range (defaulting to the last 30 days), by wallet, by rail, by kind and by account. Wallet and rail are deliberately separate axes because they answer different questions — "what happened on VK" matches the funded segment or the benefit origin, while "what came through YooKassa" matches the settling provider, and a chip spend has no rail at all, so it drops out of that filter by construction. Above the table sit the totals for everything the filter matches, not merely the page on screen, which is the point of showing them. Money is listed per currency because the rails settle in roubles, Votes and Stars and one sum across them would mean nothing. Row amounts come from the operation snapshot, since the ledger's own columns count chips rather than money. Paging, the CSV export and a refund's way back are all built from the same encoded filter, so none of them can quietly show a different slice than the screen. The export moves here from the user card and gains the filter along with money columns; it is capped, because an append-only ledger grows forever and an unbounded export would eventually time out. The refund action moves onto the funded rows here — an operator can now find a payment by filter without knowing whose it is first — and returns to the same filtered view. The destination travels with the form but is only honoured when it is a console path, so a crafted form cannot turn it into an open redirect. The user card keeps the account's standing rather than its history: balances, benefits, the risk flag and a lifetime summary (money paid and refunded per currency, chips credited and spent), plus a link into the ledger scoped to that account. Operations are rendered in one place, with filters and paging, instead of two. Tests: the filter axes, paging that neither repeats nor drops a row, totals that describe the range rather than the page, money recovered from a snapshot and absent on a spend, and the console page, the export and the card hand-off. The existing finance-panel test now asserts the summary and that the card no longer re-renders the rows.
228 lines
11 KiB
Go Template
228 lines
11 KiB
Go Template
{{define "content" -}}
|
|
{{with .Data}}
|
|
<h1>{{.DisplayName}}</h1>
|
|
<nav class="subnav"><a href="/_gm/users">« users</a> · <a href="/_gm/messages?user={{.ID}}">messages</a> · <a href="/_gm/feedback?user={{.ID}}">feedback</a></nav>
|
|
<div class="cards">
|
|
<section class="panel"><h2>Account</h2>
|
|
<ul class="kv">
|
|
<li><b>ID</b> {{.ID}}</li>
|
|
<li><b>Language</b> {{.Language}}</li>
|
|
<li><b>Timezone</b> {{.TimeZone}}</li>
|
|
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
|
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
|
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
|
|
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
|
|
<li><b>Created</b> {{.CreatedAt}}</li>
|
|
</ul>
|
|
{{if .FlaggedHighRateAt}}
|
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/clear-high-rate-flag">
|
|
<button type="submit">Clear high-rate flag</button>
|
|
</form>
|
|
{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Statistics</h2>
|
|
{{if .HasStats}}
|
|
<ul class="kv">
|
|
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
|
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
|
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
|
<li><b>Moves</b> {{.Stats.Moves}}</li>
|
|
<li><b>Hints used</b> {{.Stats.HintsUsed}}</li>
|
|
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
|
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
|
</ul>
|
|
{{else}}<p class="note">no statistics</p>{{end}}
|
|
</section>
|
|
</div>
|
|
<section class="panel"><h2>Account block</h2>
|
|
{{$uid := .ID}}
|
|
{{with .Suspension}}{{if .Blocked}}
|
|
<ul class="kv">
|
|
<li><b>Status</b> <span class="warn">{{if .Permanent}}blocked (permanent){{else}}blocked until {{.Until}} (UTC){{end}}</span></li>
|
|
{{if .BlockedAt}}<li><b>Since</b> {{.BlockedAt}}</li>{{end}}
|
|
{{if .ReasonEn}}<li><b>Reason</b> {{.ReasonEn}} / {{.ReasonRu}}</li>{{end}}
|
|
</ul>
|
|
<form class="form" method="post" action="/_gm/users/{{$uid}}/unblock">
|
|
<button type="submit">Unblock</button>
|
|
</form>
|
|
{{else}}<p class="note">not blocked</p>{{end}}{{end}}
|
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/block">
|
|
<label>Duration <select name="duration">
|
|
<option value="permanent">permanent</option>
|
|
<option value="1d">1 day</option>
|
|
<option value="3d">3 days</option>
|
|
<option value="1w">1 week</option>
|
|
<option value="1m">1 month</option>
|
|
<option value="custom">custom date (UTC)</option>
|
|
</select></label>
|
|
<label>Custom until (UTC; used when duration is "custom") <input type="datetime-local" name="until"></label>
|
|
<label>Reason <select name="reason">
|
|
<option value="">— none —</option>
|
|
{{range .Reasons}}<option value="{{.ID}}">{{.TextEn}}</option>{{end}}
|
|
</select></label>
|
|
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
|
|
</form>
|
|
</section>
|
|
<section class="panel"><h2>Finance</h2>
|
|
{{if .Finance.Present}}
|
|
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
|
|
<ul class="kv">
|
|
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
|
|
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
|
|
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
|
</ul>
|
|
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
|
<h3>Payment override</h3>
|
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
|
|
<label>Purchases for this account
|
|
<select name="override">
|
|
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
|
|
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
|
|
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
|
|
</select></label>
|
|
<div><button type="submit">Save</button></div>
|
|
</form>
|
|
<h3>Totals</h3>
|
|
<p>
|
|
Paid: {{range .Finance.Paid}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
|
· Refunded: {{range .Finance.Refunded}}<strong>{{.}}</strong> {{else}}<span class="note">nothing</span>{{end}}
|
|
· Chips credited: <strong>{{.Finance.ChipsBought}}</strong>
|
|
· Chips spent: <strong>{{.Finance.ChipsSpent}}</strong>
|
|
</p>
|
|
<p class="note"><a href="/_gm/ledger?{{.Finance.LedgerQuery}}">Open this account's operations in the ledger →</a> — every entry, with filters, paging and the refund action.</p>
|
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Grant benefits</h2>
|
|
{{if .Grant.Present}}
|
|
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
|
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
|
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
|
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
|
|
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
|
|
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
|
|
<div><button type="submit">Grant</button></div>
|
|
</form>
|
|
{{if .Grant.Products}}
|
|
<h3>Grant a product</h3>
|
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
|
|
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
|
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
|
|
<div><button type="submit">Grant product</button></div>
|
|
</form>
|
|
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
|
|
{{else}}<p class="note">payments not enabled</p>{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Roles</h2>
|
|
{{$id := .ID}}
|
|
{{if .Roles}}
|
|
<table class="list">
|
|
<thead><tr><th>Role</th><th></th></tr></thead>
|
|
<tbody>
|
|
{{range .Roles}}
|
|
<tr><td><code>{{.}}</code></td><td><form class="form" method="post" action="/_gm/users/{{$id}}/revoke-role"><input type="hidden" name="role" value="{{.}}"><button type="submit">Revoke</button></form></td></tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{else}}<p class="note">no roles</p>{{end}}
|
|
<form class="form col" method="post" action="/_gm/users/{{$id}}/grant-role">
|
|
<label>Grant role <select name="role">{{range .KnownRoles}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
|
|
<div><button type="submit">Grant</button></div>
|
|
</form>
|
|
</section>
|
|
{{if .MoveChart}}
|
|
<section class="panel"><h2>Move timing</h2>
|
|
<p class="note">Think time per move number across all games — <span class="lg lg-min">min</span> · <span class="lg lg-avg">mean</span> · <span class="lg lg-max">max</span>.</p>
|
|
{{.MoveChart}}
|
|
</section>
|
|
{{end}}
|
|
<section class="panel"><h2>Identities</h2>
|
|
<table class="list">
|
|
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
|
<tbody>
|
|
{{range .Identities}}
|
|
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
|
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{if .HasEmail}}
|
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/remove-email" onsubmit="return confirm('Erase the email identity from this account? The address will be freed.')">
|
|
<button type="submit">Erase email</button>
|
|
</form>
|
|
{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Deletion & retention</h2>
|
|
{{if .LastLoginAt}}<p class="note">Last login: {{.LastLoginAt}}{{if .LastLoginIP}} — <code>{{.LastLoginIP}}</code>{{end}}</p>{{end}}
|
|
{{if .Deleted}}<p><span class="warn">Deleted</span> at {{.DeletedAt}}{{if .DeletedName}} — was <code>{{.DeletedName}}</code>{{end}}</p>{{end}}
|
|
{{if .Retained}}
|
|
<h3>Retention journal (legal dossier of detached credentials)</h3>
|
|
<table class="list">
|
|
<thead><tr><th>Kind</th><th>Credential</th><th>Reason</th><th>Detached</th></tr></thead>
|
|
<tbody>
|
|
{{range .Retained}}<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{.Reason}}</td><td>{{.DetachedAt}}</td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
{{end}}
|
|
{{if not .Deleted}}
|
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/delete" onsubmit="return confirm('Delete this account? Its credentials are journalled and freed, its data anonymised, and its sessions revoked. This cannot be undone.')">
|
|
<button type="submit">Delete user</button>
|
|
</form>
|
|
{{end}}
|
|
</section>
|
|
<section class="panel"><h2>Friends</h2>
|
|
<table class="list">
|
|
<thead><tr><th>Account</th><th>Friends since</th></tr></thead>
|
|
<tbody>
|
|
{{range .Friends}}
|
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
|
{{else}}<tr><td colspan="2"><span class="note">no friends</span></td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
<section class="panel"><h2>Blocks</h2>
|
|
<table class="list">
|
|
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
|
<tbody>
|
|
{{range .Blocks}}
|
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
|
{{else}}<tr><td colspan="2"><span class="note">blocks no one</span></td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
<section class="panel"><h2>Blocked by</h2>
|
|
<table class="list">
|
|
<thead><tr><th>Account</th><th>Blocked at</th></tr></thead>
|
|
<tbody>
|
|
{{range .BlockedBy}}
|
|
<tr><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td>{{.Date}}</td></tr>
|
|
{{else}}<tr><td colspan="2"><span class="note">blocked by no one</span></td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{if .TelegramID}}
|
|
<section class="panel"><h2>Send Telegram message</h2>
|
|
{{if .ConnectorEnabled}}
|
|
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
|
<label>Message <textarea name="text" required></textarea></label>
|
|
<div><button type="submit">Send to user</button></div>
|
|
</form>
|
|
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
|
</section>
|
|
{{end}}
|
|
{{if .VKID}}
|
|
<section class="panel"><h2>VK</h2>
|
|
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
|
|
</section>
|
|
{{end}}
|
|
<section class="panel"><h2>Games</h2>
|
|
<table class="list">
|
|
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
|
<tbody>
|
|
{{range .Games}}
|
|
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
|
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
{{end}}
|
|
{{- end}}
|