feat(dashboard): guilds page, guild name review and guild chat in the chat log

A Guilds page (Moderation, permission guilds_manage, GM 5 by default): every guild with its name status, member count,
leader and age, a pending-only switch, and per guild its members (with ranks) and history (guild_events). Staff can
approve a guild name that waits for review, reject it (the guild becomes "Guild <id>"), rename a guild (same name rules
as the game, unique without regard to case), remove a member (a leader's guild goes to the next member, the last
member's guild is deleted) and disband a guild. Every change is audited and added to the guild's history, and the chat
server is asked (GUILD_CHANGED through master) to tell the online members. Pending guild names also show in the Review
Queue. Guild chat ("guild" in the chat log) counts as private chat like whispers and team chat (chat_private), with its
own channel filter and Prometheus counter.

Check on the dashboard: /guilds lists a guild made in game; approve/reject/rename/remove/disband update the in-game guild
window and name billboard of online members; the Review Queue shows a guild whose name waits for review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 06:16:48 -05:00
parent f6f937955e
commit 431eddd5bd
13 changed files with 410 additions and 13 deletions

View File

@@ -55,8 +55,9 @@ namespace {
{ "players_history", "Moderation", "Player movement history", "Replay where players went on the 3D world view (positions kept for position_history_days; every replay is audited)", 5 }, { "players_history", "Moderation", "Player movement history", "Replay where players went on the 3D world view (positions kept for position_history_days; every replay is audited)", 5 },
{ "moderate_names", "Moderation", "Moderate names", "The moderation queue: approve and reject character names", 3 }, { "moderate_names", "Moderation", "Moderate names", "The moderation queue: approve and reject character names", 3 },
{ "moderate_pet_names", "Moderation", "Moderate pet names", "Approve and reject pet names", 5 }, { "moderate_pet_names", "Moderation", "Moderate pet names", "Approve and reject pet names", 5 },
{ "guilds_manage", "Moderation", "Guilds", "See guilds, their members and history; approve, reject and change guild names, remove members and disband guilds", 5 },
{ "chat_view", "Moderation", "Read chat", "The chat log: what players said in zones, and what the chat filter stopped", 3 }, { "chat_view", "Moderation", "Read chat", "The chat log: what players said in zones, and what the chat filter stopped", 3 },
{ "chat_private", "Moderation", "Read private chat", "Whispers and team chat in the chat log", 8 }, { "chat_private", "Moderation", "Read private chat", "Whispers, team and guild chat in the chat log", 8 },
{ "chat_filter_manage", "Moderation", "Chat filter", "See and change the words the chat filter allows and stops, and apply them in running worlds", 5 }, { "chat_filter_manage", "Moderation", "Chat filter", "See and change the words the chat filter allows and stops, and apply them in running worlds", 5 },
{ "player_reports_view", "Moderation", "View player reports", "Read what players reported from the game's Report Abuse window", 2 }, { "player_reports_view", "Moderation", "View player reports", "Read what players reported from the game's Report Abuse window", 2 },
{ "player_reports_manage", "Moderation", "Handle player reports", "Act on or dismiss player reports (a strike also needs strikes_give)", 3 }, { "player_reports_manage", "Moderation", "Handle player reports", "Act on or dismiss player reports (a strike also needs strikes_give)", 3 },

View File

@@ -101,6 +101,7 @@
#include "ApiKeyService.h" #include "ApiKeyService.h"
#include "ApiKeyRoutes.h" #include "ApiKeyRoutes.h"
#include "GrantRoutes.h" #include "GrantRoutes.h"
#include "GuildRoutes.h"
#include "PermissionGrantsLoader.h" #include "PermissionGrantsLoader.h"
#include "JWTUtils.h" #include "JWTUtils.h"
#include "GeneralUtils.h" #include "GeneralUtils.h"
@@ -536,6 +537,7 @@ int main(int argc, char** argv) {
RegisterLeaderboardRoutes(); RegisterLeaderboardRoutes();
ApiKeyRoutes::RegisterRoutes(); ApiKeyRoutes::RegisterRoutes();
GrantRoutes::RegisterRoutes(); GrantRoutes::RegisterRoutes();
GuildRoutes::RegisterRoutes();
RequireAuthMiddleware::SetApiAccessCheck([](const HTTPContext& context) { return Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get()); }); RequireAuthMiddleware::SetApiAccessCheck([](const HTTPContext& context) { return Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get()); });
RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) { RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) {
RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page.");

View File

@@ -6,6 +6,7 @@ set(DASHBOARDROUTES_SOURCES
"AuthRoutes.cpp" "AuthRoutes.cpp"
"ApiKeyRoutes.cpp" "ApiKeyRoutes.cpp"
"GrantRoutes.cpp" "GrantRoutes.cpp"
"GuildRoutes.cpp"
"RouteUtils.cpp" "RouteUtils.cpp"
"PlayerActions.cpp" "PlayerActions.cpp"
"AccountRoutes.cpp" "AccountRoutes.cpp"

View File

@@ -22,7 +22,7 @@ namespace {
constexpr uint32_t MAX_PAGE = 500; constexpr uint32_t MAX_PAGE = 500;
uint64_t g_LastPushed = 0; uint64_t g_LastPushed = 0;
bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team"; } bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team" || channel == "guild"; }
nlohmann::json MessageJson(const IChatLog::ChatMessage& m) { nlohmann::json MessageJson(const IChatLog::ChatMessage& m) {
const auto& zones = ZoneNames(); const auto& zones = ZoneNames();
@@ -37,7 +37,7 @@ namespace {
IChatLog::ChatQuery Query(const HTTPContext& context, const std::function<std::string(const char*)>& get) { IChatLog::ChatQuery Query(const HTTPContext& context, const std::function<std::string(const char*)>& get) {
IChatLog::ChatQuery q; IChatLog::ChatQuery q;
q.channel = get("channel"); q.channel = get("channel");
// Whispers and team chat need their own permission // Whispers, team and guild chat need their own permission
q.includePrivate = Can(context, "chat_private"); q.includePrivate = Can(context, "chat_private");
if (IsPrivate(q.channel) && !q.includePrivate) q.channel = "none"; if (IsPrivate(q.channel) && !q.includePrivate) q.channel = "none";
const auto character = get("character"); const auto character = get("character");

View File

@@ -433,8 +433,8 @@ void RegisterDashboardRoutes() {
// Moderation and tools // Moderation and tools
// Holds three queues; anyone who may work one of them gets in and sees only theirs // Holds three queues; anyone who may work one of them gets in and sees only theirs
Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, properties)", [](HTTPReply& reply, const HTTPContext& context) { Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, guild names, properties)", [](HTTPReply& reply, const HTTPContext& context) {
if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "moderate_properties")) { if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "guilds_manage") && !Can(context, "moderate_properties")) {
return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to review names or properties."); return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to review names or properties.");
} }
RenderPage(reply, context, "moderation.jinja2", "moderation"); RenderPage(reply, context, "moderation.jinja2", "moderation");

View File

@@ -0,0 +1,200 @@
#include "GuildRoutes.h"
#include <ctime>
#include "Database.h"
#include "eGuildRank.h"
#include "eHTTPMethod.h"
#include "GeneralUtils.h"
#include "GuildNameRules.h"
#include "HTTPContext.h"
#include "master/PlayerAction.h"
#include "PlayerActions.h"
#include "RouteUtils.h"
#include "Web.h"
#include "WSRoutes.h"
using namespace RouteUtils;
namespace {
constexpr const char* MANAGE = "guilds_manage";
constexpr uint32_t HISTORY_LENGTH = 200;
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
std::string Actor(const HTTPContext& context) {
return "[dashboard] " + context.authenticatedUser;
}
const char* StatusName(int32_t status) {
return status == IGuilds::NAME_APPROVED ? "approved" : "pending";
}
nlohmann::json GuildJson(const IGuilds::Guild& guild) {
return { {"id", std::to_string(guild.id)}, {"name", guild.name}, {"name_status", StatusName(guild.nameStatus)},
{"founder_id", std::to_string(guild.founderId)}, {"created_at", guild.createdAt} };
}
void AddEvent(const HTTPContext& context, int64_t guildId, const std::string& kind, LWOOBJID characterId, const std::string& characterName, const std::string& detail) {
Database::Get()->InsertGuildEvent({ 0, guildId, Now(), kind, characterId, characterName, Actor(context), detail });
}
// The chat server tells the guild's online members (it is the guild authority); the reply carries the request id
nlohmann::json TellChat(int64_t guildId, uint32_t requester) {
PlayerActionRequest request;
request.action = ePlayerAction::GUILD_CHANGED;
request.targetId = guildId;
const auto id = PlayerActions::Request(request, requester, [](const PlayerActionResult& result) -> PlayerActions::Outcome {
return { true, result.affected ? "The guild's online members were told." : "The chat server isn't running; members see the change when they next log in." };
});
return { {"requestId", id} };
}
std::optional<IGuilds::Guild> RequireGuild(const HTTPContext& context, HTTPReply& reply) {
const auto guildId = PathId<int64_t>(context.path, 2);
if (!guildId) {
JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid guild ID");
return std::nullopt;
}
auto guild = Database::Get()->GetGuild(*guildId);
if (!guild) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such guild");
return guild;
}
// A name that is free: "Guild <id>", then "Guild <id>-2" and so on
std::string PlaceholderName(int64_t guildId) {
const std::string base = "Guild " + std::to_string(guildId);
std::string name = base;
for (int i = 2; Database::Get()->GetGuildByName(name); i++) name = base + "-" + std::to_string(i);
return name;
}
void Changed(const std::string& guildId) {
BroadcastTableChanged("guilds", guildId);
}
}
void GuildRoutes::RegisterRoutes() {
Route(eHTTPMethod::GET, "/guilds", Perm(MANAGE), "Guilds: members, history and name moderation", [](HTTPReply& reply, const HTTPContext& context) {
RenderPage(reply, context, "guilds.jinja2", "guilds");
});
ReadRoute(eHTTPMethod::POST, "/api/tables/guilds", Perm(MANAGE), "Guilds (DataTables), newest first. Body adds {pending} (only names waiting for review)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto request = ParseDataTablesRequest(context.body);
const auto body = ParseBody(context);
if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto page = Database::Get()->GetGuildPage(request->start, request->length, request->search, body->value("pending", false));
nlohmann::json data = nlohmann::json::array();
for (const auto& summary : page.guilds) {
auto row = GuildJson(summary.guild);
row["member_count"] = summary.memberCount;
row["leader_id"] = summary.leaderId ? std::to_string(summary.leaderId) : "";
row["leader_name"] = summary.leaderName;
data.push_back(std::move(row));
}
JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", page.total}, {"recordsFiltered", page.filtered}, {"data", data} });
});
Route(eHTTPMethod::GET, "/api/guilds/:id", Perm(MANAGE), "A guild with its members (rank 1 leader, 2 officer, 3 veteran, 4 recruit) and its history, newest first",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
auto result = GuildJson(*guild);
if (const auto founder = Database::Get()->GetCharacterInfo(guild->founderId)) result["founder_name"] = founder->name;
nlohmann::json members = nlohmann::json::array();
for (const auto& member : Database::Get()->GetGuildMembers(guild->id)) {
members.push_back({ {"id", std::to_string(member.characterId)}, {"name", member.name}, {"rank", member.rank}, {"joined_at", member.joinedAt} });
}
nlohmann::json events = nlohmann::json::array();
for (const auto& event : Database::Get()->GetGuildEvents(guild->id, HISTORY_LENGTH)) {
events.push_back({ {"id", event.id}, {"time", event.time}, {"kind", event.kind}, {"character_id", std::to_string(event.characterId)},
{"character_name", event.characterName}, {"actor", event.actor}, {"detail", event.detail} });
}
result["members"] = members;
result["events"] = events;
JsonSuccess(reply, { {"guild", result} });
});
Route(eHTTPMethod::POST, "/api/guilds/:id/approve", Perm(MANAGE), "Approve a guild name that waits for review; other players see it from then on",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
Database::Get()->SetGuildName(guild->id, guild->name, IGuilds::NAME_APPROVED);
AddEvent(context, guild->id, "name_approved", 0, "", guild->name);
Audit(context, "approve_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name);
Changed(std::to_string(guild->id));
JsonSuccess(reply, TellChat(guild->id, context.accountId));
});
Route(eHTTPMethod::POST, "/api/guilds/:id/reject", Perm(MANAGE), "Reject a guild name: the guild is renamed \"Guild <id>\". Body (optional): {reason}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto reason = body.value("reason", std::string{});
const auto name = PlaceholderName(guild->id);
Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED);
AddEvent(context, guild->id, "name_rejected", 0, "", guild->name + (reason.empty() ? "" : " (" + reason + ")"));
Audit(context, "reject_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name + (reason.empty() ? "" : " (" + reason + ")"));
Changed(std::to_string(guild->id));
auto result = TellChat(guild->id, context.accountId);
result["name"] = name;
JsonSuccess(reply, result);
});
Route(eHTTPMethod::POST, "/api/guilds/:id/rename", Perm(MANAGE), "Rename a guild (approved). Body: {name} (3-30 of letters, digits, space ' - .; unique without regard to case)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
const auto body = ParseBody(context);
if (!body || !body->contains("name") || !(*body)["name"].is_string()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give the new name");
const auto name = (*body)["name"].get<std::string>();
if (!GuildNameRules::IsValid(GeneralUtils::UTF8ToUTF16(name))) {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A guild name is 3 to 30 letters, digits, spaces, ' - and . (no space at either end or twice in a row)");
}
const auto taken = Database::Get()->GetGuildByName(name);
if (taken && taken->id != guild->id) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Guild " + std::to_string(taken->id) + " has that name");
Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED);
AddEvent(context, guild->id, "renamed", 0, "", guild->name + " -> " + name);
Audit(context, "rename_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name);
Changed(std::to_string(guild->id));
JsonSuccess(reply, TellChat(guild->id, context.accountId));
});
Route(eHTTPMethod::POST, "/api/guilds/:id/disband", Perm(MANAGE), "Disband a guild: every member leaves and the guild is deleted (its history stays)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
const auto members = Database::Get()->GetGuildMembers(guild->id);
Database::Get()->DeleteGuild(guild->id);
AddEvent(context, guild->id, "disbanded", 0, "", guild->name + ", " + std::to_string(members.size()) + " member(s)");
Audit(context, "disband_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + ", " + std::to_string(members.size()) + " member(s)");
Changed(std::to_string(guild->id));
JsonSuccess(reply, TellChat(guild->id, context.accountId));
});
Route(eHTTPMethod::POST, "/api/guilds/:id/members/:character/remove", Perm(MANAGE),
"Remove a member. A leader's guild goes to the highest-ranked, longest-serving member; the last member's guild is deleted",
[](HTTPReply& reply, const HTTPContext& context) {
const auto guild = RequireGuild(context, reply);
if (!guild) return;
const auto characterId = PathId<LWOOBJID>(context.path, 4);
const auto member = characterId ? Database::Get()->GetGuildMember(*characterId) : std::nullopt;
if (!member || member->guildId != guild->id) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That character isn't in this guild");
Database::Get()->RemoveGuildMember(member->characterId);
AddEvent(context, guild->id, "kicked", member->characterId, member->name, "");
const auto remaining = Database::Get()->GetGuildMembers(guild->id);
if (remaining.empty()) {
Database::Get()->DeleteGuild(guild->id);
AddEvent(context, guild->id, "disbanded", 0, "", "the last member was removed");
} else if (member->rank == static_cast<uint8_t>(eGuildRank::LEADER)) {
// Ordered by rank, then who joined first
Database::Get()->SetGuildMemberRank(remaining.front().characterId, static_cast<uint8_t>(eGuildRank::LEADER));
AddEvent(context, guild->id, "leader", remaining.front().characterId, remaining.front().name, "the leader was removed");
}
Audit(context, "remove_guild_member", "Guild " + std::to_string(guild->id) + " (" + guild->name + "): removed " + member->name, AuditTarget::Character(member->characterId));
Changed(std::to_string(guild->id));
JsonSuccess(reply, TellChat(guild->id, context.accountId));
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
/**
* Guilds (docs/Guilds.md): the list, each guild's members and history, and moderation (approve or reject a name that
* waits for review, rename, disband, remove a member). Needs guilds_manage. The dashboard writes the database, audits
* the change, adds it to the guild's history and asks the chat server (through master) to tell the online members.
*/
namespace GuildRoutes {
void RegisterRoutes();
}

View File

@@ -47,8 +47,8 @@ namespace {
// Chat messages counted since the dashboard started, by channel // Chat messages counted since the dashboard started, by channel
bool g_ChatStarted{}; bool g_ChatStarted{};
uint64_t g_ChatSeen{}; uint64_t g_ChatSeen{};
std::map<std::string, uint64_t> g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; std::map<std::string, uint64_t> g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} };
std::map<std::string, uint64_t> g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; std::map<std::string, uint64_t> g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} };
std::string LowerName(std::string_view name) { std::string LowerName(std::string_view name) {
std::string out(name); std::string out(name);

View File

@@ -62,7 +62,7 @@
<div class="col-sm-4"><label class="form-label small mb-1" for="chatChannel">Channel</label> <div class="col-sm-4"><label class="form-label small mb-1" for="chatChannel">Channel</label>
<select class="form-select form-select-sm" id="chatChannel"> <select class="form-select form-select-sm" id="chatChannel">
<option value="">All</option><option value="zone">Zone chat</option> <option value="">All</option><option value="zone">Zone chat</option>
{% if can.chat_private %}<option value="whisper">Whispers</option><option value="team">Team chat</option>{% endif %} {% if can.chat_private %}<option value="whisper">Whispers</option><option value="team">Team chat</option><option value="guild">Guild chat</option>{% endif %}
<option value="web">From the web and bots</option> <option value="web">From the web and bots</option>
</select></div> </select></div>
<div class="col-sm-4"><label class="form-label small mb-1" for="chatCharacter">Character</label> <div class="col-sm-4"><label class="form-label small mb-1" for="chatCharacter">Character</label>

View File

@@ -0,0 +1,150 @@
{% extends "base.jinja2" %}
{% block title %}Guilds - DarkflameServer{% endblock %}
{% block content %}
<h2 class="mb-1">Guilds</h2>
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-body-secondary mb-0">Every guild. A name the chat filter doesn't allow waits for review; other players see no guild name until it is approved. Rejecting a name renames the guild “Guild &lt;id&gt;”.</p>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="pendingOnly" data-pref="guilds.pendingOnly">
<label class="form-check-label" for="pendingOnly">Pending only</label>
</div>
</div>
<div class="card mb-4">
<div class="card-body">
<table id="guildsTable" class="table table-striped table-hover table-stack">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Status</th>
<th>Members</th>
<th>Leader</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="card d-none" id="guildDetail">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0" id="guildTitle"></h5>
<div>
<button class="btn btn-sm btn-outline-primary me-1" id="guildRename">Rename</button>
<button class="btn btn-sm btn-outline-danger" id="guildDisband">Disband</button>
</div>
</div>
<div class="card-body">
<p class="small text-body-secondary" id="guildInfo"></p>
<div class="row">
<div class="col-lg-6">
<h6>Members</h6>
<table class="table table-sm table-stack">
<thead><tr><th>Character</th><th>Rank</th><th>Joined</th><th></th></tr></thead>
<tbody id="guildMembers"></tbody>
</table>
</div>
<div class="col-lg-6">
<h6>History</h6>
<table class="table table-sm table-stack">
<thead><tr><th>When</th><th>What</th><th>Who</th><th>By</th><th>Detail</th></tr></thead>
<tbody id="guildEvents"></tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
$(function () {
var ranks = { 1: 'Leader', 2: 'Officer', 3: 'Veteran', 4: 'Recruit' };
var pending = document.getElementById('pendingOnly');
var shown = null;
var table = serverTable('#guildsTable', '/api/tables/guilds', [
{ data: 'id', orderable: false },
{ data: 'name', orderable: false },
{ data: 'name_status', orderable: false, render: function (d) { return d === 'approved' ? fmt.badge('Approved', 'success') : fmt.badge('Pending', 'warning'); } },
{ data: 'member_count', orderable: false },
{ data: 'leader_id', orderable: false, render: function (d, t, row) { return d ? fmt.character(d, row.leader_name) : '<span class="text-body-secondary small">-</span>'; } },
{ data: 'created_at', orderable: false, render: function (d) { return fmt.unix(d); } },
{ data: null, orderable: false, render: function (d) {
var buttons = '<button class="btn btn-sm btn-outline-secondary me-1" data-guild-action="view" data-id="' + esc(d.id) + '">View</button>';
if (d.name_status !== 'approved') {
buttons += '<button class="btn btn-sm btn-outline-success me-1" data-guild-action="approve" data-id="' + esc(d.id) + '">Approve</button>' +
'<button class="btn btn-sm btn-outline-danger" data-guild-action="reject" data-id="' + esc(d.id) + '" data-name="' + esc(d.name) + '">Reject</button>';
}
return buttons;
}}
], { liveTable: 'guilds', dataTable: { order: [] }, extra: function () { return { pending: pending.checked }; } });
pending.addEventListener('change', function () { table.ajax.reload(); });
function show(id) {
api.get('/api/guilds/' + id).then(function (d) {
var guild = d.guild;
shown = guild;
document.getElementById('guildDetail').classList.remove('d-none');
document.getElementById('guildTitle').textContent = guild.name + ' (' + guild.id + ')';
document.getElementById('guildInfo').innerHTML = 'Name ' + esc(guild.name_status) + '. Founded ' + esc(fmt.unix(guild.created_at)) +
(guild.founder_name ? ' by ' + fmt.character(guild.founder_id, guild.founder_name) : '') + '.';
document.getElementById('guildMembers').innerHTML = guild.members.map(function (m) {
return '<tr><td>' + fmt.character(m.id, m.name || m.id) + '</td><td>' + esc(ranks[m.rank] || m.rank) + '</td><td>' + esc(fmt.unix(m.joined_at)) +
'</td><td><button class="btn btn-sm btn-outline-danger" data-member="' + esc(m.id) + '" data-name="' + esc(m.name) + '">Remove</button></td></tr>';
}).join('') || '<tr><td colspan="4" class="text-body-secondary">No members</td></tr>';
document.getElementById('guildEvents').innerHTML = guild.events.map(function (e) {
return '<tr><td>' + esc(fmt.unix(e.time)) + '</td><td>' + esc(e.kind) + '</td><td>' + (e.character_id !== '0' ? fmt.character(e.character_id, e.character_name || e.character_id) : '') +
'</td><td>' + esc(e.actor) + '</td><td>' + esc(e.detail) + '</td></tr>';
}).join('');
}).catch(function () {});
}
function refresh() {
table.ajax.reload(null, false);
if (shown) show(shown.id);
}
$('#guildsTable').on('click', '[data-guild-action]', function () {
var action = this.getAttribute('data-guild-action');
var id = this.getAttribute('data-id');
if (action === 'view') return show(id);
if (action === 'approve') { api.action('/api/guilds/' + id + '/approve', {}, 'Approved').then(refresh).catch(function () {}); return; }
var reason = prompt('Reject the guild name “' + this.getAttribute('data-name') + '”? The guild is renamed “Guild ' + id + '”. Reason (optional, kept in its history):', '');
if (reason === null) return;
api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').then(refresh).catch(function () {});
});
document.getElementById('guildRename').addEventListener('click', function () {
if (!shown) return;
var name = prompt('New name for ' + shown.name + ' (3 to 30 letters, digits, spaces, \' - and .):', shown.name);
if (name === null || name === shown.name) return;
api.action('/api/guilds/' + shown.id + '/rename', { name: name }, 'Renamed').then(refresh).catch(function () {});
});
document.getElementById('guildDisband').addEventListener('click', function () {
if (!shown) return;
var typed = prompt('This removes every member of ' + shown.name + ' and deletes the guild (its history stays). Type the guild ID (' + shown.id + ') to confirm:');
if (typed !== shown.id) return;
var id = shown.id;
api.action('/api/guilds/' + id + '/disband', {}, 'Disbanded').then(function () {
shown = null;
document.getElementById('guildDetail').classList.add('d-none');
table.ajax.reload(null, false);
}).catch(function () {});
});
document.getElementById('guildMembers').addEventListener('click', function (event) {
var button = event.target.closest('[data-member]');
if (!button || !shown) return;
if (!confirm('Remove ' + button.getAttribute('data-name') + ' from ' + shown.name + '?')) return;
api.action('/api/guilds/' + shown.id + '/members/' + button.getAttribute('data-member') + '/remove', {}, 'Removed').then(refresh).catch(function () {});
});
});
</script>
{% endblock %}

View File

@@ -17,14 +17,14 @@
</div> </div>
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} {% set inboxPages = ["moderation", "player_reports", "bug_reports"] %}
{% if can.moderate_names or can.moderate_pet_names or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view %}
<div class="nav-group"> <div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-inbox" aria-expanded="{% if current_page in inboxPages %}true{% else %}false{% endif %}" aria-controls="nav-inbox"> <button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-inbox" aria-expanded="{% if current_page in inboxPages %}true{% else %}false{% endif %}" aria-controls="nav-inbox">
Inbox<span class="nav-group-dot" title="Something in here needs attention"></span> Inbox<span class="nav-group-dot" title="Something in here needs attention"></span>
</button> </button>
<div class="collapse{% if current_page in inboxPages %} show{% endif %}" id="nav-inbox"> <div class="collapse{% if current_page in inboxPages %} show{% endif %}" id="nav-inbox">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% if can.moderate_names or can.moderate_pet_names or can.moderate_properties %}<a href="/moderation" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "moderation" %} active{% endif %}">Review Queue <span class="badge rounded-pill text-bg-warning d-none" data-badge="moderation"></span></a>{% endif %} {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties %}<a href="/moderation" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "moderation" %} active{% endif %}">Review Queue <span class="badge rounded-pill text-bg-warning d-none" data-badge="moderation"></span></a>{% endif %}
{% if can.player_reports_view %}<a href="/player_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "player_reports" %} active{% endif %}">Player Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="player_reports"></span></a>{% endif %} {% if can.player_reports_view %}<a href="/player_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "player_reports" %} active{% endif %}">Player Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="player_reports"></span></a>{% endif %}
{% if can.bug_reports_view %}<a href="/bug_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "bug_reports" %} active{% endif %}">Bug Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="bug_reports"></span></a>{% endif %} {% if can.bug_reports_view %}<a href="/bug_reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "bug_reports" %} active{% endif %}">Bug Reports <span class="badge rounded-pill text-bg-danger d-none" data-badge="bug_reports"></span></a>{% endif %}
</div> </div>
@@ -32,8 +32,8 @@
</div> </div>
{% endif %} {% endif %}
{% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "mail", "send_mail"] %} {% set moderationPages = ["players", "chat_log", "chat_filter", "pet_names", "guilds", "mail", "send_mail"] %}
{% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.characters_mail or can.mail_send %} {% if can.players_view or can.chat_view or can.chat_filter_manage or can.moderate_pet_names or can.guilds_manage or can.characters_mail or can.mail_send %}
<div class="nav-group"> <div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-moderation" aria-expanded="{% if current_page in moderationPages %}true{% else %}false{% endif %}" aria-controls="nav-moderation"> <button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-moderation" aria-expanded="{% if current_page in moderationPages %}true{% else %}false{% endif %}" aria-controls="nav-moderation">
Moderation<span class="nav-group-dot" title="Something in here needs attention"></span> Moderation<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -44,6 +44,7 @@
{% if can.chat_view %}<a href="/chat_log" class="list-group-item list-group-item-action{% if current_page == "chat_log" %} active{% endif %}">Chat Log</a>{% endif %} {% if can.chat_view %}<a href="/chat_log" class="list-group-item list-group-item-action{% if current_page == "chat_log" %} active{% endif %}">Chat Log</a>{% endif %}
{% if can.chat_filter_manage %}<a href="/chat_filter" class="list-group-item list-group-item-action{% if current_page == "chat_filter" %} active{% endif %}">Chat Filter</a>{% endif %} {% if can.chat_filter_manage %}<a href="/chat_filter" class="list-group-item list-group-item-action{% if current_page == "chat_filter" %} active{% endif %}">Chat Filter</a>{% endif %}
{% if can.moderate_pet_names %}<a href="/pet_names" class="list-group-item list-group-item-action{% if current_page == "pet_names" %} active{% endif %}">Pet Names</a>{% endif %} {% if can.moderate_pet_names %}<a href="/pet_names" class="list-group-item list-group-item-action{% if current_page == "pet_names" %} active{% endif %}">Pet Names</a>{% endif %}
{% if can.guilds_manage %}<a href="/guilds" class="list-group-item list-group-item-action{% if current_page == "guilds" %} active{% endif %}">Guilds</a>{% endif %}
{% if can.characters_mail %}<a href="/mail" class="list-group-item list-group-item-action{% if current_page == "mail" %} active{% endif %}">Mail</a>{% endif %} {% if can.characters_mail %}<a href="/mail" class="list-group-item list-group-item-action{% if current_page == "mail" %} active{% endif %}">Mail</a>{% endif %}
{% if can.mail_send %}<a href="/send_mail" class="list-group-item list-group-item-action{% if current_page == "send_mail" %} active{% endif %}">Send Mail</a>{% endif %} {% if can.mail_send %}<a href="/send_mail" class="list-group-item list-group-item-action{% if current_page == "send_mail" %} active{% endif %}">Send Mail</a>{% endif %}
</div> </div>

