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
|
|
@ -41,8 +41,9 @@ private func authStatusKey(_ status: AuthClient.Status) -> Int {
|
|||
case .signedOut: 1
|
||||
case .guest: 2
|
||||
case .signingIn: 3
|
||||
case .signedIn: 4
|
||||
case .error: 5
|
||||
case .twoFactorRequired: 4
|
||||
case .signedIn: 5
|
||||
case .error: 6
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ public final class LoginViewModel {
|
|||
case idle
|
||||
case signingIn
|
||||
/// Sign-In ist gescheitert mit klassifiziertem Fehler.
|
||||
/// `.emailNotVerified` ist ein wichtiger Sonderfall — die UI
|
||||
/// schaltet darauf den Resend-Mail-Gate frei.
|
||||
case error(String)
|
||||
/// Sign-In ist gescheitert weil die Email noch nicht bestätigt
|
||||
/// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene
|
||||
/// Email-Adresse.
|
||||
case emailNotVerified(email: String)
|
||||
/// Sign-In war erfolgreich aber der Account hat 2FA aktiviert.
|
||||
/// UI zeigt ``ManaTwoFactorChallengeView``.
|
||||
case twoFactorRequired(email: String)
|
||||
}
|
||||
|
||||
public var email: String = ""
|
||||
|
|
@ -66,6 +67,13 @@ public final class LoginViewModel {
|
|||
case .signedIn:
|
||||
status = .idle
|
||||
password = "" // nicht im Memory lassen
|
||||
case .twoFactorRequired:
|
||||
// Sign-In war auf der ersten Stufe erfolgreich, jetzt
|
||||
// braucht der User noch den 2FA-Code. Password aus dem
|
||||
// Memory wischen — das ist verifiziert und wird nicht
|
||||
// mehr gebraucht.
|
||||
password = ""
|
||||
status = .twoFactorRequired(email: trimmed)
|
||||
case .error:
|
||||
// Strukturierten Fehler aus AuthClient.lastError lesen statt
|
||||
// den String der Status-Maschine zu re-parsen.
|
||||
|
|
|
|||
|
|
@ -48,6 +48,17 @@ public struct ManaLoginView: View {
|
|||
auth: auth,
|
||||
onBackToLogin: { model.resetToIdle() }
|
||||
)
|
||||
case .twoFactorRequired:
|
||||
ManaTwoFactorChallengeView(
|
||||
auth: auth,
|
||||
onCancel: {
|
||||
// Abbruch: User will zurück zum Email/Password-Form.
|
||||
// AuthClient.status zurücksetzen damit der Challenge-
|
||||
// Token verworfen wird; UI-Status auf idle.
|
||||
Task { await auth.signOut(keepGuestMode: true) }
|
||||
model.resetToIdle()
|
||||
}
|
||||
)
|
||||
default:
|
||||
loginForm
|
||||
}
|
||||
|
|
|
|||
101
Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift
Normal file
101
Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import ManaCore
|
||||
import SwiftUI
|
||||
|
||||
/// Wird angezeigt, wenn nach erfolgreichem Email/PW-`signIn` der
|
||||
/// `AuthClient.status` auf ``AuthClient/Status/twoFactorRequired(token:methods:email:)``
|
||||
/// gewechselt ist. Bietet TOTP-Code-Eingabe (6-stellig) plus einen
|
||||
/// Fallback auf Backup-Codes.
|
||||
///
|
||||
/// Apps müssen das selbst nicht einbauen — ``ManaLoginView`` schaltet
|
||||
/// automatisch um. Nur direkt nötig wenn die App eine eigene Login-
|
||||
/// UI-Maschine hat (z.B. Memoros AccountView).
|
||||
public struct ManaTwoFactorChallengeView: View {
|
||||
@Environment(\.manaBrand) private var brand
|
||||
@State private var model: TwoFactorChallengeViewModel
|
||||
private let onCancel: () -> Void
|
||||
|
||||
/// - Parameters:
|
||||
/// - auth: gemeinsamer `AuthClient` der App (Status muss bereits
|
||||
/// `.twoFactorRequired` sein).
|
||||
/// - onCancel: Callback wenn der User "Abbrechen" drückt. Apps
|
||||
/// setzen den AuthClient typischerweise auf `.signedOut`
|
||||
/// zurück und zeigen wieder die Login-View.
|
||||
public init(
|
||||
auth: AuthClient,
|
||||
onCancel: @escaping () -> Void
|
||||
) {
|
||||
_model = State(initialValue: TwoFactorChallengeViewModel(auth: auth))
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ManaAuthScaffold(showsHeader: false) {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 56, weight: .light))
|
||||
.foregroundStyle(brand.primary)
|
||||
|
||||
Text(model.mode == .totp ? "Zwei-Faktor-Code" : "Backup-Code")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(brand.foreground)
|
||||
|
||||
Text(promptText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(brand.mutedForeground)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
ManaTextField(placeholderText, text: $model.code)
|
||||
.autocorrectionDisabled()
|
||||
.font(.system(.title3, design: .monospaced))
|
||||
#if os(iOS)
|
||||
.keyboardType(model.mode == .totp ? .numberPad : .asciiCapable)
|
||||
.textInputAutocapitalization(model.mode == .totp ? .never : .characters)
|
||||
#endif
|
||||
|
||||
ManaPrimaryButton(
|
||||
"Bestätigen",
|
||||
isLoading: model.isVerifying,
|
||||
isEnabled: model.canSubmit
|
||||
) {
|
||||
Task { await model.submit() }
|
||||
}
|
||||
|
||||
if case let .error(message) = model.status {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(brand.error)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: { model.toggleMode() }) {
|
||||
Text(model.mode == .totp
|
||||
? "Stattdessen Backup-Code verwenden"
|
||||
: "Stattdessen 6-stelligen Code verwenden"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(brand.primary)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
Button("Abbrechen", action: onCancel)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(brand.mutedForeground)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var promptText: String {
|
||||
switch model.mode {
|
||||
case .totp:
|
||||
"Öffne deine Authenticator-App und gib den 6-stelligen Code für deinen Account ein."
|
||||
case .backupCode:
|
||||
"Gib einen deiner einmal-nutzbaren Backup-Codes ein. Jeder Code lässt sich nur einmal verwenden."
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
model.mode == .totp ? "123 456" : "xxxx-xxxx"
|
||||
}
|
||||
}
|
||||
|
|
@ -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