From 33702420a025ea50906a8f0f5a5a5d4a430678ac Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 29 Aug 2026 05:13:28 +0700 Subject: [PATCH] #11 explain SCRAM proof mismatches and make the password visible - Translate Prosody's "doesn't match the one we calculated" SCRAM failure into an actionable Russian message pointing at the typed password, since the client math itself is verified against the RFC 5802 test vector. - Set explicit username/password text content types on the login screen, disable smart text features, and add a password visibility toggle so users can verify what they submit. - Cover the mapping with a unit test and verify.sh invariants. --- Scripts/verify.sh | 8 ++++++ Sources/Shared/UI/LoginView.swift | 27 +++++++++++++++++++- Sources/Shared/XMPP/SaslFailureMessage.swift | 9 +++++++ Tests/SaslFailureMessageTests.swift | 10 ++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Scripts/verify.sh b/Scripts/verify.sh index 7361947..2f82f4f 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -313,6 +313,14 @@ grep -q 'Сервер отклонил имя пользователя или п echo "SASL failures must produce an actionable Russian message" exit 1 } +grep -q "doesn't match the one we calculated" Sources/Shared/XMPP/SaslFailureMessage.swift || { + echo "Prosody SCRAM proof mismatches must be explained in Russian" + exit 1 +} +grep -q 'passwordVisible' Sources/Shared/UI/LoginView.swift || { + echo "The login screen must let the user verify the typed password" + exit 1 +} grep -q 'func deleteGroupChat' Sources/Shared/Models/AppModel.swift || { echo "Group chats must support local deletion" exit 1 diff --git a/Sources/Shared/UI/LoginView.swift b/Sources/Shared/UI/LoginView.swift index 178a4a1..fc09295 100644 --- a/Sources/Shared/UI/LoginView.swift +++ b/Sources/Shared/UI/LoginView.swift @@ -4,6 +4,7 @@ struct LoginView: View { @ObservedObject var model: AppModel @State private var jid = "" @State private var password = "" + @State private var passwordVisible = false @State private var displayName = "" @State private var resource = "" @State private var showAdvanced = false @@ -79,6 +80,9 @@ struct LoginView: View { #if os(iOS) .keyboardType(.emailAddress) #endif + .textContentType(.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() .textFieldStyle(.roundedBorder) .disableAutocorrection(true) } @@ -86,8 +90,29 @@ struct LoginView: View { VStack(alignment: .leading, spacing: 7) { Text("Пароль") .font(.headline) - SecureField("Пароль", text: $password) + HStack(spacing: 8) { + Group { + if passwordVisible { + TextField("Пароль", text: $password) + .textContentType(.oneTimeCode) + } else { + SecureField("Пароль", text: $password) + .textContentType(.password) + } + } + .autocorrectionDisabled() + .textInputAutocapitalization(.never) .textFieldStyle(.roundedBorder) + + Button { + passwordVisible.toggle() + } label: { + Image(systemName: passwordVisible ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(passwordVisible ? "Скрыть пароль" : "Показать пароль") + } } DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) { diff --git a/Sources/Shared/XMPP/SaslFailureMessage.swift b/Sources/Shared/XMPP/SaslFailureMessage.swift index cfd20d3..a185fbf 100644 --- a/Sources/Shared/XMPP/SaslFailureMessage.swift +++ b/Sources/Shared/XMPP/SaslFailureMessage.swift @@ -8,6 +8,15 @@ import Martin enum SaslFailureMessage { static func describe(error: Error, condition: String?, serverText: String?) -> String { if let serverText, !serverText.isEmpty { + // Prosody's SCRAM proof mismatch: the password the client used + // differs from the one stored on the server. Translate it into an + // actionable message (most often this is a typo or password + // autofill substituting another account's password). + if serverText.localizedCaseInsensitiveContains( + "doesn't match the one we calculated" + ) { + return "Пароль не совпадает с сохранённым на сервере. Проверьте пароль (например, через значок глаза) и не используйте автозаполнение другого аккаунта." + } return "Сервер отклонил вход: \(serverText)" } switch condition { diff --git a/Tests/SaslFailureMessageTests.swift b/Tests/SaslFailureMessageTests.swift index 856f540..e2bfb47 100644 --- a/Tests/SaslFailureMessageTests.swift +++ b/Tests/SaslFailureMessageTests.swift @@ -37,6 +37,16 @@ final class SaslFailureMessageTests: XCTestCase { XCTAssertEqual(message, "Срок действия пароля истёк. Обновите пароль на сервере.") } + func testProsodyProofMismatchIsExplained() { + let message = SaslFailureMessage.describe( + error: SaslError.not_authorized, + condition: "not-authorized", + serverText: "The response provided by the client doesn't match the one we calculated") + XCTAssertEqual( + message, + "Пароль не совпадает с сохранённым на сервере. Проверьте пароль (например, через значок глаза) и не используйте автозаполнение другого аккаунта.") + } + func testTemporaryAuthFailureIsExplained() { let message = SaslFailureMessage.describe( error: SaslError.temporary_auth_failure, condition: nil, serverText: nil)