GroupVerify: Verification page, overrides and public Verified tags

Shown only where GroupVerify is: staff get the Verification page once its gv_* tables are in
the database, and the public site shows "Verified" only while it's on (enabled, with a group)
and not missing from css_plugins list, which the poller reads once a minute over RCON.

- Verification page: plugin state and version, Steam group, saved member list, overrides.
- Overrides (@zio/verify, not on yourself unless Root): give, change, remove; player drawer
  shows why a player is or isn't verified.
- Root sets the group and turns it on/off: saved to gv_settings, and sent as css_gv_* when the
  plugin is loaded so it applies at once.
- gv_* times use GroupVerify's own timezone setting.
- MySQL integration test (SAW_TEST_DSN), unit tests for plugin list parsing and the show rule.
This commit is contained in:
Astra 2026-10-02 17:59:26 +01:00
parent b2b4698776
commit 9e4bc63201
15 changed files with 1270 additions and 13 deletions

View file

@ -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) {
<thead><tr><th>Player</th><th class="hide-sm">Rank</th><th class="num">K / D</th><th class="num hide-sm">Ping</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.map((p) => `<tr class="clickable" data-player="${esc(p.steamid)}">
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${tagsFor(p)}</div></div></td>
<td><div class="who"><span class="team ${p.team === 3 ? "ct" : p.team === 2 ? "t" : ""}">${teamName[p.team] || "SPEC"}</span><div><b>${esc(p.name)}</b> ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}</div></div></td>
<td class="hide-sm">${rankTag(p.rank)}</td>
<td class="num">${p.kills} / ${p.deaths}</td>
<td class="num hide-sm ${p.ping > 80 ? "" : "muted"}">${p.ping}</td>
@ -246,12 +251,18 @@ async function loadResults() {
let path, status, render;
if (state.page === "players") {
path = "/api/admin/players"; render = playersTable;
} else if (state.page === "verify") {
path = "/api/admin/verify"; status = undefined; render = verifyResults;
} else if (state.page === "bans") {
path = "/api/admin/bans"; status = state.banFilter; render = (l) => punishTable(l, "ban");
} else {
path = "/api/admin/comms"; status = state.commFilter; render = (l) => punishTable(l, "comm");
}
const params = { status, q: state.query, page: state.num, filter: state.page === "players" ? state.playerFilter : undefined };
const params = {
status, q: state.query, page: state.num,
filter: state.page === "players" ? state.playerFilter : undefined,
view: state.page === "verify" ? state.verifyView : undefined,
};
const want = JSON.stringify([state.page, params]);
loadResults.want = want;
try {
@ -355,6 +366,141 @@ async function staff() {
</div>` : `<div class="empty">SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}</div>`}`;
}
/* ---------- Verification (GroupVerify) ---------- */
const VERIFY_DAYS = [[1, "1 day"], [7, "1 week"], [30, "30 days"], [90, "90 days"], [365, "1 year"], [0, "No expiry"]];
function verify() {
$("#view").innerHTML = `
<div class="page-head">
<div><h1>Verification</h1><p class="sub">GroupVerify verifies players who are in the server's Steam group, or have an override given here. Other plugins give verified players their perks.</p></div>
<div id="verify-actions"></div>
</div>
<div class="stack" id="verify-status">${loading()}</div>
<section class="panel" style="margin-top:18px">
<div class="toolbar">
${searchBox("Search by name or SteamID64", state.query)}
${segmented("verifyView", state.verifyView, [["overrides", "Overrides"], ["group", "Steam group members"]])}
</div>
<div class="table-wrap" id="results">${loading()}</div>
</section>`;
loadResults();
}
const PLUGIN_STATE = {
loaded: (v) => `<span class="tag green">Loaded</span> <span class="muted">${esc(v)}</span>`,
missing: () => '<span class="tag red">Not loaded</span>',
unknown: () => '<span class="tag">Unknown</span> <span class="muted">the game server isn\'t answering</span>',
};
// 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 ? `<button class="btn primary" type="button" data-act="override">${ICON.plus}Give override</button>` : "";
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
? `<a href="https://steamcommunity.com/gid/${esc(st.groupId)}" target="_blank" rel="noopener">${esc(st.groupId)}</a>`
: '<span class="faint">Not set</span>';
box.innerHTML = `
${warn.map((w) => `<div class="panel"><div class="panel-body"><p style="margin:0"><span class="tag amber">Off</span> ${esc(w)}</p></div></div>`).join("")}
<section class="panel">
<div class="panel-head"><h2>GroupVerify</h2>${st.active && st.plugin !== "missing" ? '<span class="tag green">On</span>' : '<span class="tag amber">Off</span>'}</div>
<div class="panel-body">
<dl class="kv">
<dt>Plugin</dt><dd>${PLUGIN_STATE[st.plugin](st.version)}</dd>
<dt>Steam group</dt><dd>${group}</dd>
<dt>Group members</dt><dd>${st.groupMembers}${st.groupFetched ? ` <span class="muted">list saved ${fmtAgo(st.groupFetched)}</span>` : ' <span class="faint">never fetched</span>'}</dd>
<dt>Overrides</dt><dd>${st.overrides}</dd>
<dt>Changes apply</dt><dd>Within ${st.reloadSeconds} seconds <span class="muted">(times in ${esc(st.timezone)})</span></dd>
</dl>
${st.canConfigure ? `<form id="gv-form" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:16px">
<label class="field" style="flex:1;min-width:240px"><span>Steam group ID64</span>
<input class="input" name="group" value="${esc(st.groupId)}" inputmode="numeric" pattern="[0-9]{0,20}" placeholder="103582791…, blank or 0 turns verification off">
<small>&lt;groupID64&gt; on https://steamcommunity.com/groups/&lt;name&gt;/memberslistxml/?xml=1</small>
</label>
<button class="btn" type="submit">Save group</button>
<button class="btn ${st.enabled ? "danger" : "primary"}" type="button" data-act="gv-toggle" data-on="${st.enabled ? "0" : "1"}">${st.enabled ? "Turn off" : "Turn on"}</button>
</form>` : '<p class="muted" style="margin:16px 0 0">Only Root can change the group or turn verification on and off.</p>'}
</div>
</section>`;
}
function verifyResults(d) {
verifyStatus(d.status);
const list = d.list;
const groupView = state.verifyView === "group";
if (!list.items.length) {
if (state.query) return `<div class="empty">Nobody matches "${esc(state.query)}".</div>`;
return `<div class="empty">${groupView ? "No group members saved yet. GroupVerify saves the list each time it fetches it." : "No overrides."}</div>`;
}
const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root"));
const who = (e) => `<td><div class="who"><div><b><a href="#" data-player="${esc(e.steamid)}">${e.name ? esc(e.name) : '<span class="faint">Never joined</span>'}</a></b><span class="sid">${esc(e.steamid)}</span></div></div></td>`;
if (groupView) {
return `<table>
<thead><tr><th>Player</th><th>In the group since</th><th class="hide-sm">Last seen in the list</th></tr></thead>
<tbody>${list.items.map((e) => `<tr>${who(e)}<td>${fmtDate(e.created)}</td><td class="hide-sm muted">${fmtAgo(e.seen)}</td></tr>`).join("")}</tbody>
</table>${pager(list)}`;
}
return `<table>
<thead><tr><th>Player</th><th>Ends</th><th class="hide-sm">Given</th><th><span class="sr">Actions</span></th></tr></thead>
<tbody>
${list.items.map((e) => `<tr>
${who(e)}
<td>${e.expires ? `${fmtDate(e.expires)}<br><small class="muted">${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}</small>` : "Never"}</td>
<td class="hide-sm">${fmtDate(e.created)}${e.addedBy ? `<br><small class="muted">by ${esc(e.addedBy)}</small>` : ""}</td>
<td><div class="row-actions">${mayEdit(e.steamid) ? `
<button class="btn sm ghost" type="button" data-act="override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Change</button>
<button class="btn sm danger" type="button" data-act="remove-override" data-sid="${esc(e.steamid)}" data-name="${esc(e.name)}">Remove</button>` : ""}
</div></td>
</tr>`).join("")}
</tbody>
</table>${pager(list)}`;
}
// verifySection is the Verification part of a player's drawer.
function verifySection(v, sid, name) {
if (!v) return "";
const o = v.override;
const badge = !v.active ? '<span class="tag amber">Verification is off</span>'
: v.verified ? VERIFIED_TAG : '<span class="tag">Not verified</span>';
return `<section>
<h3>Verification</h3>
<p style="margin:0 0 10px">${badge}</p>
<dl class="kv">
<dt>Steam group</dt><dd>${v.inGroup ? `Member <span class="muted">seen in the list ${fmtAgo(v.groupSeen)}</span>` : '<span class="faint">Not a member</span>'}</dd>
<dt>Override</dt><dd>${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` <span class="muted">from ${esc(o.addedBy)}</span>` : ""}` : '<span class="faint">None</span>'}</dd>
</dl>
${v.canOverride ? `<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn" type="button" data-act="override" data-sid="${esc(sid)}" data-name="${esc(name)}">${o ? "Change override" : "Give override"}</button>
${o ? `<button class="btn danger" type="button" data-act="remove-override" data-sid="${esc(sid)}" data-name="${esc(name)}">Remove override</button>` : ""}
</div>` : ""}
</section>`;
}
function overrideDialog(sid, name) {
openModal(`
<form method="dialog" data-submit="override">
<div class="modal-head"><h2>${sid ? "Override" : "Give override"}${name ? `: ${esc(name)}` : ""}</h2><button class="close" type="button" data-modal-close aria-label="Close">×</button></div>
<div class="modal-body">
<label class="field"><span>Player</span>
<input class="input" name="steamid" required pattern="7656119[0-9]{10}" value="${esc(sid)}" placeholder="SteamID64, like 76561198012345678" ${sid ? "readonly" : ""}>
<small>They count as verified whether or not they're in the Steam group, until it ends.</small>
</label>
<div class="field"><span>Length</span>
<div class="chips" data-chips="days">${VERIFY_DAYS.map(([v, l]) => `<button type="button" class="chip-btn" data-value="${v}" aria-pressed="${v === 30}">${l}</button>`).join("")}</div>
<small>Changing an override sets a new end from now.</small>
</div>
</div>
<div class="modal-foot"><button class="btn ghost" type="button" data-modal-close>Cancel</button><button class="btn primary" type="submit">Save override</button></div>
</form>`);
}
/* ---------- Settings ---------- */
async function settings() {
@ -472,7 +618,7 @@ async function openPlayer(sid) {
<aside class="drawer" role="dialog" aria-modal="true" aria-label="${esc(name)}">
<div class="drawer-head">
<span class="avatar lg">${initial(name)}</span>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}</div></div>
<div><h2>${esc(name)}</h2><div>${rankTag(d.rank)} ${d.online ? '<span class="tag green" style="margin-left:6px">Online</span>' : ""}${d.verify?.verified ? ` ${VERIFIED_TAG}` : ""}</div></div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
<section>
@ -498,6 +644,7 @@ async function openPlayer(sid) {
${d.adminEnds ? `<dt>Rank expires</dt><dd>${fmtDate(d.adminEnds)}</dd>` : ""}
</dl>
</section>
${verifySection(d.verify, p.steamid, name)}
${manage ? `<section>
<h3>Rank</h3>
<div style="display:flex;gap:8px">
@ -640,12 +787,12 @@ async function act(path, method, body) {
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, settings };
const RENDER = { overview, players, bans, comms, staff, verify, settings };
function renderNav() {
const c = state.counts;
const count = { players: c.players, bans: c.bans, comms: c.comms, staff: c.staff };
$("#nav-links").innerHTML = PAGES.filter((p) => !p.flag || can(p.flag)).map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
$("#nav-links").innerHTML = PAGES.filter(pageAllowed).map((p) => `<li><a class="link" href="#${p.id}" ${p.id === state.page ? 'aria-current="page"' : ""}>${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `<span class="count">${esc(count[p.id])}</span>` : ""}</a></li>`).join("");
}
// The mobile sidebar covers the Menu button, so it closes from its own Close button, the
@ -658,7 +805,7 @@ function setNav(open) {
function route() {
const page = location.hash.slice(1);
const def = PAGES.find((p) => p.id === page);
state.page = def && (!def.flag || can(def.flag)) ? page : "overview";
state.page = def && pageAllowed(def) ? page : "overview";
renderNav();
setNav(false);
if (!$("#view").innerHTML) $("#view").innerHTML = loading();
@ -763,6 +910,19 @@ document.addEventListener("click", (e) => {
case "restart":
confirmDialog.reason = false;
return confirmDialog("Restart the match", "Restart the game on the current map? The current match ends.", "Restart match", () => act("/api/admin/restart", "POST", {}));
case "override": return overrideDialog(d.sid || "", d.name || "");
case "remove-override":
confirmDialog.reason = false;
return confirmDialog("Remove override", `Remove <b>${esc(d.name || d.sid)}</b>'s override? They stay verified only if they're in the Steam group.`, "Remove",
() => act(`/api/admin/verify/overrides/${encodeURIComponent(d.sid)}`, "DELETE"));
case "gv-toggle": {
const on = d.on === "1";
confirmDialog.reason = false;
return confirmDialog(on ? "Turn verification on" : "Turn verification off", on
? "Turn GroupVerify on? Players in the Steam group and with overrides become verified."
: "Turn GroupVerify off? Nobody is verified while it's off, overrides included, and verified players lose their perks.",
on ? "Turn on" : "Turn off", () => act("/api/admin/verify/settings", "POST", { enabled: on }));
}
case "save-settings": return saveSettings();
case "discard": return settings();
}
@ -779,6 +939,11 @@ document.addEventListener("submit", async (e) => {
const kind = form.dataset.submit;
const f = form.elements;
if (form.id === "gv-form") {
await act("/api/admin/verify/settings", "POST", { groupId: f.group.value.trim() });
return;
}
if (form.id === "group-form") {
const flags = [...form.querySelectorAll('input[name="flag"]')].filter((i) => i.checked && (!i.disabled || i.dataset.kept)).map((i) => i.value);
await act(`/api/admin/groups/${form.dataset.id}`, "PUT", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags });
@ -811,6 +976,11 @@ document.addEventListener("submit", async (e) => {
case "map": await act("/api/admin/map", "POST", { map: f.value.value.trim() }); break;
case "say": await act("/api/admin/say", "POST", { message: f.value.value }); break;
case "kick": await act("/api/admin/kick", "POST", { steamid: form.dataset.sid, reason: f.value.value }); break;
case "override": {
const days = form.querySelector('[data-chips="days"] [aria-pressed="true"]');
await act("/api/admin/verify/overrides", "POST", { steamid: f.steamid.value.trim(), days: Number(days?.dataset.value ?? 30) });
break;
}
case "confirm": await confirmDialog.run?.(f); break;
}
});

View file

@ -125,6 +125,9 @@ function statusTag(p) {
}
const COMM_LABEL = { GAG: "Gag", MUTE: "Mute", SILENCE: "Silence" };
// Verified by GroupVerify: in the server's Steam group, or given an override by staff.
const VERIFIED_TAG = '<span class="tag verified" title="In the server\'s Steam group, or verified by staff">Verified</span>';
const COMM_HELP = { GAG: "Text chat blocked", MUTE: "Voice blocked", SILENCE: "Text and voice blocked" };
function segmented(name, value, options) {

View file

@ -38,7 +38,7 @@ async function server() {
<thead><tr><th>Player</th><th class="num">Kills</th><th class="num">Deaths</th><th class="num hide-sm">Ping</th></tr></thead>
<tbody>
${list.map((p) => `<tr ${p.bot ? "" : `class="clickable" data-player="${esc(p.steamid)}"`}>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td><div class="who"><div><b>${esc(p.name)}</b>${p.verified ? ` ${VERIFIED_TAG}` : ""}${p.bot ? '<span class="sid">Bot</span>' : p.rank ? rankTag(p.rank) : ""}</div></div></td>
<td class="num">${p.kills}</td>
<td class="num">${p.deaths}</td>
<td class="num hide-sm muted">${p.bot ? "" : p.ping}</td>
@ -238,8 +238,8 @@ async function openPlayer(sid) {
<div><h2>${esc(name)}</h2><span class="sid">${esc(p.steamid)} · <a href="${steamProfile(p.steamid)}" target="_blank" rel="noopener">Steam profile</a></span>${p.rank ? `<div>${rankTag(p.rank)}</div>` : ""}</div>
<button class="close" type="button" data-close aria-label="Close">×</button>
</div>
${now.length || p.online ? `<section>
<div class="chips">${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
${now.length || p.online || p.verified ? `<section>
<div class="chips">${p.verified ? VERIFIED_TAG : ""}${p.online ? '<span class="tag">On the server</span>' : ""}${now.map((h) => `<span class="tag ${h.kind === "ban" ? "red" : "amber"}">${h.kind === "ban" ? "Banned" : COMM_HELP[h.type]}${h.duration ? `, ${fmtLeft(served(h).left)}` : ", permanently"}</span>`).join("")}</div>
</section>` : ""}
<section>
<h3>History</h3>

View file

@ -427,6 +427,7 @@ td.num, th.num { text-align: right; white-space: nowrap; }
.tag.red { background: var(--accent-soft); color: #ff5a78; }
.tag.amber { background: var(--amber-soft); color: var(--amber); }
.tag.green { background: var(--green-soft); color: var(--green); }
.tag.verified { background: rgba(79, 179, 217, .14); color: #4fb3d9; }
.rank {
display: inline-flex;