- 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.
189 lines
7.4 KiB
Swift
189 lines
7.4 KiB
Swift
import SwiftUI
|
|
|
|
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
|
|
@State private var manualHost = ""
|
|
@State private var manualPort = ""
|
|
@State private var directTLS = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.08, green: 0.38, blue: 0.78),
|
|
Color(red: 0.12, green: 0.62, blue: 0.96),
|
|
Color(red: 0.44, green: 0.84, blue: 0.96),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
Circle()
|
|
.fill(.white.opacity(0.12))
|
|
.frame(width: 420, height: 420)
|
|
.offset(x: -210, y: -260)
|
|
|
|
ScrollView {
|
|
VStack(spacing: 28) {
|
|
brand
|
|
loginCard
|
|
}
|
|
.frame(maxWidth: 460)
|
|
.padding(.horizontal, 24)
|
|
.padding(.vertical, 48)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var brand: some View {
|
|
VStack(spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
|
.fill(.white)
|
|
.frame(width: 88, height: 88)
|
|
.shadow(color: .black.opacity(0.16), radius: 22, y: 10)
|
|
Image(systemName: "bubble.left.and.bubble.right.fill")
|
|
.font(.system(size: 39, weight: .semibold))
|
|
.foregroundStyle(
|
|
LinearGradient(
|
|
colors: [.blue, .cyan],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
|
|
Text("Luma")
|
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
Text("Приватный XMPP без привязки к одному серверу")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.86))
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
|
|
private var loginCard: some View {
|
|
VStack(spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text("XMPP-аккаунт")
|
|
.font(.headline)
|
|
TextField("you@example.org", text: $jid)
|
|
#if os(iOS)
|
|
.keyboardType(.emailAddress)
|
|
#endif
|
|
.textContentType(.username)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.textFieldStyle(.roundedBorder)
|
|
.disableAutocorrection(true)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Text("Пароль")
|
|
.font(.headline)
|
|
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) {
|
|
VStack(spacing: 14) {
|
|
TextField("Отображаемое имя", text: $displayName)
|
|
.textFieldStyle(.roundedBorder)
|
|
TextField("Автоматически (Luma-XXXX)", text: $resource)
|
|
.textFieldStyle(.roundedBorder)
|
|
Text(
|
|
"Оставьте пустым — Luma создаст уникальный ресурс для этого устройства, чтобы macOS и iOS не конфликтовали между собой."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
|
.textFieldStyle(.roundedBorder)
|
|
TextField(
|
|
directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort
|
|
)
|
|
.textFieldStyle(.roundedBorder)
|
|
Toggle("Direct TLS", isOn: $directTLS)
|
|
Text(
|
|
"Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(.top, 14)
|
|
}
|
|
|
|
Button {
|
|
Task { await signIn() }
|
|
} label: {
|
|
HStack {
|
|
if model.isAuthenticating {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
Text(model.isAuthenticating ? "Подключение…" : "Войти")
|
|
.fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 34)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.disabled(model.isAuthenticating)
|
|
|
|
Label(
|
|
"Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.",
|
|
systemImage: "lock.shield.fill"
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.padding(26)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
|
.shadow(color: .black.opacity(0.14), radius: 24, y: 12)
|
|
}
|
|
|
|
private func signIn() async {
|
|
let port = Int(manualPort.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
let account = AccountConfiguration(
|
|
jid: jid,
|
|
displayName: displayName,
|
|
resource: resource,
|
|
manualHost: manualHost,
|
|
manualPort: port,
|
|
usesDirectTLS: directTLS
|
|
)
|
|
await model.signIn(account: account, password: password)
|
|
}
|
|
}
|