mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-20 11:42:49 +00:00
feat: contraband list with flagging and optional removal
Staff list contraband items on a new dashboard page (item search, reason, flag or flag and remove; contraband_manage to edit, reports_view to see). World servers check every inventory when a character loads and every item a player receives: each find is an economy flag of the new kind Contraband, shown with the other flags and in the character's related data. Items marked for removal are taken away, with a character snapshot kept first so they can be given back, an audit entry and a mail or chat message telling the player why. Staff are skipped unless contraband_ignore_staff is off. Worlds reload the list when it changes (RELOAD_CONTRABAND, added at the end of ePlayerAction). Fixes #1563 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,7 @@
|
||||
#include "master/MessageCapture.h"
|
||||
#include "PublicRoutes.h"
|
||||
#include "Showcase.h"
|
||||
#include "ContrabandRoutes.h"
|
||||
#include "FeaturedProperties.h"
|
||||
#include "PasswordRecovery.h"
|
||||
#include "SettingsRoutes.h"
|
||||
@@ -524,6 +525,7 @@ int main(int argc, char** argv) {
|
||||
RegisterPublicRoutes();
|
||||
RegisterShowcaseRoutes();
|
||||
FeaturedProperties::RegisterRoutes();
|
||||
ContrabandRoutes::RegisterRoutes();
|
||||
RegisterPasswordRecoveryRoutes();
|
||||
RegisterWSRoutes();
|
||||
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
|
||||
|
||||
@@ -1425,27 +1425,29 @@ namespace {
|
||||
}
|
||||
char decoded[256]{};
|
||||
mg_url_decode(query.c_str(), query.size(), decoded, sizeof(decoded), 1);
|
||||
query = decoded;
|
||||
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt(
|
||||
"SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) "
|
||||
"ORDER BY name LIMIT 50;");
|
||||
stmt.bind(1, query.c_str());
|
||||
stmt.bind(2, query.c_str());
|
||||
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
|
||||
auto result = stmt.execQuery();
|
||||
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
while (!result.eof()) {
|
||||
const std::string displayName = result.getStringField("displayName", "");
|
||||
items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} });
|
||||
result.nextRow();
|
||||
}
|
||||
JsonReply(reply, eHTTPStatusCode::OK, items);
|
||||
JsonReply(reply, eHTTPStatusCode::OK, SearchItems(decoded));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
nlohmann::json SearchItems(const std::string& query) {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt(
|
||||
"SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) "
|
||||
"ORDER BY name LIMIT 50;");
|
||||
stmt.bind(1, query.c_str());
|
||||
stmt.bind(2, query.c_str());
|
||||
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
|
||||
auto result = stmt.execQuery();
|
||||
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
while (!result.eof()) {
|
||||
const std::string displayName = result.getStringField("displayName", "");
|
||||
items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} });
|
||||
result.nextRow();
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// For the property showcase (Showcase.cpp), which serves the same model data to people who aren't the owner
|
||||
namespace PropertyAssets {
|
||||
std::optional<std::pair<std::string, std::string>> ModelLxfml(const IPropertyContents::Model& model, LWOOBJID propertyId) { return ::ModelLxfml(model, propertyId); }
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
void RegisterAPIRoutes();
|
||||
|
||||
// Items (Objects of type Loot) whose name or display name contains the text, or whose LOT it is: [{lot, name}], at most 50
|
||||
nlohmann::json SearchItems(const std::string& query);
|
||||
|
||||
@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"PublicRoutes.cpp"
|
||||
"Showcase.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"ContrabandRoutes.cpp"
|
||||
"PasswordRecovery.cpp"
|
||||
"PrometheusMetrics.cpp"
|
||||
"../email/SmtpClient.cpp"
|
||||
|
||||
111
dDashboardServer/routes/ContrabandRoutes.cpp
Normal file
111
dDashboardServer/routes/ContrabandRoutes.cpp
Normal file
@@ -0,0 +1,111 @@
|
||||
#include "ContrabandRoutes.h"
|
||||
|
||||
#include <ctime>
|
||||
|
||||
#include "APIRoutes.h"
|
||||
#include "CDClientDatabase.h"
|
||||
#include "ClientAssets.h"
|
||||
#include "Database.h"
|
||||
#include "DashboardRoutes.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "master/PlayerAction.h"
|
||||
#include "PlayerActions.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "WSRoutes.h"
|
||||
#include "eHTTPMethod.h"
|
||||
|
||||
using namespace RouteUtils;
|
||||
|
||||
namespace {
|
||||
using eContrabandAction = IContraband::eContrabandAction;
|
||||
constexpr size_t MAX_REASON = 300;
|
||||
|
||||
const char* ActionName(eContrabandAction action) {
|
||||
return action == eContrabandAction::REMOVE ? "remove" : "flag";
|
||||
}
|
||||
|
||||
std::optional<eContrabandAction> ParseAction(const std::string& text) {
|
||||
if (text == "flag") return eContrabandAction::FLAG;
|
||||
if (text == "remove") return eContrabandAction::REMOVE;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool IsItem(LOT lot) {
|
||||
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Objects WHERE id = ? LIMIT 1;");
|
||||
stmt.bind(1, static_cast<int32_t>(lot));
|
||||
return !stmt.execQuery().eof();
|
||||
}
|
||||
|
||||
std::string Trimmed(std::string text, size_t max) {
|
||||
text.erase(0, text.find_first_not_of(" \t\r\n"));
|
||||
text.erase(text.find_last_not_of(" \t\r\n") + 1);
|
||||
return text.substr(0, max);
|
||||
}
|
||||
|
||||
// Tell every running world to load the list again
|
||||
uint32_t ReloadWorlds(uint32_t requester) {
|
||||
PlayerActionRequest request;
|
||||
request.action = ePlayerAction::RELOAD_CONTRABAND;
|
||||
return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) {
|
||||
return PlayerActions::Outcome{ true, result.affected ? "The list was updated in " + std::to_string(result.affected) + " world(s)"
|
||||
: "No world is running; they'll use the list when they start" };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
namespace ContrabandRoutes {
|
||||
void RegisterRoutes() {
|
||||
Route(eHTTPMethod::GET, "/contraband", Perm("reports_view"), "Items players aren't allowed to have",
|
||||
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "contraband.jinja2", "contraband"); });
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/contraband", Perm("reports_view"),
|
||||
"The contraband list: {items: [{lot, name, reason, action: flag|remove, added_by, added_at}], canManage}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
nlohmann::json items = nlohmann::json::array();
|
||||
for (const auto& item : Database::Get()->GetContrabandItems()) {
|
||||
items.push_back({ {"lot", item.lot}, {"name", ClientAssets::ItemName(item.lot)}, {"reason", item.reason}, {"action", ActionName(item.action)},
|
||||
{"added_by", item.addedBy}, {"added_at", item.addedAt} });
|
||||
}
|
||||
JsonSuccess(reply, { {"items", items}, {"canManage", Can(context, "contraband_manage")} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/contraband/items", Perm("contraband_manage"), "Search items to add by name or LOT: [{lot, name}]. Query: ?q=",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
JsonReply(reply, eHTTPStatusCode::OK, SearchItems(Trimmed(QueryValue(context.queryString, "q"), 64)));
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/contraband", Perm("contraband_manage"),
|
||||
"Add an item to the list or change its entry; running worlds pick it up at once. Body: {lot, reason, action: flag|remove}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
const auto lot = body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional<LOT>((*body)["lot"].get<LOT>())
|
||||
: GeneralUtils::TryParse<LOT>(body->value("lot", ""));
|
||||
if (!lot || *lot <= 0 || !IsItem(*lot)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick an item from the list or enter its LOT");
|
||||
const auto action = ParseAction(body->value("action", "flag"));
|
||||
if (!action) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "action must be flag or remove");
|
||||
const auto reason = Trimmed(body->value("reason", ""), MAX_REASON);
|
||||
|
||||
bool existed = false;
|
||||
for (const auto& item : Database::Get()->GetContrabandItems()) existed = existed || item.lot == *lot;
|
||||
Database::Get()->SetContrabandItem({ *lot, reason, *action, context.authenticatedUser, static_cast<int64_t>(std::time(nullptr)) });
|
||||
const auto name = ClientAssets::ItemName(*lot);
|
||||
Audit(context, existed ? "contraband_change" : "contraband_add", std::string(existed ? "Changed" : "Added") + " contraband item " + name + " (" +
|
||||
std::to_string(*lot) + "): " + (*action == eContrabandAction::REMOVE ? "flag and remove" : "flag only") + (reason.empty() ? "" : ", \"" + reason + "\""));
|
||||
BroadcastTableChanged("contraband");
|
||||
JsonSuccess(reply, { {"message", name + (existed ? " updated" : " added")}, {"requestId", ReloadWorlds(context.accountId)} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/contraband/delete", Perm("contraband_manage"), "Take an item off the list. Body: {lot}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto body = ParseBody(context);
|
||||
const auto lot = body && body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional<LOT>((*body)["lot"].get<LOT>()) : std::nullopt;
|
||||
if (!lot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid lot");
|
||||
if (!Database::Get()->DeleteContrabandItem(*lot)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That item isn't on the list");
|
||||
const auto name = ClientAssets::ItemName(*lot);
|
||||
Audit(context, "contraband_remove", "Took " + name + " (" + std::to_string(*lot) + ") off the contraband list");
|
||||
BroadcastTableChanged("contraband");
|
||||
JsonSuccess(reply, { {"message", name + " is no longer contraband"}, {"requestId", ReloadWorlds(context.accountId)} });
|
||||
});
|
||||
}
|
||||
}
|
||||
11
dDashboardServer/routes/ContrabandRoutes.h
Normal file
11
dDashboardServer/routes/ContrabandRoutes.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
/**
|
||||
* The Contraband page: items staff don't want players to have (IContraband). Each has a reason and says whether a
|
||||
* character holding one is only flagged or also has it removed (see dGame/dUtilities/Contraband.h). Viewing needs
|
||||
* reports_view, changing the list contraband_manage; every change is audited and running worlds reload the list.
|
||||
* What was found shows up as economy flags of kind Contraband (Economy page, and the character's related data).
|
||||
*/
|
||||
namespace ContrabandRoutes {
|
||||
void RegisterRoutes();
|
||||
}
|
||||
@@ -342,6 +342,10 @@ namespace {
|
||||
c.Add(Unit(Int(DASHBOARD, "anomaly_item_minimum", "Item spike: at least", "...and more than this many.", "200", 0, std::nullopt), "items"));
|
||||
c.Add(Bool(DASHBOARD, "economy_duplicate_scan", "Nightly duplicate scan", "Reads every character's inventory.", true));
|
||||
|
||||
c.AddSection("Contraband", "Items on the Contraband page are flagged, or removed, when a character loads or receives one. The list itself is edited on that page.");
|
||||
c.Add(Bool(WORLD, "contraband_ignore_staff", "Don't check staff", "Accounts with a GM level keep listed items and aren't flagged.", true));
|
||||
c.Add(Bool(WORLD, "contraband_notify_players", "Tell players", "A mail (at login) or a chat message (when received) says which item was removed and why.", true));
|
||||
|
||||
c.AddSection("Backups");
|
||||
c.Add(Format(Text(DASHBOARD, "backup_folder", "Backup folder", "Relative to the server binaries unless absolute.", "backups"), eFormat::PATH));
|
||||
c.Add(Unit(Int(DASHBOARD, "backup_keep", "Backups to keep", "Older backups are deleted after each new one. 0 keeps them all.", "7", 0, 1000), "backups"));
|
||||
|
||||
76
dDashboardServer/static/js/contraband.js
Normal file
76
dDashboardServer/static/js/contraband.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The Contraband page: the list of items players shouldn't have, and (with contraband_manage) adding, changing and
|
||||
* removing entries. Running worlds reload the list after every change.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var canManage = false;
|
||||
var lotInput = document.getElementById('lotInput');
|
||||
|
||||
function row(i) {
|
||||
var action = i.action === 'remove' ? fmt.badge('Flag and remove', 'danger') : fmt.badge('Flag', 'warning');
|
||||
var buttons = canManage ? '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-edit="' + esc(i.lot) + '">Edit</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove="' + esc(i.lot) + '">Remove</button>' : '';
|
||||
return '<tr><td><strong>' + esc(i.name) + '</strong> <span class="text-body-secondary small">' + esc(i.lot) + '</span></td>' +
|
||||
'<td>' + esc(i.reason) + '</td><td>' + action + '</td><td class="small">' + esc(i.added_by) + ', ' + esc(fmt.unix(i.added_at)) + '</td>' +
|
||||
'<td class="text-end text-nowrap">' + buttons + '</td></tr>';
|
||||
}
|
||||
|
||||
var items = [];
|
||||
function load() {
|
||||
api.get('/api/contraband').then(function (d) {
|
||||
if (!d.success) return;
|
||||
items = d.items;
|
||||
canManage = d.canManage;
|
||||
document.getElementById('addCard').classList.toggle('d-none', !canManage);
|
||||
document.getElementById('rows').innerHTML = items.map(row).join('') ||
|
||||
'<tr><td colspan="5" class="text-body-secondary">Nothing is contraband.</td></tr>';
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
var timer = null;
|
||||
lotInput.addEventListener('input', function () {
|
||||
clearTimeout(timer);
|
||||
var q = lotInput.value.trim();
|
||||
if (q.length < 2 || /^\d+$/.test(q)) return;
|
||||
timer = setTimeout(function () {
|
||||
api.get('/api/contraband/items?q=' + encodeURIComponent(q)).then(function (found) {
|
||||
document.getElementById('lotSuggestions').innerHTML = (Array.isArray(found) ? found : []).map(function (i) {
|
||||
return '<option value="' + esc(i.lot) + '">' + esc(i.name) + '</option>';
|
||||
}).join('');
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
|
||||
function done(d) {
|
||||
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
|
||||
toast(d.message, 'success');
|
||||
load();
|
||||
}
|
||||
|
||||
document.getElementById('addForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var lot = parseInt(lotInput.value, 10);
|
||||
if (!(lot > 0)) { toast('Pick an item from the list or enter its LOT', 'warning'); return; }
|
||||
api.post('/api/contraband', { lot: lot, reason: document.getElementById('reasonInput').value, action: document.getElementById('actionInput').value })
|
||||
.then(function (d) { if (d.success) { lotInput.value = ''; document.getElementById('reasonInput').value = ''; } done(d); });
|
||||
});
|
||||
|
||||
document.getElementById('rows').addEventListener('click', function (e) {
|
||||
var edit = e.target.closest('[data-edit]'), remove = e.target.closest('[data-remove]');
|
||||
if (edit) {
|
||||
var item = items.filter(function (i) { return String(i.lot) === edit.dataset.edit; })[0];
|
||||
if (!item) return;
|
||||
lotInput.value = item.lot;
|
||||
document.getElementById('reasonInput').value = item.reason;
|
||||
document.getElementById('actionInput').value = item.action;
|
||||
lotInput.focus();
|
||||
} else if (remove) {
|
||||
if (!confirm('Take this item off the contraband list?')) return;
|
||||
api.post('/api/contraband/delete', { lot: parseInt(remove.dataset.remove, 10) }).then(done);
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -11,7 +11,7 @@
|
||||
// Names come from the server's enums (Labels); only the colours are kept here
|
||||
var METHOD_COLOURS = { 1: 'info', 2: 'secondary', 3: 'success', 4: 'light' };
|
||||
function methodBadge(value) { return fmt.badge(Labels.name('transferMethods', value) || '?', METHOD_COLOURS[value] || 'secondary'); }
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger' };
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' };
|
||||
var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' };
|
||||
function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); }
|
||||
function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); }
|
||||
|
||||
@@ -1588,7 +1588,7 @@
|
||||
|
||||
// ---- Flags ----
|
||||
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning' };
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' };
|
||||
var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' };
|
||||
function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); }
|
||||
function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); }
|
||||
|
||||
42
dDashboardServer/templates/contraband.jinja2
Normal file
42
dDashboardServer/templates/contraband.jinja2
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Contraband - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="contrabandPage">
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">Contraband</h2>
|
||||
<p class="text-body-secondary mb-0">Items players shouldn't have. World servers check every inventory (vault included) when a character loads,
|
||||
and every item a player receives. <strong>Flag</strong> adds an economy flag for staff to review; <strong>Remove</strong> flags it and takes the
|
||||
item away (a snapshot of the character is kept first, so it can be given back from the character page). Staff accounts aren't checked unless
|
||||
<code>contraband_ignore_staff</code> is off. Running worlds pick up changes straight away.
|
||||
Flags found: <a href="/reports#flags">Economy & Map, Flags</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3 d-none" id="addCard">
|
||||
<div class="card-body">
|
||||
<form class="row g-2 align-items-end" id="addForm">
|
||||
<div class="col-md-4"><label class="form-label small mb-1" for="lotInput">Item</label>
|
||||
<input class="form-control form-control-sm" id="lotInput" list="lotSuggestions" required autocomplete="off" placeholder="Name or LOT">
|
||||
<datalist id="lotSuggestions"></datalist></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-1" for="reasonInput">Reason</label>
|
||||
<input class="form-control form-control-sm" id="reasonInput" maxlength="300" placeholder="Shown to staff, and to the player if it's removed"></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-1" for="actionInput">When found</label>
|
||||
<select class="form-select form-select-sm" id="actionInput"><option value="flag">Flag</option><option value="remove">Flag and remove</option></select></div>
|
||||
<div class="col-md-2"><button type="submit" class="btn btn-sm btn-primary w-100">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="table table-sm align-middle mb-0 table-stack">
|
||||
<thead><tr><th>Item</th><th>Reason</th><th>When found</th><th>Added</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/contraband.js"></script>
|
||||
{% endblock %}
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set worldPages = ["world3d", "reports"] %}
|
||||
{% set worldPages = ["world3d", "reports", "contraband"] %}
|
||||
{% if can.players_view or can.reports_view %}
|
||||
<div class="nav-group">
|
||||
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-world" aria-expanded="{% if current_page in worldPages %}true{% else %}false{% endif %}" aria-controls="nav-world">
|
||||
@@ -58,6 +58,7 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% if can.players_view %}<a href="/world3d" class="list-group-item list-group-item-action{% if current_page == "world3d" %} active{% endif %}">World 3D</a>{% endif %}
|
||||
{% if can.reports_view %}<a href="/reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "reports" %} active{% endif %}">Economy & Map <span class="badge rounded-pill text-bg-danger d-none" data-badge="reports" title="Open economy flags"></span></a>{% endif %}
|
||||
{% if can.reports_view %}<a href="/contraband" class="list-group-item list-group-item-action{% if current_page == "contraband" %} active{% endif %}">Contraband</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user