Support binding 3PIDs

Closes network/neochat#565
This commit is contained in:
James Graham
2024-05-31 09:25:42 +00:00
parent ab4af48e52
commit 227ebd610a
11 changed files with 521 additions and 69 deletions

View File

@@ -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

View File

@@ -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<QUrl>(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<QUrl>(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()}});

View File

@@ -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();

View File

@@ -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<QUrl>(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<QUrl>(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<CreateRoomJob::StateEvent> initialStateEvents;

View File

@@ -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();

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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<Quotient::Unbind3pidFromAccountJob>(type, threePId);
connect(job, &Quotient::BaseJob::success, this, [this]() {
m_connection->threePIdModel()->refreshModel();
});
}
void ThreePIdAddHelper::back()
{
switch (m_newIdStatus) {

View File

@@ -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.
*/

230
src/threepidbindhelper.cpp Normal file
View File

@@ -0,0 +1,230 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "threepidbindhelper.h"
#include <QNetworkReply>
#include <Quotient/converters.h>
#include <Quotient/csapi/definitions/auth_data.h>
#include <Quotient/csapi/definitions/request_msisdn_validation.h>
#include <Quotient/csapi/openid.h>
#include <Quotient/jobs/basejob.h>
#include <Quotient/networkaccessmanager.h>
#include <KLocalizedString>
#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<Quotient::RequestOpenIdTokenJob>(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<Quotient::Bind3PIDJob>(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<Quotient::Unbind3pidFromAccountJob>(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"

146
src/threepidbindhelper.h Normal file
View File

@@ -0,0 +1,146 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QObject>
#include <QQmlEngine>
#include <Quotient/jobs/basejob.h>
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<NeoChatConnection> 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);
};