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>
107 lines
3.6 KiB
Swift
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 []
|
|
}
|
|
}
|