v0.3.0 — ManaTwoFactorChallengeView
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>
This commit is contained in:
parent
6417b4cd33
commit
c1555565b6
7 changed files with 348 additions and 4 deletions
|
|
@ -0,0 +1,83 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue