${p.ends ? `Until ${fmtDate(p.ends)}` : "No expiry"}
${manage && p.steamid !== d.me ? `` : ""}
`;
const allFlags = [...d.flags];
if (g) for (const f of g.flags) if (!allFlags.some((x) => x.flag === f)) allFlags.push({ flag: f, label: f, help: "Custom permission" });
const root = g && g.flags.includes("@css/root");
v.innerHTML = `
Staff & ranks
Ranks are listed by immunity. Staff can only act on players whose immunity isn't higher than their own.${manage ? "" : " Only Root can change ranks and staff."}
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", "Confirmed 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) => `
${fmtDate(e.created)}${e.addedBy ? ` by ${esc(e.addedBy)}` : ""}
${mayEdit(e.steamid) ? `
` : ""}
`).join("")}
${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 ? 'Verification is off'
: v.verified ? VERIFIED_TAG : 'Not verified';
return `
Verification
${badge}
Steam group
${v.inGroup ? `Member confirmed with Steam ${fmtAgo(v.groupSeen)}` : 'Not confirmed as a member'}
Override
${o ? `${o.expires ? `Until ${fmtDate(o.expires)}` : "No expiry"}${o.addedBy ? ` from ${esc(o.addedBy)}` : ""}` : 'None'}
${v.canOverride ? `
${o ? `` : ""}
` : ""}
`;
}
function overrideDialog(sid, name) {
openModal(`
`);
}
/* ---------- Notifications ---------- */
// Push notifications to this staff member's devices (internal/web/notify.go, sw.js). Preferences
// apply to all their devices and save as soon as they change.
const b64urlToBytes = (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
const bytesToB64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const browserTimezone = () => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } };
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
async function sha256Hex(text) {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// This browser's push subscription, or null.
async function pushSubscription() {
const reg = await navigator.serviceWorker.getRegistration();
return reg ? reg.pushManager.getSubscription() : null;
}
async function notifications() {
const v = $("#view");
let d;
try { d = await api("/api/admin/notifications"); } catch (err) { v.innerHTML = errorBox(err); return; }
let here = false;
if (d.available && pushSupported() && window.crypto?.subtle) {
try {
const sub = await pushSubscription();
here = !!sub && d.devices.includes(await sha256Hex(sub.endpoint));
} catch { /* treat as off */ }
}
if (state.page !== "notifications") return;
state.notif = d;
const p = d.prefs;
const others = d.devices.length - (here ? 1 : 0);
const row = (label, help, control) => `
${label}${help ? `
${help}
` : ""}
${control}
`;
let device;
if (!d.available) device = row("This device", "Notifications aren't set up on the panel. Its log says why.", 'Unavailable');
else if (!pushSupported()) device = row("This device", "This browser can't get notifications. On an iPhone or iPad, add the site to your home screen and open it from there.", 'Not supported');
else if (Notification.permission === "denied") device = row("This device", "Notifications are blocked for this site. Allow them in the browser's site settings (or the app's notification settings), then come back.", 'Blocked');
else if (here) device = row("This device", "Notifications are on here.", '');
else device = row("This device", "Turn notifications on to get the ones picked below here, even with the panel closed.", '');
const dis = d.available ? "" : "disabled";
const groups = [...new Set(d.types.map((t) => t.group))];
v.innerHTML = `
Notifications
Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.
")}
${can("@css/root") ? row("Reload admins", "Re-read ranks and staff from the database. Runs css_reloadadmins.", "", '') : ""}
${can("@css/generic") ? row("Restart the match", "Restarts the game on the current map. Runs css_rr.", "", '') : ""}
${can("@css/changemap") ? row("Change map", "Switch to another map, or a Workshop map with ws:.", "", '') : ""}
You have unsaved changes.
`;
}
function markDirty() { const b = $("#savebar"); if (b) b.hidden = false; }
async function saveSettings() {
const cvars = {};
const sa = {};
document.querySelectorAll("[data-cvar].dirty").forEach((el) => {
const key = el.dataset.cvar;
if (el.classList.contains("switch")) cvars[key] = el.getAttribute("aria-checked") === "true";
else cvars[key] = el.type === "number" ? Number(el.value) : el.value;
});
document.querySelectorAll("[data-sa].dirty").forEach((el) => {
const key = el.dataset.sa;
if (el.classList.contains("switch")) sa[key] = el.getAttribute("aria-checked") === "true";
else sa[key] = Number(el.value);
});
const msgs = [];
try {
if (Object.keys(cvars).length) msgs.push((await api("/api/admin/settings/cvars", { method: "POST", body: cvars })).message);
if (Object.keys(sa).length) msgs.push((await api("/api/admin/settings/simpleadmin", { method: "POST", body: sa })).message);
} catch (err) {
toast(err.message);
return;
}
toast(msgs.join(" ") || "Nothing to save.");
settings();
}
/* ---------- Player drawer ---------- */
async function openPlayer(sid) {
$("#drawer-root").innerHTML = ``;
let d;
try { d = await api(`/api/admin/players/${encodeURIComponent(sid)}`); } catch (err) {
$(".drawer").innerHTML = errorBox(err);
return;
}
const p = d.player;
const name = p.name || p.steamid;
const history = [
...d.bans.map((b) => ({ ...b, label: "Ban" })),
...d.comms.map((c) => ({ ...c, label: COMM_LABEL[c.type] })),
...d.warns.map((w) => ({ ...w, label: "Warning" })),
].sort((a, b) => new Date(b.created) - new Date(a.created));
const t = d.canTarget;
const btn = (act, label, perm, extra = "", cls = "") => can(perm)
? `` : "";
const manage = can("@css/root") && p.steamid !== state.me.steamid;
$("#drawer-root").innerHTML = `
`;
$(".drawer .close").focus();
}
/* ---------- Dialogs ---------- */
const DURATIONS = [[30, "30 min"], [60, "1 hour"], [1440, "1 day"], [10080, "1 week"], [43200, "30 days"], [0, "Permanent"]];
const REASONS = {
ban: ["Cheating", "Ban evasion", "Toxicity", "Griefing", "Exploiting"],
comm: ["Spam", "Toxicity", "Mic spam", "Soundboard", "Advertising"],
warn: ["Toxicity", "Spam", "Griefing", "Ignoring staff"],
};
function punishDialog(kind, { sid = "", name = "", type = "GAG", reason = "" } = {}) {
const title = { ban: "Ban player", comm: "Gag or mute player", warn: "Warn player" }[kind];
const submit = { ban: "Ban player", comm: "Apply block", warn: "Warn player" }[kind];
const perm = kind === "ban" ? "@css/permban" : "@css/permmute";
openModal(`
`);
}
// preset picks the rank for someone new (current is their rank now, "" if they have none).
async function rankDialog(sid, name, current, preset = "") {
if (!staff.data) {
try { staff.data = await api("/api/admin/staff"); } catch (err) { toast(err.message); return; }
}
const groups = staff.data.groups;
const opts = (groups || []).map((g) => ``).join("");
openModal(`
`);
}
function newGroupDialog() {
openModal(`
`);
}
function textDialog(kind) {
const cfg = {
map: { title: "Change map", label: "Map", placeholder: "de_mirage, or ws:", submit: "Change map", pattern: "(ws:)?[A-Za-z0-9_\\-]{1,64}" },
say: { title: "Message everyone", label: "Message", placeholder: "Shown in chat to everyone on the server", submit: "Send", pattern: ".{1,200}" },
kick: { title: "Kick player", label: "Reason", placeholder: "Shown to the player", submit: "Kick", pattern: ".{0,200}" },
}[kind];
return cfg;
}
function inputDialog(kind, data = {}) {
const c = textDialog(kind);
openModal(`
`);
}
function confirmDialog(title, body, label, run) {
confirmDialog.run = run;
openModal(`
`);
}
function openModal(html) {
const m = $("#modal");
m.innerHTML = html;
m.showModal();
}
// act runs an API call, shows its message, and refreshes the page and any open drawer.
async function act(path, method, body) {
try {
const res = await api(path, { method, body });
toast(res.message || "Done.");
} catch (err) {
toast(err.message);
return false;
}
const drawerSid = $(".drawer [data-sid]")?.dataset.sid;
route();
if (drawerSid) openPlayer(drawerSid);
return true;
}
/* ---------- Routing & events ---------- */
const RENDER = { overview, players, bans, comms, staff, verify, settings, notifications };
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(pageAllowed).map((p) => `