auth and projects
This commit is contained in:
1 parent
558bb005f4
commit
0ba2db0ec2
14 files changed
+1207
-94
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
))))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user