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)