diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e99735bf7..2ded00d66 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -290,6 +290,7 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp + src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h @@ -383,6 +384,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_alts.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..b3b31b8da --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,183 @@ +#include "tab_alts.h" + +#include "../utility/epoch_format.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_user_alts.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_VIA = 1; +constexpr int COL_ALTS_DEPTH = 2; +constexpr int COL_ALTS_SHARED = 3; +constexpr int COL_ALTS_EMAIL = 4; +constexpr int COL_ALTS_CLIENTID = 5; +constexpr int COL_ALTS_REGISTERED = 6; +constexpr int COL_ALTS_LAST_LOGIN = 7; +constexpr int COL_ALTS_WARNS = 8; +constexpr int COL_ALTS_BANS = 9; +constexpr int COL_ALTS_ACTIVE = 10; +constexpr int COL_ALTS_COUNT = 11; + +constexpr int MAX_ALT_DEPTH = 3; +} // namespace + +TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + statusLabel = new QLabel; + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + altsTable->setSelectionMode(QAbstractItemView::SingleSelection); + altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + altsTable->verticalHeader()->setVisible(false); + altsTable->setAlternatingRowColors(true); + altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + altsTable->horizontalHeader()->setStretchLastSection(true); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(altsTable); + + retranslateUi(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), + tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), + tr("Active")}); +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + return formatEpochTimestamp(ts, tr("Unknown")); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(userName, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server answers RespOk with no rows when the user does not exist. + if (resp.alts_size() == 0) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + return; + } + + altsTable->setRowCount(resp.alts_size()); + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + // The server appends the frontier user as a depth-0 seed row. + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + i, COL_ALTS_SHARED, + new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..3b83cc4a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,54 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class Response; +class TabSupervisor; + +/** + * Staff alt-graph investigation tool. Shows accounts related to the + * investigated user (shared email, client id, or recent IP address), + * including transitive alts-of-alts, with per-row match evidence. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void altsResponse(const Response &response); + +private: + void requestAlts(const QString &userName, int depth); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QLabel *statusLabel; + QTableWidget *altsTable; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index be1adeef8..c28217f25 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,5 +1,6 @@ #include "tab_moderation.h" +#include "../utility/epoch_format.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -215,10 +216,7 @@ void TabModeration::retranslateUi() QString TabModeration::formatEpoch(quint64 ts) const { - if (ts == 0) { - return tr("Unknown"); - } - return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabModeration::clearUserData() diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index cf71dd7ed..a7fc2e92b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -21,6 +21,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -156,7 +157,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setObjectName("tabSupervisor"); @@ -574,6 +575,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -622,6 +624,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -930,6 +935,21 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + setCurrentWidget(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); diff --git a/cockatrice/src/interface/widgets/utility/epoch_format.h b/cockatrice/src/interface/widgets/utility/epoch_format.h new file mode 100644 index 000000000..977053997 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/epoch_format.h @@ -0,0 +1,27 @@ +/** + * @file epoch_format.h + * @brief Shared timestamp formatting for the moderation tables. + */ + +#ifndef EPOCH_FORMAT_H +#define EPOCH_FORMAT_H + +#include +#include + +/** + * @brief Format a unix timestamp the way the moderation tabs show dates. + * + * @param ts Seconds since the epoch; 0 means "unknown". + * @param unknownText Label returned for 0, translated by the caller so the + * translation context stays with the tab. + */ +inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText) +{ + if (ts == 0) { + return unknownText; + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +#endif // EPOCH_FORMAT_H