diff --git a/.gitignore b/.gitignore index 1821213..89631fb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ *.xcodeproj Package.resolved .DS_Store -build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb2766..72daa99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,73 +6,6 @@ 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. diff --git a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift index be611ef..6d92a41 100644 --- a/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift +++ b/Sources/ManaAuthUI/Gate/ManaAuthGateModifier.swift @@ -41,9 +41,8 @@ private func authStatusKey(_ status: AuthClient.Status) -> Int { case .signedOut: 1 case .guest: 2 case .signingIn: 3 - case .twoFactorRequired: 4 - case .signedIn: 5 - case .error: 6 + case .signedIn: 4 + case .error: 5 } } diff --git a/Sources/ManaAuthUI/Login/LoginViewModel.swift b/Sources/ManaAuthUI/Login/LoginViewModel.swift index cec1bf6..8de3b9c 100644 --- a/Sources/ManaAuthUI/Login/LoginViewModel.swift +++ b/Sources/ManaAuthUI/Login/LoginViewModel.swift @@ -12,14 +12,13 @@ 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 = "" @@ -67,13 +66,6 @@ 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 cb4276a..5632834 100644 --- a/Sources/ManaAuthUI/Login/ManaLoginView.swift +++ b/Sources/ManaAuthUI/Login/ManaLoginView.swift @@ -48,17 +48,6 @@ 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 deleted file mode 100644 index e925cb6..0000000 --- a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorAccountRow.swift +++ /dev/null @@ -1,310 +0,0 @@ -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 deleted file mode 100644 index 67f8bd7..0000000 --- a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorChallengeView.swift +++ /dev/null @@ -1,101 +0,0 @@ -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 deleted file mode 100644 index 789ba62..0000000 --- a/Sources/ManaAuthUI/TwoFactor/ManaTwoFactorEnrollView.swift +++ /dev/null @@ -1,335 +0,0 @@ -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 deleted file mode 100644 index 76fa344..0000000 --- a/Sources/ManaAuthUI/TwoFactor/TwoFactorChallengeViewModel.swift +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index 00809e0..0000000 --- a/Sources/ManaAuthUI/TwoFactor/TwoFactorEnrollmentViewModel.swift +++ /dev/null @@ -1,107 +0,0 @@ -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/TwoFactorChallengeViewModelTests.swift b/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift deleted file mode 100644 index 7778b25..0000000 --- a/Tests/ManaAuthUITests/TwoFactorChallengeViewModelTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 1c22ac6..0000000 --- a/Tests/ManaAuthUITests/TwoFactorEnrollmentViewModelTests.swift +++ /dev/null @@ -1,130 +0,0 @@ -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"]) - } -}