View File

@@ -31,6 +31,18 @@
</div> </div>
{% endif %} {% endif %}
{% if can.guilds_manage %}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Pending Guild Names</h5></div>
<div class="card-body">
<table id="pendingGuildsTable" class="table table-striped table-hover table-stack">
<thead><tr><th>ID</th><th>Guild Name</th><th>Leader</th><th>Members</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
{% endif %}
{% if can.moderate_properties %} {% if can.moderate_properties %}
<div class="card"> <div class="card">
<div class="card-header"><h5 class="mb-0">Pending Properties</h5></div> <div class="card-header"><h5 class="mb-0">Pending Properties</h5></div>
@@ -85,6 +97,26 @@ $(function () {
], $.extend({ liveTable: 'pet_names' }, small)); ], $.extend({ liveTable: 'pet_names' }, small));
} }
if (DASH.can('guilds_manage')) {
serverTable('#pendingGuildsTable', '/api/tables/guilds', [
{ data: 'id', orderable: false },
{ data: 'name', orderable: false, render: function (d) { return fmt.link('/guilds', d); } },
{ data: 'leader_id', orderable: false, render: function (d, t, row) { return d ? fmt.character(d, row.leader_name) : ''; } },
{ data: 'member_count', orderable: false },
{ data: 'id', orderable: false, render: function (d, t, row) {
return '<button class="btn btn-sm btn-outline-success me-1" data-guild-action="approve" data-id="' + esc(d) + '">Approve</button>' +
'<button class="btn btn-sm btn-outline-danger" data-guild-action="reject" data-id="' + esc(d) + '" data-name="' + esc(row.name) + '">Reject</button>';
} }
], $.extend({ liveTable: 'guilds', extra: function () { return { pending: true }; } }, small));
$('#pendingGuildsTable').on('click', '[data-guild-action]', function () {
var id = this.getAttribute('data-id');
if (this.getAttribute('data-guild-action') === 'approve') { api.action('/api/guilds/' + id + '/approve', {}, 'Approved').catch(function () {}); return; }
var reason = prompt('Reject the guild name “' + this.getAttribute('data-name') + '”? The guild is renamed “Guild ' + id + '”. Reason (optional, kept in its history):', '');
if (reason === null) return;
api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').catch(function () {});
});
}
if (DASH.can('moderate_properties')) { if (DASH.can('moderate_properties')) {
serverTable('#pendingPropsTable', '/api/tables/pending_properties', [ serverTable('#pendingPropsTable', '/api/tables/pending_properties', [
{ data: 'id', orderable: false }, { data: 'id', orderable: false },

View File

@@ -17,7 +17,7 @@ public:
struct ChatMessage { struct ChatMessage {
uint64_t id{}; uint64_t id{};
int64_t time{}; int64_t time{};
std::string channel; // zone, whisper, team, web std::string channel; // zone, whisper, team, guild, web
LWOOBJID senderId{}; // the character; 0 for messages from the web LWOOBJID senderId{}; // the character; 0 for messages from the web
std::string senderName; std::string senderName;
uint32_t accountId{}; uint32_t accountId{};