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
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-05-14
|
||||||
|
|
||||||
|
Minor — `ManaTwoFactorChallengeView` für 2FA-Login. Setzt
|
||||||
|
mana-swift-core ≥ 1.3.0 voraus (Status `.twoFactorRequired`).
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- `ManaTwoFactorChallengeView` + `TwoFactorChallengeViewModel` —
|
||||||
|
6-stelliger TOTP-Code-Input (Number-Pad auf iOS), Fallback auf
|
||||||
|
Backup-Codes via Toggle, "Abbrechen" routet via
|
||||||
|
`auth.signOut(keepGuestMode:)` zurück zum Login.
|
||||||
|
- `LoginViewModel.Status.twoFactorRequired(email:)` als neuer Case.
|
||||||
|
- `ManaLoginView` schaltet bei `.twoFactorRequired` automatisch auf
|
||||||
|
`ManaTwoFactorChallengeView` um (analog zu `.emailNotVerified`).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 6 neue Tests für `TwoFactorChallengeViewModel`: canSubmit-Guards
|
||||||
|
(TOTP 6 Ziffern, Backup beliebig), toggleMode-State-Reset, submit
|
||||||
|
bei Erfolg/Fehler, Backup-Code-Routing.
|
||||||
|
- 39/39 grün.
|
||||||
|
|
||||||
## [0.2.0] — 2026-05-13
|
## [0.2.0] — 2026-05-13
|
||||||
|
|
||||||
Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus.
|
Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus.
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,9 @@ private func authStatusKey(_ status: AuthClient.Status) -> Int {
|
||||||
case .signedOut: 1
|
case .signedOut: 1
|
||||||
case .guest: 2
|
case .guest: 2
|
||||||
case .signingIn: 3
|
case .signingIn: 3
|
||||||
case .signedIn: 4
|
case .twoFactorRequired: 4
|
||||||
case .error: 5
|
case .signedIn: 5
|
||||||
|
case .error: 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,14 @@ public final class LoginViewModel {
|
||||||
case idle
|
case idle
|
||||||
case signingIn
|
case signingIn
|
||||||
/// Sign-In ist gescheitert mit klassifiziertem Fehler.
|
/// Sign-In ist gescheitert mit klassifiziertem Fehler.
|
||||||
/// `.emailNotVerified` ist ein wichtiger Sonderfall — die UI
|
|
||||||
/// schaltet darauf den Resend-Mail-Gate frei.
|
|
||||||
case error(String)
|
case error(String)
|
||||||
/// Sign-In ist gescheitert weil die Email noch nicht bestätigt
|
/// Sign-In ist gescheitert weil die Email noch nicht bestätigt
|
||||||
/// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene
|
/// ist. UI zeigt den Resend-Gate für die zuletzt eingegebene
|
||||||
/// Email-Adresse.
|
/// Email-Adresse.
|
||||||
case emailNotVerified(email: String)
|
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 = ""
|
public var email: String = ""
|
||||||
|
|
@ -66,6 +67,13 @@ public final class LoginViewModel {
|
||||||
case .signedIn:
|
case .signedIn:
|
||||||
status = .idle
|
status = .idle
|
||||||
password = "" // nicht im Memory lassen
|
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:
|
case .error:
|
||||||
// Strukturierten Fehler aus AuthClient.lastError lesen statt
|
// Strukturierten Fehler aus AuthClient.lastError lesen statt
|
||||||
// den String der Status-Maschine zu re-parsen.
|
// den String der Status-Maschine zu re-parsen.
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,17 @@ public struct ManaLoginView: View {
|
||||||
auth: auth,
|
auth: auth,
|
||||||
onBackToLogin: { model.resetToIdle() }
|
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:
|
default:
|
||||||
loginForm
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift
Normal file
118
Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import Foundation
|
||||||
|
import ManaCore
|
||||||
|
import Testing
|
||||||
|
@testable import ManaAuthUI
|
||||||
|
|
||||||
|
@Suite("TwoFactorChallengeViewModel")
|
||||||
|
@MainActor
|
||||||
|
struct TwoFactorChallengeViewModelTests {
|
||||||
|
/// Bringt den AuthClient in den `.twoFactorRequired`-Status.
|
||||||
|
private func challengedAuth() async -> MockedAuth {
|
||||||
|
let mocked = makeMockedAuth()
|
||||||
|
mocked.setHandler { _ in
|
||||||
|
(200, Data(#"""
|
||||||
|
{"twoFactorRequired":true,"twoFactorMethods":["totp"],"twoFactorToken":"tf-x"}
|
||||||
|
"""#.utf8))
|
||||||
|
}
|
||||||
|
await mocked.auth.signIn(email: "u@x.de", password: "pw")
|
||||||
|
return mocked
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("canSubmit fordert 6-stellige Ziffern im TOTP-Modus")
|
||||||
|
func canSubmitTotpDigits() {
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
||||||
|
model.code = ""
|
||||||
|
#expect(model.canSubmit == false)
|
||||||
|
model.code = "12345"
|
||||||
|
#expect(model.canSubmit == false) // 5 Ziffern
|
||||||
|
model.code = "123456"
|
||||||
|
#expect(model.canSubmit == true)
|
||||||
|
model.code = "123 456" // erlaubt Whitespace → 6 Ziffern
|
||||||
|
#expect(model.canSubmit == true)
|
||||||
|
model.code = "abcdef"
|
||||||
|
#expect(model.canSubmit == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("canSubmit im Backup-Modus akzeptiert nicht-leere Strings")
|
||||||
|
func canSubmitBackupCode() {
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
||||||
|
model.toggleMode()
|
||||||
|
#expect(model.mode == .backupCode)
|
||||||
|
model.code = ""
|
||||||
|
#expect(model.canSubmit == false)
|
||||||
|
model.code = "abc-def-ghi"
|
||||||
|
#expect(model.canSubmit == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("toggleMode wechselt mode + cleared code")
|
||||||
|
func toggleModeClearsCode() {
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: makeMockedAuth().auth)
|
||||||
|
model.code = "123456"
|
||||||
|
model.toggleMode()
|
||||||
|
#expect(model.mode == .backupCode)
|
||||||
|
#expect(model.code == "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("submit mit erfolgreichem TOTP setzt AuthClient auf signedIn")
|
||||||
|
func submitTotpSuccess() async {
|
||||||
|
let mocked = await challengedAuth()
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
||||||
|
model.code = "123456"
|
||||||
|
|
||||||
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
||||||
|
mocked.setHandler { request in
|
||||||
|
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-totp")
|
||||||
|
return (200, Data(#"""
|
||||||
|
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
||||||
|
"""#.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.submit()
|
||||||
|
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
||||||
|
#expect(model.code == "")
|
||||||
|
#expect(model.status == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("submit mit falschem TOTP → .error, AuthClient bleibt twoFactorRequired")
|
||||||
|
func submitTotpWrongCode() async {
|
||||||
|
let mocked = await challengedAuth()
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
||||||
|
model.code = "000000"
|
||||||
|
|
||||||
|
mocked.setHandler { _ in
|
||||||
|
(401, Data(#"{"error":"TWO_FACTOR_FAILED","status":401}"#.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.submit()
|
||||||
|
if case let .error(message) = model.status {
|
||||||
|
#expect(message == "Zwei-Faktor-Code falsch.")
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .error, got \(model.status)")
|
||||||
|
}
|
||||||
|
// AuthClient bleibt im challenge-Status, User kann retry
|
||||||
|
if case .twoFactorRequired = mocked.auth.status {
|
||||||
|
#expect(Bool(true))
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected .twoFactorRequired, got \(mocked.auth.status)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("submit im Backup-Modus ruft verify-backup-code-Endpoint")
|
||||||
|
func submitBackupCodeRoutesCorrectly() async {
|
||||||
|
let mocked = await challengedAuth()
|
||||||
|
let model = TwoFactorChallengeViewModel(auth: mocked.auth)
|
||||||
|
model.toggleMode()
|
||||||
|
model.code = "abc-def-ghi"
|
||||||
|
|
||||||
|
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
||||||
|
mocked.setHandler { request in
|
||||||
|
#expect(request.url?.path == "/api/v1/auth/two-factor/verify-backup-code")
|
||||||
|
return (200, Data(#"""
|
||||||
|
{"success":true,"accessToken":"\#(access)","refreshToken":"r1"}
|
||||||
|
"""#.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
await model.submit()
|
||||||
|
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue