diff --git a/.gitignore b/.gitignore index 89631fb..1821213 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.xcodeproj Package.resolved .DS_Store +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f95fbc5..6bb2766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,113 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] +## [0.5.0] — 2026-05-14 + +Minor — `ManaTwoFactorAccountRow` + `ManaBackupCodeRegenerateView`. +Macht den 2FA-Vollausbau in der AccountView nutzbar. Setzt +mana-swift-core ≥ 1.5.0 voraus (`getProfile()`). + +### Neu + +- `ManaTwoFactorAccountRow` — Drop-in für AccountView. Holt den + 2FA-Status via `AuthClient.getProfile()` und zeigt: + - **Off:** "Zwei-Faktor aktivieren" → öffnet `ManaTwoFactorEnrollView` + - **An:** "Zwei-Faktor aktiv" + "Backup-Codes erneuern" + + "Zwei-Faktor deaktivieren" +- `ManaBackupCodeRegenerateView` — Re-Auth via Passwort, zeigt neue + Backup-Codes + Copy-to-Clipboard. +- `TwoFactorAccountRowModel` — internes `@Observable`-VM, reloaded + Status nach Enroll/Disable/Regenerate. + +Damit ist 2FA in den Apps end-to-end nutzbar — User kann aktivieren, +Backup-Codes verwalten, deaktivieren. Der Login-Flow ist seit v0.3.0 +durchgängig. + +## [0.4.0] — 2026-05-14 + +Minor — 2FA-Enrollment-UI (Mini-Sprint B). Setzt mana-swift-core +≥ 1.4.0 voraus. + +### Neu + +- `ManaTwoFactorEnrollView` + `TwoFactorEnrollmentViewModel` — + 3-Phasen-Wizard: + 1. Passwort eingeben (Re-Auth) + 2. QR-Code (via `CoreImage.CIFilter.qrCodeGenerator`, plattform- + unabhängig auf iOS+macOS) scannen + 6-stelligen Test-Code + eingeben + 3. Backup-Codes anzeigen + Copy-to-Clipboard +- `ManaTwoFactorDisableView` — Single-Step-Sheet, Re-Auth via + Passwort + destruktiver Bestätigungs-Button. + +### Tests + +- 5 neue Tests für Enroll-VM (Success, falsches PW, canSubmitVerify + 6-Ziffern-Guard, confirmVerify Phase-Wechsel, backupCodes-Accessor). +- 44/44 grün. + +## [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. +Komplett additiv; braucht `mana-swift-core` ≥ 1.2.0. + +### ManaAuthUI — neu: ManaAuthGate + +- `ManaAuthGate` — `@Observable`-State-Maschine, die eine Aktion erst + laufen lässt, wenn der User eingeloggt ist. Wenn nicht, wird das + Sign-In-Sheet aufgeklappt und die Aktion gemerkt; nach erfolgreichem + Sign-In läuft sie automatisch. +- Zwei `require`-Overloads: synchron (`() -> Void`) und async + (`() async -> Void`). Konsumenten schreiben `gate.require { ... }` + ohne sich um das Gate-Lifecycle zu kümmern. +- `ManaAuthGateModifier` / `View.manaAuthGate(_:signIn:)` — hängt das + Sign-In-Sheet an einen Root-View und beobachtet `auth.status`. + Wechsel auf `.signedIn` schließt das Sheet und löst die Pending- + Aktion aus; manuelles Dismiss verwirft die Pending-Aktion. +- `lastReason` als optionaler Telemetrie-Hint pro `require`-Call. + +### Konvention + +Native-Apps sollen `mana-swift-core` v1.2.0 + Guest-Mode + diesen Gate +als Standardweg nutzen. Pattern für Cards/Manaspur/Memoro: + +1. Beim App-Start: `bootstrap()`, dann bei `.signedOut` → `enterGuestMode()`. +2. Root-View zeigt immer App-Inhalte; **nie** eine Vollbild-Login-Wall. +3. Aktionen, die einen Account brauchen, werden in + `gate.require { ... }` gewrappt — Login wird zur Inline-Eskalation + statt zum App-Block. + +Memoro hat dieses Muster informell schon umgesetzt (ContentView ohne +Hard-Gate). Cards + Manaspur ziehen mit ihren nächsten Releases nach. + +### Tests + +- 7 neue Tests für `ManaAuthGate`: sofortiger Run bei `.signedIn`, + Defer bei `.signedOut`/`.guest`, `resolvePending` nach Sign-In, + `cancelPending`, `lastReason`-Tracking. + ## [0.1.0] — 2026-05-13 Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A, siehe diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGate.swift b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift new file mode 100644 index 0000000..4ea9580 --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGate.swift @@ -0,0 +1,130 @@ +import ManaCore +import Observation + +/// Action-Level-Gate für Apps mit Guest-/Login-optional-Modus. +/// +/// Hintergrund: in `mana-swift-core` v1.2.0 wurde `AuthClient.Status` +/// um den anonymen `.guest`-Modus erweitert. Apps sollen ihre Inhalte +/// in diesem Modus möglichst vollständig zugänglich machen — nur +/// schreibende Server-Aktionen (KI-Generierung, Publish, Cross-Device- +/// Sync) sollen Login erzwingen. +/// +/// `ManaAuthGate` wrappt diese Logik so, dass sie aus Buttons heraus +/// einzeilig nutzbar bleibt: +/// +/// ```swift +/// @Environment(ManaAuthGate.self) private var gate +/// +/// Button("Karte mit KI generieren") { +/// gate.require { +/// // läuft erst, wenn .signedIn — sonst poppt das Login-Sheet, +/// // und die Aktion läuft nach erfolgreichem Sign-In. +/// await viewModel.generateCard() +/// } +/// } +/// ``` +/// +/// In der App-Root wird der Gate einmal aufgebaut und via Modifier +/// `manaAuthGate(_:signIn:)` an die View gehängt: +/// +/// ```swift +/// RootView() +/// .manaAuthGate(authGate) { +/// ManaLoginView( +/// auth: authClient, +/// onSignUpTapped: { /* push SignUp */ }, +/// onForgotTapped: { /* push Forgot */ } +/// ) +/// } +/// .environment(authGate) +/// ``` +/// +/// Verhalten: +/// - Status ist `.signedIn` → Action läuft sofort. +/// - Status ist `.guest`/`.signedOut`/`.error`/`.unknown` → Sign-In- +/// Sheet wird präsentiert, die Action gemerkt; nach Wechsel auf +/// `.signedIn` läuft die Action und das Sheet wird geschlossen. +/// - User schließt das Sheet ohne Login → Action wird verworfen. +@MainActor +@Observable +public final class ManaAuthGate { + public let auth: AuthClient + public var isPresentingSignIn: Bool = false + + /// Letzter aufgetretener Trigger-Grund. Diagnostik/Telemetrie: Apps + /// können hier ablesen, *was* den User in den Login-Flow geführt + /// hat (z.B. "KI-Generierung" vs. "Deck publish"), ohne dass das + /// in der Action selbst stehen muss. + public private(set) var lastReason: String? + + private var pending: PendingAction? + + public init(auth: AuthClient) { + self.auth = auth + } + + /// Führt `action` sofort aus, wenn der User eingeloggt ist. Sonst + /// wird das Sign-In-Sheet aufgeklappt und die Action wird ausgeführt, + /// sobald `auth.status` auf `.signedIn` wechselt. + /// + /// - Parameters: + /// - reason: Optional, kurzer technischer Kennzeichner für + /// Telemetrie/Logs. Wird der UI **nicht** angezeigt. + /// - action: Die zu schützende Aktion. Wird **nicht** ausgeführt, + /// wenn der User das Sheet ohne Login schließt. + public func require(reason: String? = nil, _ action: @escaping @MainActor () -> Void) { + lastReason = reason + if case .signedIn = auth.status { + action() + return + } + pending = .sync(action) + isPresentingSignIn = true + } + + /// Async-Variante von ``require(reason:_:)``. Die Action darf + /// `await`-Aufrufe enthalten; sie wird in einem neuen `Task` + /// gestartet. + public func require( + reason: String? = nil, + _ action: @escaping @MainActor () async -> Void + ) { + lastReason = reason + if case .signedIn = auth.status { + Task { await action() } + return + } + pending = .async(action) + isPresentingSignIn = true + } + + /// Wechselt der Auth-Status auf `.signedIn`, läuft die ausstehende + /// Action und das Sheet wird geschlossen. Vom ViewModifier + /// aufgerufen, kann aber auch manuell genutzt werden. + public func resolvePending() { + guard case .signedIn = auth.status else { return } + let next = pending + pending = nil + isPresentingSignIn = false + switch next { + case .none: + return + case let .sync(action): + action() + case let .async(action): + Task { await action() } + } + } + + /// Verwirft eine ausstehende Aktion. Vom ViewModifier nach + /// `sheet(onDismiss:)` aufgerufen — wenn der User das Sheet ohne + /// Login schließt, soll die Aktion *nicht* nachträglich laufen. + public func cancelPending() { + pending = nil + } + + private enum PendingAction { + case sync(@MainActor () -> Void) + case async(@MainActor () async -> Void) + } +} diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift new file mode 100644 index 0000000..be611ef --- /dev/null +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -0,0 +1,63 @@ +import ManaCore +import SwiftUI + +/// ViewModifier, der das Sign-In-Sheet für einen ``ManaAuthGate`` +/// präsentiert. Die App liefert via `signIn`-Builder, welche Auth- +/// View im Sheet gezeigt wird (üblich: ``ManaLoginView``). +/// +/// Beobachtet `gate.auth.status` und schließt das Sheet automatisch +/// sobald der User eingeloggt ist; die ausstehende Aktion wird dann +/// gestartet. Wird das Sheet vorher dismisst, wird die Aktion verworfen. +public struct ManaAuthGateModifier: ViewModifier { + @Bindable private var gate: ManaAuthGate + private let signInContent: () -> SignInContent + + public init(gate: ManaAuthGate, @ViewBuilder signIn: @escaping () -> SignInContent) { + self.gate = gate + signInContent = signIn + } + + public func body(content: Content) -> some View { + content + .sheet( + isPresented: $gate.isPresentingSignIn, + onDismiss: { gate.cancelPending() } + ) { + signInContent() + .onChange(of: authStatusKey(gate.auth.status)) { _, _ in + gate.resolvePending() + } + } + } +} + +/// Reduziert `AuthClient.Status` auf einen `Equatable`-stabilen Key +/// für `onChange(of:)`. `Status` selbst ist `Equatable` — aber assoziierte +/// Werte (Email-String) sind hier irrelevant, wir wollen nur auf den +/// Übergang signedOut/guest → signedIn reagieren. +private func authStatusKey(_ status: AuthClient.Status) -> Int { + switch status { + case .unknown: 0 + case .signedOut: 1 + case .guest: 2 + case .signingIn: 3 + case .twoFactorRequired: 4 + case .signedIn: 5 + case .error: 6 + } +} + +public extension View { + /// Hängt einen ``ManaAuthGate`` an die View. Aufrufe von + /// `gate.require { ... }` führen entweder zur Aktion (signedIn) oder + /// zum Sign-In-Sheet, das der `signIn`-Builder liefert. + /// + /// Idiomatischer Einsatz: einmal am App-Root, danach den Gate via + /// `.environment(gate)` in den Sub-Views verfügbar machen. + func manaAuthGate( + _ gate: ManaAuthGate, + @ViewBuilder signIn: @escaping () -> some View + ) -> some View { + modifier(ManaAuthGateModifier(gate: gate, signIn: signIn)) + } +} 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/ManaTwoFactorAccountRow.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift new file mode 100644 index 0000000..e925cb6 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift @@ -0,0 +1,310 @@ +import ManaCore +import Observation +import SwiftUI + +/// Account-Section-Block für die 2FA-Verwaltung. Apps bauen den +/// einfach in ihre AccountView ein: +/// +/// ```swift +/// ManaTwoFactorAccountRow(auth: auth) +/// .manaBrand(brand) +/// ``` +/// +/// Die Row holt den 2FA-Status beim ersten Erscheinen via +/// `AuthClient.getProfile()` und zeigt dann entweder: +/// - "Zwei-Faktor aktivieren" (Enroll-Sheet) bei `twoFactorEnabled == false` +/// - "Zwei-Faktor deaktivieren" + "Backup-Codes erneuern" bei `true` +/// +/// Nach Enroll/Disable wird der Status automatisch neu geladen, +/// damit die Row sich konsistent updated. +@MainActor +@Observable +final class TwoFactorAccountRowModel { + enum LoadState: Equatable { + case loading + case loaded(twoFactorEnabled: Bool) + case error(String) + } + + private(set) var state: LoadState = .loading + private let auth: AuthClient + + init(auth: AuthClient) { + self.auth = auth + } + + func reload() async { + state = .loading + do { + let profile = try await auth.getProfile() + state = .loaded(twoFactorEnabled: profile.twoFactorEnabled) + } catch let error as AuthError { + if case .notSignedIn = error { + state = .error("Nicht angemeldet") + } else { + state = .error(error.errorDescription ?? "Status konnte nicht geladen werden") + } + } catch { + state = .error(String(describing: error)) + } + } +} + +public struct ManaTwoFactorAccountRow: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorAccountRowModel + @State private var showEnroll = false + @State private var showDisable = false + @State private var showRegenerate = false + private let auth: AuthClient + + public init(auth: AuthClient) { + self.auth = auth + _model = State(initialValue: TwoFactorAccountRowModel(auth: auth)) + } + + public var body: some View { + Group { + switch model.state { + case .loading: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("2FA-Status lädt…") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + case let .loaded(enabled): + if enabled { + enabledRow + } else { + disabledRow + } + case let .error(message): + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + } + } + .task { + await model.reload() + } + .sheet(isPresented: $showEnroll, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorEnrollView(auth: auth, onDone: { showEnroll = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showDisable, onDismiss: { + Task { await model.reload() } + }) { + ManaTwoFactorDisableView(auth: auth, onDone: { showDisable = false }) + .manaBrand(brand) + } + .sheet(isPresented: $showRegenerate, onDismiss: { + Task { await model.reload() } + }) { + ManaBackupCodeRegenerateView(auth: auth, onDone: { showRegenerate = false }) + .manaBrand(brand) + } + } + + @ViewBuilder + private var disabledRow: some View { + Button(action: { showEnroll = true }) { + HStack { + Image(systemName: "lock.shield") + .foregroundStyle(brand.mutedForeground) + VStack(alignment: .leading, spacing: 2) { + Text("Zwei-Faktor aktivieren") + .foregroundStyle(brand.foreground) + Text("TOTP-App mit Backup-Codes") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(brand.mutedForeground) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var enabledRow: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(brand.success) + Text("Zwei-Faktor aktiv") + .foregroundStyle(brand.foreground) + Spacer() + } + + Button(action: { showRegenerate = true }) { + Text("Backup-Codes erneuern") + .font(.subheadline) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + Button(role: .destructive, action: { showDisable = true }) { + Text("Zwei-Faktor deaktivieren") + .font(.subheadline) + .foregroundStyle(brand.error) + } + .buttonStyle(.plain) + } + } + +} + +/// Sheet zum Erneuern der Backup-Codes. Re-Auth via Passwort, +/// zeigt danach die neuen Codes. +public struct ManaBackupCodeRegenerateView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var newCodes: [String] = [] + @State private var status: Status = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum Status: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Backup-Codes erneuern") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Die alten Codes werden ungültig. Bestätige mit deinem Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "Neue Codes generieren", + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Neue Codes generiert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Codes JETZT. Alte Codes sind ungültig.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(newCodes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(newCodes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + private func submit() async { + status = .working + do { + newCodes = try await auth.regenerateBackupCodes(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Erneuerung fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif 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/ManaTwoFactorEnrollView.swift b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift new file mode 100644 index 0000000..789ba62 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift @@ -0,0 +1,335 @@ +import CoreImage.CIFilterBuiltins +import ManaCore +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +/// Account-Sheet: TOTP-2FA aktivieren. 3-Phasen-Wizard. +/// +/// 1. Passwort eingeben (Re-Auth) +/// 2. QR-Code mit Authenticator-App scannen + Test-Code eingeben +/// 3. Backup-Codes anzeigen und vom User bestätigen lassen +public struct ManaTwoFactorEnrollView: View { + @Environment(\.manaBrand) private var brand + @State private var model: TwoFactorEnrollmentViewModel + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + _model = State(initialValue: TwoFactorEnrollmentViewModel(auth: auth)) + self.onDone = onDone + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch model.phase { + case .password: + passwordPhase + case let .verify(uri, _): + verifyPhase(uri: uri) + case let .backupCodes(codes): + backupCodesPhase(codes: codes) + } + } + } + + // MARK: - Phase 1: Password + + @ViewBuilder + private var passwordPhase: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.primary) + + Text("Zwei-Faktor aktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Schütze deinen Account mit einem zusätzlichen Code. Bestätige dazu erst dein Passwort.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField( + "Passwort", + text: $model.password, + textContentType: .password + ) + + ManaPrimaryButton( + "Weiter", + isLoading: model.isWorking, + isEnabled: model.canSubmitPassword + ) { + Task { await model.enrollWithPassword() } + } + + if case let .error(message) = model.status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 2: QR + Verify + + @ViewBuilder + private func verifyPhase(uri: String) -> some View { + VStack(spacing: 16) { + Text("Code scannen") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Öffne deine Authenticator-App (z.B. 1Password, Aegis, Google Authenticator) und scanne diesen QR-Code.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + qrCode(for: uri) + .frame(width: 220, height: 220) + .padding(8) + .background(Color.white, in: RoundedRectangle(cornerRadius: 12)) + + Text("Gib zur Bestätigung den 6-stelligen Code aus der App ein:") + .font(.subheadline) + .foregroundStyle(brand.foreground) + .multilineTextAlignment(.center) + .padding(.top, 8) + + ManaTextField("123 456", text: $model.verifyCode) + .autocorrectionDisabled() + .font(.system(.title3, design: .monospaced)) + #if os(iOS) + .keyboardType(.numberPad) + .textInputAutocapitalization(.never) + #endif + + ManaPrimaryButton( + "Weiter zu Backup-Codes", + isEnabled: model.canSubmitVerify + ) { + model.confirmVerify() + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + // MARK: - Phase 3: Backup-Codes + + @ViewBuilder + private func backupCodesPhase(codes: [String]) -> some View { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.success) + + Text("Zwei-Faktor aktiv") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Sichere diese Backup-Codes JETZT. Du brauchst sie wenn du dein Authenticator-Gerät verlierst. Jeder Code lässt sich nur einmal verwenden.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + VStack(spacing: 6) { + ForEach(codes, id: \.self) { code in + Text(code) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(brand.foreground) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 6)) + } + } + .padding(.vertical, 8) + + Button(action: { copyToClipboard(codes.joined(separator: "\n")) }) { + Label("Alle Codes kopieren", systemImage: "doc.on.doc") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(brand.surface, in: RoundedRectangle(cornerRadius: 8)) + .foregroundStyle(brand.primary) + } + .buttonStyle(.plain) + + ManaPrimaryButton("Fertig — Codes sind gesichert") { + onDone() + } + .padding(.top, 12) + } + } + + // MARK: - QR-Code + + /// Rendert eine `otpauth://`-URI als QR-Code via `CoreImage`. Auf + /// iOS/macOS sind `CIFilter.qrCodeGenerator()` system-bordmittel. + @ViewBuilder + private func qrCode(for content: String) -> some View { + if let cgImage = makeQRCode(from: content) { + #if canImport(UIKit) + Image(uiImage: UIImage(cgImage: cgImage)) + .interpolation(.none) + .resizable() + .scaledToFit() + #elseif canImport(AppKit) + Image(nsImage: NSImage(cgImage: cgImage, size: NSSize(width: 220, height: 220))) + .interpolation(.none) + .resizable() + .scaledToFit() + #else + Text(content) + .font(.system(.caption, design: .monospaced)) + #endif + } else { + Text("QR-Code konnte nicht generiert werden — bitte URI manuell kopieren:\n\(content)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(brand.mutedForeground) + } + } + + private func makeQRCode(from string: String) -> CGImage? { + let context = CIContext() + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(string.utf8) + filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + // Upscale damit der QR-Code scharf bleibt (kein anti-aliasing). + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaled = output.transformed(by: transform) + return context.createCGImage(scaled, from: scaled.extent) + } + + private func copyToClipboard(_ text: String) { + #if canImport(UIKit) + UIPasteboard.general.string = text + #elseif canImport(AppKit) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #endif + } +} + +/// Account-Sheet: TOTP-2FA wieder deaktivieren. Einfacher Single-Step +/// mit Passwort-Re-Auth. +public struct ManaTwoFactorDisableView: View { + @Environment(\.manaBrand) private var brand + @State private var password: String = "" + @State private var status: DisableStatus = .idle + private let auth: AuthClient + private let onDone: () -> Void + + public init(auth: AuthClient, onDone: @escaping () -> Void) { + self.auth = auth + self.onDone = onDone + } + + private enum DisableStatus: Equatable { + case idle + case working + case done + case error(String) + } + + public var body: some View { + ManaAuthScaffold(showsHeader: false) { + switch status { + case .done: + doneView + default: + formView + } + } + } + + @ViewBuilder + private var formView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.shield") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktivieren") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + Text("Dein Account wird wieder nur mit Email + Passwort geschützt. Backup-Codes verlieren ihre Gültigkeit.") + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .multilineTextAlignment(.center) + + ManaSecureField("Passwort", text: $password, textContentType: .password) + + ManaPrimaryButton( + "2FA deaktivieren", + role: .destructive, + isLoading: status == .working, + isEnabled: !password.isEmpty && status != .working + ) { + Task { await submit() } + } + + if case let .error(message) = status { + Text(message) + .font(.footnote) + .foregroundStyle(brand.error) + .multilineTextAlignment(.center) + } + + Button("Abbrechen", action: onDone) + .font(.subheadline) + .foregroundStyle(brand.mutedForeground) + .padding(.top, 12) + } + } + + @ViewBuilder + private var doneView: some View { + VStack(spacing: 16) { + Image(systemName: "lock.open") + .font(.system(size: 56, weight: .light)) + .foregroundStyle(brand.mutedForeground) + + Text("Zwei-Faktor deaktiviert") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(brand.foreground) + + ManaPrimaryButton("Fertig") { onDone() } + .padding(.top, 16) + } + } + + private func submit() async { + guard !password.isEmpty else { return } + status = .working + do { + try await auth.disableTotp(password: password) + password = "" + status = .done + } catch let error as AuthError { + status = .error(error.errorDescription ?? "Deaktivieren fehlgeschlagen") + } catch { + status = .error(String(describing: error)) + } + } +} 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/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift new file mode 100644 index 0000000..00809e0 --- /dev/null +++ b/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift @@ -0,0 +1,107 @@ +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 [] + } +} diff --git a/Tests/ManaAuthUITests/ManaAuthGateTests.swift b/Tests/ManaAuthUITests/ManaAuthGateTests.swift new file mode 100644 index 0000000..4c17059 --- /dev/null +++ b/Tests/ManaAuthUITests/ManaAuthGateTests.swift @@ -0,0 +1,125 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("ManaAuthGate") +@MainActor +struct ManaAuthGateTests { + @Test("require mit .signedIn führt Action sofort aus, kein Sheet") + func runsImmediatelyWhenSignedIn() async throws { + let mocked = makeMockedAuth() + await signInMockedAuth(mocked) + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(didRun) + #expect(!gate.isPresentingSignIn) + } + + @Test("require mit .signedOut öffnet Sheet, Action wartet") + func defersWhenSignedOut() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() // → .signedOut + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("require mit .guest öffnet Sheet, Action wartet") + func defersWhenGuest() throws { + let mocked = makeMockedAuth() + _ = try mocked.auth.enterGuestMode() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("resolvePending läuft, sobald Status auf .signedIn wechselt") + func resolvesPendingAfterSignIn() async throws { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + #expect(!didRun) + #expect(gate.isPresentingSignIn) + + await signInMockedAuth(mocked) + gate.resolvePending() + + #expect(didRun) + #expect(!gate.isPresentingSignIn) + } + + @Test("resolvePending ist no-op wenn noch nicht signedIn") + func resolvePendingNoOpWhenNotSignedIn() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + gate.resolvePending() + + #expect(!didRun) + #expect(gate.isPresentingSignIn) + } + + @Test("cancelPending verwirft Action — danach kein Lauf bei resolvePending") + func cancelDiscardsPending() async throws { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + var didRun = false + gate.require { didRun = true } + gate.cancelPending() + + await signInMockedAuth(mocked) + gate.resolvePending() + + #expect(!didRun) + } + + @Test("lastReason wird gesetzt") + func lastReasonIsRecorded() { + let mocked = makeMockedAuth() + mocked.auth.bootstrap() + let gate = ManaAuthGate(auth: mocked.auth) + + gate.require(reason: "ai-generate") {} + #expect(gate.lastReason == "ai-generate") + } + + // Die async-Overload (`Task { await action() }`) ist trivial und + // ein dedizierter Test über `Task.sleep` ist timing-fragil. + // Die sync-Variante prüft die State-Maschine vollständig; die + // async-Variante teilt sich Pending/Resolve-Logik mit der sync- + // Variante (siehe ManaAuthGate.swift). +} + +/// Loggt den `MockedAuth` über den echten signIn-Pfad ein. Wird genutzt +/// statt direktem `persistSession`, weil letzteres `internal` zu ManaCore +/// ist und aus den ui-Tests nicht erreichbar. +@MainActor +func signInMockedAuth(_ mocked: MockedAuth, email: String = "u@x.de") async { + // Gültiger HS256-Header.payload (exp 2_000_000_000) — JWT.expiry() + // läuft nicht in den Refresh-Pfad. + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8)) + } + await mocked.auth.signIn(email: email, password: "Aa-123456789") +} 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")) + } +} diff --git a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift new file mode 100644 index 0000000..1c22ac6 --- /dev/null +++ b/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift @@ -0,0 +1,130 @@ +import Foundation +import ManaCore +import Testing +@testable import ManaAuthUI + +@Suite("TwoFactorEnrollmentViewModel") +@MainActor +struct TwoFactorEnrollmentViewModelTests { + private func signedInAuth() async -> MockedAuth { + let mocked = makeMockedAuth() + let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig" + mocked.setHandler { _ in + (200, Data(#"{"accessToken":"\#(access)","refreshToken":"session-tok"}"#.utf8)) + } + await mocked.auth.signIn(email: "u@x.de", password: "pw") + return mocked + } + + @Test("enrollWithPassword erfolgreich → phase wechselt auf verify") + func enrollSuccess() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/Mana:u@x.de?secret=ABC","backupCodes":["a","b","c"]} + """#.utf8)) + } + + await model.enrollWithPassword() + if case let .verify(uri, codes) = model.phase { + #expect(uri.hasPrefix("otpauth://totp/")) + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .verify, got \(model.phase)") + } + #expect(model.password == "") // out of memory + } + + @Test("enrollWithPassword falsches PW → .error, phase bleibt password") + func enrollWrongPassword() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "wrong" + + mocked.setHandler { _ in + (401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8)) + } + + await model.enrollWithPassword() + if case .password = model.phase { + #expect(Bool(true)) + } else { + Issue.record("Expected .password, got \(model.phase)") + } + if case let .error(message) = model.status { + #expect(message == "Email oder Passwort falsch") + } else { + Issue.record("Expected .error, got \(model.status)") + } + } + + @Test("canSubmitVerify fordert 6 Ziffern") + func canSubmitVerify() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a"]} + """#.utf8)) + } + await model.enrollWithPassword() + + model.verifyCode = "" + #expect(model.canSubmitVerify == false) + model.verifyCode = "12345" + #expect(model.canSubmitVerify == false) + model.verifyCode = "123456" + #expect(model.canSubmitVerify == true) + model.verifyCode = "abcdef" + #expect(model.canSubmitVerify == false) + } + + @Test("confirmVerify wechselt von verify auf backupCodes") + func confirmVerifySwitchesPhase() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["a","b","c"]} + """#.utf8)) + } + await model.enrollWithPassword() + model.verifyCode = "123456" + + model.confirmVerify() + if case let .backupCodes(codes) = model.phase { + #expect(codes == ["a", "b", "c"]) + } else { + Issue.record("Expected .backupCodes, got \(model.phase)") + } + #expect(model.verifyCode == "") + } + + @Test("backupCodes computed property returnt Codes aus verify- und backupCodes-Phase") + func backupCodesAccessor() async { + let mocked = await signedInAuth() + let model = TwoFactorEnrollmentViewModel(auth: mocked.auth) + // Phase .password → keine Codes + #expect(model.backupCodes == []) + + model.password = "pw" + mocked.setHandler { _ in + (200, Data(#""" + {"totpURI":"otpauth://totp/X","backupCodes":["c1","c2"]} + """#.utf8)) + } + await model.enrollWithPassword() + // Phase .verify → Codes verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + + model.verifyCode = "123456" + model.confirmVerify() + // Phase .backupCodes → Codes weiter verfügbar + #expect(model.backupCodes == ["c1", "c2"]) + } +}