mana-swift-ui/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift
Till JS c1555565b6 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>
2026-05-14 00:20:30 +02:00

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))
}
}
}