[Server/Client/Protocol] Add developer staff role

Introduce a Developer staff level (proto flag 32, DB admin bit 8) that
sits between admin and moderator: no kick/ban/warn/report/admin powers,
but gets server log access via a new developer command container family
(GET_SERVER_STATS, VIEWLOG_HISTORY) and an idle-timeout exemption.

- Protocol: IsDeveloper flag, developer_commands.proto envelope,
  Command_GetServerStats/Command_GetLogHistory, Response_GetServerStats,
  Command_AdjustMod.should_be_developer
- Servatrice: fail-closed developer dispatcher, uptime snapshot handler,
  shared log history handler reuse, bit-8 DB mapping
- Client: burgundy pawn/badge/labels/sort order, prepareDeveloperCommand,
  minimal Developer stats tab, log tab access, promote/demote actions

Took 24 minutes

Took 18 seconds
This commit is contained in:
Lukas Brübach 2026-08-23 19:40:16 +02:00
parent 7d867b9745
commit 1ff7ffcaa4
31 changed files with 515 additions and 23 deletions

View file

@ -51,6 +51,8 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDemoteFromMod = new QAction(QString(), this);
aPromoteToJudge = new QAction(QString(), this);
aDemoteFromJudge = new QAction(QString(), this);
aPromoteToDeveloper = new QAction(QString(), this);
aDemoteFromDeveloper = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
@ -76,6 +78,8 @@ void UserContextMenu::retranslateUi()
aDemoteFromMod->setText(tr("Dem&ote user from moderator"));
aPromoteToJudge->setText(tr("Promote user to &judge"));
aDemoteFromJudge->setText(tr("Demote user from judge"));
aPromoteToDeveloper->setText(tr("Promote user to &developer"));
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
}
@ -268,7 +272,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const
const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext);
if (resp.response_code() == Response::RespOk) {
if (cmd.should_be_mod() || cmd.should_be_judge()) {
if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) {
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Success"),
tr("Successfully promoted user."));
} else {
@ -276,7 +280,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const
}
} else {
if (cmd.should_be_mod() || cmd.should_be_judge()) {
if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) {
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Failed"), tr("Failed to promote user."));
} else {
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Failed"), tr("Failed to demote user."));
@ -437,6 +441,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
(tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
menu->addAction(aPromoteToJudge);
}
if (userLevel.testFlag(ServerInfo_User::IsDeveloper) &&
(tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
menu->addAction(aDemoteFromDeveloper);
} else if (userLevel.testFlag(ServerInfo_User::IsRegistered) &&
(tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) {
menu->addAction(aPromoteToDeveloper);
}
}
aDetails->setEnabled(true);
aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
@ -455,6 +468,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aInvestigateUser->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
aPromoteToJudge->setEnabled(anotherUser);
aDemoteFromJudge->setEnabled(anotherUser);
aPromoteToDeveloper->setEnabled(anotherUser);
aDemoteFromDeveloper->setEnabled(anotherUser);
QAction *actionClicked = menu->exec(pos);
if (actionClicked == nullptr) {
@ -489,6 +506,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execAdjustMod(userName, actionClicked == aPromoteToMod);
} else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) {
execAdjustJudge(userName, actionClicked == aPromoteToJudge);
} else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) {
execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper);
} else if (actionClicked == aBanHistory) {
execBanHistory(userName);
} else if (actionClicked == aWarnUser) {
@ -706,4 +725,14 @@ void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudg
PendingCommand *pend = client->prepareAdminCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse);
client->sendCommand(pend);
}
void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper)
{
Command_AdjustMod cmd;
cmd.set_user_name(userName.toStdString());
cmd.set_should_be_developer(shouldBeDeveloper);
PendingCommand *pend = client->prepareAdminCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse);
client->sendCommand(pend);
}

View file

@ -45,6 +45,7 @@ private:
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aPromoteToDeveloper, *aDemoteFromDeveloper;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
@ -123,6 +124,7 @@ public:
void execInvestigateUser(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);
private:
void execInvite(const QString &userName, const GameInviteOption &option);

View file

@ -122,6 +122,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user)
QString userLevelText;
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
userLevelText = tr("Administrator");
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
userLevelText = tr("Developer");
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
userLevelText = tr("Moderator");
} else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {

View file

@ -245,6 +245,9 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
if (level.testFlag(ServerInfo_User::IsAdmin)) {
return QColor(245, 158, 11);
}
if (level.testFlag(ServerInfo_User::IsDeveloper)) {
return QColor(185, 28, 28);
}
if (level.testFlag(ServerInfo_User::IsModerator)) {
return QColor(59, 130, 246);
}
@ -300,6 +303,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
} badge;
if (level.testFlag(ServerInfo_User::IsAdmin)) {
badge = {"ADMIN", QColor(245, 158, 11)};
} else if (level.testFlag(ServerInfo_User::IsDeveloper)) {
badge = {"DEV", QColor(185, 28, 28)};
} else if (level.testFlag(ServerInfo_User::IsModerator)) {
badge = {"MOD", QColor(59, 130, 246)};
} else if (level.testFlag(ServerInfo_User::IsJudge)) {

View file

@ -49,6 +49,8 @@ QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool onl
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
accentColor = QColor(245, 158, 11);
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
accentColor = QColor(185, 28, 28);
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
accentColor = QColor(59, 130, 246);
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
@ -299,6 +301,8 @@ QList<UserListPainter::Badge> UserListPainter::buildBadges(const UserLevelFlags
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
badges << Badge{"ADMIN", QColor(245, 158, 11)};
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
badges << Badge{"DEV", QColor(185, 28, 28)};
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
badges << Badge{"MOD", QColor(59, 130, 246)};
} else if (userLevel.testFlag(ServerInfo_User::IsJudge)) {
@ -385,9 +389,9 @@ void UserListPainter::paint(QPainter *painter,
const QString userName = QString::fromStdString(userInfo.name());
const QString privLevel = QString::fromStdString(userInfo.privlevel());
const QColor accentColor = getAccentColor(userLevel, online);
const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) ||
userLevel.testFlag(ServerInfo_User::IsModerator) ||
userLevel.testFlag(ServerInfo_User::IsJudge);
const bool hasRole =
userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) ||
userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
const int cardRight = getCardRight(option, rect);

View file

@ -234,9 +234,11 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const
const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt());
const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt());
// Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator
// Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer
// or a moderator
static const QList<ServerInfo_User_UserLevelFlag> userLevelWithNoOtherPrefOrder = {
ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator};
ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper,
ServerInfo_User_UserLevelFlag_IsModerator};
for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) {
if (lhsUserLevelFlags.testFlag(userLevelEntry) &&
lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) {