356bf1a5ba
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact → a kind='email' identity match) with a search input, and an 'Erase email' action on the user card that deletes the bound email identity and its pending confirmations, freeing the address. It refuses to remove the account's only identity (ErrLastIdentity), which would leave it unreachable. Integration tests for both.
578 lines
18 KiB
Go
578 lines
18 KiB
Go
package adminconsole
|
|
|
|
import "html/template"
|
|
|
|
// The *View types are the display models the gin handlers fill and the templates
|
|
// render. Time values are pre-formatted to strings by the handlers so the
|
|
// templates stay logic-free.
|
|
|
|
// Pager is the shared list pagination state.
|
|
type Pager struct {
|
|
Page int
|
|
PageSize int
|
|
Total int
|
|
HasPrev bool
|
|
HasNext bool
|
|
PrevPage int
|
|
NextPage int
|
|
}
|
|
|
|
// NewPager builds the pagination state for a 1-based page of pageSize over total
|
|
// items.
|
|
func NewPager(page, pageSize, total int) Pager {
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
|
|
p.HasPrev = page > 1
|
|
p.HasNext = page*pageSize < total
|
|
return p
|
|
}
|
|
|
|
// VariantVersions lists the dictionary versions resident for one variant.
|
|
type VariantVersions struct {
|
|
Variant string
|
|
Latest string
|
|
Versions []string
|
|
}
|
|
|
|
// DashboardView is the landing-page summary.
|
|
type DashboardView struct {
|
|
Accounts int
|
|
Games int
|
|
ActiveGames int
|
|
OpenComplaints int
|
|
OpenFeedback int
|
|
PendingChanges int
|
|
// ActiveVersion is the dictionary version new games pin (the persisted active
|
|
// version), distinct from the per-variant resident versions.
|
|
ActiveVersion string
|
|
Variants []VariantVersions
|
|
}
|
|
|
|
// UsersView is the paginated account list.
|
|
type UsersView struct {
|
|
Items []UserRow
|
|
Pager Pager
|
|
// Robots is the active people/robots toggle; NameMask/ExternalIDMask are the current
|
|
// glob filters; FilterQuery is those URL-encoded for the pager links. It is an
|
|
// already-escaped query fragment (url.Values.Encode), so it is typed template.URL to
|
|
// be emitted verbatim — interpolated as a plain string it would have its "=" and "&"
|
|
// percent-encoded again by the contextual escaper.
|
|
Robots bool
|
|
NameMask string
|
|
ExternalIDMask string
|
|
EmailExact string
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
|
|
// pre-formatted move-duration summary (empty when it has no timed move);
|
|
// FlaggedHighRate marks the soft high-rate badge.
|
|
type UserRow struct {
|
|
ID string
|
|
DisplayName string
|
|
Kind string
|
|
Language string
|
|
Guest bool
|
|
FlaggedHighRate bool
|
|
CreatedAt string
|
|
HasMoveStats bool
|
|
MoveMin string
|
|
MoveAvg string
|
|
MoveMax string
|
|
}
|
|
|
|
// MessagesView is the paginated chat-message moderation list. NameMask/ExtMask are the
|
|
// current sender glob filters; GameID/UserID pin the list to one game / sender (set from a
|
|
// game or user card); FilterQuery is the active filters URL-encoded for the pager and CSV
|
|
// links — an already-escaped query fragment, hence template.URL so it is not re-encoded
|
|
// inside the link (see UsersView.FilterQuery).
|
|
type MessagesView struct {
|
|
Items []MessageRow
|
|
Pager Pager
|
|
NameMask string
|
|
ExtMask string
|
|
GameID string
|
|
UserID string
|
|
UnreadOnly bool
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// MessageRow is one chat message in the moderation list: its sender (linked to the user
|
|
// card), source, IP, body, game (linked to the game card), time, and whether it is still
|
|
// unread by at least one recipient.
|
|
type MessageRow struct {
|
|
ID string
|
|
SenderID string
|
|
SenderName string
|
|
Source string
|
|
IP string
|
|
Body string
|
|
GameID string
|
|
CreatedAt string
|
|
Unread bool
|
|
}
|
|
|
|
// ChatMessageDetailView is one chat message with its per-seat read breakdown, for the
|
|
// console message card.
|
|
type ChatMessageDetailView struct {
|
|
ID string
|
|
GameID string
|
|
SenderID string
|
|
SenderName string
|
|
Source string
|
|
Kind string
|
|
Body string
|
|
IP string
|
|
CreatedAt string
|
|
Unread bool
|
|
Seats []ChatSeatStatusRow
|
|
}
|
|
|
|
// ChatSeatStatusRow is one seat's read status on the message card: the seat index, the
|
|
// occupant (linked to the user card) and its role ("sender", "read" or "unread").
|
|
type ChatSeatStatusRow struct {
|
|
Seat int
|
|
AccountID string
|
|
DisplayName string
|
|
Role string
|
|
}
|
|
|
|
// UserDetailView is one account with its stats, identities and recent games.
|
|
type UserDetailView struct {
|
|
ID string
|
|
DisplayName string
|
|
Language string
|
|
TimeZone string
|
|
Guest bool
|
|
NotificationsInAppOnly bool
|
|
PaidAccount bool
|
|
// MergedInto is the primary account id when this account has been retired by a
|
|
// merge, or empty for a live account.
|
|
MergedInto string
|
|
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
|
|
// empty for an unflagged account; the card shows it with the Clear action.
|
|
FlaggedHighRateAt string
|
|
HintBalance int
|
|
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
|
|
// server's maxHintGrant), passed through so the policy value lives in one place.
|
|
HintGrantMax int
|
|
CreatedAt string
|
|
HasStats bool
|
|
Stats StatsRow
|
|
Identities []IdentityRow
|
|
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
|
|
HasEmail bool
|
|
Games []GameRow
|
|
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
|
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
|
// user id with a link to the VK profile (there is no VK messaging to drive).
|
|
TelegramID string
|
|
VKID string
|
|
ConnectorEnabled bool
|
|
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
|
// time (min/mean/max), empty when the account has no timed move.
|
|
MoveChart template.HTML
|
|
// Suspension is the account's current manual-block state, shown with the block/unblock
|
|
// form; Reasons is the operator-editable reason picklist offered in the block form.
|
|
Suspension SuspensionView
|
|
Reasons []ReasonOption
|
|
// Roles is the account's current roles (each revocable); KnownRoles is the set the
|
|
// grant form offers. The first role is the feedback ban (see internal/account).
|
|
Roles []string
|
|
KnownRoles []string
|
|
// Blocks, BlockedBy and Friends are the social graph on the card: who this account has
|
|
// blocked, who currently blocks it, and its mutual friendships — each cross-linked to the
|
|
// other account with the date it happened. They are the full truth; the asymmetric block
|
|
// suppression that hides relationships from players never applies to the console.
|
|
Blocks []RelationRow
|
|
BlockedBy []RelationRow
|
|
Friends []RelationRow
|
|
}
|
|
|
|
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
|
|
// lists: the other account's id (the link target), its display name, and the pre-formatted date.
|
|
type RelationRow struct {
|
|
AccountID string
|
|
DisplayName string
|
|
Date string
|
|
}
|
|
|
|
// SuspensionView is an account's current manual-block state shown on the user card: whether it
|
|
// is blocked, whether the block is permanent, the pre-formatted expiry (empty when permanent),
|
|
// when it was applied, and the reason snapshot in both languages (empty when none was cited).
|
|
type SuspensionView struct {
|
|
Blocked bool
|
|
Permanent bool
|
|
Until string
|
|
BlockedAt string
|
|
ReasonEn string
|
|
ReasonRu string
|
|
}
|
|
|
|
// ReasonOption is one suspension-reason picklist entry offered in the block form's dropdown.
|
|
type ReasonOption struct {
|
|
ID string
|
|
TextEn string
|
|
TextRu string
|
|
}
|
|
|
|
// StatsRow is an account's lifetime statistics.
|
|
type StatsRow struct {
|
|
Wins int
|
|
Losses int
|
|
Draws int
|
|
MaxGamePoints int
|
|
MaxWordPoints int
|
|
Moves int
|
|
HintsUsed int
|
|
}
|
|
|
|
// IdentityRow is one platform/email identity of an account.
|
|
type IdentityRow struct {
|
|
Kind string
|
|
ExternalID string
|
|
Confirmed bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// GameRow is one game row in a list.
|
|
type GameRow struct {
|
|
ID string
|
|
Variant string
|
|
Status string
|
|
Players int
|
|
UpdatedAt string
|
|
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
|
|
VsAI bool
|
|
}
|
|
|
|
// GamesView is the paginated games list, optionally filtered by status.
|
|
type GamesView struct {
|
|
Items []GameRow
|
|
Status string
|
|
Pager Pager
|
|
}
|
|
|
|
// GameDetailView is one game with its seats.
|
|
type GameDetailView struct {
|
|
ID string
|
|
Variant string
|
|
DictVersion string
|
|
Status string
|
|
Players int
|
|
ToMove int
|
|
EndReason string
|
|
MoveCount int
|
|
CreatedAt string
|
|
UpdatedAt string
|
|
FinishedAt string
|
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
|
VsAI bool
|
|
Seats []SeatRow
|
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
|
HasRobot bool
|
|
RobotTargetPct int
|
|
// ReplayJSON is the game-replay payload (board, seats, per-step racks/scores/bag) the
|
|
// game_detail page feeds to its vanilla-JS stepper; HasReplay gates the replay section.
|
|
ReplayJSON template.JS
|
|
HasReplay bool
|
|
// SetupDraws is the first-move draw — one row per tile drawn (docs/ARCHITECTURE.md §6) —
|
|
// and FirstMover is the resolved name of the seat-0 player the draw elected.
|
|
SetupDraws []SetupDrawRow
|
|
FirstMover string
|
|
}
|
|
|
|
// SetupDrawRow is one tile drawn in the first-move seeding (docs/ARCHITECTURE.md §6): the
|
|
// round, the player (Name/AccountID, or "(opponent)" with an empty AccountID for an
|
|
// auto-match synthetic draw not yet back-filled), the drawn letter (upper-cased; "?" for a
|
|
// blank) and its draw rank.
|
|
type SetupDrawRow struct {
|
|
Round int
|
|
Name string
|
|
AccountID string
|
|
Letter string
|
|
Blank bool
|
|
Rank int
|
|
}
|
|
|
|
// SeatRow is one seat of a game. For a robot seat (IsRobot) RobotIntent is the game's
|
|
// deterministic play-to-win decision ("play to win"/"play to lose"), and NextMove is the
|
|
// scheduled next-move ETA shown only while it is that robot's turn in an active game.
|
|
type SeatRow struct {
|
|
Seat int
|
|
DisplayName string
|
|
AccountID string
|
|
Score int
|
|
HintsUsed int
|
|
Winner bool
|
|
IsRobot bool
|
|
RobotIntent string
|
|
NextMove string
|
|
}
|
|
|
|
// ComplaintsView is the paginated complaint review queue.
|
|
type ComplaintsView struct {
|
|
Items []ComplaintRow
|
|
Status string
|
|
Pager Pager
|
|
}
|
|
|
|
// ComplaintRow is one complaint row in the queue.
|
|
type ComplaintRow struct {
|
|
ID string
|
|
Word string
|
|
Variant string
|
|
WasValid bool
|
|
Status string
|
|
Disposition string
|
|
CreatedAt string
|
|
}
|
|
|
|
// ComplaintDetailView is one complaint with its resolution state and form.
|
|
type ComplaintDetailView struct {
|
|
ID string
|
|
Word string
|
|
Variant string
|
|
DictVersion string
|
|
WasValid bool
|
|
Note string
|
|
Status string
|
|
Disposition string
|
|
ResolutionNote string
|
|
CreatedAt string
|
|
ResolvedAt string
|
|
GameID string
|
|
Resolved bool
|
|
}
|
|
|
|
// DictionaryView lists the resident versions per variant, the active version new
|
|
// games pin, and the pending wordlist changes from accepted complaints.
|
|
type DictionaryView struct {
|
|
// ActiveVersion is the dictionary version new games pin; the update form sets it.
|
|
ActiveVersion string
|
|
Variants []VariantVersions
|
|
Changes []DictChangeRow
|
|
}
|
|
|
|
// DictChangeRow is one pending wordlist edit.
|
|
type DictChangeRow struct {
|
|
Variant string
|
|
Word string
|
|
Action string
|
|
ResolvedAt string
|
|
}
|
|
|
|
// DictionaryPreviewView is the second step of a dictionary update: the version
|
|
// parsed from the uploaded archive (editable before confirming), the staging token
|
|
// that names the uploaded files on disk, the active version the diff is against, and
|
|
// the per-variant word diff.
|
|
type DictionaryPreviewView struct {
|
|
Version string
|
|
Token string
|
|
ActiveVersion string
|
|
Variants []VariantDiffRow
|
|
}
|
|
|
|
// VariantDiffRow summarises one variant's word diff in the update preview.
|
|
// AddedCount/RemovedCount are the full totals; AddedSample/RemovedSample are the
|
|
// first words shown (capped); AddedTruncated/RemovedTruncated mark a capped list;
|
|
// LargeRemoval flags a removal large enough to warrant caution before confirming.
|
|
type VariantDiffRow struct {
|
|
Variant string
|
|
AddedCount int
|
|
RemovedCount int
|
|
AddedSample []string
|
|
RemovedSample []string
|
|
AddedTruncated bool
|
|
RemovedTruncated bool
|
|
LargeRemoval bool
|
|
}
|
|
|
|
// BroadcastView is the operator-broadcast form page.
|
|
type BroadcastView struct {
|
|
ConnectorEnabled bool
|
|
}
|
|
|
|
// ThrottledView is the rate-limit observability page: the temporary IP bans the
|
|
// gateway is currently enforcing, the recent gateway-reported throttle episodes
|
|
// (in-memory, reset on restart) and the accounts currently carrying the high-rate
|
|
// flag. FlagThreshold and FlagWindow caption the active auto-flag tuning.
|
|
type ThrottledView struct {
|
|
Bans []BanRow
|
|
Episodes []ThrottleEpisodeRow
|
|
Flagged []FlaggedAccountRow
|
|
FlagThreshold int
|
|
FlagWindow string
|
|
}
|
|
|
|
// BanRow is one temporary IP ban the gateway is enforcing, with its reason and its
|
|
// since/expiry timestamps; the row carries an unban action.
|
|
type BanRow struct {
|
|
IP string
|
|
Reason string
|
|
Since string
|
|
Expires string
|
|
}
|
|
|
|
// ThrottleEpisodeRow is one recently throttled limiter key. UserID links to the
|
|
// user card and is set only for the user class (the other classes key by IP).
|
|
type ThrottleEpisodeRow struct {
|
|
Class string
|
|
Key string
|
|
UserID string
|
|
Rejected int
|
|
FirstSeen string
|
|
LastSeen string
|
|
}
|
|
|
|
// FlaggedAccountRow is one account carrying the high-rate flag.
|
|
type FlaggedAccountRow struct {
|
|
ID string
|
|
DisplayName string
|
|
FlaggedAt string
|
|
}
|
|
|
|
// ReasonsView is the suspension-reason picklist management page: every editable reason entry.
|
|
type ReasonsView struct {
|
|
Items []ReasonRow
|
|
}
|
|
|
|
// ReasonRow is one editable suspension-reason entry, with its English and Russian text.
|
|
type ReasonRow struct {
|
|
ID string
|
|
TextEn string
|
|
TextRu string
|
|
CreatedAt string
|
|
}
|
|
|
|
// MessageView is the result page shown after a POST action.
|
|
type MessageView struct {
|
|
Heading string
|
|
Body string
|
|
Back string
|
|
}
|
|
|
|
// BannersView is the advertising-campaign list page.
|
|
type BannersView struct {
|
|
Items []BannerCampaignRow
|
|
}
|
|
|
|
// BannerCampaignRow is one campaign in the list. Window is a human-readable
|
|
// validity window ("perpetual" for the default); ActiveNow reports whether it
|
|
// would rotate right now (enabled and within its window).
|
|
type BannerCampaignRow struct {
|
|
ID string
|
|
Name string
|
|
Weight int
|
|
IsDefault bool
|
|
Enabled bool
|
|
Window string
|
|
Messages int
|
|
ActiveNow bool
|
|
}
|
|
|
|
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
|
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
|
type BannerDetailView struct {
|
|
ID string
|
|
Name string
|
|
Weight int
|
|
IsDefault bool
|
|
Enabled bool
|
|
StartsAt string
|
|
EndsAt string
|
|
Messages []BannerMessageRow
|
|
}
|
|
|
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
|
// reorder buttons (disabled at the ends).
|
|
type BannerMessageRow struct {
|
|
ID string
|
|
BodyEn string
|
|
BodyRu string
|
|
First bool
|
|
Last bool
|
|
}
|
|
|
|
// BannerSettingsView is the global display-timings form.
|
|
type BannerSettingsView struct {
|
|
HoldMs int
|
|
EdgePauseMs int
|
|
ScrollPxPerSec int
|
|
FadeOutMs int
|
|
GapMs int
|
|
FadeInMs int
|
|
}
|
|
|
|
// FeedbackView is the paginated user-feedback queue. Status is the active
|
|
// unread/read/archived filter; NameMask/ExtMask are the sender glob filters;
|
|
// UserID pins the list to one account (the per-user link from /users);
|
|
// FilterQuery is the active filters URL-encoded for the pager links (already
|
|
// escaped, hence template.URL — see UsersView.FilterQuery).
|
|
type FeedbackView struct {
|
|
Items []FeedbackRow
|
|
Status string
|
|
NameMask string
|
|
ExtMask string
|
|
UserID string
|
|
Pager Pager
|
|
FilterQuery template.URL
|
|
}
|
|
|
|
// FeedbackRow is one feedback message in the queue: its sender (linked to the user
|
|
// card), source, channel, whether it has an attachment / a reply, its state and
|
|
// time.
|
|
type FeedbackRow struct {
|
|
ID string
|
|
AccountID string
|
|
SenderName string
|
|
Source string
|
|
Channel string
|
|
HasAttachment bool
|
|
Read bool
|
|
Replied bool
|
|
Archived bool
|
|
CreatedAt string
|
|
}
|
|
|
|
// FeedbackDetailView is one feedback message with its body, attachment, state and
|
|
// the reply / archive / delete forms. Body, AttachmentName, SenderName and IP are
|
|
// user-controlled and rendered as plain auto-escaped text. IsImage gates the inline
|
|
// <img> preview; Banned shows whether the sender already holds the feedback ban.
|
|
type FeedbackDetailView struct {
|
|
ID string
|
|
AccountID string
|
|
SenderName string
|
|
Source string
|
|
Channel string
|
|
// InterfaceLanguage is the sender's interface language (account preference).
|
|
InterfaceLanguage string
|
|
IP string
|
|
Body string
|
|
HasAttachment bool
|
|
AttachmentName string
|
|
IsImage bool
|
|
Read bool
|
|
Archived bool
|
|
Replied bool
|
|
ReplyBody string
|
|
RepliedAt string
|
|
CreatedAt string
|
|
// Version is the client app build the report was sent from (empty for rows that predate it).
|
|
Version string
|
|
// The Filed time is shown in three zones so the operator can tell what is certainly known from
|
|
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
|
|
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
|
|
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
|
|
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
|
|
// default — the template then shows "N/A" so the missing datum is explicit.
|
|
CreatedAtBrowser string
|
|
BrowserTZ string
|
|
CreatedAtUser string
|
|
UserTZ string
|
|
Banned bool
|
|
}
|