This commit is contained in:
vadim committed 2026-10-29 13:12:45 +03:00
1 parent 558bb005f4
commit 9ed2f17768
14 files changed
+1207 -94

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
//
// AuthService.swift
// Snail
//
import Foundation
nonisolated struct User: Codable, Identifiable, Hashable, Sendable {
let id: String
let name: String
let email: String
let emailVerified: Bool
let image: String?
let createdAt: Date
/// При входе по коду better-auth создаёт пользователя с пустым именем.
var displayName: String {
name.trimmingCharacters(in: .whitespaces).isEmpty ? email : name
}
var initials: String {
let source = name.trimmingCharacters(in: .whitespaces).isEmpty ? email : name
let letters = source
.split(whereSeparator: { $0 == " " || $0 == "@" || $0 == "." })
.prefix(2)
.compactMap(\.first)
return String(letters).uppercased()
}
}
/// Эндпоинты better-auth (плагин emailOTP) и профиля.
nonisolated struct AuthService: Sendable {
private let client: APIClient
init(client: APIClient = APIClient()) {
self.client = client
}
func sendSignInCode(to email: String) async throws {
let _: SuccessResponse = try await client.send(
"POST",
"auth/email-otp/send-verification-otp",
body: ["email": email, "type": "sign-in"]
)
}
func signIn(email: String, code: String) async throws -> User {
let response: UserResponse = try await client.send(
"POST",
"auth/sign-in/email-otp",
body: ["email": email, "otp": code]
)
return response.user
}
/// Текущий пользователь или nil, если сессии нет.
func currentUser() async throws -> User? {
do {
let response: Envelope<UserResponse> = try await client.send("GET", "users/me")
return response.data.user
} catch APIError.unauthorized {
return nil
}
}
func signOut() async {
let _: SuccessResponse? = try? await client.send("POST", "auth/sign-out", body: [:])
client.clearCookies()
}
}
private nonisolated struct SuccessResponse: Decodable {
let success: Bool
}
private nonisolated struct UserResponse: Decodable {
let user: User
}
+59
View File
@@ -0,0 +1,59 @@
//
// AuthStore.swift
// Snail
//
import Foundation
import Observation
@Observable
final class AuthStore {
enum State: Equatable {
case loading
case signedOut
case signedIn(User)
}
private(set) var state: State
private let service: AuthService
init(service: AuthService = AuthService(), state: State = .loading) {
self.service = service
self.state = state
}
var currentUser: User? {
if case .signedIn(let user) = state { user } else { nil }
}
/// Проверяет сохранённую cookie сессии при запуске.
func restoreSession() async {
do {
state = try await service.currentUser().map(State.signedIn) ?? .signedOut
} catch {
state = .signedOut
}
}
func sendCode(to email: String) async throws {
try await service.sendSignInCode(to: email)
}
func signIn(email: String, code: String) async throws {
state = .signedIn(try await service.signIn(email: email, code: code))
}
/// Обновляет профиль; если сессия больше не действительна — разлогинивает.
func refreshUser() async {
do {
state = try await service.currentUser().map(State.signedIn) ?? .signedOut
} catch {
// Сетевая ошибка — оставляем текущие данные.
}
}
func signOut() async {
await service.signOut()
state = .signedOut
}
}
+103
View File
@@ -0,0 +1,103 @@
//
// ProfileView.swift
// Snail
//
import SwiftUI
struct ProfileView: View {
@Environment(AuthStore.self) private var auth
@Environment(\.dismiss) private var dismiss
@State private var isConfirmingSignOut = false
@State private var isSigningOut = false
var body: some View {
NavigationStack {
List {
if let user = auth.currentUser {
header(for: user)
Section("Аккаунт") {
LabeledContent("Электронная почта", value: user.email)
LabeledContent("Почта подтверждена", value: user.emailVerified ? String(localized: "Да") : String(localized: "Нет"))
LabeledContent("Дата регистрации", value: user.createdAt.formatted(date: .long, time: .omitted))
}
}
Section {
Button(role: .destructive) {
isConfirmingSignOut = true
} label: {
HStack {
Text("Выйти")
Spacer()
if isSigningOut {
ProgressView()
}
}
}
.disabled(isSigningOut)
.confirmationDialog("Выйти из аккаунта?", isPresented: $isConfirmingSignOut, titleVisibility: .visible) {
Button("Выйти", role: .destructive, action: signOut)
} message: {
Text("Чтобы войти снова, понадобится код из письма.")
}
}
}
.navigationTitle("Профиль")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await auth.refreshUser() }
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
}
}
private func header(for user: User) -> some View {
Section {
VStack(spacing: 12) {
Text(user.initials)
.font(.title.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 80, height: 80)
.background(Color.accentColor.gradient, in: .circle)
.accessibilityHidden(true)
VStack(spacing: 4) {
Text(user.displayName)
.font(.title2.weight(.semibold))
if user.displayName != user.email {
Text(user.email)
.foregroundStyle(.secondary)
}
}
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.listRowBackground(Color.clear)
.accessibilityElement(children: .combine)
}
}
private func signOut() {
isSigningOut = true
Task {
await auth.signOut()
isSigningOut = false
}
}
}
#Preview {
ProfileView()
.environment(AuthStore(state: .signedIn(User(
id: "1",
name: "Вадим",
email: "vadim@example.com",
emailVerified: true,
image: nil,
createdAt: .now
))))
}
+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))
}