Apps mit aktivem 2FA bekommen jetzt eine native Challenge-View nach Email/Password-Login. ManaLoginView schaltet automatisch um wenn AuthClient.status auf .twoFactorRequired wechselt. Components: - ManaTwoFactorChallengeView — Scaffold-View mit 6-stelligem Code- Input, Backup-Code-Toggle, Cancel zurück zum Login - TwoFactorChallengeViewModel — @Observable State-Maschine, wraps AuthClient.verifyTotp/verifyBackupCode - LoginViewModel.Status.twoFactorRequired(email:) als neuer Case; submit() routet automatisch dorthin wenn der AuthClient den Challenge-Status zurückgibt 6 neue Tests, 39/39 grün. Setzt mana-swift-core ≥ 1.3.0 voraus. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
83 lines
2.6 KiB
Swift
83 lines
2.6 KiB
Swift
import Foundation
|
|
import ManaCore
|
|
import Observation
|
|
|
|
/// State-Maschine für ``ManaTwoFactorChallengeView``. Setzt auf den
|
|
/// `.twoFactorRequired`-Zustand des `AuthClient` auf, der nach einem
|
|
/// erfolgreichen Email/PW-`signIn` mit 2FA-aktiviertem Account
|
|
/// gesetzt wird.
|
|
@MainActor
|
|
@Observable
|
|
public final class TwoFactorChallengeViewModel {
|
|
public enum Mode: Equatable, Sendable {
|
|
case totp
|
|
case backupCode
|
|
}
|
|
|
|
public enum Status: Equatable, Sendable {
|
|
case idle
|
|
case verifying
|
|
case error(String)
|
|
}
|
|
|
|
public var mode: Mode = .totp
|
|
public var code: String = ""
|
|
public var trustDevice: Bool = false
|
|
public private(set) var status: Status = .idle
|
|
|
|
private let auth: AuthClient
|
|
|
|
public init(auth: AuthClient) {
|
|
self.auth = auth
|
|
}
|
|
|
|
public var canSubmit: Bool {
|
|
guard !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false }
|
|
if case .verifying = status { return false }
|
|
switch mode {
|
|
case .totp:
|
|
// TOTP: 6 Ziffern (Better-Auth-Default)
|
|
let digitsOnly = code.filter { $0.isNumber }
|
|
return digitsOnly.count == 6
|
|
case .backupCode:
|
|
// Backup-Codes: ~10 Zeichen alphanumerisch + Trenner.
|
|
// Pragmatik: nicht-leer reicht — Server validiert exakt.
|
|
return !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
}
|
|
|
|
public var isVerifying: Bool {
|
|
if case .verifying = status { return true }
|
|
return false
|
|
}
|
|
|
|
public func toggleMode() {
|
|
mode = mode == .totp ? .backupCode : .totp
|
|
code = ""
|
|
status = .idle
|
|
}
|
|
|
|
public func submit() async {
|
|
let cleaned = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !cleaned.isEmpty else { return }
|
|
|
|
status = .verifying
|
|
do {
|
|
switch mode {
|
|
case .totp:
|
|
try await auth.verifyTotp(code: cleaned, trustDevice: trustDevice)
|
|
case .backupCode:
|
|
try await auth.verifyBackupCode(code: cleaned, trustDevice: trustDevice)
|
|
}
|
|
// Bei Erfolg: Status bleibt .verifying — die View beobachtet
|
|
// den AuthClient.status (.signedIn) und reagiert über den
|
|
// umgebenden Gate/Root-View. Code aus dem Memory wischen.
|
|
code = ""
|
|
status = .idle
|
|
} catch let error as AuthError {
|
|
status = .error(error.errorDescription ?? "Verifikation fehlgeschlagen")
|
|
} catch {
|
|
status = .error(String(describing: error))
|
|
}
|
|
}
|
|
}
|