diff --git a/CHANGELOG.md b/CHANGELOG.md index 72daa99..493cb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [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 Minor — Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift index 6d92a41..be611ef 100644 --- a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -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 } } diff --git a/Sources/ManaAuthUI/Login/LoginViewModel.swift b/Sources/ManaAuthUI/Login/LoginViewModel.swift index 8de3b9c..cec1bf6 100644 --- a/Sources/ManaAuthUI/Login/LoginViewModel.swift +++ b/Sources/ManaAuthUI/Login/LoginViewModel.swift @@ -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. diff --git a/Sources/ManaAuthUI/Login/ManaLoginView.swift b/Sources/ManaAuthUI/Login/ManaLoginView.swift index 5632834..cb4276a 100644 --- a/Sources/ManaAuthUI/Login/ManaLoginView.swift +++ b/Sources/ManaAuthUI/Login/ManaLoginView.swift @@ -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 } diff --git a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift new file mode 100644 index 0000000..67f8bd7 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift @@ -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" + } +} diff --git a/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift new file mode 100644 index 0000000..76fa344 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift @@ -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)) + } + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift new file mode 100644 index 0000000..7778b25 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift @@ -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")) + } +}