// Staff panel: hash-routed pages over the /api/admin endpoints. Buttons only appear for what the // signed-in rank may do, but the server checks every action again. const PAGES = [ { id: "overview", label: "Overview" }, { id: "players", label: "Players" }, { 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" }, { id: "notifications", label: "Notifications" }, ]; 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, // Collapsed panels, see foldHead. fold: { ranks: false, rank: true, members: true, sa: true, connections: true, maintenance: true, rcon: true }, me: null, counts: {}, }; const can = (flag) => !!state.me && (state.me.perms.includes("@css/root") || state.me.perms.includes(flag)); /* ---------- Overview ---------- */ const ACTIVITY_VERB = { ban: "banned", warn: "warned", unban: "unbanned", comm: { GAG: "gagged", MUTE: "muted", SILENCE: "silenced" }, unmute: { GAG: "lifted the gag on", MUTE: "lifted the mute on", SILENCE: "lifted the silence on" }, }; const ACTIVITY_COLOR = { ban: "var(--accent)", comm: "var(--amber)", warn: "var(--faint)", unban: "var(--green)", unmute: "var(--green)" }; function activityLine(a) { const verb = typeof ACTIVITY_VERB[a.kind] === "object" ? ACTIVITY_VERB[a.kind][a.type] : ACTIVITY_VERB[a.kind]; const len = (a.kind === "ban" || a.kind === "comm") ? (a.duration === 0 ? " permanently" : ` for ${fmtDuration(a.duration)}`) : ""; return `
  • ${esc(a.admin)} ${verb} ${esc(a.name || a.steamid)}${len}

    ${fmtAgo(a.at)}${a.reason ? `, ${esc(a.reason)}` : ""}
  • `; } function liveHeader(s) { if (!s.online) { return `

    The panel can't reach the game server, so live status and in-game actions are off. Bans and blocks still save to the database.

    Offline
    `; } return `

    ${esc(s.hostname)}${s.warmup ? ", warmup" : ""}

    ${esc(s.map)}
    ${s.count}/${s.maxPlayers}Players
    ${s.scoreT} : ${s.scoreCt}${s.warmup ? "Warmup, no rounds yet" : "Rounds won, T vs CT"}
    ${s.staffOnline}Staff online
    `; } function tagsFor(p) { return (p.tags || []).map((t) => { if (t.startsWith("WARN:")) { const n = t.slice(5); return `${esc(n)} warn${n === "1" ? "" : "s"}`; } return `${COMM_LABEL[t]}`; }).join(" "); } function onlineTable(players) { const list = players.filter((p) => !p.bot).sort((a, b) => (a.team === b.team ? b.kills - a.kills : b.team - a.team)); if (!list.length) return '
    Nobody is on the server.
    '; const teamName = { 2: "T", 3: "CT" }; return ` ${list.map((p) => ``).join("")}
    PlayerRankK / DPingActions
    ${teamName[p.team] || "SPEC"}
    ${esc(p.name)} ${p.verified ? VERIFIED_TAG : ""} ${tagsFor(p)}
    ${rankTag(p.rank)} ${p.kills} / ${p.deaths} ${p.ping}
    ${p.canTarget ? ` ${can("@css/chat") ? `` : ""} ${can("@css/kick") ? `` : ""} ${can("@css/ban") ? `` : ""}` : ""}
    `; } async function overview() { const v = $("#view"); let d; try { d = await api("/api/admin/overview"); } catch (err) { v.innerHTML = errorBox(err); return; } if (state.page !== "overview") return; state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" }; renderNav(); setServerPick(d.server); const s = d.server; v.innerHTML = ` ${liveHeader(s)}

    On the server

    ${can("@css/changemap") ? '' : ""} ${can("@css/chat") ? '' : ""}
    ${s.online ? onlineTable(s.players) : '
    Offline.
    '}

    Recent actions

    All bans
    ${d.activity.length ? `
      ${d.activity.map(activityLine).join("")}
    ` : '
    Nothing yet.
    '}

    Ending soon

    ${d.activeBans} bans, ${d.activeComms} blocks active

    ${d.expiring.length ? `
      ${d.expiring.map((p) => `
    • ${esc(p.name || p.steamid)} ${p.kind === "ban" ? "ban" : COMM_LABEL[p.type].toLowerCase()}${fmtLeft(served(p).left)}
    • `).join("")}
    ` : '
    No timed bans or blocks.
    '}
    `; } function setServerPick(s) { const el = $("#server-pick"); el.innerHTML = s.online ? `${esc(s.hostname)}${esc(s.map)}, ${s.count}/${s.maxPlayers} players` : `Server offlineLive status unavailable`; } /* ---------- Players ---------- */ function players() { $("#view").innerHTML = `

    Players

    Everyone SimpleAdmin has seen join, with their rank and record. Search by name, SteamID64${can("@css/showip") ? " or IP address" : ""}.

    ${searchBox("Search players", state.query)} ${segmented("playerFilter", state.playerFilter, [["online", "Online"], ["ranked", "Ranked"], ["all", "All"]])}
    ${loading()}
    `; loadResults(); } function playersTable(list) { if (!list.items.length) { if (state.query) return `
    No players match "${esc(state.query)}". Try a SteamID64 or part of a name.
    `; return `
    ${state.playerFilter === "online" ? "Nobody is on the server." : "No players yet."}
    `; } return ` ${list.items.map((p) => ``).join("")}
    PlayerRankRecordLast seen
    ${initial(p.name)}
    ${esc(p.name || p.steamid)}${esc(p.steamid)}
    ${rankTag(p.rank)} ${p.banned ? 'Banned' : ""} ${p.bans ? `${p.bans} ban${p.bans > 1 ? "s" : ""}` : ""} ${p.comms ? `${p.comms} block${p.comms > 1 ? "s" : ""}` : ""} ${p.warns ? `${p.warns} warn${p.warns > 1 ? "s" : ""}` : ""} ${!p.bans && !p.comms && !p.warns ? 'Clean' : ""} ${p.online ? 'Online' : p.lastSeen ? `${fmtAgo(p.lastSeen)}` : 'Never joined'}
    ${pager(list)}`; } /* ---------- Bans and comms ---------- */ function punishTable(list, kind) { if (!list.items.length) { return `
    ${state.query ? `Nothing matches "${esc(state.query)}".` : kind === "ban" ? "No bans in this view." : "No gags or mutes in this view."}
    `; } const canLift = kind === "ban" ? can("@css/unban") : can("@css/chat"); const canAdd = kind === "ban" ? can("@css/ban") : can("@css/chat"); return ` ${kind === "comm" ? "" : ""} ${list.items.map((p) => ` ${kind === "comm" ? `` : ""} `).join("")}
    PlayerTypeReason Length Issued Status Actions
    ${p.steamid ? `${esc(p.name || p.steamid)}` : esc(p.name || "Unknown")}${esc(p.steamid)}${p.ip ? `
    ${esc(p.ip)}` : ""}
    ${COMM_LABEL[p.type]}${esc(p.reason)}${p.liftedBy || p.liftReason ? `
    Lifted${p.liftedBy ? ` by ${esc(p.liftedBy)}` : ""}${p.liftReason ? `: ${esc(p.liftReason)}` : ""}` : ""}
    ${termCell(p)} ${fmtDate(p.created)}
    by ${esc(p.admin || "Console")}
    ${statusTag(p)}
    ${p.status === "active" && canLift && p.steamid ? `` : ""} ${p.status !== "active" && canAdd && p.steamid ? `` : ""}
    ${pager(list)}`; } function bans() { $("#view").innerHTML = `

    Bans

    The bar under each length shows how much of the ban has been served. Striped bars never expire.

    ${can("@css/ban") ? `` : ""}
    ${searchBox(`Search by name, SteamID, ${can("@css/showip") ? "IP, " : ""}reason or admin`, state.query)} ${segmented("banFilter", state.banFilter, [["active", "Active"], ["expired", "Expired"], ["lifted", "Lifted"], ["all", "All"]])}
    ${loading()}
    `; loadResults(); } function comms() { $("#view").innerHTML = `

    Gags & mutes

    A gag blocks text chat, a mute blocks voice, and a silence blocks both.

    ${can("@css/chat") ? `` : ""}
    ${searchBox("Search by name, SteamID, reason or admin", state.query)} ${segmented("commFilter", state.commFilter, [["active", "Active"], ["GAG", "Gags"], ["MUTE", "Mutes"], ["SILENCE", "Silences"], ["expired", "Expired"], ["all", "All"]])}
    ${loading()}
    `; loadResults(); } async function loadResults() { const box = $("#results"); if (!box) return; 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, view: state.page === "verify" ? state.verifyView : undefined, }; const want = JSON.stringify([state.page, params]); loadResults.want = want; try { const list = await api(path + qs(params)); if (loadResults.want !== want) return; box.innerHTML = render(list); } catch (err) { box.innerHTML = errorBox(err); } } /* ---------- Collapsible panels ---------- */ // A collapsible panel's head: clicking anywhere on it but its own buttons opens or closes it. // state.fold remembers which are closed until the page is reloaded; a panel not in it starts open. const foldHead = (key, title, aside = "") => `

    ${aside}
    `; const folded = (key) => (state.fold[key] ? " folded" : ""); /* ---------- Staff & ranks ---------- */ async function staff() { const v = $("#view"); let d; try { d = await api("/api/admin/staff"); } catch (err) { v.innerHTML = errorBox(err); return; } if (state.page !== "staff") return; staff.data = d; const manage = d.canManage; if (!d.groups.some((g) => g.id === state.group)) state.group = d.groups[0]?.id ?? null; const g = d.groups.find((x) => x.id === state.group); const ladderItem = (x) => `
  • `; const staffGroups = d.groups.filter((x) => !x.supporter); const supporters = d.groups.filter((x) => x.supporter); const memberRow = (p, groupName) => `
    ${avatar(p.steamid, p.name)}
    ${esc(p.name)}${esc(p.steamid)}
    ${p.online ? 'Online' : p.lastSeen ? `Seen ${fmtAgo(p.lastSeen)}` : 'Never joined'} ${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."}

    ${manage ? `
    ` : ""}
    ${d.groups.length ? `
    ${foldHead("ranks", "Ranks", `

    Immunity

    ${esc(g.label)}

    `)}
    ${foldHead("rank", `${esc(g.label)}`, `${esc(g.name)}`)}

    Permissions

    ${allFlags.map((f) => { const on = g.flags.includes(f.flag); const inherited = root && f.flag !== "@css/root"; return ``; }).join("")}
    ${manage ? `
    ` : ""}
    ${foldHead("members", "Members", `

    ${g.members.length} ${g.members.length === 1 ? "person" : "people"}

    ${manage ? `` : ""}
    `)}
    ${g.members.length ? `${g.members.map((p) => memberRow(p, g.name)).join("")}
    ` : `
    Nobody has this rank yet.${manage ? " Use Add staff to give it to someone." : ""}
    `}
    ${d.unranked.length ? `

    Admins without a rank

    Permissions set directly in SimpleAdmin

    ${d.unranked.map((p) => memberRow(p, "")).join("")}
    ` : ""}
    ` : `
    SimpleAdmin has no ranks for this server yet.${manage ? " Use New rank to create one." : ""}
    `}`; } /* ---------- 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 = `

    Verification

    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: () => 'Unknown the 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) => `

    Off ${esc(w)}

    `).join("")}

    GroupVerify

    ${st.active && st.plugin !== "missing" ? 'On' : 'Off'}
    Plugin
    ${PLUGIN_STATE[st.plugin](st.version)}
    Steam group
    ${group}
    Clan tag
    ${st.clanTag ? `${esc(st.clanTag)} for verified players without a rank; unverified ones get none` : 'None: players keep their own tags'}
    Group members
    ${st.groupMembers}${st.groupFetched ? ` last confirmed with Steam ${fmtAgo(st.groupFetched)}` : ' nobody confirmed yet'}
    Overrides
    ${st.overrides}
    Changes apply
    Within ${st.reloadSeconds} seconds (times in ${esc(st.timezone)})
    ${st.canConfigure ? `
    The <groupID64> from your group's member list XML.
    ` : '

    Only Root can change the group or turn verification on and off.

    '}
    `; } function verifyResults(d) { verifyStatus(d.status); const list = d.list; const groupView = state.verifyView === "group"; if (!list.items.length) { if (state.query) return `
    Nobody matches "${esc(state.query)}".
    `; return `
    ${groupView ? "Nobody confirmed yet. GroupVerify checks players with Steam as they join." : "No overrides."}
    `; } const mayEdit = (sid) => d.status.canOverride && (sid !== state.me.steamid || can("@css/root")); const who = (e) => `
    ${e.name ? esc(e.name) : 'Never joined'}${esc(e.steamid)}
    `; if (groupView) { return `${list.items.map((e) => `${who(e)}`).join("")}
    PlayerFirst confirmedLast confirmed
    ${fmtDate(e.created)}${fmtAgo(e.seen)}
    ${pager(list)}`; } return ` ${list.items.map((e) => ` ${who(e)} `).join("")}
    PlayerEndsGivenActions
    ${e.expires ? `${fmtDate(e.expires)}
    ${fmtLeft((new Date(e.expires) - Date.now()) / MIN)}` : "Never"}
    ${fmtDate(e.created)}${e.addedBy ? `
    by ${esc(e.addedBy)}` : ""}
    ${mayEdit(e.steamid) ? ` ` : ""}
    ${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; // The count row is only worth showing when there's a device besides this one. const total = d.devices.length; const others = total - (here ? 1 : 0); const row = (label, help, control, cls = "") => `
    ${label}${help ? `

    ${help}

    ` : ""}
    ${control}
    `; // A row whose control is one switch: on phones the switch goes before the text. const toggle = (label, help, sw) => row(label, help, sw, " toggle"); 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"; v.innerHTML = `

    Notifications

    Get bans, gags, mutes and server alerts on your phone or computer. Nothing about your own actions is sent to you.

    Devices

    ${device} ${others > 0 ? row("Your devices", `${total} ${total === 1 ? "device has" : "devices have"} notifications on${here ? ", this one included" : ", not this one"}.`, '') : ""}

    Quiet hours

    Nothing is sent between these times, in your timezone (${esc(browserTimezone())}).

    ${toggle("Quiet hours", "", ``)} ${row("From and until", "", `
    to
    `)}

    What to send

    For all your devices.

    ${d.types.map((t) => toggle(esc(t.label) + (t.ready ? "" : ' Coming later'), t.help ? esc(t.help) : "", ``)).join("")}
    `; } let notifSave; function saveNotifPrefs() { const types = [...document.querySelectorAll("[data-ntype]")].filter((s) => s.getAttribute("aria-checked") === "true").map((s) => s.dataset.ntype); const body = { types, quiet: { enabled: $("[data-notif=quiet]").getAttribute("aria-checked") === "true", start: $("#quiet-start").value, end: $("#quiet-end").value }, timezone: browserTimezone(), }; // Several quick toggles make one save. clearTimeout(notifSave); notifSave = setTimeout(async () => { try { await api("/api/admin/notifications/prefs", { method: "PUT", body }); toast("Saved."); } catch (err) { toast(err.message); } }, 400); } async function enableNotifications() { const d = state.notif; try { if (await Notification.requestPermission() !== "granted") { toast("Notifications weren't allowed."); notifications(); return; } if (!(await navigator.serviceWorker.getRegistration())) await navigator.serviceWorker.register("/sw.js"); const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); // A subscription made for another panel key can't be reused. if (sub && sub.options?.applicationServerKey && bytesToB64url(sub.options.applicationServerKey) !== d.publicKey) { await sub.unsubscribe(); sub = null; } if (!sub) sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64urlToBytes(d.publicKey) }); await act("/api/admin/notifications/subscribe", "POST", { ...sub.toJSON(), userAgent: navigator.userAgent, timezone: browserTimezone() }); } catch (err) { toast(`Couldn't turn notifications on: ${err.message}`); } } async function disableNotifications(all) { let endpoint = ""; try { const sub = await pushSubscription(); if (sub) { endpoint = sub.endpoint; await sub.unsubscribe(); } } catch { /* the server copy is removed below either way */ } if (all) return act("/api/admin/notifications/unsubscribe", "POST", { all: true }); if (!endpoint) { notifications(); return; } return act("/api/admin/notifications/unsubscribe", "POST", { endpoint }); } /* ---------- Settings ---------- */ async function settings() { const v = $("#view"); let d; try { d = await api("/api/admin/settings"); } catch (err) { v.innerHTML = errorBox(err); return; } if (state.page !== "settings") return; const sa = d.simpleadmin; const cvarControl = (c) => { if (c.type === "bool") return ``; if (c.type === "int") return ``; return ``; }; const saControl = (s) => { const val = sa.values[s.key]; const dis = d.canRoot ? "" : "disabled"; if (s.type === "bool") return ``; if (s.type === "select") return ``; return ``; }; const row = (label, help, key, control) => `
    ${esc(label)}${help ? `

    ${esc(help)}

    ` : ""}${key ? `${esc(key)}` : ""}
    ${control}
    `; v.innerHTML = `

    Server settings

    Server changes go live over RCON. SimpleAdmin's settings are saved to its config file.

    ${foldHead("cvars", "Server", "

    Live over RCON. Resets when the server restarts.

    ")} ${!d.canCvar ? '
    Your rank can\'t change server settings.
    ' : !d.server.online ? '
    The game server isn\'t answering, so these can\'t be read or changed right now.
    ' : (d.cvars || []).map((c) => row(c.label, c.help, c.key, cvarControl(c))).join("")}
    ${foldHead("sa", "Punishments", `

    ${sa.available ? `${sa.path ? `Saved to ${esc(sa.path)}. ` : ""}Applies after the next server restart.` : "Read-only"}

    `)} ${!sa.available ? '
    The panel wasn\'t given CS2-SimpleAdmin.json (SAW_SA_CONFIG), so these can\'t be shown or changed here.
    ' : sa.error ? `
    ${esc(sa.error)}
    ` : sa.settings.map((s) => row(s.label, s.help, s.key, saControl(s))).join("")}
    ${d.canRcon ? `
    ${foldHead("rcon", "Console", "

    Runs as the server console. Doesn't wait for the server's reply.

    ")}
    ` : ""}
    ${foldHead("connections", "Connections")} ${row("Database", "SimpleAdmin's MySQL database.", "", d.database.connected ? 'Connected' : 'Not connected')} ${row("Game server", d.server.online ? "Live status through the WebPanelBridge plugin." : d.server.error || "", "", d.server.online ? 'Connected' : 'Not answering')}
    ${foldHead("maintenance", "Maintenance", "

    Each of these asks first.

    ")} ${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:.", "", '') : ""}
    `; } 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) => `
  • ${p.label}${count[p.id] !== undefined && count[p.id] !== "" ? `${esc(count[p.id])}` : ""}
  • `).join(""); } // The mobile sidebar covers the Menu button, so it closes from its own Close button, the // backdrop, Escape, or picking a page. function setNav(open) { $("#nav").classList.toggle("open", open); $("#menu-btn").setAttribute("aria-expanded", String(open)); } function route() { const page = location.hash.slice(1); const def = PAGES.find((p) => p.id === page); state.page = def && pageAllowed(def) ? page : "overview"; renderNav(); setNav(false); if (!$("#view").innerHTML) $("#view").innerHTML = loading(); RENDER[state.page](); } window.addEventListener("hashchange", () => { state.query = ""; state.num = 1; $("#view").innerHTML = loading(); route(); window.scrollTo(0, 0); }); const search = debounce(() => loadResults(), 250); document.addEventListener("input", (e) => { const t = e.target; if (t.id === "q") { state.query = t.value; state.num = 1; search(); return; } if (t.matches("[data-cvar], [data-sa]")) { t.classList.add("dirty"); markDirty(); } }); document.addEventListener("change", (e) => { if (e.target.matches("select[data-sa]")) { e.target.classList.add("dirty"); markDirty(); } if (e.target.matches("[data-notif-time]")) saveNotifPrefs(); }); document.addEventListener("click", (e) => { const t = e.target; if (t.closest("#menu-btn")) { setNav(!$("#nav").classList.contains("open")); return; } if (t.closest("#nav-close, #nav-backdrop")) { setNav(false); return; } if (t.closest("#signout")) { api("/auth/logout", { method: "POST" }).finally(() => { location.href = "/"; }); return; } const seg = t.closest("[data-seg] button"); if (seg) { const group = seg.parentElement; group.querySelectorAll("button").forEach((b) => b.setAttribute("aria-pressed", String(b === seg))); const key = group.dataset.seg; if (key in state) { state[key] = seg.dataset.value; state.num = 1; loadResults(); } return; } const pg = t.closest("[data-page]"); if (pg && !pg.disabled) { state.num = Number(pg.dataset.page); loadResults(); return; } const chip = t.closest("[data-chips] .chip-btn"); if (chip) { const box = chip.parentElement; if (box.dataset.chips === "reason") $("#reason").value = chip.dataset.value; box.querySelectorAll(".chip-btn").forEach((b) => b.setAttribute("aria-pressed", String(b === chip))); return; } const sw = t.closest(".switch"); if (sw && !sw.disabled && sw.matches("[data-ntype], [data-notif]")) { sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true")); saveNotifPrefs(); return; } if (sw && !sw.disabled) { sw.setAttribute("aria-checked", String(sw.getAttribute("aria-checked") !== "true")); sw.classList.add("dirty"); markDirty(); return; } const fold = t.closest("[data-fold]"); if (fold && !t.closest("[data-act]")) { const shut = !state.fold[fold.dataset.fold]; state.fold[fold.dataset.fold] = shut; fold.closest(".panel").classList.toggle("folded", shut); $(".fold-toggle", fold).setAttribute("aria-expanded", String(!shut)); return; } const grp = t.closest("[data-group]:not([data-act])"); if (grp) { state.group = Number(grp.dataset.group); staff(); return; } if (t.closest("[data-close]")) { closeDrawer(); return; } if (t.closest("[data-modal-close]")) { $("#modal").close(); return; } const a = t.closest("[data-act]"); if (a) { e.preventDefault(); e.stopPropagation(); if (a.disabled) return; const d = a.dataset; switch (d.act) { case "ban": return punishDialog("ban", { sid: d.sid, name: d.name, reason: d.reason }); case "comm": return punishDialog("comm", { sid: d.sid, name: d.name, type: d.type, reason: d.reason }); case "warn": return punishDialog("warn", { sid: d.sid, name: d.name }); case "kick": return inputDialog("kick", { sid: d.sid, name: d.name }); case "map": return inputDialog("map"); case "say": return inputDialog("say"); case "lift": { const what = d.kind === "ban" ? "Unban" : `Lift ${COMM_LABEL[d.type].toLowerCase()}`; confirmDialog.reason = true; return confirmDialog(what, d.kind === "ban" ? `Unban ${esc(d.name || "this player")}? They can join again straight away. This lifts every active ban on their SteamID.` : `Lift the ${COMM_LABEL[d.type].toLowerCase()} on ${esc(d.name || "this player")}?`, d.kind === "ban" ? "Unban" : "Lift", (form) => act("/api/admin/penalties/lift", "POST", { kind: d.kind, id: Number(d.id), reason: form.reason?.value || "" })); } case "rank": return rankDialog(d.sid, d.name, d.group); case "add-staff": return rankDialog("", "", "", d.group || ""); case "new-group": return newGroupDialog(); case "delete-group": confirmDialog.reason = false; return confirmDialog("Delete rank", `Delete ${esc(d.name)}? Its members become regular players.`, "Delete rank", () => act(`/api/admin/groups/${d.id}`, "DELETE")); case "reload-admins": confirmDialog.reason = false; return confirmDialog("Reload admins", "Re-read all ranks and staff from the database?", "Reload admins", () => act("/api/admin/reload-admins", "POST", {})); 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 ${esc(d.name || d.sid)}'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 "notif-on": return enableNotifications(); case "notif-off": return disableNotifications(false); case "notif-off-all": confirmDialog.reason = false; return confirmDialog("Turn off everywhere", "Turn notifications off on all your devices? Turn them on again on each device you want them on.", "Turn off", () => disableNotifications(true)); case "notif-test": return act("/api/admin/notifications/test", "POST", {}); case "save-settings": return saveSettings(); case "discard": return settings(); } return; } const row = t.closest("[data-player]"); if (row) { e.preventDefault(); openPlayer(row.dataset.player); } }); document.addEventListener("submit", async (e) => { const form = e.target; e.preventDefault(); const kind = form.dataset.submit; const f = form.elements; if (form.id === "gv-form") { // Only what changed, so saving the tag doesn't resend the group (and the other way round). const body = {}; if (f.group.value.trim() !== f.group.dataset.was) body.groupId = f.group.value.trim(); if (f.clantag.value.trim() !== f.clantag.dataset.was) body.clanTag = f.clantag.value.trim(); if (!Object.keys(body).length) { toast("Nothing changed."); return; } await act("/api/admin/verify/settings", "POST", body); return; } if (form.id === "rcon-form") { // Not act(): re-rendering the page would throw away unsaved settings above. try { const res = await api("/api/admin/rcon", { method: "POST", body: { command: f.command.value } }); toast(res.message || "Sent."); f.command.value = ""; } catch (err) { toast(err.message); } 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 }); return; } $("#modal").close(); switch (kind) { case "penalty": { const dur = form.querySelector('[data-chips="dur"] [aria-pressed="true"]'); const type = form.querySelector('[data-seg="commType"] [aria-pressed="true"]')?.dataset.value; await act("/api/admin/penalties", "POST", { kind: form.dataset.kind, type: type || "", steamid: f.steamid.value.trim(), duration: Number(dur?.dataset.value ?? 60), reason: f.reason.value, }); break; } case "rank": { const sid = f.steamid.value.trim(); const group = f.group.value; if (form.dataset.had) await act(`/api/admin/staff/${encodeURIComponent(sid)}`, "PUT", { group, days: Number(f.days.value) }); else await act("/api/admin/staff", "POST", { steamid: sid, group, days: Number(f.days.value) }); break; } case "new-group": if (await act("/api/admin/groups", "POST", { name: f.name.value.trim(), immunity: Number(f.immunity.value), flags: ["@css/generic"] })) { state.group = null; } break; 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; } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("#nav").classList.contains("open")) { setNav(false); return; } if (e.key === "Escape" && $("#drawer-root").innerHTML && !$("#modal").open) closeDrawer(); }); (async () => { try { state.me = await api("/api/me"); } catch (err) { $("#view").innerHTML = errorBox(err); return; } if (!state.me.staff) { location.href = "/admin"; return; } const name = state.me.site; const i = name.indexOf("."); document.querySelectorAll(".site-name").forEach((el) => { el.innerHTML = i > 0 ? `${esc(name.slice(0, i))}.${esc(name.slice(i + 1))}` : esc(name); }); $("#me").innerHTML = `${avatar(state.me.steamid, state.me.name)}
    ${esc(state.me.name)}${esc(state.me.rank?.label || "Staff")}
    `; // Fill the sidebar counts once, even when starting on another page. api("/api/admin/overview").then((d) => { state.counts = { bans: d.activeBans, comms: d.activeComms, staff: d.staffCount, players: d.server.online ? `${d.server.count}/${d.server.maxPlayers}` : "" }; setServerPick(d.server); renderNav(); }).catch(() => {}); route(); })();