diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index beaf3a5d4..d5539d781 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -185,6 +185,8 @@ add_library(neochat STATIC enums/powerlevel.h models/permissionsmodel.cpp models/permissionsmodel.h + threepidbindhelper.cpp + threepidbindhelper.h ) set_source_files_properties(qml/OsmLocationPlugin.qml PROPERTIES diff --git a/src/identityserverhelper.cpp b/src/identityserverhelper.cpp index 2d49894e1..24384d9dc 100644 --- a/src/identityserverhelper.cpp +++ b/src/identityserverhelper.cpp @@ -33,43 +33,6 @@ void IdentityServerHelper::setConnection(NeoChatConnection *connection) m_connection = connection; Q_EMIT connectionChanged(); - Q_EMIT currentServerChanged(); - - connect(m_connection, &NeoChatConnection::accountDataChanged, this, [this](const QString &type) { - if (type == QLatin1String("m.identity_server")) { - Q_EMIT currentServerChanged(); - } - }); -} - -QString IdentityServerHelper::currentServer() const -{ - if (m_connection == nullptr) { - return {}; - } - - if (!m_connection->hasAccountData(QLatin1String("m.identity_server"))) { - return i18nc("@info", "No identity server configured"); - } - - const auto url = m_connection->accountData(QLatin1String("m.identity_server"))->contentPart(QLatin1String("base_url")); - if (!url.isEmpty()) { - return url.toString(); - } - return i18nc("@info", "No identity server configured"); -} - -bool IdentityServerHelper::hasCurrentServer() const -{ - if (m_connection == nullptr && !m_connection->hasAccountData(QLatin1String("m.identity_server"))) { - return false; - } - - const auto url = m_connection->accountData(QLatin1String("m.identity_server"))->contentPart(QLatin1String("base_url")); - if (!url.isEmpty()) { - return true; - } - return false; } QString IdentityServerHelper::url() const @@ -100,7 +63,7 @@ void IdentityServerHelper::checkUrl() m_idServerCheckRequest.clear(); } - if (m_url == currentServer()) { + if (m_url == m_connection->identityServer().toString()) { m_status = Match; Q_EMIT statusChanged(); return; @@ -134,7 +97,7 @@ void IdentityServerHelper::checkUrl() void IdentityServerHelper::setIdentityServer() { - if (m_url == currentServer()) { + if (m_url == m_connection->identityServer().toString()) { return; } @@ -145,7 +108,7 @@ void IdentityServerHelper::setIdentityServer() void IdentityServerHelper::clearIdentityServer() { - if (currentServer().isEmpty()) { + if (m_connection->identityServer().isEmpty()) { return; } m_connection->setAccountData(QLatin1String("m.identity_server"), {{QLatin1String("base_url"), QString()}}); diff --git a/src/identityserverhelper.h b/src/identityserverhelper.h index 3e94d3c3d..7012c9d7f 100644 --- a/src/identityserverhelper.h +++ b/src/identityserverhelper.h @@ -26,16 +26,6 @@ class IdentityServerHelper : public QObject */ Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged) - /** - * @brief The current identity server. - */ - Q_PROPERTY(QString currentServer READ currentServer NOTIFY currentServerChanged) - - /** - * @brief Whether an identity server is currently configured. - */ - Q_PROPERTY(bool hasCurrentServer READ hasCurrentServer NOTIFY currentServerChanged) - /** * @brief The URL for the desired server. */ @@ -64,10 +54,6 @@ public: [[nodiscard]] NeoChatConnection *connection() const; void setConnection(NeoChatConnection *connection); - [[nodiscard]] QString currentServer() const; - - [[nodiscard]] bool hasCurrentServer() const; - [[nodiscard]] QString url() const; void setUrl(const QString &url); @@ -87,7 +73,6 @@ public: Q_SIGNALS: void connectionChanged(); - void currentServerChanged(); void urlChanged(); void statusChanged(); diff --git a/src/neochatconnection.cpp b/src/neochatconnection.cpp index b7c243c79..c3ec48043 100644 --- a/src/neochatconnection.cpp +++ b/src/neochatconnection.cpp @@ -60,6 +60,9 @@ void NeoChatConnection::connectSignals() if (type == QLatin1String("org.kde.neochat.account_label")) { Q_EMIT labelChanged(); } + if (type == QLatin1String("m.identity_server")) { + Q_EMIT identityServerChanged(); + } }); connect(this, &NeoChatConnection::syncDone, this, [this] { setIsOnline(true); @@ -256,6 +259,41 @@ ThreePIdModel *NeoChatConnection::threePIdModel() const return m_threePIdModel; } +bool NeoChatConnection::hasIdentityServer() const +{ + if (!hasAccountData(QLatin1String("m.identity_server"))) { + return false; + } + + const auto url = accountData(QLatin1String("m.identity_server"))->contentPart(QLatin1String("base_url")); + if (!url.isEmpty()) { + return true; + } + return false; +} + +QUrl NeoChatConnection::identityServer() const +{ + if (!hasAccountData(QLatin1String("m.identity_server"))) { + return {}; + } + + const auto url = accountData(QLatin1String("m.identity_server"))->contentPart(QLatin1String("base_url")); + if (!url.isEmpty()) { + return url; + } + return {}; +} + +QString NeoChatConnection::identityServerUIString() const +{ + if (!hasIdentityServer()) { + return i18nc("@info", "No identity server configured"); + } + + return identityServer().toString(); +} + void NeoChatConnection::createRoom(const QString &name, const QString &topic, const QString &parent, bool setChildParent) { QList initialStateEvents; diff --git a/src/neochatconnection.h b/src/neochatconnection.h index 9eff1bdad..4c57766ee 100644 --- a/src/neochatconnection.h +++ b/src/neochatconnection.h @@ -36,6 +36,19 @@ class NeoChatConnection : public Quotient::Connection */ Q_PROPERTY(ThreePIdModel *threePIdModel READ threePIdModel CONSTANT) + /** + * @brief Whether an identity server is configured. + */ + Q_PROPERTY(bool hasIdentityServer READ hasIdentityServer NOTIFY identityServerChanged) + + /** + * @brief The identity server URL as a string for showing in a UI. + * + * Will return the string "No identity server configured" if no identity + * server configured. Otherwise it returns the URL as a string. + */ + Q_PROPERTY(QString identityServer READ identityServerUIString NOTIFY identityServerChanged) + /** * @brief The total number of notifications for all direct chats. */ @@ -105,6 +118,17 @@ public: ThreePIdModel *threePIdModel() const; + bool hasIdentityServer() const; + + /** + * @brief The identity server URL. + * + * Empty if no identity server configured. + */ + QUrl identityServer() const; + + QString identityServerUIString() const; + /** * @brief Create new room for a group chat. */ @@ -162,6 +186,7 @@ public: Q_SIGNALS: void labelChanged(); + void identityServerChanged(); void directChatNotificationsChanged(); void directChatsHaveHighlightNotificationsChanged(); void homeNotificationsChanged(); diff --git a/src/settings/IdentityServerDelegate.qml b/src/settings/IdentityServerDelegate.qml index e9788a12a..be112735c 100644 --- a/src/settings/IdentityServerDelegate.qml +++ b/src/settings/IdentityServerDelegate.qml @@ -17,7 +17,7 @@ FormCard.AbstractFormDelegate { property bool editServerUrl: false - text: identityServerHelper.currentServer + text: connection.identityServer onClicked: editIdServerButton.toggle() @@ -114,7 +114,7 @@ FormCard.AbstractFormDelegate { } QQC2.ToolButton { id: removeIdServerButton - visible: identityServerHelper.hasCurrentServer + visible: root.connection.hasIdentityServer display: QQC2.AbstractButton.IconOnly text: i18nc("@action:button", "Remove identity server") icon.name: "edit-delete-remove" diff --git a/src/settings/ThreePIdCard.qml b/src/settings/ThreePIdCard.qml index 8ad8d7c98..e5650cef6 100644 --- a/src/settings/ThreePIdCard.qml +++ b/src/settings/ThreePIdCard.qml @@ -38,22 +38,72 @@ ColumnLayout { required property string address required property string medium - contentItem: RowLayout { - QQC2.Label { + contentItem: ColumnLayout { + RowLayout { + QQC2.Label { + Layout.fillWidth: true + text: threePIdDelegate.address + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + color: Kirigami.Theme.textColor + } + QQC2.ToolButton { + visible: threePIdBindHelper.bindStatus === ThreePIdBindHelper.Ready && root.connection.hasIdentityServer + text: i18nc("@action:button", "Share") + icon.name: "send-to-symbolic" + onClicked: threePIdBindHelper.bindStatus === ThreePIdBindHelper.Verification ? threePIdBindHelper.finalizeNewIdBind() : threePIdBindHelper.initiateNewIdBind() + } + QQC2.ToolButton { + text: i18nc("@action:button", "Remove") + icon.name: "edit-delete-remove" + onClicked: threePIdAddHelper.remove3PId(threePIdDelegate.address, threePIdDelegate.medium) + } + } + Kirigami.InlineMessage { + id: errorHandler + visible: threePIdBindHelper.bindStatusString.length > 0 + Layout.topMargin: visible ? Kirigami.Units.smallSpacing : 0 Layout.fillWidth: true - text: threePIdDelegate.address - textFormat: Text.PlainText - elide: Text.ElideRight - wrapMode: Text.Wrap - maximumLineCount: 2 - color: Kirigami.Theme.textColor + text: threePIdBindHelper.bindStatusString + type: threePIdBindHelper.statusType } - QQC2.ToolButton { - text: i18nc("@action:button", "Remove") - icon.name: "edit-delete-remove" - onClicked: threePIdAddHelper.remove3PId(threePIdDelegate.address, threePIdDelegate.medium) + RowLayout { + visible: threePIdBindHelper.bindStatus !== ThreePIdBindHelper.Ready + Item { + Layout.fillWidth: true + } + QQC2.ToolButton { + text: i18nc("@action:button", "Complete") + icon.name: "answer-correct" + onClicked: threePIdBindHelper.finalizeNewIdBind() + } + QQC2.ToolButton { + text: i18nc("@action:button", "Cancel") + icon.name: "edit-delete-remove" + onClicked: threePIdBindHelper.cancel() + } } } + + ThreePIdBindHelper { + id: threePIdBindHelper + + readonly property int statusType: switch(bindStatus) { + case ThreePIdBindHelper.Invalid: + case ThreePIdBindHelper.AuthFailure: + return Kirigami.MessageType.Error; + case ThreePIdBindHelper.VerificationFailure: + return Kirigami.MessageType.Warning; + default: + return Kirigami.MessageType.Information; + } + + connection: root.connection + newId: threePIdDelegate.address + medium: threePIdDelegate.medium + } } diff --git a/src/threepidaddhelper.cpp b/src/threepidaddhelper.cpp index dc369d3cc..e2f72db6d 100644 --- a/src/threepidaddhelper.cpp +++ b/src/threepidaddhelper.cpp @@ -183,6 +183,14 @@ void ThreePIdAddHelper::remove3PId(const QString &threePId, const QString &type) }); } +void ThreePIdAddHelper::unbind3PId(const QString &threePId, const QString &type) +{ + const auto job = m_connection->callApi(type, threePId); + connect(job, &Quotient::BaseJob::success, this, [this]() { + m_connection->threePIdModel()->refreshModel(); + }); +} + void ThreePIdAddHelper::back() { switch (m_newIdStatus) { diff --git a/src/threepidaddhelper.h b/src/threepidaddhelper.h index 36b4c5e8f..4221d14e6 100644 --- a/src/threepidaddhelper.h +++ b/src/threepidaddhelper.h @@ -103,6 +103,11 @@ public: */ Q_INVOKABLE void remove3PId(const QString &threePId, const QString &type); + /** + * @brief Remove the given 3PID. + */ + Q_INVOKABLE void unbind3PId(const QString &threePId, const QString &type); + /** * @brief Go back a step in the process. */ diff --git a/src/threepidbindhelper.cpp b/src/threepidbindhelper.cpp new file mode 100644 index 000000000..a893e34f6 --- /dev/null +++ b/src/threepidbindhelper.cpp @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "threepidbindhelper.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "neochatconnection.h" + +ThreePIdBindHelper::ThreePIdBindHelper(QObject *parent) + : QObject(parent) +{ +} + +NeoChatConnection *ThreePIdBindHelper::connection() const +{ + return m_connection; +} + +void ThreePIdBindHelper::setConnection(NeoChatConnection *connection) +{ + if (m_connection == connection) { + return; + } + m_connection = connection; + Q_EMIT connectionChanged(); +} + +QString ThreePIdBindHelper::medium() const +{ + return m_medium; +} + +void ThreePIdBindHelper::setMedium(const QString &medium) +{ + if (m_medium == medium) { + return; + } + m_medium = medium; + Q_EMIT mediumChanged(); +} + +QString ThreePIdBindHelper::newId() const +{ + return m_newId; +} + +void ThreePIdBindHelper::setNewId(const QString &newId) +{ + if (newId == m_newId) { + return; + } + m_newId = newId; + Q_EMIT newIdChanged(); + + m_newIdSecret.clear(); + m_newIdSid.clear(); + m_identityServerToken.clear(); + m_bindStatus = Ready; + Q_EMIT bindStatusChanged(); +} + +QString ThreePIdBindHelper::newCountryCode() const +{ + return m_newCountryCode; +} + +void ThreePIdBindHelper::setNewCountryCode(const QString &newCountryCode) +{ + if (newCountryCode == m_newCountryCode) { + return; + } + m_newCountryCode = newCountryCode; + Q_EMIT newCountryCodeChanged(); + + m_newIdSecret.clear(); + m_newIdSid.clear(); + m_identityServerToken.clear(); + m_bindStatus = Ready; + Q_EMIT bindStatusChanged(); +} + +void ThreePIdBindHelper::initiateNewIdBind() +{ + if (m_newId.isEmpty() || m_connection == nullptr || !m_connection->hasIdentityServer()) { + return; + } + + const auto openIdJob = m_connection->callApi(m_connection->userId()); + connect(openIdJob, &Quotient::BaseJob::success, this, [this, openIdJob]() { + const auto requestUrl = QUrl(m_connection->identityServer().toString() + QStringLiteral("/_matrix/identity/v2/account/register")); + if (!(requestUrl.scheme() == QStringLiteral("https") || requestUrl.scheme() == QStringLiteral("http"))) { + m_bindStatus = AuthFailure; + Q_EMIT bindStatusChanged(); + return; + } + + QNetworkRequest request(requestUrl); + auto newRequest = Quotient::NetworkAccessManager::instance()->post(request, QJsonDocument(openIdJob->jsonData()).toJson()); + connect(newRequest, &QNetworkReply::finished, this, [this, newRequest]() { + QJsonObject replyJson = parseJson(newRequest->readAll()); + m_identityServerToken = replyJson[QLatin1String("token")].toString(); + + const auto requestUrl = QUrl(m_connection->identityServer().toString() + QStringLiteral("/_matrix/identity/v2/validate/email/requestToken")); + if (!(requestUrl.scheme() == QStringLiteral("https") || requestUrl.scheme() == QStringLiteral("http"))) { + m_bindStatus = AuthFailure; + Q_EMIT bindStatusChanged(); + return; + } + + QNetworkRequest validationRequest(requestUrl); + validationRequest.setRawHeader("Authorization", "Bearer " + m_identityServerToken.toLatin1()); + + auto tokenRequest = Quotient::NetworkAccessManager::instance()->post(validationRequest, validationRequestData()); + connect(tokenRequest, &QNetworkReply::finished, this, [this, tokenRequest]() { + tokenRequestFinished(tokenRequest); + }); + }); + }); +} + +QByteArray ThreePIdBindHelper::validationRequestData() +{ + m_newIdSecret = QString::fromLatin1(QUuid::createUuid().toString().toLatin1().toBase64()); + QJsonObject requestData = { + {QLatin1String("client_secret"), m_newIdSecret}, + {QLatin1String("send_attempt"), 0}, + }; + + if (m_medium == QLatin1String("email")) { + requestData[QLatin1String("email")] = m_newId; + } else { + requestData[QLatin1String("phone_number")] = m_newId; + requestData[QLatin1String("country")] = m_newCountryCode; + } + + return QJsonDocument(requestData).toJson(); +} + +void ThreePIdBindHelper::tokenRequestFinished(QNetworkReply *reply) +{ + if (reply->error() != QNetworkReply::NoError) { + return; + } + + QJsonObject replyJson = parseJson(reply->readAll()); + m_newIdSid = replyJson[QLatin1String("sid")].toString(); + + if (m_newIdSid.isEmpty()) { + m_bindStatus = Invalid; + Q_EMIT bindStatusChanged(); + } else { + m_bindStatus = Verification; + Q_EMIT bindStatusChanged(); + } +} + +ThreePIdBindHelper::ThreePIdStatus ThreePIdBindHelper::bindStatus() const +{ + return m_bindStatus; +} + +QString ThreePIdBindHelper::bindStatusString() const +{ + switch (m_bindStatus) { + case Verification: + return i18n("%1. Please follow the instructions there and then click the button above", + m_medium == QStringLiteral("email") ? i18n("We've sent you an email") : i18n("We've sent you a text message")); + case Invalid: + return m_medium == QStringLiteral("email") ? i18n("The entered email is not valid") : i18n("The entered phone number is not valid"); + case VerificationFailure: + return m_medium == QStringLiteral("email") + ? i18n("The email has not been verified. Please go to the email and follow the instructions there and then click the button above") + : i18n("The phone number has not been verified. Please go to the text message and follow the instructions there and then click the button above"); + default: + return {}; + } +} + +void ThreePIdBindHelper::finalizeNewIdBind() +{ + const auto job = m_connection->callApi(m_newIdSecret, m_connection->identityServer().host(), m_identityServerToken, m_newIdSid); + connect(job, &Quotient::BaseJob::success, this, [this] { + m_bindStatus = Success; + Q_EMIT bindStatusChanged(); + }); + connect(job, &Quotient::BaseJob::failure, this, [this, job]() { + if (job->jsonData()[QLatin1String("errcode")] == QLatin1String("M_SESSION_NOT_VALIDATED")) { + m_bindStatus = VerificationFailure; + Q_EMIT bindStatusChanged(); + } else { + m_bindStatus = Other; + Q_EMIT bindStatusChanged(); + } + }); +} + +void ThreePIdBindHelper::unbind3PId(const QString &threePId, const QString &type) +{ + const auto job = m_connection->callApi(type, threePId); + connect(job, &Quotient::BaseJob::success, this, [this]() { + m_connection->threePIdModel()->refreshModel(); + }); +} + +void ThreePIdBindHelper::cancel() +{ + m_newIdSecret.clear(); + m_newIdSid.clear(); + m_identityServerToken.clear(); + m_bindStatus = Ready; + Q_EMIT bindStatusChanged(); +} + +QJsonObject ThreePIdBindHelper::parseJson(const QByteArray &json) +{ + const auto document = QJsonDocument::fromJson(json); + return document.object(); +} + +#include "moc_threepidbindhelper.cpp" diff --git a/src/threepidbindhelper.h b/src/threepidbindhelper.h new file mode 100644 index 000000000..d1249bfa6 --- /dev/null +++ b/src/threepidbindhelper.h @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include + +class NeoChatConnection; + +/** + * @class ThreePIdBindHelper + * + * This class is designed to help the process of bindind a 3PID to an identity server. + * It will manage the various stages of verification and authentication. + */ +class ThreePIdBindHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + + /** + * @brief The connection to bind a 3PID for. + */ + Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged) + + /** + * @brief The type of 3PID being bound. + * + * email or msisdn. + */ + Q_PROPERTY(QString medium READ medium WRITE setMedium NOTIFY mediumChanged) + + /** + * @brief The 3PID to bind. + * + * Email or phone number depending on type. + */ + Q_PROPERTY(QString newId READ newId WRITE setNewId NOTIFY newIdChanged) + + /** + * @brief The country code if a phone number is being bound. + */ + Q_PROPERTY(QString newCountryCode READ newCountryCode WRITE setNewCountryCode NOTIFY newCountryCodeChanged) + + /** + * @brief The current status. + * + * @sa ThreePIdStatus + */ + Q_PROPERTY(ThreePIdStatus bindStatus READ bindStatus NOTIFY bindStatusChanged) + + /** + * @brief The current status as a string. + * + * @sa ThreePIdStatus + */ + Q_PROPERTY(QString bindStatusString READ bindStatusString NOTIFY bindStatusChanged) + +public: + /** + * @brief Defines the current status for binding a 3PID. + */ + enum ThreePIdStatus { + Ready, /**< The process is ready to start. I.e. there is no ongoing attempt to set a new 3PID. */ + Verification, /**< The request to verify the new 3PID has been sent. */ + Authentication, /**< The user needs to authenticate. */ + Success, /**< The 3PID has been successfully added. */ + Invalid, /**< The 3PID can't be used. */ + AuthFailure, /**< The authentication was wrong. */ + VerificationFailure, /**< The verification has not been completed. */ + Other, /**< An unknown problem occurred. */ + }; + Q_ENUM(ThreePIdStatus) + + explicit ThreePIdBindHelper(QObject *parent = nullptr); + + [[nodiscard]] NeoChatConnection *connection() const; + void setConnection(NeoChatConnection *connection); + + [[nodiscard]] QString medium() const; + void setMedium(const QString &medium); + + [[nodiscard]] QString newId() const; + void setNewId(const QString &newEmail); + + [[nodiscard]] QString newCountryCode() const; + void setNewCountryCode(const QString &newCountryCode); + + /** + * @brief Start the process to bind the new 3PID. + * + * This will start the process of verifying the 3PID credentials that have been given. + * Will fail if no identity server is configured. + */ + Q_INVOKABLE void initiateNewIdBind(); + + [[nodiscard]] ThreePIdStatus bindStatus() const; + + [[nodiscard]] QString bindStatusString() const; + + /** + * @brief Finalize the process of binding the new 3PID. + * + * Will fail if the user hasn't completed the verification with the identity + * server. + */ + Q_INVOKABLE void finalizeNewIdBind(); + + /** + * @brief Unbind the given 3PID. + */ + Q_INVOKABLE void unbind3PId(const QString &threePId, const QString &type); + + /** + * @brief Cancel the process. + */ + Q_INVOKABLE void cancel(); + +Q_SIGNALS: + void connectionChanged(); + void mediumChanged(); + void newIdChanged(); + void newCountryCodeChanged(); + void newEmailSessionStartedChanged(); + void bindStatusChanged(); + +private: + QPointer m_connection; + QString m_medium = QString(); + + ThreePIdStatus m_bindStatus = Ready; + QString m_newId = QString(); + QString m_newCountryCode = QString(); + QString m_newIdSecret = QString(); + QString m_newIdSid = QString(); + QString m_identityServerToken = QString(); + + QByteArray validationRequestData(); + + void tokenRequestFinished(QNetworkReply *reply); + + static QJsonObject parseJson(const QByteArray &json); +};