auth and projects

This commit is contained in:
vadim committed 2026-10-29 13:14:46 +03:00
1 parent 558bb005f4
commit 0ba2db0ec2
14 files changed
+1207 -94

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
//
// SignInView.swift
// Snail
//
import SwiftUI
/// Вход в два шага: адрес почты → одноразовый код из письма.
struct SignInView: View {
@Environment(AuthStore.self) private var auth
private enum Step {
case email
case code
}
private enum Field {
case email
case code
}
/// Длина кода и пауза перед повторной отправкой (совпадают с настройками better-auth по умолчанию).
private static let codeLength = 6
private static let resendDelay: TimeInterval = 60
@AppStorage("lastSignInEmail") private var rememberedEmail = ""
@State private var step: Step = .email
@State private var email = ""
@State private var code = ""
@State private var isLoading = false
@State private var errorMessage: String?
@State private var resendAvailableAt = Date.distantPast
@FocusState private var focusedField: Field?
private var trimmedEmail: String {
email.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var isEmailValid: Bool {
trimmedEmail.wholeMatch(of: /[^@\s]+@[^@\s]+\.[^@\s]+/) != nil
}
var body: some View {
NavigationStack {
Form {
switch step {
case .email:
emailSection
case .code:
codeSection
}
}
.navigationTitle(step == .email ? "Вход" : "Код из письма")
.scrollDismissesKeyboard(.interactively)
.disabled(isLoading)
.animation(.default, value: step)
.onAppear {
if email.isEmpty { email = rememberedEmail }
focusedField = .email
}
}
}
// MARK: - Шаг 1: почта
@ViewBuilder
private var emailSection: some View {
Section {
TextField("Электронная почта", text: $email, prompt: Text(verbatim: "name@example.com"))
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.continue)
.focused($focusedField, equals: .email)
.onSubmit(sendCode)
.onChange(of: email) { errorMessage = nil }
} footer: {
footer(hint: "Мы отправим на этот адрес одноразовый код для входа.")
}
Section {
Button(action: sendCode) {
ProgressButtonLabel(title: "Получить код", isLoading: isLoading)
}
.disabled(!isEmailValid)
}
}
// MARK: - Шаг 2: код
@ViewBuilder
private var codeSection: some View {
Section {
TextField("Код", text: $code, prompt: Text(verbatim: String(repeating: "0", count: Self.codeLength)))
.textContentType(.oneTimeCode)
.keyboardType(.numberPad)
.font(.title2.monospacedDigit())
.focused($focusedField, equals: .code)
.onChange(of: code) { _, newValue in
// Оставляем только цифры и отправляем, как только код введён полностью
// (в том числе при автозаполнении из письма).
let digits = String(newValue.filter(\.isNumber).prefix(Self.codeLength))
if digits != newValue {
code = digits
return
}
if !digits.isEmpty { errorMessage = nil }
if digits.count == Self.codeLength { verifyCode() }
}
} footer: {
footer(hint: "Код отправлен на \(trimmedEmail). Если письма нет, проверьте папку «Спам».")
}
Section {
Button(action: verifyCode) {
ProgressButtonLabel(title: "Войти", isLoading: isLoading)
}
.disabled(code.count != Self.codeLength)
TimelineView(.periodic(from: .now, by: 1)) { context in
let remaining = Int(resendAvailableAt.timeIntervalSince(context.date).rounded(.up))
Button(action: sendCode) {
if remaining > 0 {
Text("Отправить повторно через \(remaining) с")
} else {
Text("Отправить код повторно")
}
}
.disabled(remaining > 0)
}
Button("Изменить адрес почты") {
code = ""
errorMessage = nil
step = .email
focusedField = .email
}
}
}
@ViewBuilder
private func footer(hint: LocalizedStringKey) -> some View {
if let errorMessage {
Label(errorMessage, systemImage: "exclamationmark.circle")
.foregroundStyle(.red)
} else {
Text(hint)
}
}
// MARK: - Действия
private func sendCode() {
guard isEmailValid, !isLoading else { return }
Task {
await perform {
try await auth.sendCode(to: trimmedEmail)
rememberedEmail = trimmedEmail
resendAvailableAt = .now.addingTimeInterval(Self.resendDelay)
code = ""
step = .code
focusedField = .code
}
}
}
private func verifyCode() {
guard code.count == Self.codeLength, !isLoading else { return }
Task {
await perform {
try await auth.signIn(email: trimmedEmail, code: code)
}
if errorMessage != nil {
code = ""
focusedField = .code
}
}
}
private func perform(_ action: () async throws -> Void) async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
try await action()
} catch {
errorMessage = error.localizedDescription
if case APIError.server(_, "TOO_MANY_ATTEMPTS", _) = error {
// Код больше не примут — сразу разрешаем запросить новый.
resendAvailableAt = .distantPast
}
AccessibilityNotification.Announcement(error.localizedDescription).post()
}
}
}
/// Подпись кнопки с индикатором загрузки, не меняющая ширину строки.
private struct ProgressButtonLabel: View {
let title: LocalizedStringKey
let isLoading: Bool
var body: some View {
HStack {
Text(title)
Spacer()
if isLoading {
ProgressView()
}
}
.accessibilityElement(children: .combine)
.accessibilityValue(isLoading ? Text("Загрузка") : Text(verbatim: ""))
}
}
#Preview {
SignInView()
.environment(AuthStore(state: .signedOut))
}