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 ea52c58..493cb81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,98 +6,6 @@ Alle Änderungen werden hier dokumentiert. Format orientiert an ## [Unreleased] -## [0.6.0] — 2026-05-17 - -Minor — **neues Library-Product `ManaWebShell`**. WKWebView-Hülle für -Hybrid-Apps (Web-Lese-Surfaces + native Submit/Widget/ShareExt). -Extrahiert aus den fast-byte-identischen `WebShell/`-Ordnern in -`seepuls-native` und `zitare-native` (~900 LOC, davon ~700 LOC -Duplikat). Audit 2026-05-17 Vorschlag V2. - -### Neu - -- `WebShellView` (public SwiftUI View) — `WKWebView`-Wrapper mit - Progress-Bar, Pull-to-Refresh (iOS), Fehler-Snackbar, External-Link- - Delegation in den System-Browser. Universal (iOS + macOS). -- `WebShellConfig` (public, Sendable) — Host-Whitelist mit Wildcard- - Support (`"*.mana.how"`), User-Agent, Theme-Hints (background, - progressTint, errorBackground/Foreground/Icon), User-Scripts. -- `WebTarget` (public, Equatable+Sendable) — URL + monoton wachsender - `reloadToken`. Forciert Reload bei Universal-Link auf aktuelle URL. -- `WebNavState` (public, @Observable, @MainActor) — reaktiver - Navigation-State (isLoading, estimatedProgress, lastError, - currentURL, canGoBack). -- `WebShellCoordinator` (public, @MainActor) — `WKNavigationDelegate` - + `WKUIDelegate`-Implementierung. KVO-Observations, Pull-to-Refresh- - Action. -- `WebShellScripts` (public Enum, @MainActor) — vor-gefertigte - `WKUserScript`-Helfer: `preferDarkScheme`, `syncDarkMode(localStorageKey:)`, - `hideElements(selectors:tagName:)`. Apps stapeln nach Bedarf. - -### Logging - -- ManaWebShell loggt unter Subsystem `ev.mana.webshell`, Kategorie - `web`. App-OSLog bleibt unverändert. - -### Tests - -- `ManaWebShellTests` mit 6 Tests gegen `WebShellConfig.isAllowed`. - Coverage für exakte Hosts, `*.root`-Wildcard, Root-selbst, - Negativ-Cases, leere Whitelist. 6/6 grün. - -### Migrations-Hinweis - -`seepuls-native` und `zitare-native` können ihre lokalen -`Sources/Features/WebShell/`-Dateien gegen `ManaWebShell` ersetzen. -Pattern in `mana/docs/playbooks/HYBRID_NATIVE_APP.md` (entsteht -parallel). App-spezifisches (CookieBridge, App-Theme als -`config.backgroundColor`) bleibt in der App. - -## [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 diff --git a/Package.swift b/Package.swift index 7b28ec9..482de59 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,6 @@ let package = Package( ], products: [ .library(name: "ManaAuthUI", targets: ["ManaAuthUI"]), - .library(name: "ManaWebShell", targets: ["ManaWebShell"]), ], dependencies: [ // Lokaler Dev-Pfad. Apps konsumieren beide Pakete parallel über @@ -30,22 +29,10 @@ let package = Package( .enableExperimentalFeature("StrictConcurrency"), ] ), - .target( - name: "ManaWebShell", - path: "Sources/ManaWebShell", - swiftSettings: [ - .enableExperimentalFeature("StrictConcurrency"), - ] - ), .testTarget( name: "ManaAuthUITests", dependencies: ["ManaAuthUI"], path: "Tests/ManaAuthUITests" ), - .testTarget( - name: "ManaWebShellTests", - dependencies: ["ManaWebShell"], - path: "Tests/ManaWebShellTests" - ), ] ) 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/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/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/Sources/ManaWebShell/WebNavState.swift b/Sources/ManaWebShell/WebNavState.swift deleted file mode 100644 index c467daf..0000000 --- a/Sources/ManaWebShell/WebNavState.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// Reactive Navigation-State, geteilt zwischen SwiftUI und Coordinator. -/// Auf `MainActor` — alle Mutationen passieren via WKWebView-Callbacks -/// (KVO + Delegate), die WebKit auf Main liefert. -@Observable -@MainActor -public final class WebNavState { - public var isLoading: Bool = false - public var estimatedProgress: Double = 0 - public var lastError: String? - public var currentURL: URL? - public var canGoBack: Bool = false - - public init() {} -} diff --git a/Sources/ManaWebShell/WebShellConfig.swift b/Sources/ManaWebShell/WebShellConfig.swift deleted file mode 100644 index 2570d0f..0000000 --- a/Sources/ManaWebShell/WebShellConfig.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import SwiftUI -import WebKit - -/// Konfiguration für ``WebShellView``. -/// -/// Beispiel: -/// -/// ```swift -/// WebShellView( -/// target: WebTarget(url: URL(string: "https://seepuls.mana.how")!), -/// config: WebShellConfig( -/// allowedHosts: ["seepuls.mana.how", "*.mana.how", "mana.how"], -/// userAgent: "SeepulsNative/0.1 (iOS)" -/// ) -/// ) -/// ``` -/// -/// Apps mit eigenem Theme injizieren `background` / `progressTint` / -/// `warning` etc. — default werden System-Farben benutzt. -public struct WebShellConfig: Sendable { - /// Liste erlaubter Hosts. Unterstützt: - /// - exakte Hosts: `"seepuls.mana.how"` - /// - Wildcard-Subdomains: `"*.mana.how"` - /// - /// Pfade auf nicht-gelisteten Hosts werden via `OpenURLAction` an - /// den System-Browser delegiert. Ein leeres Array bedeutet - /// **alles extern** — selten gewünscht, aber explizit erlaubt. - public let allowedHosts: [String] - - /// `applicationNameForUserAgent`. WKWebView hängt das an seinen - /// Standard-UA an, ersetzt ihn nicht. Konvention im mana-Ökosystem: - /// `"Native/ ()"`. - public let userAgent: String - - /// Hintergrund hinter dem WKWebView (verhindert Flash vor first - /// paint). Default: `.clear`. Caller setzt typischerweise auf - /// App-Theme-Background. - public let backgroundColor: Color - - /// Tint der Fortschritts-Linie oben (Linear-ProgressView). Default: - /// `.accentColor`. - public let progressTint: Color - - /// Hintergrund der Fehler-Snackbar. Default: `.gray.opacity(0.15)`. - public let errorBackgroundColor: Color - - /// Vordergrund der Fehler-Snackbar (Icon + Text). Default: `.primary`. - public let errorForegroundColor: Color - - /// Icon-Farbe (Warn-Dreieck) in der Fehler-Snackbar. Default: `.orange`. - public let errorIconColor: Color - - /// User-Scripts, die in `WKUserContentController` injiziert werden - /// (Reihenfolge bleibt erhalten). Häufig genutzt: Theme-Sync, - /// Web-Nav-Verstecken. Siehe ``WebShellScripts`` für Default-Helfer. - public let userScripts: [WKUserScript] - - public init( - allowedHosts: [String], - userAgent: String, - backgroundColor: Color = .clear, - progressTint: Color = .accentColor, - errorBackgroundColor: Color = Color.gray.opacity(0.15), - errorForegroundColor: Color = .primary, - errorIconColor: Color = .orange, - userScripts: [WKUserScript] = [] - ) { - self.allowedHosts = allowedHosts - self.userAgent = userAgent - self.backgroundColor = backgroundColor - self.progressTint = progressTint - self.errorBackgroundColor = errorBackgroundColor - self.errorForegroundColor = errorForegroundColor - self.errorIconColor = errorIconColor - self.userScripts = userScripts - } - - /// Prüft, ob ein Host in dieser Konfiguration erlaubt ist. - /// Unterstützt `*.`-Wildcards (subdomain-suffix + Root selbst). - public func isAllowed(host: String) -> Bool { - for pattern in allowedHosts { - if pattern.hasPrefix("*.") { - let suffix = String(pattern.dropFirst(1)) // ".mana.how" - if host.hasSuffix(suffix) { return true } - let root = String(suffix.dropFirst(1)) // "mana.how" - if host == root { return true } - } else if host == pattern { - return true - } - } - return false - } -} diff --git a/Sources/ManaWebShell/WebShellCoordinator.swift b/Sources/ManaWebShell/WebShellCoordinator.swift deleted file mode 100644 index ab349cb..0000000 --- a/Sources/ManaWebShell/WebShellCoordinator.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import OSLog -import SwiftUI -import WebKit - -#if canImport(UIKit) -import UIKit -#endif - -private let log = Logger(subsystem: "ev.mana.webshell", category: "web") - -/// `WKNavigationDelegate` + `WKUIDelegate` für ``WebShellView``. Hält -/// den reactive ``WebNavState`` aktuell, lenkt externe Links in den -/// System-Browser und treibt Pull-to-Refresh an. -/// -/// Lebt auf `MainActor` (Closures von WKWebView liefern auf Main). -/// KVO-Observations werden bei `deinit` entfernt. -@MainActor -public final class WebShellCoordinator: NSObject, WKNavigationDelegate, WKUIDelegate { - let navState: WebNavState - let openURL: OpenURLAction - let config: WebShellConfig - var lastTarget: WebTarget? - - private var progressObservation: NSKeyValueObservation? - private var loadingObservation: NSKeyValueObservation? - private var canGoBackObservation: NSKeyValueObservation? - private var urlObservation: NSKeyValueObservation? - #if canImport(UIKit) - private weak var refreshControl: UIRefreshControl? - #endif - - init(navState: WebNavState, openURL: OpenURLAction, config: WebShellConfig) { - self.navState = navState - self.openURL = openURL - self.config = config - super.init() - } - - deinit { - progressObservation?.invalidate() - loadingObservation?.invalidate() - canGoBackObservation?.invalidate() - urlObservation?.invalidate() - } - - func observe(webView: WKWebView) { - progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in - guard let value = change.newValue else { return } - Task { @MainActor in - self?.navState.estimatedProgress = value - } - } - loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] _, change in - guard let value = change.newValue else { return } - Task { @MainActor in - self?.navState.isLoading = value - } - } - canGoBackObservation = webView.observe(\.canGoBack, options: [.new]) { [weak self] _, change in - guard let value = change.newValue else { return } - Task { @MainActor in - self?.navState.canGoBack = value - } - } - urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in - let value = change.newValue ?? nil - Task { @MainActor in - self?.navState.currentURL = value - } - } - } - - func load(_ url: URL, into webView: WKWebView) { - log.info("WebShell load: \(url.absoluteString, privacy: .public)") - navState.lastError = nil - let request = URLRequest(url: url) - webView.load(request) - } - - #if canImport(UIKit) - func attachRefresh(_ control: UIRefreshControl, webView: WKWebView) { - refreshControl = control - control.addAction( - UIAction { [weak self, weak webView] _ in - webView?.reload() - Task { @MainActor in - self?.refreshControl?.endRefreshing() - } - }, - for: .valueChanged - ) - } - #endif - - // MARK: - WKNavigationDelegate - - public func webView( - _: WKWebView, - decidePolicyFor navigationAction: WKNavigationAction - ) async -> WKNavigationActionPolicy { - guard let url = navigationAction.request.url else { return .allow } - if let host = url.host, config.isAllowed(host: host) { - return .allow - } - if url.scheme == "http" || url.scheme == "https" { - log.info("WebShell → openURL extern: \(url.absoluteString, privacy: .public)") - openURL(url) - return .cancel - } - return .cancel - } - - public func webView(_: WKWebView, didFail _: WKNavigation, withError error: Error) { - log.warning("didFail: \(String(describing: error), privacy: .public)") - navState.lastError = (error as NSError).localizedDescription - } - - public func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation, withError error: Error) { - log.warning("didFailProvisional: \(String(describing: error), privacy: .public)") - navState.lastError = (error as NSError).localizedDescription - } - - public func webView(_: WKWebView, didFinish _: WKNavigation) { - navState.lastError = nil - } - - // MARK: - WKUIDelegate - - public func webView( - _ webView: WKWebView, - createWebViewWith _: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, - windowFeatures _: WKWindowFeatures - ) -> WKWebView? { - // `target=_blank`-Links: kein neues Fenster, im aktuellen WebView - // laden bzw. extern öffnen. - if let url = navigationAction.request.url { - if let host = url.host, config.isAllowed(host: host) { - webView.load(navigationAction.request) - } else { - openURL(url) - } - } - return nil - } -} diff --git a/Sources/ManaWebShell/WebShellScripts.swift b/Sources/ManaWebShell/WebShellScripts.swift deleted file mode 100644 index 3291b89..0000000 --- a/Sources/ManaWebShell/WebShellScripts.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import WebKit - -/// Vor-gefertigte `WKUserScript`-Helfer für ``WebShellView``. Apps -/// pickern, was sie brauchen, und reichen das Ergebnis als -/// `config.userScripts` durch. -/// -/// `WKUserScript` ist MainActor-isolated; deshalb sind die Factory- -/// Methoden hier ebenfalls MainActor. Aufrufer leben sowieso auf Main -/// (SwiftUI `makeUIView`/`makeNSView` sind MainActor). -@MainActor -public enum WebShellScripts { - /// Erzwingt Dark-Color-Scheme im WebView, indem ein `` injiziert und `.dark` an - /// `` gehängt wird. Sinnvoll für Web-Apps, die nur Dark- - /// Styles haben (Seepuls) oder bei denen die App das Light/Dark - /// hart festlegt. - public static let preferDarkScheme: WKUserScript = .init( - source: """ - (function() { - var meta = document.querySelector('meta[name="color-scheme"]'); - if (!meta) { - meta = document.createElement('meta'); - meta.setAttribute('name', 'color-scheme'); - (document.head || document.documentElement).appendChild(meta); - } - meta.setAttribute('content', 'dark'); - var html = document.documentElement; - if (html) html.classList.add('dark'); - })(); - """, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - - /// Synct den System-Dark-Mode in den WebView via - /// `matchMedia('(prefers-color-scheme: dark)')`. Setzt eine - /// `.dark`-Klasse auf `` und optional einen `localStorage`- - /// Key, an dem das Web-Theme hängt. Listener für Live-Switch - /// während die Page offen ist. - /// - /// - Parameter localStorageKey: Key, an dem das Web seinen Theme- - /// State liest. `nil` falls Web nur auf `.dark` reagiert. - public static func syncDarkMode(localStorageKey: String? = nil) -> WKUserScript { - let setStorage: String - if let key = localStorageKey { - let escaped = key.replacingOccurrences(of: "'", with: "\\'") - setStorage = """ - try { - if (isDark) localStorage.setItem('\(escaped)', 'dark'); - else localStorage.removeItem('\(escaped)'); - } catch (e) {} - """ - } else { - setStorage = "" - } - let source = """ - (function() { - function apply(isDark) { - \(setStorage) - var html = document.documentElement; - if (!html) return; - if (isDark) html.classList.add('dark'); - else html.classList.remove('dark'); - } - var mq = window.matchMedia('(prefers-color-scheme: dark)'); - apply(mq.matches); - if (mq.addEventListener) { - mq.addEventListener('change', function(e) { apply(e.matches); }); - } - })(); - """ - return WKUserScript( - source: source, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - } - - /// Versteckt eine Top-Nav-Komponente per CSS, damit eine native - /// TabBar nicht doppelt rendert. Mehrere Selektoren werden - /// gestapelt (mit `,`-Group), damit ein Markup-Refactor in - /// Web-Land das Hide nicht still bricht. - /// - /// Konvention für Selektor-Kaskaden: - /// 1. `nav[data-app-nav]` / `header[data-app-nav]` — explizites - /// Attribut, falls Web es markieren will (greift sofort) - /// 2. strukturell (`body header:has(a.brand)` o.ä.) — heutige - /// Realität - /// 3. positionell (`body > nav:first-of-type`) — Fallback - /// - /// - Parameter selectors: CSS-Selektoren, die `display: none - /// !important` bekommen. Werden mit `,` gejoint. - /// - Parameter tagName: Wert für das `data-mana-webshell`- - /// Attribut auf dem Style-Tag (debugging, source inspection). - public static func hideElements( - selectors: [String], - tagName: String = "hide" - ) -> WKUserScript { - let joined = selectors.joined(separator: ",\n") - let escapedTag = tagName.replacingOccurrences(of: "'", with: "\\'") - let source = """ - (function() { - var css = `\(joined) { - display: none !important; - }`; - var style = document.createElement('style'); - style.setAttribute('data-mana-webshell', '\(escapedTag)'); - style.textContent = css; - (document.head || document.documentElement).appendChild(style); - })(); - """ - return WKUserScript( - source: source, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - } -} diff --git a/Sources/ManaWebShell/WebShellView.swift b/Sources/ManaWebShell/WebShellView.swift deleted file mode 100644 index 98cc341..0000000 --- a/Sources/ManaWebShell/WebShellView.swift +++ /dev/null @@ -1,183 +0,0 @@ -import SwiftUI -import WebKit - -/// SwiftUI-Hülle um `WKWebView`. Eine Instanz gehört üblicherweise zu -/// einem Tab/Screen und behält ihren Web-State (Scroll-Position, -/// Browser-History) während die View lebt. -/// -/// **Verhalten:** -/// - Lädt `target` beim ersten Auftauchen. -/// - Wechselt `target` während die View lebt → lädt neue URL (oder -/// reloaded, wenn `target.reloadToken` sich erhöht). -/// - Pull-to-Refresh über `UIRefreshControl` (iOS / iPadOS). -/// - Links auf nicht-gelisteten Hosts (siehe ``WebShellConfig/allowedHosts``) -/// und `target=_blank` öffnen im System-Browser via -/// `OpenURLAction`, nicht im WebView. -/// - Cookies werden über `WKWebsiteDataStore.default()` geteilt. -/// -/// **Theme-Hint:** -/// `config.backgroundColor` ist der Hintergrund hinter dem WKWebView -/// — verhindert weißen Flash bis zum first paint. Apps mit Dark-Theme -/// setzen das auf ihren Theme-Background. -public struct WebShellView: View { - let target: WebTarget - let config: WebShellConfig - - @State private var navState = WebNavState() - @Environment(\.openURL) private var openURL - - public init(target: WebTarget, config: WebShellConfig) { - self.target = target - self.config = config - } - - public var body: some View { - VStack(spacing: 0) { - if navState.isLoading { - ProgressView(value: navState.estimatedProgress) - .progressViewStyle(.linear) - .tint(config.progressTint) - .frame(height: 2) - } - #if canImport(UIKit) - WebViewRepresentable( - target: target, - navState: navState, - openURL: openURL, - config: config - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(config.backgroundColor) - #elseif canImport(AppKit) - MacWebViewRepresentable( - target: target, - navState: navState, - openURL: openURL, - config: config - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(config.backgroundColor) - #endif - if let error = navState.lastError { - errorBar(error) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - private func errorBar(_ message: String) -> some View { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(config.errorIconColor) - Text(message) - .font(.caption) - .lineLimit(2) - .foregroundStyle(config.errorForegroundColor) - Spacer() - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(config.errorBackgroundColor) - } -} - -#if canImport(UIKit) -import UIKit - -private struct WebViewRepresentable: UIViewRepresentable { - let target: WebTarget - let navState: WebNavState - let openURL: OpenURLAction - let config: WebShellConfig - - func makeCoordinator() -> WebShellCoordinator { - WebShellCoordinator(navState: navState, openURL: openURL, config: config) - } - - func makeUIView(context: Context) -> WKWebView { - let wkConfig = WKWebViewConfiguration() - wkConfig.websiteDataStore = .default() - wkConfig.applicationNameForUserAgent = config.userAgent - for script in config.userScripts { - wkConfig.userContentController.addUserScript(script) - } - let webView = WKWebView(frame: .zero, configuration: wkConfig) - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - webView.allowsBackForwardNavigationGestures = true - // Ohne diese drei flackert WKWebView bis zum first paint weiß - // gegen das App-Theme — egal was der SwiftUI-Container als - // Background setzt. - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = .clear - webView.scrollView.refreshControl = makeRefreshControl( - webView: webView, - coordinator: context.coordinator - ) - context.coordinator.observe(webView: webView) - context.coordinator.load(target.url, into: webView) - context.coordinator.lastTarget = target - return webView - } - - func updateUIView(_ webView: WKWebView, context: Context) { - let coord = context.coordinator - if coord.lastTarget != target { - coord.load(target.url, into: webView) - coord.lastTarget = target - } - } - - private func makeRefreshControl( - webView: WKWebView, - coordinator: WebShellCoordinator - ) -> UIRefreshControl { - let refresh = UIRefreshControl() - coordinator.attachRefresh(refresh, webView: webView) - return refresh - } -} - -#elseif canImport(AppKit) -import AppKit - -private struct MacWebViewRepresentable: NSViewRepresentable { - let target: WebTarget - let navState: WebNavState - let openURL: OpenURLAction - let config: WebShellConfig - - func makeCoordinator() -> WebShellCoordinator { - WebShellCoordinator(navState: navState, openURL: openURL, config: config) - } - - func makeNSView(context: Context) -> WKWebView { - let wkConfig = WKWebViewConfiguration() - wkConfig.websiteDataStore = .default() - wkConfig.applicationNameForUserAgent = config.userAgent - for script in config.userScripts { - wkConfig.userContentController.addUserScript(script) - } - let webView = WKWebView(frame: .zero, configuration: wkConfig) - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - webView.allowsBackForwardNavigationGestures = true - // macOS-Pendant zu UIView.isOpaque=false — sonst weißer Flash - // vor first paint. - webView.setValue(false, forKey: "drawsBackground") - context.coordinator.observe(webView: webView) - context.coordinator.load(target.url, into: webView) - context.coordinator.lastTarget = target - return webView - } - - func updateNSView(_ webView: WKWebView, context: Context) { - let coord = context.coordinator - if coord.lastTarget != target { - coord.load(target.url, into: webView) - coord.lastTarget = target - } - } -} -#endif diff --git a/Sources/ManaWebShell/WebTarget.swift b/Sources/ManaWebShell/WebTarget.swift deleted file mode 100644 index 682525d..0000000 --- a/Sources/ManaWebShell/WebTarget.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -/// URL + monoton wachsende `reloadToken`. Ein neuer Token zwingt den -/// WebView, dieselbe URL nochmal zu laden — wird gebraucht wenn der -/// User auf einen Universal-Link tappt, der zur aktuellen URL führt. -public struct WebTarget: Equatable, Sendable { - public let url: URL - public let reloadToken: Int - - public init(url: URL, reloadToken: Int = 0) { - self.url = url - self.reloadToken = reloadToken - } -} 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"]) - } -} diff --git a/Tests/ManaWebShellTests/WebShellConfigTests.swift b/Tests/ManaWebShellTests/WebShellConfigTests.swift deleted file mode 100644 index 46bddcb..0000000 --- a/Tests/ManaWebShellTests/WebShellConfigTests.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Testing -@testable import ManaWebShell - -@Suite("WebShellConfig — Host-Whitelist") -struct WebShellConfigTests { - private func config(_ hosts: [String]) -> WebShellConfig { - WebShellConfig(allowedHosts: hosts, userAgent: "TestNative/0.1") - } - - @Test("Exakter Host matched") - func exactMatch() { - let c = config(["seepuls.mana.how"]) - #expect(c.isAllowed(host: "seepuls.mana.how")) - #expect(!c.isAllowed(host: "other.mana.how")) - #expect(!c.isAllowed(host: "mana.how")) - #expect(!c.isAllowed(host: "evil.com")) - } - - @Test("Wildcard *.root matched Subdomain") - func wildcardSubdomain() { - let c = config(["*.mana.how"]) - #expect(c.isAllowed(host: "seepuls.mana.how")) - #expect(c.isAllowed(host: "auth.mana.how")) - #expect(c.isAllowed(host: "deep.nested.mana.how")) - } - - @Test("Wildcard *.root matched Root selbst") - func wildcardCoversRoot() { - let c = config(["*.mana.how"]) - #expect(c.isAllowed(host: "mana.how")) - } - - @Test("Wildcard matched nicht andere TLDs") - func wildcardScoped() { - let c = config(["*.mana.how"]) - #expect(!c.isAllowed(host: "mana.com")) - #expect(!c.isAllowed(host: "fake-mana.how")) - #expect(!c.isAllowed(host: "evil.com")) - } - - @Test("Mehrere Patterns kombinieren") - func mixedPatterns() { - let c = config(["zitare.com", "www.zitare.com", "*.mana.how"]) - #expect(c.isAllowed(host: "zitare.com")) - #expect(c.isAllowed(host: "www.zitare.com")) - #expect(c.isAllowed(host: "auth.mana.how")) - #expect(c.isAllowed(host: "mana.how")) - #expect(!c.isAllowed(host: "other.zitare.com")) - } - - @Test("Leere Whitelist verbietet alles") - func emptyDenies() { - let c = config([]) - #expect(!c.isAllowed(host: "anything.com")) - } -} diff --git a/devlog/2026-05-13/data.json b/devlog/2026-05-13/data.json deleted file mode 100644 index 81727ad..0000000 --- a/devlog/2026-05-13/data.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "date": "2026-05-13", - "day_number": 1, - "weekday": "Mittwoch", - "commits": 5, - "authors": [ - { - "name": "Till JS", - "count": 5 - } - ], - "additions": 4247, - "deletions": 4, - "net_lines": 4243, - "files_changed": 39, - "new_files": 0, - "deleted_files": 0, - "session": { - "first_commit_at": "2026-05-13T17:22:42.000Z", - "last_commit_at": "2026-05-13T23:08:41.000Z", - "total_span_minutes": 346, - "active_minutes": 48, - "pauses": [ - { - "from": "19:22", - "to": "22:16", - "minutes": 174 - }, - { - "from": "22:16", - "to": "00:20", - "minutes": 124 - } - ], - "longest_focus_minutes": 48 - }, - "top_dirs": [ - { - "path": "CHANGELOG.md", - "pct": 11 - }, - { - "path": "Sources/ManaAuthUI/TwoFactor", - "pct": 11 - }, - { - "path": "Sources/ManaAuthUI/Login", - "pct": 9 - }, - { - "path": "Sources/ManaAuthUI/Reset", - "pct": 9 - }, - { - "path": "Sources/ManaAuthUI/Account", - "pct": 6 - } - ], - "top_extensions": [ - { - "ext": ".swift", - "count": 38 - }, - { - "ext": ".md", - "count": 7 - }, - { - "ext": ".gitignore", - "count": 2 - } - ], - "tags": [], - "commits_list": [ - { - "hash": "0a2cb34", - "short": "v0.1.0 — initialer Sprint, vollständige Auth-Reise als SwiftUI", - "type": null, - "scope": null, - "additions": 2614, - "deletions": 0, - "timestamp": "2026-05-13T19:22:42+02:00" - }, - { - "hash": "6417b4c", - "short": "v0.2.0 — ManaAuthGate für Action-Level-Login-Eskalation", - "type": null, - "scope": null, - "additions": 357, - "deletions": 0, - "timestamp": "2026-05-13T22:16:27+02:00" - }, - { - "hash": "c155556", - "short": "v0.3.0 — ManaTwoFactorChallengeView", - "type": null, - "scope": null, - "additions": 348, - "deletions": 4, - "timestamp": "2026-05-14T00:20:30+02:00" - }, - { - "hash": "dc8e5a4", - "short": "v0.4.0 — ManaTwoFactorEnrollView + ManaTwoFactorDisableView", - "type": null, - "scope": null, - "additions": 595, - "deletions": 0, - "timestamp": "2026-05-14T00:39:03+02:00" - }, - { - "hash": "117538f", - "short": "v0.5.0 — ManaTwoFactorAccountRow + ManaBackupCodeRegenerateView", - "type": null, - "scope": null, - "additions": 333, - "deletions": 0, - "timestamp": "2026-05-14T01:08:41+02:00" - } - ], - "review_state": "auto", - "llm": { - "model": null, - "generated_at": null - } -} diff --git a/devlog/2026-05-13/macher.md b/devlog/2026-05-13/macher.md deleted file mode 100644 index 9042a76..0000000 --- a/devlog/2026-05-13/macher.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -date: 2026-05-13 -day: 1 -view: macher -weekday: Mittwoch -commits: 5 -review: written ---- -# Mittwoch, 2026-05-13 — Tag 1 (Macher-Sicht) - -Initialer Sprint des Pakets. Aus drei fast-byte-identischen -`LoginView.swift`-Files in cards-native, manaspur-native und -memoro-native wird ein gemeinsames Swift-Package — plus das, was -bisher gar nicht da war: Sign-Up, E-Mail-Verifikation, Passwort-Reset, -Account-Management. Und 2FA, weil die Lücke beim Aufräumen sichtbar -wurde. - -## Stats - -5 Commits, +4 247 / −4 LoC, 39 Files. Sessionspanne 17:22 → 01:08, -~48 aktive Minuten in einem Durchstich. Bei +4 243 netto ist das -v0.1.0 mit allem Anhang, nicht „aktive Tipparbeit" — die Inhalte -flossen aus den drei App-Repos zusammen. - -## Versionsschritte des Tages - -- **v0.1.0** — vollständige Auth-Reise als SwiftUI: Login, Sign-Up, - Email-Verify-Gate, Forgot-/Reset-Password, Change-Email, - Change-Password, Delete-Account. ViewModels strikt getrennt von - Views, jeder Flow eigene `@Observable`-State-Maschine. -- **v0.2.0** — `ManaAuthGate`, der „bitte-erst-einloggen"-Wrapper - für Action-Level-Eskalation. Cards/Manaspur/Memoro brauchen das - pro Aktion, nicht pro Screen. -- **v0.3.0** — `ManaTwoFactorChallengeView` für den Login-Step, - wenn der Server 2FA verlangt. -- **v0.4.0** — `ManaTwoFactorEnrollView` (QR-Code + Verify) und - `ManaTwoFactorDisableView` (Passwort-Bestätigung). -- **v0.5.0** — `ManaTwoFactorAccountRow` für den Account-Tab und - `ManaBackupCodeRegenerateView`. - -Fünf Tags in einer Session ist viel — der Schnitt war pro -Feature-Komplex, damit Consumer-Apps gezielt minor-bumpen können -ohne 2FA mitzunehmen, das sie noch nicht zeigen. - -## Architektur-Entscheidungen - -- **Pure SwiftUI, keine UI-Lib** — gleiche Regel wie `ManaCore`. - Senkt Drift-Risiko zwischen Vereins-Apps. -- **App injiziert `ManaBrandConfig`** — Pakets-Sources kennen keinen - App-Namen, keine Farben. `forest`/`mana`/künftige Themes leben in - der konsumierenden App, bis Token-Theme-Variants kommen. -- **ViewModel-zuerst-Pattern.** Tests gehen gegen ViewModels via - URLProtocol-Mock, Views sind dünn und ungetestet — das passt zum - Swift-Test-Realismus auf macOS-CI. -- **Account-Löschung ist Pflicht** (App-Store-Guideline 5.1.1(v)); - `ManaDeleteAccountView` ist Bestandteil jedes Sign-Up-Anbieters. -- **2FA-Flow läuft komplett über `ManaCore` v1.2.0** (Guest-Mode + - Refresh-Resilience), keine eigenen API-Wrapper in UI. - -## Trade-offs - -- 4 243 netto Zeilen für „v0.1.0 + v0.5.0 in einer Session" — das - hat nur funktioniert, weil drei Source-Repos schon Login-Code in - ähnlicher Form hatten. Hätten wir Sign-Up parallel in drei Apps - gebaut, wäre der Tag dreimal so lang. -- Sprache deutsch im Public-API. Lokalisierungs-Refactor später, - wenn EN-Bedarf real wird; jetzt würde die Indirection bremsen. -- 2FA UI ist da, aber Server-seitig fehlt der Endpoint in - `mana-auth` noch in mancher Form — die Views laufen daher - zunächst gegen Stub-Antworten. - -## Offene Punkte - -- ManaTokens-Theme-Variants → erst dann `ManaBrandConfig` ersetzen. -- Snapshot-Tests für Views fehlen; ViewModel-Tests laufen. -- Localizable.xcstrings (EN) noch nicht angelegt — kommt mit der - ersten konkreten EN-Anforderung. -- Cross-App-Probelauf: 2FA-Enroll von cards-native gegen Production - noch nicht durchgespielt. diff --git a/devlog/2026-05-13/spieler.md b/devlog/2026-05-13/spieler.md deleted file mode 100644 index 10ed6e2..0000000 --- a/devlog/2026-05-13/spieler.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -date: 2026-05-13 -day: 1 -view: spieler -weekday: Mittwoch -commits: 5 -review: written ---- -# Mittwoch, 2026-05-13 — Tag 1 - -Drei Apps hatten bisher fast identische Login-Bildschirme, Sign-Up -fehlte komplett, und „Account löschen" konnte nirgendwo richtig -gezeigt werden. Heute ist daraus ein gemeinsamer Bausatz geworden — -**ManaAuthUI**. - -## Was sich für dich ändert (in Cards, Manaspur, Memoro) - -- Sign-In, Registrierung, „Passwort vergessen?" und E-Mail-Verifikation - sehen jetzt überall gleich aus und führen sauber von einem Schritt - zum nächsten. Wenn du eine App schon kennst, kennst du die andere. -- **Zwei-Faktor-Schutz für deinen Account** ist nun durchgängig - möglich. Du kannst ihn einschalten (über eine Authenticator-App), - Backup-Codes neu erzeugen und ihn wieder ausschalten — vorausgesetzt, - du bestätigst kurz mit Passwort. -- **Account-Löschung** ist überall erreichbar, mit klarem - Bestätigungs-Schritt. Kein Kleingedrucktes, kein versteckter Pfad. -- Wer mitten in einer Aktion plötzlich gefragt wird „bitte erst noch - einloggen", bekommt das jetzt als sanfte Eskalation, nicht als harten - Rauswurf. - -## Hintergrund - -Diese Sachen waren bisher pro App gebaut — mit Unterschieden, die -keinem Menschen helfen. Ab jetzt: eine gemeinsame Tür für alle -Vereins-Apps. Was du in einer App lernst, hilft dir in der nächsten.