diff --git a/README.md b/README.md
index 0fa2a1c..0161ace 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ A SourceBans-style web panel for [CS2-SimpleAdmin](https://github.com/daffyyyy/C
- **Staff panel** (`/admin`): sign in through Steam. Ban, gag, mute, silence, warn and kick; lift
bans and blocks; manage ranks, permissions and staff; change server cvars and SimpleAdmin's
punishment settings.
+- **Verification** (optional): who our GroupVerify plugin verifies, with overrides to give and
+ remove, and its settings. Only appears when GroupVerify is installed (see below).
Live data comes from the **WebPanelBridge** plugin (`plugins/WebPanelBridge` in this repo) over
RCON. Without it the panel still works, but shows the server as offline and can't kick or warn.
@@ -42,7 +44,8 @@ RCON. Without it the panel still works, but shows the server as offline and can'
| Change map | `@css/changemap` |
| Server cvars | `@css/cvar` |
| See the Staff & ranks page | `@zio/webpanel` |
- | Ranks, staff, SimpleAdmin settings, reload admins | `@css/root` |
+ | Give and remove verification overrides | `@zio/verify` (not on themselves, unless Root) |
+ | Ranks, staff, SimpleAdmin settings, reload admins, GroupVerify's group and on/off | `@css/root` |
`@css/root` grants everything. Nobody can act on a player whose immunity is higher than theirs,
or on themselves, or give a rank with more immunity than their own.
@@ -100,6 +103,34 @@ and deleting a rank that people hold). Our SimpleAdmin fork (1.8.2b-zio10+) logs
that lapse when their expiry passes, `css_delgroup`, and edits made straight in the database.
History starts when the table is created.
+### Verification (GroupVerify, optional)
+
+[GroupVerify](https://git.zio.sh/cs2/GroupVerify) (`plugins/GroupVerify` in the cs2 repo) verifies
+players who are in the server's Steam group or have a manual override, and other plugins give
+verified players perks. It keeps everything in SimpleAdmin's database (`gv_verified`,
+`gv_settings`), so the panel needs no setup for it:
+
+- **Staff** get a Verification page as soon as GroupVerify's tables are in the database: whether
+ it's on, the plugin's state and version, the Steam group, its saved member list, and the
+ overrides. The player drawer shows why a player is or isn't verified. With `@zio/verify` (give
+ it to the ranks that should have it in the rank editor), staff give overrides for a day up to a
+ year or with no expiry, change them and remove them. Root sets the group and turns verification
+ on and off.
+- **Everyone** sees a "Verified" tag on the scoreboard and on player pages, but only while
+ verification is on (enabled, with a group set) and GroupVerify isn't missing from the server's
+ plugin list. The public site never shows why someone is verified, or who gave an override.
+- **Whether it's loaded** comes from `css_plugins list` over RCON, read once a minute alongside
+ the live status. While the server is down it's unknown, and that doesn't hide anything.
+- **Changes**: overrides are written to `gv_verified` and GroupVerify picks them up on its next
+ reload (`db_reload_seconds`, 60 by default). Settings are written to `gv_settings` and, when
+ the plugin is loaded, also sent as its own `css_gv_group`/`css_gv_enable`/`css_gv_disable`, so
+ they apply at once.
+- **Times** in its tables are in GroupVerify's own `timezone` setting, which the panel follows.
+ Keep it the same as SimpleAdmin's `Timezone`.
+
+The panel never shows a group member list from Steam itself: "Steam group members" is the list
+GroupVerify last fetched and saved.
+
### Name history (optional)
SimpleAdmin keeps one row per SteamID + IP in `sa_players_ips` and overwrites its name on each
@@ -129,5 +160,16 @@ SimpleAdmin database; the file has the revert. Names are still only recorded at
go vet ./... && go test ./...
```
+`internal/web/verify_db_test.go` also runs the Verification endpoints against a real MySQL when
+`SAW_TEST_DSN` is set. It empties SimpleAdmin's admin and group tables and GroupVerify's, so give
+it a scratch database holding SimpleAdmin 1.8.2b's migrations and GroupVerify's
+`Migrations/001_CreateTables.sql`:
+
+```
+podman run -d --rm --name sa-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=sa -p 127.0.0.1:33061:3306 mysql:8.4
+# apply the migrations, then:
+SAW_TEST_DSN='root:test@tcp(127.0.0.1:33061)/sa?parseTime=true&loc=UTC' go test ./internal/web -run VerifyDB
+```
+
The pages are plain HTML, CSS and JS in `internal/web/assets`, embedded into the binary. The
design mockup they came from is in `docs/mockup` at the repo root.
diff --git a/internal/live/live.go b/internal/live/live.go
index b922e74..a864794 100644
--- a/internal/live/live.go
+++ b/internal/live/live.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"log/slog"
+ "regexp"
"strings"
"sync"
"time"
@@ -114,12 +115,46 @@ func Parse(out string) (Status, error) {
return st, nil
}
+// pluginLine is one plugin in `css_plugins list`: [#3:LOADED]: "GroupVerify" (0.2.0) by astra
+var pluginLine = regexp.MustCompile(`\[#\d+:([A-Z]+)\]: "([^"]*)" \(([^)]*)\)`)
+
+// ParsePlugins reads `css_plugins list` output into loaded plugins' versions by ModuleName. ok is
+// false when the output isn't a plugin list at all.
+func ParsePlugins(out string) (loaded map[string]string, ok bool) {
+ if !strings.Contains(out, "List of all plugins") {
+ return nil, false
+ }
+ loaded = map[string]string{}
+ for _, m := range pluginLine.FindAllStringSubmatch(out, -1) {
+ if m[1] == "LOADED" {
+ loaded[m[2]] = m[3]
+ }
+ }
+ return loaded, true
+}
+
+// Whether a CSS plugin is loaded on the server, as far as the last check knows.
+type PluginState string
+
+const (
+ PluginLoaded PluginState = "loaded"
+ PluginMissing PluginState = "missing"
+ PluginUnknown PluginState = "unknown" // server down, or not checked yet
+)
+
+// pluginInterval is how often the plugin list is reread; plugins rarely change.
+const pluginInterval = time.Minute
+
type Poller struct {
rc *rcon.Client
interval time.Duration
mu sync.RWMutex
current Status
+
+ // From css_plugins list, nil while unknown.
+ plugins map[string]string
+ pluginsAt time.Time
}
func NewPoller(rc *rcon.Client, interval time.Duration) *Poller {
@@ -169,10 +204,44 @@ func (p *Poller) Refresh() Status {
st.Error = p.current.Error
}
p.current = st
+ if !st.Online {
+ p.plugins = nil
+ }
+ checkPlugins := st.Online && time.Since(p.pluginsAt) >= pluginInterval
+ if checkPlugins {
+ p.pluginsAt = time.Now() // claimed, so concurrent refreshes don't all ask
+ }
p.mu.Unlock()
+ if checkPlugins {
+ p.refreshPlugins()
+ }
return st
}
+func (p *Poller) refreshPlugins() {
+ out, err := p.rc.Exec("css_plugins list")
+ loaded, ok := ParsePlugins(out)
+ if err != nil || !ok {
+ loaded = nil
+ }
+ p.mu.Lock()
+ p.plugins = loaded
+ p.mu.Unlock()
+}
+
+// Plugin reports whether a CSS plugin (by ModuleName) is loaded, and its version if it is.
+func (p *Poller) Plugin(name string) (PluginState, string) {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+ if p.plugins == nil {
+ return PluginUnknown, ""
+ }
+ if v, ok := p.plugins[name]; ok {
+ return PluginLoaded, v
+ }
+ return PluginMissing, ""
+}
+
func (p *Poller) Current() Status {
p.mu.RLock()
defer p.mu.RUnlock()
diff --git a/internal/live/live_test.go b/internal/live/live_test.go
index b7a86c1..a5c829e 100644
--- a/internal/live/live_test.go
+++ b/internal/live/live_test.go
@@ -36,3 +36,27 @@ func TestParseErrors(t *testing.T) {
t.Fatal("output without end marker should be an error")
}
}
+
+func TestParsePlugins(t *testing.T) {
+ out := ` List of all plugins currently loaded by CounterStrikeSharp: 3 plugins loaded.
+ [#1:LOADED]: "CS2-SimpleAdmin (RELEASE)" (1.8.2b-zio10) by daffyy
+ Simple admin plugin
+ [#2:LOADED]: "GroupVerify" (0.2.0) by astra
+ Verifies players by Steam group membership or a manual override
+ [#3:UNLOADED]: "NoGoZones" (0.14.0) by astra
+ Termination Reason: whatever
+`
+ loaded, ok := ParsePlugins(out)
+ if !ok {
+ t.Fatal("not recognised as a plugin list")
+ }
+ if loaded["GroupVerify"] != "0.2.0" || loaded["CS2-SimpleAdmin (RELEASE)"] != "1.8.2b-zio10" {
+ t.Fatalf("loaded %v", loaded)
+ }
+ if _, ok := loaded["NoGoZones"]; ok {
+ t.Fatal("unloaded plugins must not count as loaded")
+ }
+ if _, ok := ParsePlugins("Unknown command \"css_plugins\"\n"); ok {
+ t.Fatal("an error reply isn't a plugin list")
+ }
+}
diff --git a/internal/store/verify.go b/internal/store/verify.go
new file mode 100644
index 0000000..1d20a65
--- /dev/null
+++ b/internal/store/verify.go
@@ -0,0 +1,268 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GroupVerify's tables (plugins/GroupVerify in the cs2 repo) live in the same database. The plugin
+// creates them; the panel reads them, writes 'manual' override rows and changes gv_settings. Their
+// times are wall-clock values in gv_settings' timezone, the zone GroupVerify itself uses, so they're
+// read and written in that location rather than SimpleAdmin's.
+
+// VerifySettings is gv_settings.
+type VerifySettings struct {
+ Enabled bool
+ GroupID string // "" when no group is set
+ ReloadSeconds int // how often GroupVerify rereads its tables
+ Location *time.Location
+}
+
+// Active matches GroupVerify: it only verifies anyone when enabled with a group set.
+func (v VerifySettings) Active() bool { return v.Enabled && v.GroupID != "" }
+
+// HasVerifyTables reports whether GroupVerify's tables exist in this database.
+func (s *Store) HasVerifyTables(ctx context.Context) (bool, error) {
+ var n int
+ err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
+ WHERE table_schema = DATABASE() AND table_name IN ('gv_verified', 'gv_settings')`).Scan(&n)
+ return n == 2, err
+}
+
+// VerifySettings reads gv_settings, with GroupVerify's defaults for anything missing.
+func (s *Store) VerifySettings(ctx context.Context) (VerifySettings, error) {
+ v := VerifySettings{Enabled: true, ReloadSeconds: 60, Location: time.UTC}
+ rows, err := s.db.QueryContext(ctx, `SELECT name, value FROM gv_settings`)
+ if err != nil {
+ return v, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var name, value string
+ if err := rows.Scan(&name, &value); err != nil {
+ return v, err
+ }
+ value = strings.TrimSpace(value)
+ switch name {
+ case "enabled":
+ v.Enabled = value != "0"
+ case "group_id64":
+ if n, err := strconv.ParseUint(value, 10, 64); err == nil && n != 0 {
+ v.GroupID = value
+ }
+ case "db_reload_seconds":
+ if n, err := strconv.Atoi(value); err == nil && n >= 10 {
+ v.ReloadSeconds = n
+ }
+ case "timezone":
+ if loc, err := time.LoadLocation(value); err == nil {
+ v.Location = loc
+ }
+ }
+ }
+ return v, rows.Err()
+}
+
+// SetVerifySetting writes one gv_settings value.
+func (s *Store) SetVerifySetting(ctx context.Context, name, value string) error {
+ _, err := s.db.ExecContext(ctx, `INSERT INTO gv_settings (name, value) VALUES (?, ?)
+ ON DUPLICATE KEY UPDATE value = VALUES(value)`, name, value)
+ return err
+}
+
+func wallIn(t time.Time, loc *time.Location) time.Time {
+ t = t.In(loc)
+ return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, time.UTC)
+}
+
+func fromWallIn(t time.Time, loc *time.Location) time.Time {
+ return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
+}
+
+// VerifyEntry is a row of gv_verified: an override, or a Steam group member.
+type VerifyEntry struct {
+ SteamID string `json:"steamid"`
+ Name string `json:"name"` // the last name SimpleAdmin saw, "" if they never joined
+ Source string `json:"source"`
+ Expires *time.Time `json:"expires,omitempty"` // overrides only; nil never ends
+ AddedBy string `json:"addedBy,omitempty"`
+ Created time.Time `json:"created"`
+ Seen time.Time `json:"seen"` // group rows: last seen in the group's member list
+}
+
+// VerifyStatus is why a player is or isn't verified, as far as the database knows.
+type VerifyStatus struct {
+ InGroup bool `json:"inGroup"`
+ GroupSeen *time.Time `json:"groupSeen,omitempty"`
+ Override *VerifyEntry `json:"override,omitempty"` // the unexpired override, if any
+}
+
+func (v VerifyStatus) Verified() bool { return v.InGroup || v.Override != nil }
+
+// VerifyStatuses looks up several players at once. Players with no rows are left out.
+func (s *Store) VerifyStatuses(ctx context.Context, loc *time.Location, steamids []string) (map[string]VerifyStatus, error) {
+ out := map[string]VerifyStatus{}
+ if len(steamids) == 0 {
+ return out, nil
+ }
+ in, args := inList(steamids)
+ rows, err := s.db.QueryContext(ctx, `SELECT steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at
+ FROM gv_verified WHERE steamid64 IN (`+in+`)`, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ now := time.Now()
+ for rows.Next() {
+ var id int64
+ var e VerifyEntry
+ var override bool
+ var expires sql.NullTime
+ if err := rows.Scan(&id, &e.Source, &override, &expires, &e.AddedBy, &e.Created, &e.Seen); err != nil {
+ return nil, err
+ }
+ e.SteamID = strconv.FormatInt(id, 10)
+ e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc)
+ if expires.Valid {
+ t := fromWallIn(expires.Time, loc)
+ e.Expires = &t
+ }
+ st := out[e.SteamID]
+ if e.Source == "group" {
+ st.InGroup = true
+ st.GroupSeen = &e.Seen
+ }
+ // The same rule as GroupVerify: any unexpired override row counts, the longest one wins.
+ if override && (e.Expires == nil || e.Expires.After(now)) {
+ if st.Override == nil || (st.Override.Expires != nil && (e.Expires == nil || e.Expires.After(*st.Override.Expires))) {
+ entry := e
+ st.Override = &entry
+ }
+ }
+ out[e.SteamID] = st
+ }
+ return out, rows.Err()
+}
+
+// What VerifyList lists.
+const (
+ VerifyOverrides = "overrides" // unexpired rows with override = 1
+ VerifyGroup = "group" // the Steam group's members, as of GroupVerify's last fetch
+)
+
+// VerifyList lists overrides or group members, newest first. Search matches a SteamID64 or a name
+// SimpleAdmin has seen them use.
+func (s *Store) VerifyList(ctx context.Context, loc *time.Location, kind, search string, lim, offset int) ([]VerifyEntry, int, error) {
+ var conds []string
+ var args []any
+ if kind == VerifyGroup {
+ conds = append(conds, "v.source = 'group'")
+ } else {
+ conds = append(conds, "v.`override` = 1", "(v.expires_at IS NULL OR v.expires_at > ?)")
+ args = append(args, wallIn(time.Now(), loc))
+ }
+ if t := strings.TrimSpace(search); t != "" {
+ or := "v.steamid64 IN (SELECT steamid FROM sa_players_ips WHERE name LIKE ?)"
+ orArgs := []any{"%" + escapeLike(t) + "%"}
+ if n, err := strconv.ParseInt(t, 10, 64); err == nil {
+ or += " OR v.steamid64 = ?"
+ orArgs = append(orArgs, n)
+ }
+ conds = append(conds, "("+or+")")
+ args = append(args, orArgs...)
+ }
+ where := " WHERE " + strings.Join(conds, " AND ")
+
+ var total int
+ if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM gv_verified v"+where, args...).Scan(&total); err != nil {
+ return nil, 0, err
+ }
+ rows, err := s.db.QueryContext(ctx, `SELECT v.steamid64, v.source, v.expires_at, v.added_by, v.created_at, v.updated_at,
+ COALESCE((SELECT p.name FROM sa_players_ips p WHERE p.steamid = v.steamid64 ORDER BY p.used_at DESC LIMIT 1), '')
+ FROM gv_verified v`+where+` ORDER BY v.created_at DESC, v.steamid64 LIMIT ? OFFSET ?`,
+ append(args, limit(lim), offset)...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer rows.Close()
+ out := []VerifyEntry{}
+ for rows.Next() {
+ var id int64
+ var e VerifyEntry
+ var expires sql.NullTime
+ if err := rows.Scan(&id, &e.Source, &expires, &e.AddedBy, &e.Created, &e.Seen, &e.Name); err != nil {
+ return nil, 0, err
+ }
+ e.SteamID = strconv.FormatInt(id, 10)
+ e.Created, e.Seen = fromWallIn(e.Created, loc), fromWallIn(e.Seen, loc)
+ if expires.Valid {
+ t := fromWallIn(expires.Time, loc)
+ e.Expires = &t
+ }
+ out = append(out, e)
+ }
+ return out, total, rows.Err()
+}
+
+// VerifyCounts is how many group members and unexpired overrides there are, and when GroupVerify
+// last saved the group's member list (nil if it never has).
+func (s *Store) VerifyCounts(ctx context.Context, loc *time.Location) (group, overrides int, fetched *time.Time, err error) {
+ var last sql.NullTime
+ err = s.db.QueryRowContext(ctx, `SELECT
+ COALESCE(SUM(source = 'group'), 0),
+ COALESCE(SUM(`+"`override`"+` = 1 AND (expires_at IS NULL OR expires_at > ?)), 0),
+ MAX(CASE WHEN source = 'group' THEN updated_at END)
+ FROM gv_verified`, wallIn(time.Now(), loc)).Scan(&group, &overrides, &last)
+ if last.Valid {
+ t := fromWallIn(last.Time, loc)
+ fetched = &t
+ }
+ return group, overrides, fetched, err
+}
+
+// SetOverride gives a player a manual override ending at expires (nil: never), or changes the one
+// they have. created_at keeps when it was first given.
+func (s *Store) SetOverride(ctx context.Context, loc *time.Location, steamid string, expires *time.Time, addedBy string) error {
+ n, err := parseSID(steamid)
+ if err != nil {
+ return err
+ }
+ var ends any
+ if expires != nil {
+ ends = wallIn(*expires, loc)
+ }
+ now := wallIn(time.Now(), loc)
+ _, err = s.db.ExecContext(ctx, `INSERT INTO gv_verified (steamid64, source, `+"`override`"+`, expires_at, added_by, created_at, updated_at)
+ VALUES (?, 'manual', 1, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE `+"`override`"+` = 1, expires_at = VALUES(expires_at), added_by = VALUES(added_by), updated_at = VALUES(updated_at)`,
+ n, ends, addedBy, now, now)
+ return err
+}
+
+// RemoveOverride takes a player's override away: deletes their manual row, and clears override on
+// their group row in case it was set by hand. Reports whether there was one.
+func (s *Store) RemoveOverride(ctx context.Context, steamid string) (bool, error) {
+ n, err := parseSID(steamid)
+ if err != nil {
+ return false, err
+ }
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return false, err
+ }
+ defer tx.Rollback()
+ res, err := tx.ExecContext(ctx, `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`, n)
+ if err != nil {
+ return false, err
+ }
+ deleted, _ := res.RowsAffected()
+ res, err = tx.ExecContext(ctx, `UPDATE gv_verified SET `+"`override`"+` = 0 WHERE steamid64 = ? AND source = 'group' AND `+"`override`"+` = 1`, n)
+ if err != nil {
+ return false, err
+ }
+ cleared, _ := res.RowsAffected()
+ return deleted+cleared > 0, tx.Commit()
+}
diff --git a/internal/web/admin.go b/internal/web/admin.go
index d8f99af..577b267 100644
--- a/internal/web/admin.go
+++ b/internal/web/admin.go
@@ -35,6 +35,7 @@ func nonNil[T any](v []T) []T {
func (s *Server) adminOverview(w http.ResponseWriter, r *http.Request, id *Identity, x *staffIndex) {
ctx := r.Context()
view := s.serverView(x, s.poll.Current())
+ s.markVerified(ctx, view.Players)
var ids []string
for _, p := range view.Players {
@@ -238,6 +239,12 @@ func (s *Server) adminPlayer(w http.ResponseWriter, r *http.Request, id *Identit
resp["groups"] = a.Groups
resp["adminEnds"] = a.Ends
}
+ if v, err := s.playerVerify(ctx, id, steamid); err != nil {
+ internal(w, r, err)
+ return
+ } else if v != nil {
+ resp["verify"] = v
+ }
writeJSON(w, http.StatusOK, resp)
}
@@ -299,6 +306,7 @@ var knownFlags = [][3]string{
{"@zio/vote", "Start votes", "css_vote"},
{"@zio/plugins", "Plugins", "css_plugins and css"},
{"@zio/webpanel", "Staff page", "See the Staff & ranks page in this panel"},
+ {"@zio/verify", "Verification overrides", "Give and remove GroupVerify overrides in this panel"},
{"@css/root", "Root", "Every permission, and managing ranks and staff"},
}
diff --git a/internal/web/assets/admin.js b/internal/web/assets/admin.js
index 24b98b2..797cba9 100644
--- a/internal/web/assets/admin.js
+++ b/internal/web/assets/admin.js
@@ -7,14 +7,19 @@ const PAGES = [
{ id: "bans", label: "Bans" },
{ id: "comms", label: "Gags & mutes" },
{ id: "staff", label: "Staff & ranks", flag: "@zio/webpanel" },
+ // Only when GroupVerify's tables are in the database.
+ { id: "verify", label: "Verification", show: () => !!state.me?.verify },
{ id: "settings", label: "Server settings" },
];
+const pageAllowed = (p) => (!p.flag || can(p.flag)) && (!p.show || p.show());
+
const state = {
page: "overview",
banFilter: "active",
commFilter: "active",
playerFilter: "online",
+ verifyView: "overrides",
query: "",
num: 1,
group: null,
@@ -75,7 +80,7 @@ function onlineTable(players) {
GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.
+
+
+
${loading()}
+
+
+ ${searchBox("Search by name or SteamID64", state.query)}
+ ${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])}
+
+
${loading()}
+ `;
+ loadResults();
+}
+
+const PLUGIN_STATE = {
+ loaded: (v) => `Loaded${esc(v)}`,
+ missing: () => 'Not loaded',
+ unknown: () => 'Unknownthe game server isn\'t answering',
+};
+
+// verifyStatus is the top of the page, drawn once per visit so typing in the group field survives
+// searches and paging.
+function verifyStatus(st) {
+ const box = $("#verify-status");
+ if (!box || box.dataset.drawn) return;
+ box.dataset.drawn = "1";
+ $("#verify-actions").innerHTML = st.canOverride ? `` : "";
+ const warn = [];
+ if (st.plugin === "missing") warn.push("GroupVerify isn't loaded on the game server, so nobody is verified in game, and the public site doesn't show verification.");
+ if (!st.enabled) warn.push("Verification is turned off: nobody is verified, overrides included.");
+ else if (!st.groupId) warn.push("No Steam group is set, so verification is off: nobody is verified, overrides included.");
+ const group = st.groupId
+ ? `${esc(st.groupId)}`
+ : 'Not set';
+ box.innerHTML = `
+ ${warn.map((w) => `