mana-swift-ui/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift
Till JS dc8e5a4e9b v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView
3-Phasen-Wizard für 2FA-Enrollment + Single-Step-Sheet für Disable.
Setzt mana-swift-core ≥ 1.4.0 voraus.

ManaTwoFactorEnrollView:
1. Passwort-Re-Auth → server liefert otpauth-URI + Backup-Codes
2. QR-Code-Display (CoreImage.CIFilter.qrCodeGenerator) + 6-stellige
   Test-Code-Eingabe
3. Backup-Codes-Liste mit Copy-to-Clipboard

ManaTwoFactorDisableView:
- Re-Auth via Passwort, destructive-Button, .done-Konfirmation

5 neue Tests, 44/44 grün.

🤖 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:39:03 +02:00

107 lines
3.6 KiB
Swift

import Foundation
import ManaCore
import Observation
/// State-Maschine für ``ManaTwoFactorEnrollView``. 3-Phasen-Wizard:
///
/// 1. **Re-Auth** User gibt aktuelles Passwort ein
/// 2. **QR + Verify** App zeigt QR-Code, User scannt mit Authenticator
/// und gibt zur Bestätigung einen 6-stelligen Code ein
/// 3. **Backup-Codes** App zeigt die generierten Codes, User sichert
/// sie (Kopieren in die Zwischenablage)
///
/// Schritte 1+2 sind atomar gegen den Server: `enrollTotp(password:)`
/// liefert URI **und** Backup-Codes in einem Call. Der Verify-Step
/// in der UI ist defensiv der User muss zeigen können dass er den
/// QR-Code wirklich gescannt hat, bevor wir ihm die Backup-Codes
/// zeigen. Wenn er den Code nicht hat, kann er den Enroll-Vorgang
/// abbrechen und der Server-Side ist die TOTP-Konfiguration trotzdem
/// als aktiv markiert er muss dann disableTotp(password:) aufrufen.
@MainActor
@Observable
public final class TwoFactorEnrollmentViewModel {
public enum Phase: Equatable, Sendable {
case password
case verify(uri: String, backupCodes: [String])
case backupCodes([String])
}
public enum Status: Equatable, Sendable {
case idle
case working
case error(String)
}
public var password: String = ""
public var verifyCode: String = ""
public private(set) var phase: Phase = .password
public private(set) var status: Status = .idle
private let auth: AuthClient
public init(auth: AuthClient) {
self.auth = auth
}
// MARK: - Phase 1: Password
public var canSubmitPassword: Bool {
guard !password.isEmpty else { return false }
if case .working = status { return false }
return true
}
public var isWorking: Bool {
if case .working = status { return true }
return false
}
public func enrollWithPassword() async {
guard canSubmitPassword else { return }
status = .working
do {
let enrollment = try await auth.enrollTotp(password: password)
password = ""
phase = .verify(uri: enrollment.totpURI, backupCodes: enrollment.backupCodes)
status = .idle
} catch let error as AuthError {
status = .error(error.errorDescription ?? "Aktivierung fehlgeschlagen")
} catch {
status = .error(String(describing: error))
}
}
// MARK: - Phase 2: Verify
public var canSubmitVerify: Bool {
let digits = verifyCode.filter { $0.isNumber }
return digits.count == 6 && !isWorking
}
/// Server-seitig ist die 2FA-Konfiguration nach `enrollTotp` schon
/// aktiv wir nutzen `verifyTotp` nicht zur Bestätigung des Setups,
/// sondern verlassen uns auf den User dass er den QR-Code richtig
/// gescannt hat. Better-Auth-API hat keinen "verify-setup-Endpoint"
/// (verify-totp ist nur im Login-Challenge-Flow gültig). Der
/// Bestätigungs-Schritt ist also rein UI-defensiv: zeigt einen
/// Code-Input, der erstmal nur lokal die Eingabe sammelt und dann
/// in den Backup-Codes-Schritt umschaltet.
public func confirmVerify() {
if case let .verify(_, codes) = phase {
verifyCode = ""
phase = .backupCodes(codes)
status = .idle
}
}
// MARK: - Phase 3: Backup-Codes
/// Die generierten Backup-Codes (8-stellige Strings, üblich 10
/// Stück). UI zeigt sie zum Kopieren/Sichern.
public var backupCodes: [String] {
if case let .backupCodes(codes) = phase { return codes }
if case let .verify(_, codes) = phase { return codes }
return []
}
}