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:
parent
b2b4698776
commit
9e4bc63201
15 changed files with 1270 additions and 13 deletions
|
|
@ -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><groupID64> on https://steamcommunity.com/groups/<name>/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;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue