feat: online players get grant changes at once

Adding or removing a grant sends the existing PLAYER_ACTION refresh through master to every world: REFRESH_ACCOUNT
for an account's grants, REFRESH_CHARACTER for a character's. A world with that account's sessions (or the loaded
character) drops the grants it kept, so the next command reads them again: no relog. The dashboard toasts whether the
player was online. The dashboard's own open pages already get the new rights on their next request, and their
WebSockets are checked again.

Check: with a character in game, grant it /spawn on the dashboard (toast: "Applied in game at once") and use /spawn
without relogging; remove the grant and /spawn is refused again at once. With the player offline the toast says it
applies when they next play.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 01:13:35 -05:00
parent e00d22152e
commit 641fd72969
3 changed files with 28 additions and 9 deletions

View File

@@ -8,6 +8,8 @@
#include "eHTTPMethod.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "master/PlayerAction.h"
#include "PlayerActions.h"
#include "HTTPContext.h"
#include "PermissionGrants.h"
#include "Permissions.h"
@@ -168,10 +170,23 @@ namespace {
std::map<uint32_t, bool> manageable;
};
// Grants apply at once: the dashboard's open pages get their account's rights again
void Applied(const Target& target) {
/**
* Grants apply at once: the dashboard's open pages get their account's rights again, and the world servers read the
* grants of the account's online sessions (or the character, if it is loaded) again, as for a GM level change.
* Returns the request ID of the refresh (the page shows its result).
*/
uint32_t Applied(const HTTPContext& context, const Target& target) {
Game::web.RecheckWebSockets(target.accountId);
BroadcastTableChanged("grants", std::to_string(target.accountId));
PlayerActionRequest request;
const bool account = target.type == PermissionGrants::ACCOUNT;
request.action = account ? ePlayerAction::REFRESH_ACCOUNT : ePlayerAction::REFRESH_CHARACTER;
request.accountId = target.accountId;
request.characterId = account ? 0 : target.id;
return PlayerActions::Request(request, context.accountId, [account](const PlayerActionResult& result) {
if (result.affected == 0) return PlayerActions::Outcome{ true, account ? "The account isn't online; it applies when they next play" : "The character isn't online; it applies when they next play" };
return PlayerActions::Outcome{ true, "Applied in game at once" };
});
}
}
@@ -287,8 +302,8 @@ void GrantRoutes::RegisterRoutes() {
const auto description = std::string(deny ? "Took away " : "Granted ") + what + (deny ? " from " : " to ") + target->Describe() +
(expiresAt ? " until " + UtcTime(expiresAt) : "") + (note.empty() ? "" : ": " + note) + OwnAccountNote(context.accountId, target->accountId);
Audit(context, deny ? "deny_permission" : "grant_permission", description, target->Audit());
Applied(*target);
JsonSuccess(reply, { {"id", grant.id}, {"message", std::string(deny ? "Took away " : "Granted ") + what} });
const auto requestId = Applied(context, *target);
JsonSuccess(reply, { {"id", grant.id}, {"requestId", requestId}, {"message", std::string(deny ? "Took away " : "Granted ") + what} });
});
Route(eHTTPMethod::POST, "/api/grants/:id/remove", Perm(MANAGE),
@@ -311,7 +326,7 @@ void GrantRoutes::RegisterRoutes() {
const auto what = PermissionGrants::Describe(*kind, grant->name);
Audit(context, "remove_grant", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what + (grant->deny ? " from " : " to ") +
target->Describe() + " (given by " + grant->grantedBy + ")" + OwnAccountNote(context.accountId, target->accountId), target->Audit());
Applied(*target);
JsonSuccess(reply, { {"message", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what} });
const auto requestId = Applied(context, *target);
JsonSuccess(reply, { {"requestId", requestId}, {"message", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what} });
});
}

View File

@@ -48,10 +48,12 @@ namespace {
return static_cast<uint32_t>(users.size());
}
// Pick up a GM level change: lower the in-game level if it now exceeds the account's maximum
// Pick up a GM level change: lower the in-game level if it now exceeds the account's maximum. Also a change of the
// account's permission grants: they are read again the next time a command is used
uint32_t RefreshAccount(uint32_t accountId) {
const auto users = UserManager::Instance()->GetUsersForAccount(accountId);
if (users.empty()) return 0;
for (auto* user : users) user->ForgetGrants();
const auto info = Database::Get()->GetAccountInfo(users.front()->GetUsername());
if (!info) return 0;
@@ -83,10 +85,12 @@ namespace {
return static_cast<uint32_t>(users.size());
}
// Restrictions or permission grants of a character changed
uint32_t RefreshCharacter(LWOOBJID characterId) {
auto* entity = PlayerManager::GetPlayer(characterId);
auto* character = entity ? entity->GetCharacter() : nullptr;
if (!character) return 0;
if (auto* user = character->GetParentUser()) user->ForgetGrants();
const auto info = Database::Get()->GetCharacterInfo(characterId);
if (!info) return 0;

View File

@@ -18,8 +18,8 @@
*/
enum class ePlayerAction : uint8_t {
KICK_ACCOUNT, // Disconnect every session of accountId
REFRESH_ACCOUNT, // Reload account data (GM level) for online sessions of accountId
REFRESH_CHARACTER, // Reload character data (restrictions) for characterId if loaded
REFRESH_ACCOUNT, // Reload account data (GM level, permission grants) for online sessions of accountId
REFRESH_CHARACTER, // Reload character data (restrictions, permission grants) for characterId if loaded
RESCUE_CHARACTER, // Transfer characterId to zoneId if it is loaded in a world, landing on spawn point `text` (empty: the zone's default)
// Tell a player a moderator decided on something they asked for (approved = yes/no, text = name or reason)
NAME_MODERATED, // characterId's requested name; text is the name