feat(GODT-2540): notify user of wrong IMAP password.
This commit is contained in:
@ -75,7 +75,7 @@ if(NOT UNIX)
|
|||||||
set(CMAKE_INSTALL_BINDIR ".")
|
set(CMAKE_INSTALL_BINDIR ".")
|
||||||
endif(NOT UNIX)
|
endif(NOT UNIX)
|
||||||
|
|
||||||
find_package(Qt6 COMPONENTS Core Quick Qml QuickControls2 Widgets REQUIRED)
|
find_package(Qt6 COMPONENTS Core Quick Qml QuickControls2 Widgets Svg REQUIRED)
|
||||||
qt_standard_project_setup()
|
qt_standard_project_setup()
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
message(STATUS "Using Qt ${Qt6_VERSION}")
|
message(STATUS "Using Qt ${Qt6_VERSION}")
|
||||||
@ -147,6 +147,7 @@ target_link_libraries(bridge-gui
|
|||||||
Qt6::Quick
|
Qt6::Quick
|
||||||
Qt6::Qml
|
Qt6::Qml
|
||||||
Qt6::QuickControls2
|
Qt6::QuickControls2
|
||||||
|
Qt6::Svg
|
||||||
sentry::sentry
|
sentry::sentry
|
||||||
bridgepp
|
bridgepp
|
||||||
)
|
)
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
#include <QtQml>
|
#include <QtQml>
|
||||||
#include <QtWidgets>
|
#include <QtWidgets>
|
||||||
#include <QtQuickControls2>
|
#include <QtQuickControls2>
|
||||||
|
#include <QtSvg>
|
||||||
#include <AppController.h>
|
#include <AppController.h>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -994,15 +994,34 @@ void QMLBackend::onUserBadEvent(QString const &userID, QString const &) {
|
|||||||
void QMLBackend::onIMAPLoginFailed(QString const &username) {
|
void QMLBackend::onIMAPLoginFailed(QString const &username) {
|
||||||
HANDLE_EXCEPTION(
|
HANDLE_EXCEPTION(
|
||||||
SPUser const user = users_->getUserWithUsernameOrEmail(username);
|
SPUser const user = users_->getUserWithUsernameOrEmail(username);
|
||||||
if ((!user) || (user->state() != UserState::SignedOut)) { // We want to pop-up only if a signed-out user has been detected
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user->isInIMAPLoginFailureCooldown()) {
|
|
||||||
return;
|
qint64 const cooldownDurationMs = 10 * 60 * 1000; // 10 minutes cooldown period for notifications
|
||||||
|
switch (user->state()) {
|
||||||
|
case UserState::SignedOut:
|
||||||
|
if (user->isNotificationInCooldown(User::ENotification::IMAPLoginWhileSignedOut)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user->startNotificationCooldownPeriod(User::ENotification::IMAPLoginWhileSignedOut, cooldownDurationMs);
|
||||||
|
emit selectUser(user->id(), true);
|
||||||
|
emit imapLoginWhileSignedOut(username);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UserState::Connected:
|
||||||
|
if (user->isNotificationInCooldown(User::ENotification::IMAPPasswordFailure)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user->startNotificationCooldownPeriod(User::ENotification::IMAPPasswordFailure, cooldownDurationMs);
|
||||||
|
emit selectUser(user->id(), false);
|
||||||
|
trayIcon_->showErrorPopupNotification(tr("Incorrect password"),
|
||||||
|
tr("Your email client can't connect to Proton Bridge. Make sure you are using the local Bridge password shown in Bridge."));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
user->startImapLoginFailureCooldown(60 * 60 * 1000); // 1 hour cooldown during which we will not display this notification to this user again.
|
|
||||||
emit selectUser(user->id());
|
|
||||||
emit imapLoginWhileSignedOut(username);
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1134,7 +1153,7 @@ void QMLBackend::displayBadEventDialog(QString const &userID) {
|
|||||||
emit userBadEvent(userID,
|
emit userBadEvent(userID,
|
||||||
tr("Bridge ran into an internal error and it is not able to proceed with the account %1. Synchronize your local database now or logout"
|
tr("Bridge ran into an internal error and it is not able to proceed with the account %1. Synchronize your local database now or logout"
|
||||||
" to do it later. Synchronization time depends on the size of your mailbox.").arg(elideLongString(user->primaryEmailOrUsername(), 30)));
|
" to do it later. Synchronization time depends on the size of your mailbox.").arg(elideLongString(user->primaryEmailOrUsername(), 30)));
|
||||||
emit selectUser(userID);
|
emit selectUser(userID, true);
|
||||||
emit showMainWindow();
|
emit showMainWindow();
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -180,6 +180,8 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
|
|||||||
void onVersionChanged(); ///< Slot for the version change signal.
|
void onVersionChanged(); ///< Slot for the version change signal.
|
||||||
void setMailServerSettings(int imapPort, int smtpPort, bool useSSLForIMAP, bool useSSLForSMTP) const; ///< Forwards a connection mode change request from QML to gRPC
|
void setMailServerSettings(int imapPort, int smtpPort, bool useSSLForIMAP, bool useSSLForSMTP) const; ///< Forwards a connection mode change request from QML to gRPC
|
||||||
void sendBadEventUserFeedback(QString const &userID, bool doResync); ///< Slot the providing user feedback for a bad event.
|
void sendBadEventUserFeedback(QString const &userID, bool doResync); ///< Slot the providing user feedback for a bad event.
|
||||||
|
|
||||||
|
public slots: // slots for functions that need to be processed locally.
|
||||||
void setNormalTrayIcon(); ///< Set the tray icon to normal.
|
void setNormalTrayIcon(); ///< Set the tray icon to normal.
|
||||||
void setErrorTrayIcon(QString const& stateString, QString const &statusIcon); ///< Set the tray icon to 'error' state.
|
void setErrorTrayIcon(QString const& stateString, QString const &statusIcon); ///< Set the tray icon to 'error' state.
|
||||||
void setWarnTrayIcon(QString const& stateString, QString const &statusIcon); ///< Set the tray icon to 'warn' state.
|
void setWarnTrayIcon(QString const& stateString, QString const &statusIcon); ///< Set the tray icon to 'warn' state.
|
||||||
@ -245,7 +247,7 @@ signals: // Signals received from the Go backend, to be forwarded to QML
|
|||||||
void hideMainWindow(); ///< Signal for the 'hideMainWindow' gRPC stream event.
|
void hideMainWindow(); ///< Signal for the 'hideMainWindow' gRPC stream event.
|
||||||
void showHelp(); ///< Signal for the 'showHelp' event (from the context menu).
|
void showHelp(); ///< Signal for the 'showHelp' event (from the context menu).
|
||||||
void showSettings(); ///< Signal for the 'showHelp' event (from the context menu).
|
void showSettings(); ///< Signal for the 'showHelp' event (from the context menu).
|
||||||
void selectUser(QString const& userID); ///< Signal emitted in order to selected a user with a given ID in the list.
|
void selectUser(QString const& userID, bool forceShowWindow); ///< Signal emitted in order to selected a user with a given ID in the list.
|
||||||
void genericError(QString const &title, QString const &description); ///< Signal for the 'genericError' gRPC stream event.
|
void genericError(QString const &title, QString const &description); ///< Signal for the 'genericError' gRPC stream event.
|
||||||
void imapLoginWhileSignedOut(QString const& username); ///< Signal for the notification of IMAP login attempt on a signed out account.
|
void imapLoginWhileSignedOut(QString const& username); ///< Signal for the notification of IMAP login attempt on a signed out account.
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,50 @@ QIcon loadIconFromImage(QString const &path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
/// \brief Load a multi-resolution icon from a SVG file. The image is assumed to be square. SVG is rasterized in 256, 128, 64, 32 and 16px.
|
||||||
|
///
|
||||||
|
/// Note: QPixmap can load SVG files directly, but our SVG file are defined in small shape size and QPixmap will rasterize them a very low resolution
|
||||||
|
/// by default (eg. 16x16), which is insufficient for some uses. As a consequence, we manually generate a multi-resolution icon that render smoothly
|
||||||
|
/// at any acceptable resolution for an icon.
|
||||||
|
///
|
||||||
|
/// \param[in] path The path of the SVG file.
|
||||||
|
/// \return The icon.
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
QIcon loadIconFromSVG(QString const &path, QColor const &color = QColor()) {
|
||||||
|
QSvgRenderer renderer(path);
|
||||||
|
QIcon icon;
|
||||||
|
qint32 size = 256;
|
||||||
|
|
||||||
|
while (size >= 16) {
|
||||||
|
QPixmap pixmap(size, size);
|
||||||
|
pixmap.fill(QColor(0, 0, 0, 0));
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
renderer.render(&painter);
|
||||||
|
if (color.isValid()) {
|
||||||
|
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||||
|
painter.fillRect(pixmap.rect(), color);
|
||||||
|
}
|
||||||
|
painter.end();
|
||||||
|
icon.addPixmap(pixmap);
|
||||||
|
size /= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
//
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
QIcon loadIcon(QString const& path) {
|
||||||
|
if (path.endsWith(".svg", Qt::CaseInsensitive)) {
|
||||||
|
return loadIconFromSVG(path);
|
||||||
|
}
|
||||||
|
return loadIconFromImage(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
/// \brief Retrieve the color associated with a tray icon state.
|
/// \brief Retrieve the color associated with a tray icon state.
|
||||||
///
|
///
|
||||||
@ -95,6 +139,18 @@ QString stateText(TrayIcon::State state) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
/// \brief converts a QML resource path to Qt resource path.
|
||||||
|
/// QML resource paths are a bit different from qt resource paths
|
||||||
|
/// \param[in] path The resource path.
|
||||||
|
/// \return
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
QString qmlResourcePathToQt(QString const &path) {
|
||||||
|
QString result = path;
|
||||||
|
result.replace(QRegularExpression(R"(^\.\/)"), ":/qml/");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
|
|
||||||
@ -111,7 +167,8 @@ TrayIcon::TrayIcon()
|
|||||||
connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow);
|
connect(menu_.get(), &QMenu::aboutToShow, this, &TrayIcon::onMenuAboutToShow);
|
||||||
connect(this, &TrayIcon::selectUser, &app().backend(), &QMLBackend::selectUser);
|
connect(this, &TrayIcon::selectUser, &app().backend(), &QMLBackend::selectUser);
|
||||||
connect(this, &TrayIcon::activated, this, &TrayIcon::onActivated);
|
connect(this, &TrayIcon::activated, this, &TrayIcon::onActivated);
|
||||||
|
// some OSes/Desktop managers will automatically show main window when clicked, but not all, so we do it manually.
|
||||||
|
connect(this, &TrayIcon::messageClicked, &app().backend(), &QMLBackend::showMainWindow);
|
||||||
this->show();
|
this->show();
|
||||||
this->setState(State::Normal, QString(), QString());
|
this->setState(State::Normal, QString(), QString());
|
||||||
|
|
||||||
@ -151,7 +208,7 @@ void TrayIcon::onUserClicked() {
|
|||||||
throw Exception("Could not retrieve context menu's selected user.");
|
throw Exception("Could not retrieve context menu's selected user.");
|
||||||
}
|
}
|
||||||
|
|
||||||
emit selectUser(userID);
|
emit selectUser(userID, true);
|
||||||
} catch (Exception const &e) {
|
} catch (Exception const &e) {
|
||||||
app().log().error(e.qwhat());
|
app().log().error(e.qwhat());
|
||||||
}
|
}
|
||||||
@ -242,15 +299,23 @@ void TrayIcon::setState(TrayIcon::State state, QString const &stateString, QStri
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
/// \param[in] title The title.
|
||||||
|
/// \param[in] message The message.
|
||||||
|
//****************************************************************************************************************************************************
|
||||||
|
void TrayIcon::showErrorPopupNotification(QString const &title, QString const &message) {
|
||||||
|
// this->showMessage(title, message, loadIconFromSVG(":/qml/icons/ic-exclamation-circle-filled.svg", errorColor));
|
||||||
|
this->showMessage(title, message, loadIconFromSVG(":/qml/icons/ic-alert.svg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
/// \param[in] svgPath The path of the SVG file for the icon.
|
/// \param[in] svgPath The path of the SVG file for the icon.
|
||||||
/// \param[in] color The color to apply to the icon.
|
/// \param[in] color The color to apply to the icon.
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
void TrayIcon::generateStatusIcon(QString const &svgPath, QColor const &color) {
|
void TrayIcon::generateStatusIcon(QString const &svgPath, QColor const &color) {
|
||||||
// We use the SVG path as pixmap mask and fill it with the appropriate color
|
// We use the SVG path as pixmap mask and fill it with the appropriate color
|
||||||
QString resourcePath = svgPath;
|
QPixmap pixmap(qmlResourcePathToQt(svgPath));
|
||||||
resourcePath.replace(QRegularExpression(R"(^\.\/)"), ":/qml/"); // QML resource path are a bit different from the Qt resources path.
|
|
||||||
QPixmap pixmap(resourcePath);
|
|
||||||
QPainter painter(&pixmap);
|
QPainter painter(&pixmap);
|
||||||
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
painter.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||||
painter.fillRect(pixmap.rect(), color);
|
painter.fillRect(pixmap.rect(), color);
|
||||||
@ -259,9 +324,9 @@ void TrayIcon::generateStatusIcon(QString const &svgPath, QColor const &color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//**********************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
//
|
//
|
||||||
//**********************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
void TrayIcon::refreshContextMenu() {
|
void TrayIcon::refreshContextMenu() {
|
||||||
if (!menu_) {
|
if (!menu_) {
|
||||||
app().log().error("Native tray icon context menu is null.");
|
app().log().error("Native tray icon context menu is null.");
|
||||||
@ -294,3 +359,5 @@ void TrayIcon::refreshContextMenu() {
|
|||||||
menu_->addSeparator();
|
menu_->addSeparator();
|
||||||
menu_->addAction(tr("&Quit Bridge"), QKeySequence("Ctrl+Q"), &app().backend(), &QMLBackend::quit);
|
menu_->addAction(tr("&Quit Bridge"), QKeySequence("Ctrl+Q"), &app().backend(), &QMLBackend::quit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -41,10 +41,10 @@ public: // data members
|
|||||||
TrayIcon& operator=(TrayIcon const&) = delete; ///< Disabled assignment operator.
|
TrayIcon& operator=(TrayIcon const&) = delete; ///< Disabled assignment operator.
|
||||||
TrayIcon& operator=(TrayIcon&&) = delete; ///< Disabled move assignment operator.
|
TrayIcon& operator=(TrayIcon&&) = delete; ///< Disabled move assignment operator.
|
||||||
void setState(State state, QString const& stateString, QString const &statusIconPath); ///< Set the state of the icon
|
void setState(State state, QString const& stateString, QString const &statusIconPath); ///< Set the state of the icon
|
||||||
void showNotificationPopup(QString const& title, QString const &message, QString const& iconPath); ///< Display a pop up notification.
|
void showErrorPopupNotification(QString const& title, QString const &message); ///< Display a pop up notification.
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void selectUser(QString const& userID); ///< Signal for selecting a user with a given userID
|
void selectUser(QString const& userID, bool forceShowWindow); ///< Signal for selecting a user with a given userID
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onMenuAboutToShow(); ///< Slot called before the context menu is shown.
|
void onMenuAboutToShow(); ///< Slot called before the context menu is shown.
|
||||||
|
|||||||
@ -95,9 +95,11 @@ ApplicationWindow {
|
|||||||
root.showAndRise()
|
root.showAndRise()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSelectUser(userID) {
|
function onSelectUser(userID, forceShowWindow) {
|
||||||
contentWrapper.selectUser(userID)
|
contentWrapper.selectUser(userID)
|
||||||
root.showAndRise()
|
if (forceShowWindow) {
|
||||||
|
root.showAndRise()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -34,9 +34,7 @@ SPUser User::newUser(QObject *parent) {
|
|||||||
/// \param[in] parent The parent object.
|
/// \param[in] parent The parent object.
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
User::User(QObject *parent)
|
User::User(QObject *parent)
|
||||||
: QObject(parent)
|
: QObject(parent) {
|
||||||
, imapFailureCooldownEndTime_(QDateTime::currentDateTime()) {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -355,22 +353,18 @@ QString User::stateToString(UserState state) {
|
|||||||
|
|
||||||
|
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
/// We display a notification and pop the application window if an IMAP client tries to connect to a signed out account, but we do not want to
|
/// \param[in] durationMSecs The duration of the period in milliseconds.
|
||||||
/// do it repeatedly, as it's an intrusive action. This function let's you define a period of time during which the notification should not be
|
|
||||||
/// displayed.
|
|
||||||
///
|
|
||||||
/// \param durationMSecs The duration of the period in milliseconds.
|
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
void User::startImapLoginFailureCooldown(qint64 durationMSecs) {
|
void User::startNotificationCooldownPeriod(User::ENotification notification, qint64 durationMSecs) {
|
||||||
imapFailureCooldownEndTime_ = QDateTime::currentDateTime().addMSecs(durationMSecs);
|
notificationCooldownList_[notification] = QDateTime::currentDateTime().addMSecs(durationMSecs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
/// \return true if we currently are in a cooldown period for the notification
|
/// \return true iff the notification is currently in a cooldown period.
|
||||||
//****************************************************************************************************************************************************
|
//****************************************************************************************************************************************************
|
||||||
bool User::isInIMAPLoginFailureCooldown() const {
|
bool User::isNotificationInCooldown(User::ENotification notification) const {
|
||||||
return QDateTime::currentDateTime() < imapFailureCooldownEndTime_;
|
return notificationCooldownList_.contains(notification) && (QDateTime::currentDateTime() < notificationCooldownList_[notification]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,12 @@ typedef std::shared_ptr<class User> SPUser; ///< Type definition for shared poin
|
|||||||
class User : public QObject {
|
class User : public QObject {
|
||||||
|
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
public: // data types
|
||||||
|
enum class ENotification {
|
||||||
|
IMAPLoginWhileSignedOut, ///< An IMAP client tried to login while the user is signed out.
|
||||||
|
IMAPPasswordFailure, ///< An IMAP client provided an invalid password for the user.
|
||||||
|
};
|
||||||
|
|
||||||
public: // static member function
|
public: // static member function
|
||||||
static SPUser newUser(QObject *parent); ///< Create a new user
|
static SPUser newUser(QObject *parent); ///< Create a new user
|
||||||
static QString stateToString(UserState state); ///< Return a string describing a user state.
|
static QString stateToString(UserState state); ///< Return a string describing a user state.
|
||||||
@ -74,8 +80,8 @@ public: // member functions.
|
|||||||
User &operator=(User &&) = delete; ///< Disabled move assignment operator.
|
User &operator=(User &&) = delete; ///< Disabled move assignment operator.
|
||||||
void update(User const &user); ///< Update the user.
|
void update(User const &user); ///< Update the user.
|
||||||
Q_INVOKABLE QString primaryEmailOrUsername() const; ///< Return the user primary email, or, if unknown its username.
|
Q_INVOKABLE QString primaryEmailOrUsername() const; ///< Return the user primary email, or, if unknown its username.
|
||||||
void startImapLoginFailureCooldown(qint64 durationMSecs); ///< Start the user cooldown period for the IMAP login attempt while signed-out notification.
|
void startNotificationCooldownPeriod(ENotification notification, qint64 durationMSecs); ///< Start the user cooldown period for a notification.
|
||||||
bool isInIMAPLoginFailureCooldown() const; ///< Check if the user in a IMAP login failure notification.
|
bool isNotificationInCooldown(ENotification notification) const; ///< Return true iff the notification is in a cooldown period.
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
// slots for QML generated calls
|
// slots for QML generated calls
|
||||||
@ -147,7 +153,7 @@ private: // member functions.
|
|||||||
User(QObject *parent); ///< Default constructor.
|
User(QObject *parent); ///< Default constructor.
|
||||||
|
|
||||||
private: // data members.
|
private: // data members.
|
||||||
QDateTime imapFailureCooldownEndTime_; ///< The end date/time for the IMAP login failure notification cooldown period.
|
QMap<ENotification, QDateTime> notificationCooldownList_; ///< A list of cooldown period end time for notifications.
|
||||||
QString id_; ///< The userID.
|
QString id_; ///< The userID.
|
||||||
QString username_; ///< The username
|
QString username_; ///< The username
|
||||||
QString password_; ///< The IMAP password of the user.
|
QString password_; ///< The IMAP password of the user.
|
||||||
|
|||||||
Reference in New Issue
Block a user